v0.1.4-p3

main
mula.liu 2026-03-18 18:00:48 +08:00
parent 34988b2436
commit 775572e07c
5 changed files with 209 additions and 5 deletions

View File

@ -67,7 +67,7 @@ class WhisperSpeechService:
raise SpeechServiceError(
f"STT model file not found under STT_MODEL_DIR: {candidate}"
)
return str(candidate.resolve())
return str(candidate.resolve())
def reset_runtime(self) -> None:
with self._model_lock:

View File

@ -126,6 +126,15 @@ export const dashboardEn = {
user: 'User',
inputPlaceholder: 'Type a command and press Enter (Shift+Enter for newline)',
disabledPlaceholder: 'Bot is not running. Command input is disabled.',
controlCommands: 'Control Commands',
controlCommandsDesc: 'Send built-in nanobot slash commands',
controlCommandNew: 'New session',
controlCommandStop: 'Stop task',
controlCommandRestart: 'Restart bot',
controlCommandHelp: 'Show commands',
controlCommandsShow: 'Show control commands',
controlCommandsHide: 'Hide control commands',
controlCommandSent: (command: string) => `Control command sent: ${command}`,
sending: 'Sending...',
send: 'Send',
interrupt: 'Interrupt',

View File

@ -126,6 +126,15 @@ export const dashboardZhCn = {
user: '用户',
inputPlaceholder: '输入指令后回车发送Shift+Enter 换行)',
disabledPlaceholder: 'Bot 未运行,无法发送指令。',
controlCommands: '控制命令',
controlCommandsDesc: '发送 nanobot 内建 slash 指令',
controlCommandNew: '新会话',
controlCommandStop: '停止任务',
controlCommandRestart: '重启进程',
controlCommandHelp: '查看命令',
controlCommandsShow: '展开控制命令',
controlCommandsHide: '收起控制命令',
controlCommandSent: (command: string) => `已发送控制命令 ${command}`,
sending: '发送中...',
send: '发送',
interrupt: '中断',

View File

@ -218,7 +218,6 @@
.ops-bot-card:hover {
border-color: color-mix(in oklab, var(--brand) 58%, var(--line) 42%);
transform: translateY(-1px);
box-shadow: 0 8px 18px color-mix(in oklab, var(--brand) 14%, transparent);
}
@ -1292,6 +1291,7 @@
}
.ops-composer-shell {
position: relative;
display: grid;
grid-template-rows: minmax(96px, auto) auto;
gap: 0;
@ -1320,6 +1320,95 @@
min-height: 0;
}
.ops-composer-float-controls {
position: absolute;
top: 14px;
right: 8px;
z-index: 4;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
pointer-events: none;
}
.ops-control-command-drawer {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
transform: translateX(10px);
transition: max-width 0.22s ease, opacity 0.18s ease, transform 0.22s ease;
pointer-events: none;
}
.ops-control-command-drawer.is-open {
max-width: 240px;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.ops-control-command-chip {
height: 24px;
padding: 0 8px;
border: 1px solid color-mix(in oklab, var(--line) 70%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 94%, transparent);
color: var(--text);
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
box-shadow: 0 4px 10px rgba(9, 15, 28, 0.08);
}
.ops-control-command-chip .mono {
font-size: 11px;
line-height: 1;
color: var(--brand);
}
.ops-control-command-chip:hover:not(:disabled) {
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
background: color-mix(in oklab, var(--brand-soft) 28%, var(--panel) 72%);
}
.ops-control-command-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ops-control-command-toggle {
width: 24px;
height: 24px;
border: 1px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 95%, var(--brand-soft) 5%);
color: var(--brand);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
box-shadow: 0 4px 10px rgba(9, 15, 28, 0.08);
pointer-events: auto;
}
.ops-control-command-toggle:hover {
transform: translateX(-1px);
background: color-mix(in oklab, var(--brand-soft) 26%, var(--panel) 74%);
}
.ops-control-command-toggle.is-open {
background: color-mix(in oklab, var(--brand-soft) 34%, var(--panel) 66%);
}
.ops-composer-quote {
grid-column: 1 / -1;
border: 1px solid color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
@ -1360,7 +1449,7 @@
resize: none;
font-size: 14px;
line-height: 1.45;
padding: 14px 14px 10px 14px;
padding: 14px 42px 10px 14px;
border: 0 !important;
border-bottom: 0 !important;
border-radius: 10px 10px 0 0;
@ -1369,6 +1458,11 @@
outline: none;
}
.ops-composer-shell.is-command-open .ops-composer-input,
.ops-composer-shell.is-command-open .ops-composer-input.input {
padding-right: 230px;
}
.ops-composer-shell:focus-within {
border-color: var(--brand);
}
@ -1494,6 +1588,11 @@
}
@media (max-width: 720px) {
.ops-control-command-drawer.is-open {
max-width: 220px;
flex-wrap: wrap;
}
.ops-voice-wave {
gap: 4px;
padding: 0 4px;

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios';
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Command, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
@ -1137,6 +1137,7 @@ export function BotDashboardModule({
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [isBatchOperating, setIsBatchOperating] = useState(false);
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [controlCommandByBot, setControlCommandByBot] = useState<Record<string, string>>({});
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false,
sendToolHints: false,
@ -1180,6 +1181,7 @@ export function BotDashboardModule({
const [isSavingTemplates, setIsSavingTemplates] = useState(false);
const [templateAgentText, setTemplateAgentText] = useState('');
const [templateTopicText, setTemplateTopicText] = useState('');
const [controlCommandPanelOpen, setControlCommandPanelOpen] = useState(false);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
const botSearchInputName = useMemo(
() => `nbot-search-${Math.random().toString(36).slice(2, 10)}`,
@ -1679,6 +1681,8 @@ export function BotDashboardModule({
const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false;
const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
const isChatEnabled = Boolean(canChat && !isSending);
const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : '';
const canSendControlCommand = Boolean(selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing);
const conversation = useMemo(() => mergeConversation(messages), [messages]);
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
@ -2044,6 +2048,10 @@ export function BotDashboardModule({
persistComposerDraft(selectedBotId, command, pendingAttachments);
}, [selectedBotId, composerDraftHydrated, command, pendingAttachments]);
useEffect(() => {
setControlCommandPanelOpen(false);
}, [selectedBotId]);
useEffect(() => {
return () => {
clearVoiceTimer();
@ -3903,6 +3911,38 @@ export function BotDashboardModule({
}
};
const sendControlCommand = async (slashCommand: '/new' | '/restart') => {
if (!selectedBot || !canSendControlCommand) return;
if (activeControlCommand) return;
try {
setControlCommandByBot((prev) => ({ ...prev, [selectedBot.id]: slashCommand }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: slashCommand },
{ timeout: 12000 },
);
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
if (slashCommand === '/new') {
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
}
setControlCommandPanelOpen(false);
notify(t.controlCommandSent(slashCommand), { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
notify(msg, { tone: 'error' });
} finally {
setControlCommandByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}
};
const interruptExecution = async () => {
if (!selectedBot || !canChat) return;
if (interruptingByBot[selectedBot.id]) return;
@ -3916,6 +3956,7 @@ export function BotDashboardModule({
if (!res.data?.success) {
throw new Error(t.backendDeliverFail);
}
setControlCommandPanelOpen(false);
notify(t.interruptSent, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
@ -5299,7 +5340,53 @@ export function BotDashboardModule({
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<div className="ops-composer-shell">
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls">
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || Boolean(interruptingByBot[selectedBot.id])}
onClick={() => void sendControlCommand('/restart')}
aria-label="/restart"
title="/restart"
>
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
<span className="mono">/restart</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || Boolean(interruptingByBot[selectedBot.id])}
onClick={() => void sendControlCommand('/new')}
aria-label="/new"
title="/new"
>
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
<span className="mono">/new</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!selectedBot || !canChat || Boolean(activeControlCommand) || Boolean(interruptingByBot[selectedBot.id])}
onClick={() => void interruptExecution()}
aria-label="/stop"
title="/stop"
>
{interruptingByBot[selectedBot.id] ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
</div>
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={() => setControlCommandPanelOpen((prev) => !prev)}
aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
title={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
>
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
</button>
</div>
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"