diff --git a/app.py b/app.py index 7e7bc3bb..702a86e9 100644 --- a/app.py +++ b/app.py @@ -15,9 +15,9 @@ import threading _channel_mgr = None -# Desktop mode: a lighter runtime for the packaged Electron client. The plugin -# framework is still bundled (it's tiny and on the web channel's import path), -# but we skip loading actual plugins and MCP tools to keep startup fast. +# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are +# loaded in a background thread (so command plugins like cow_cli/godcmd work +# without slowing startup), while MCP warmup is still skipped to keep it fast. DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1" @@ -80,8 +80,16 @@ class ChannelManager: if self._primary_channel is None and channels: self._primary_channel = channels[0][1] - if first_start and not DESKTOP_MODE: - PluginManager().load_plugins() + 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() # Cloud client is optional. It is only started when # use_linkai=True AND cloud_deployment_id is set. diff --git a/desktop/build/cowagent-backend.spec b/desktop/build/cowagent-backend.spec index 9e20841e..b6886a03 100644 --- a/desktop/build/cowagent-backend.spec +++ b/desktop/build/cowagent-backend.spec @@ -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. diff --git a/desktop/src/renderer/src/components/ChatInput.tsx b/desktop/src/renderer/src/components/ChatInput.tsx index 9138ca8a..3724d90a 100644 --- a/desktop/src/renderer/src/components/ChatInput.tsx +++ b/desktop/src/renderer/src/components/ChatInput.tsx @@ -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(function ChatInput const textareaRef = useRef(null) const fileInputRef = useRef(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(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(function ChatInput {/* Slash command menu */} {slashOpen && filtered.length > 0 && ( -
+
+
+ {t('slash_menu_title')} +
{filtered.map((c, i) => ( ))}
diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index 3ba01042..2e65724b 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -331,6 +331,23 @@ const translations: Record> = { 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> = { 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', }, } diff --git a/desktop/src/renderer/src/pages/ChatPage.tsx b/desktop/src/renderer/src/pages/ChatPage.tsx index 136ee5de..d14db09f 100644 --- a/desktop/src/renderer/src/pages/ChatPage.tsx +++ b/desktop/src/renderer/src/pages/ChatPage.tsx @@ -51,14 +51,19 @@ const ChatPage: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ baseUrl }) => { const handleScroll = useCallback( async (e: React.UIEvent) => { 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) diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py index f652c701..26e1e232 100644 --- a/plugins/plugin_manager.py +++ b/plugins/plugin_manager.py @@ -9,11 +9,30 @@ import sys from common.log import logger from common.singleton import singleton 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 * +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 class PluginManager: def __init__(self): @@ -45,15 +64,21 @@ class PluginManager: return wrapper 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) def load_config(self): logger.debug("Loading plugins config...") modified = False - if os.path.exists("./plugins/plugins.json"): - with open("./plugins/plugins.json", "r", encoding="utf-8") as f: + # Prefer the writable copy (data dir); fall back to the one shipped in + # 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["plugins"] = SortedDict(lambda k, v: v["priority"], pconf["plugins"], reverse=True) else: @@ -73,7 +98,7 @@ class PluginManager: 从 plugins/config.json 中加载所有插件的配置并写入 config.py 的全局配置中,供插件中使用 插件实例中通过 config.pconf(plugin_name) 即可获取该插件的配置 """ - all_config_path = "./plugins/config.json" + all_config_path = os.path.join(_plugins_resource_dir(), "config.json") try: if os.path.exists(all_config_path): # read from all plugins config @@ -88,7 +113,7 @@ class PluginManager: def scan_plugins(self): logger.debug("Scanning plugins ...") - plugins_dir = "./plugins" + plugins_dir = _plugins_resource_dir() raws = [self.plugins[name] for name in self.plugins] for plugin_name in os.listdir(plugins_dir): plugin_path = os.path.join(plugins_dir, plugin_name)