feat(desktop): support plugin commands

This commit is contained in:
zhayujie
2026-06-27 12:19:12 +08:00
parent 12cd626949
commit 538281da51
6 changed files with 188 additions and 36 deletions

View File

@@ -52,15 +52,19 @@ hiddenimports += collect_submodules('models')
hiddenimports += collect_submodules('voice')
hiddenimports += collect_submodules('bridge')
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
# so the framework package must be present even though desktop mode never loads
# actual plugins (it's only ~tens of KB of code).
# Plugin framework + plugins. WebChannel -> ChatChannel imports
# `from plugins import *`, and desktop mode loads plugins (in a background
# thread) so command plugins like cow_cli/godcmd (/status, #help) work. Plugin
# modules are imported dynamically by name in scan_plugins(), so list them
# explicitly. The `cli` package is a cow_cli dependency (`from cli import ...`).
hiddenimports += [
'plugins',
'plugins.event',
'plugins.plugin',
'plugins.plugin_manager',
]
hiddenimports += collect_submodules('plugins')
hiddenimports += collect_submodules('cli')
# Third-party SDKs that use lazy/conditional imports internally.
hiddenimports += collect_submodules('dashscope')
@@ -75,6 +79,9 @@ hiddenimports += [
datas = [
(rp('config-template.json'), '.'),
(rp('skills'), 'skills'),
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
# (it doesn't rely solely on imports), so ship the package directory too.
(rp('plugins'), 'plugins'),
# Web console served on the backend port: ship chat.html plus its static
# assets (~1.9MB) so the browser-based console works as a debug/fallback
# entry alongside the Electron UI.

View File

@@ -9,7 +9,9 @@ export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
interface SlashCommand {
cmd: string
desc: string
action: 'new' | 'clear'
// 'new'/'clear' run a local action; 'send' (default) is a completion that
// gets sent to the backend as a normal message (handled by command plugins).
action?: 'new' | 'clear'
}
interface ChatInputProps {
@@ -35,9 +37,25 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Local actions ('new'/'clear') plus completion commands handled by backend
// command plugins (cow_cli/godcmd). Commands ending with a space expect an
// argument, so selecting them keeps focus in the input instead of sending.
const slashCommands: SlashCommand[] = [
{ cmd: '/new', desc: t('session_new'), action: 'new' },
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
{ cmd: '/clear', desc: t('slash_clear'), action: 'clear' },
{ cmd: '/help', desc: t('slash_help') },
{ cmd: '/status', desc: t('slash_status') },
{ cmd: '/context', desc: t('slash_context') },
{ cmd: '/skill list', desc: t('slash_skill_list') },
{ cmd: '/skill search ', desc: t('slash_skill_search') },
{ cmd: '/skill install ', desc: t('slash_skill_install') },
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
{ cmd: '/knowledge', desc: t('slash_knowledge') },
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
{ cmd: '/config', desc: t('slash_config') },
{ cmd: '/cancel', desc: t('slash_cancel') },
{ cmd: '/logs', desc: t('slash_logs') },
{ cmd: '/version', desc: t('slash_version') },
]
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
@@ -60,11 +78,30 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
})
const runSlash = (c: SlashCommand) => {
setText('')
setSlashOpen(false)
resetHeight()
if (c.action === 'new') onNewChat()
else if (c.action === 'clear') onClearContext()
if (c.action === 'new') {
setText('')
resetHeight()
onNewChat()
return
}
if (c.action === 'clear') {
setText('')
resetHeight()
onClearContext()
return
}
// Completion command. If it expects an argument (trailing space), keep it
// in the input so the user can type the argument; otherwise send it now.
const needsArg = c.cmd.endsWith(' ')
if (needsArg) {
setText(c.cmd)
requestAnimationFrame(() => textareaRef.current?.focus())
} else {
onSend(c.cmd.trim(), [])
setText('')
resetHeight()
}
}
const handleSubmit = useCallback(() => {
@@ -205,18 +242,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
{/* Slash command menu */}
{slashOpen && filtered.length > 0 && (
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
<div className="absolute bottom-full left-0 right-0 mb-1.5 max-h-80 overflow-y-auto rounded-xl border border-default bg-elevated shadow-xl z-30 p-1.5">
<div className="px-2.5 pt-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-content-tertiary">
{t('slash_menu_title')}
</div>
{filtered.map((c, i) => (
<button
key={c.cmd}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => runSlash(c)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
className={`w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-lg text-left cursor-pointer transition-colors ${
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
}`}
>
<span className="text-sm font-medium text-accent">{c.cmd}</span>
<span className="text-xs text-content-tertiary">{c.desc}</span>
<span
className={`text-[13px] font-medium font-mono whitespace-nowrap ${
i === slashIndex ? 'text-accent' : 'text-content-secondary'
}`}
>
{c.cmd}
</span>
<span className="text-xs text-content-tertiary whitespace-nowrap truncate">{c.desc}</span>
</button>
))}
</div>

View File

@@ -331,6 +331,23 @@ const translations: Record<string, Record<string, string>> = {
status_error: '初始化失败',
status_error_desc: '客户端初始化失败,请重试',
status_retry: '重试',
// slash command descriptions
slash_menu_title: '命令',
slash_new: '新建对话',
slash_clear: '清除对话上下文',
slash_help: '显示命令帮助',
slash_status: '查看运行状态',
slash_context: '查看对话上下文',
slash_skill_list: '查看已安装技能',
slash_skill_search: '搜索技能',
slash_skill_install: '安装技能 (名称或 GitHub URL)',
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
slash_knowledge: '查看知识库统计',
slash_knowledge_list: '查看知识库文件树',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
},
en: {
console: 'Console',
@@ -664,6 +681,23 @@ const translations: Record<string, Record<string, string>> = {
status_error: 'Initialization Failed',
status_error_desc: 'Failed to initialize the client, please retry',
status_retry: 'Retry',
// slash command descriptions
slash_menu_title: 'Commands',
slash_new: 'New chat',
slash_clear: 'Clear conversation context',
slash_help: 'Show command help',
slash_status: 'Show running status',
slash_context: 'Show conversation context',
slash_skill_list: 'List installed skills',
slash_skill_search: 'Search skills',
slash_skill_install: 'Install a skill (name or GitHub URL)',
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
slash_knowledge: 'Show knowledge base stats',
slash_knowledge_list: 'Show knowledge base file tree',
slash_config: 'Show current config',
slash_cancel: 'Abort the running agent task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
},
}

View File

@@ -51,14 +51,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
}, [activeId, ensureSession, loadHistory])
const scrollToBottom = useCallback((smooth = true) => {
const el = scrollRef.current
if (!el) return
// Jump straight to the bottom; instant for session switches, smooth for streaming.
if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
// Defer to the next frame so we read the height *after* the new content has
// been laid out (markdown/streaming renders a frame later than the effect).
requestAnimationFrame(() => {
const el = scrollRef.current
if (!el) return
// Smooth animations get interrupted by high-frequency streaming updates
// and never catch up, so jump instantly while following the stream.
if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
})
}, [])
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
@@ -66,6 +71,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const lastSessionRef = useRef('')
const lastLenRef = useRef(0)
const pendingSnapRef = useRef(false)
// True while we should keep the view pinned to the bottom (e.g. during
// streaming). Cleared when the user scrolls up to read earlier messages.
const followBottomRef = useRef(true)
// Tracks the previous streaming state so we can do one final snap to the
// bottom right when streaming ends (the last chunk of a long command output
// often lands together with isStreaming flipping to false).
const wasStreamingRef = useRef(false)
useEffect(() => {
const el = scrollRef.current
if (!el) return
@@ -74,12 +86,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
lastSessionRef.current = activeId
lastLenRef.current = messages.length
pendingSnapRef.current = true
followBottomRef.current = true
}
if (pendingSnapRef.current) {
// Instant snap on switch and on the first content that lands afterwards.
lastLenRef.current = messages.length
requestAnimationFrame(() => scrollToBottom(false))
scrollToBottom(false)
if (messages.length > 0) pendingSnapRef.current = false
return
}
@@ -87,8 +100,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const grew = messages.length !== lastLenRef.current
lastLenRef.current = messages.length
if (nearBottom || grew) scrollToBottom(true)
}, [messages, activeId, scrollToBottom])
// Follow the bottom when: a new message arrived, the user is already near
// the bottom, or we're streaming and the user hasn't scrolled up. This
// keeps long command/streaming output (where length is unchanged but the
// content keeps growing) glued to the latest line.
// One final snap right when streaming ends, so the tail of a long command
// output isn't left scrolled off-screen.
const justFinished = wasStreamingRef.current && !isStreaming
wasStreamingRef.current = isStreaming
const following = isStreaming && followBottomRef.current
if (grew || nearBottom || following || (justFinished && followBottomRef.current)) {
// Instant jump while streaming/new content (smooth animations get
// interrupted by rapid updates and never reach the bottom); smooth only
// for a lone increment when the user is already sitting near the bottom.
const smooth = nearBottom && !following && !grew && !justFinished
scrollToBottom(smooth)
}
}, [messages, activeId, isStreaming, scrollToBottom])
const handleSend = useCallback(
async (text: string, attachments: Attachment[]) => {
@@ -146,6 +175,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const handleScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
// Track whether the user wants to stay pinned to the bottom: scrolling up
// pauses auto-follow; returning near the bottom resumes it.
followBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const s = useChatStore.getState().sessions[activeId]
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
setLoadingMore(true)