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

16
app.py
View File

@@ -15,9 +15,9 @@ import threading
_channel_mgr = None _channel_mgr = None
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin # Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
# framework is still bundled (it's tiny and on the web channel's import path), # loaded in a background thread (so command plugins like cow_cli/godcmd work
# but we skip loading actual plugins and MCP tools to keep startup fast. # without slowing startup), while MCP warmup is still skipped to keep it fast.
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1" DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
@@ -80,7 +80,15 @@ class ChannelManager:
if self._primary_channel is None and channels: if self._primary_channel is None and channels:
self._primary_channel = channels[0][1] self._primary_channel = channels[0][1]
if first_start and not DESKTOP_MODE: if first_start:
if DESKTOP_MODE:
# Load plugins in the background so command plugins
# (cow_cli / godcmd, e.g. /status, #help) work in the
# desktop client, without blocking web-service readiness.
threading.Thread(
target=PluginManager().load_plugins, daemon=True
).start()
else:
PluginManager().load_plugins() PluginManager().load_plugins()
# Cloud client is optional. It is only started when # Cloud client is optional. It is only started when

View File

@@ -52,15 +52,19 @@ hiddenimports += collect_submodules('models')
hiddenimports += collect_submodules('voice') hiddenimports += collect_submodules('voice')
hiddenimports += collect_submodules('bridge') hiddenimports += collect_submodules('bridge')
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`, # Plugin framework + plugins. WebChannel -> ChatChannel imports
# so the framework package must be present even though desktop mode never loads # `from plugins import *`, and desktop mode loads plugins (in a background
# actual plugins (it's only ~tens of KB of code). # 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 += [ hiddenimports += [
'plugins', 'plugins',
'plugins.event', 'plugins.event',
'plugins.plugin', 'plugins.plugin',
'plugins.plugin_manager', 'plugins.plugin_manager',
] ]
hiddenimports += collect_submodules('plugins')
hiddenimports += collect_submodules('cli')
# Third-party SDKs that use lazy/conditional imports internally. # Third-party SDKs that use lazy/conditional imports internally.
hiddenimports += collect_submodules('dashscope') hiddenimports += collect_submodules('dashscope')
@@ -75,6 +79,9 @@ hiddenimports += [
datas = [ datas = [
(rp('config-template.json'), '.'), (rp('config-template.json'), '.'),
(rp('skills'), 'skills'), (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 # 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 # assets (~1.9MB) so the browser-based console works as a debug/fallback
# entry alongside the Electron UI. # entry alongside the Electron UI.

View File

@@ -9,7 +9,9 @@ export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
interface SlashCommand { interface SlashCommand {
cmd: string cmd: string
desc: 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 { interface ChatInputProps {
@@ -35,9 +37,25 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(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[] = [ const slashCommands: SlashCommand[] = [
{ cmd: '/new', desc: t('session_new'), action: 'new' }, { cmd: '/new', desc: t('slash_new'), action: 'new' },
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' }, { 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())) 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) => { const runSlash = (c: SlashCommand) => {
setText('')
setSlashOpen(false) setSlashOpen(false)
if (c.action === 'new') {
setText('')
resetHeight() resetHeight()
if (c.action === 'new') onNewChat() onNewChat()
else if (c.action === 'clear') onClearContext() 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(() => { const handleSubmit = useCallback(() => {
@@ -205,18 +242,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
{/* Slash command menu */} {/* Slash command menu */}
{slashOpen && filtered.length > 0 && ( {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) => ( {filtered.map((c, i) => (
<button <button
key={c.cmd} key={c.cmd}
onMouseEnter={() => setSlashIndex(i)} onMouseEnter={() => setSlashIndex(i)}
onClick={() => runSlash(c)} 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' i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
}`} }`}
> >
<span className="text-sm font-medium text-accent">{c.cmd}</span> <span
<span className="text-xs text-content-tertiary">{c.desc}</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> </button>
))} ))}
</div> </div>

View File

@@ -331,6 +331,23 @@ const translations: Record<string, Record<string, string>> = {
status_error: '初始化失败', status_error: '初始化失败',
status_error_desc: '客户端初始化失败,请重试', status_error_desc: '客户端初始化失败,请重试',
status_retry: '重试', 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: { en: {
console: 'Console', console: 'Console',
@@ -664,6 +681,23 @@ const translations: Record<string, Record<string, string>> = {
status_error: 'Initialization Failed', status_error: 'Initialization Failed',
status_error_desc: 'Failed to initialize the client, please retry', status_error_desc: 'Failed to initialize the client, please retry',
status_retry: '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]) }, [activeId, ensureSession, loadHistory])
const scrollToBottom = useCallback((smooth = true) => { const scrollToBottom = useCallback((smooth = true) => {
// 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 const el = scrollRef.current
if (!el) return if (!el) return
// Jump straight to the bottom; instant for session switches, smooth for streaming. // Smooth animations get interrupted by high-frequency streaming updates
// and never catch up, so jump instantly while following the stream.
if (smooth) { if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else { } else {
el.scrollTop = el.scrollHeight el.scrollTop = el.scrollHeight
} }
})
}, []) }, [])
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation). // 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 lastSessionRef = useRef('')
const lastLenRef = useRef(0) const lastLenRef = useRef(0)
const pendingSnapRef = useRef(false) 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(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
@@ -74,12 +86,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
lastSessionRef.current = activeId lastSessionRef.current = activeId
lastLenRef.current = messages.length lastLenRef.current = messages.length
pendingSnapRef.current = true pendingSnapRef.current = true
followBottomRef.current = true
} }
if (pendingSnapRef.current) { if (pendingSnapRef.current) {
// Instant snap on switch and on the first content that lands afterwards. // Instant snap on switch and on the first content that lands afterwards.
lastLenRef.current = messages.length lastLenRef.current = messages.length
requestAnimationFrame(() => scrollToBottom(false)) scrollToBottom(false)
if (messages.length > 0) pendingSnapRef.current = false if (messages.length > 0) pendingSnapRef.current = false
return return
} }
@@ -87,8 +100,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160 const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const grew = messages.length !== lastLenRef.current const grew = messages.length !== lastLenRef.current
lastLenRef.current = messages.length lastLenRef.current = messages.length
if (nearBottom || grew) scrollToBottom(true) // Follow the bottom when: a new message arrived, the user is already near
}, [messages, activeId, scrollToBottom]) // 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( const handleSend = useCallback(
async (text: string, attachments: Attachment[]) => { async (text: string, attachments: Attachment[]) => {
@@ -146,6 +175,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const handleScroll = useCallback( const handleScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => { async (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget 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] const s = useChatStore.getState().sessions[activeId]
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) { if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
setLoadingMore(true) setLoadingMore(true)

View File

@@ -9,11 +9,30 @@ import sys
from common.log import logger from common.log import logger
from common.singleton import singleton from common.singleton import singleton
from common.sorted_dict import SortedDict from common.sorted_dict import SortedDict
from config import conf, remove_plugin_config, write_plugin_config from config import conf, remove_plugin_config, write_plugin_config, get_data_root, get_resource_root
from .event import * from .event import *
def _plugins_resource_dir():
"""Read-only plugins source dir. In a frozen bundle it lives under the
resource root (sys._MEIPASS); from source it's the CWD-relative ./plugins."""
if getattr(sys, "frozen", False):
return os.path.join(get_resource_root(), "plugins")
return "./plugins"
def _plugins_data_dir():
"""Writable dir for plugin runtime config (plugins.json). In a frozen
bundle the resource dir is read-only, so redirect writes to the data root
(~/.cow); from source it stays the CWD-relative ./plugins."""
if getattr(sys, "frozen", False):
d = os.path.join(get_data_root(), "plugins")
os.makedirs(d, exist_ok=True)
return d
return "./plugins"
@singleton @singleton
class PluginManager: class PluginManager:
def __init__(self): def __init__(self):
@@ -45,15 +64,21 @@ class PluginManager:
return wrapper return wrapper
def save_config(self): def save_config(self):
with open("./plugins/plugins.json", "w", encoding="utf-8") as f: cfg_path = os.path.join(_plugins_data_dir(), "plugins.json")
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump(self.pconf, f, indent=4, ensure_ascii=False) json.dump(self.pconf, f, indent=4, ensure_ascii=False)
def load_config(self): def load_config(self):
logger.debug("Loading plugins config...") logger.debug("Loading plugins config...")
modified = False modified = False
if os.path.exists("./plugins/plugins.json"): # Prefer the writable copy (data dir); fall back to the one shipped in
with open("./plugins/plugins.json", "r", encoding="utf-8") as f: # the resource dir (first run in a frozen bundle, before any save).
data_cfg = os.path.join(_plugins_data_dir(), "plugins.json")
res_cfg = os.path.join(_plugins_resource_dir(), "plugins.json")
cfg_path = data_cfg if os.path.exists(data_cfg) else res_cfg
if os.path.exists(cfg_path):
with open(cfg_path, "r", encoding="utf-8") as f:
pconf = json.load(f) pconf = json.load(f)
pconf["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True) pconf["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True)
else: else:
@@ -73,7 +98,7 @@ class PluginManager:
从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用 从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用
插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置 插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置
""" """
all_config_path = "./plugins/config.json" all_config_path = os.path.join(_plugins_resource_dir(), "config.json")
try: try:
if os.path.exists(all_config_path): if os.path.exists(all_config_path):
# read from all plugins config # read from all plugins config
@@ -88,7 +113,7 @@ class PluginManager:
def scan_plugins(self): def scan_plugins(self):
logger.debug("Scanning plugins ...") logger.debug("Scanning plugins ...")
plugins_dir = "./plugins" plugins_dir = _plugins_resource_dir()
raws = [self.plugins[name] for name in self.plugins] raws = [self.plugins[name] for name in self.plugins]
for plugin_name in os.listdir(plugins_dir): for plugin_name in os.listdir(plugins_dir):
plugin_path = os.path.join(plugins_dir, plugin_name) plugin_path = os.path.join(plugins_dir, plugin_name)