diff --git a/backend/core/speech_service.py b/backend/core/speech_service.py index 9aeac87..6cee286 100644 --- a/backend/core/speech_service.py +++ b/backend/core/speech_service.py @@ -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: diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index f534908..075eafc 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -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', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 1ed0735..8c2b4a5 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -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: '中断', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 3cdcb9d..60c047f 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -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; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 4e75e12..e169ddd 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -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([]); + const [controlCommandByBot, setControlCommandByBot] = useState>({}); 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(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' }} /> -
+
+
+
+ + + +
+ +