From fcf4eb78dc51051e8024bd75dac89865fbe3b0aa Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sun, 31 May 2026 16:49:35 +0800 Subject: [PATCH] feat(i18n): add global language resolution and localize user-facing text --- agent/protocol/agent_stream.py | 46 +-- channel/chat_channel.py | 7 +- channel/web/chat.html | 23 +- channel/web/static/js/console.js | 108 +++++-- channel/web/web_channel.py | 25 +- cli/commands/install.py | 77 +++-- cli/commands/process.py | 14 +- cli/commands/skill.py | 12 +- cli/utils.py | 16 + common/i18n.py | 177 +++++++++++ config-template.json | 1 + config.py | 11 + docker/docker-compose.yml | 1 + models/chatgpt/chat_gpt_bot.py | 33 ++- plugins/cow_cli/cow_cli.py | 486 +++++++++++++++++++------------ 15 files changed, 748 insertions(+), 289 deletions(-) create mode 100644 common/i18n.py diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index e3be20b8..0eb63b75 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -12,6 +12,7 @@ from agent.protocol.models import LLMRequest, LLMModel from agent.protocol.message_utils import sanitize_claude_messages, compress_turn_to_text_only from agent.tools.base_tool import BaseTool, ToolResult from common.log import logger +from common.i18n import t as _t # Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content). try: @@ -317,7 +318,10 @@ class AgentStreamExecutor: # Hard stop at 8 failures - abort with critical message if same_tool_failures >= 8: - return True, f"抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", True + return True, _t( + "抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", + "Sorry, I couldn't complete this task. I may have misunderstood, or my current approach isn't quite right.\n\nYou could try:\n• Rephrasing your request\n• Breaking the task into smaller steps\n• Taking a different approach", + ), True # Warning at 6 failures if same_tool_failures >= 6: @@ -436,14 +440,16 @@ class AgentStreamExecutor: elif not assistant_msg: # Still empty (no text and no tool_calls): use fallback logger.warning(f"[Agent] Still empty after explicit request") - final_response = ( - "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", ) logger.info(f"Generated fallback response for empty LLM output") else: - # 第一轮就空回复,直接 fallback - final_response = ( - "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。" + # First-turn empty reply, fall back directly + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", ) logger.info(f"Generated fallback response for empty LLM output") else: @@ -514,7 +520,7 @@ class AgentStreamExecutor: # Check for critical error - abort entire conversation if result.get("status") == "critical_error": logger.error(f"💥 检测到严重错误,终止对话") - final_response = result.get('result', '任务执行失败') + final_response = result.get('result') or _t("任务执行失败", "Task execution failed") return final_response # Log tool result in compact format @@ -650,15 +656,15 @@ class AgentStreamExecutor: logger.info(f"💭 Summary: {summary_response[:150]}{'...' if len(summary_response) > 150 else ''}") else: # Fallback if model still doesn't respond - final_response = ( - f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" - "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", ) except Exception as e: logger.warning(f"Failed to get summary from LLM: {e}") - final_response = ( - f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" - "任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。" + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", ) finally: # Remove the injected user prompt from history to avoid polluting @@ -953,13 +959,15 @@ class AgentStreamExecutor: self.messages.clear() self._clear_session_db() if is_context_overflow: - raise Exception( - "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。" - ) + raise Exception(_t( + "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。", + "Sorry, the conversation history got too long and overflowed the context. I've cleared the history — please describe your request again.", + )) else: - raise Exception( - "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。" - ) + raise Exception(_t( + "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。", + "Sorry, something went wrong with the earlier conversation. I've cleared the history — please send your message again.", + )) # Check if error is rate limit (429) is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower diff --git a/channel/chat_channel.py b/channel/chat_channel.py index 6a9a1952..9104a38e 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -10,6 +10,7 @@ from bridge.reply import * from channel.channel import Channel from common.dequeue import Dequeue from common import memory +from common.i18n import t as _t from plugins import * try: @@ -265,7 +266,7 @@ class ChatChannel(Channel): if reply.type in self.NOT_SUPPORT_REPLYTYPE: logger.error("[chat_channel]reply type not support: " + str(reply.type)) reply.type = ReplyType.ERROR - reply.content = "不支持发送的消息类型: " + str(reply.type) + reply.content = _t("不支持发送的消息类型: ", "Unsupported message type: ") + str(reply.type) if reply.type == ReplyType.TEXT: reply_text = reply.content @@ -476,9 +477,9 @@ class ChatChannel(Channel): cancelled = get_cancel_registry().cancel_session(session_id) text = ( - "🛑 已中止" + _t("🛑 已中止", "🛑 Cancelled") if cancelled > 0 - else "当前没有可中止的任务。" + else _t("当前没有可中止的任务。", "Nothing to cancel.") ) logger.info( f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}" diff --git a/channel/web/chat.html b/channel/web/chat.html index d90adb15..1bbf0f04 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -47,11 +47,30 @@ This runs synchronously in so the correct class is on before any CSS or body rendering occurs. --> diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 6d0a66fc..e5865051 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -91,6 +91,27 @@ const I18N = { example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况', example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能', example_web_title: '指令中心', example_web_text: '查看全部命令', + slash_help: '显示命令帮助', + slash_status: '查看运行状态', + slash_context: '查看对话上下文', + slash_context_clear: '清除对话上下文', + slash_skill_list: '查看已安装技能', + slash_skill_list_remote: '浏览技能广场', + slash_skill_search: '搜索技能', + slash_skill_install: '安装技能 (名称或 GitHub URL)', + slash_skill_uninstall: '卸载技能', + slash_skill_info: '查看技能详情', + slash_skill_enable: '启用技能', + slash_skill_disable: '禁用技能', + slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)', + slash_knowledge: '查看知识库统计', + slash_knowledge_list: '查看知识库文件树', + slash_knowledge_on: '开启知识库', + slash_knowledge_off: '关闭知识库', + slash_config: '查看当前配置', + slash_cancel: '中止当前正在运行的 Agent 任务', + slash_logs: '查看最近日志', + slash_version: '查看版本', input_placeholder: '输入消息,或输入 / 使用指令', config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_model: '模型配置', config_agent: 'Agent 配置', @@ -265,6 +286,27 @@ const I18N = { example_knowledge_title: 'Knowledge', example_knowledge_text: 'Show me the current knowledge base', example_skill_title: 'Skills', example_skill_text: 'Show current tools and skills', example_web_title: 'Commands', example_web_text: 'Show all commands', + slash_help: 'Show this help', + slash_status: 'Show running status', + slash_context: 'Show conversation context', + slash_context_clear: 'Clear conversation context', + slash_skill_list: 'List installed skills', + slash_skill_list_remote: 'Browse Skill Hub', + slash_skill_search: 'Search skills', + slash_skill_install: 'Install a skill (name or GitHub URL)', + slash_skill_uninstall: 'Uninstall a skill', + slash_skill_info: 'Show skill details', + slash_skill_enable: 'Enable a skill', + slash_skill_disable: 'Disable a skill', + 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_knowledge_on: 'Enable knowledge base', + slash_knowledge_off: 'Disable knowledge base', + slash_config: 'Show current config', + slash_cancel: 'Abort the running Agent task', + slash_logs: 'Show recent logs', + slash_version: 'Show version', input_placeholder: 'Type a message, or press / for commands', config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_model: 'Model Configuration', config_agent: 'Agent Configuration', @@ -361,7 +403,25 @@ const I18N = { } }; -let currentLang = localStorage.getItem('cow_lang') || 'zh'; +// Resolve language by priority: user choice (localStorage) -> backend-detected +// (cow_lang) -> browser language -> 'zh'. Shares __cowResolveLang__ defined in +// chat.html; falls back to a local resolver if loaded standalone. +let currentLang = (typeof window.__cowResolveLang__ === 'function') + ? window.__cowResolveLang__() + : (function () { + const norm = (raw) => { + if (!raw) return ''; + const v = String(raw).trim().toLowerCase(); + if (v === 'auto') return ''; + if (v.indexOf('zh') === 0) return 'zh'; + if (v.indexOf('en') === 0) return 'en'; + return ''; + }; + return norm(localStorage.getItem('cow_lang')) + || norm(window.__COW_DEFAULT_LANG__) + || norm(navigator.language) + || 'zh'; + })(); function t(key) { return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key; @@ -1298,28 +1358,30 @@ chatInput.addEventListener('compositionstart', () => { isComposing = true; }); chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); }); // ── Slash Command Menu ─────────────────────────────────────── +// desc holds an i18n key, resolved via t() at render time so the menu follows +// the current UI language. const SLASH_COMMANDS = [ - { cmd: '/help', desc: '显示命令帮助' }, - { cmd: '/status', desc: '查看运行状态' }, - { cmd: '/context', desc: '查看对话上下文' }, - { cmd: '/context clear', desc: '清除对话上下文' }, - { cmd: '/skill list', desc: '查看已安装技能' }, - { cmd: '/skill list --remote', desc: '浏览技能广场' }, - { cmd: '/skill search ', desc: '搜索技能' }, - { cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' }, - { cmd: '/skill uninstall ', desc: '卸载技能' }, - { cmd: '/skill info ', desc: '查看技能详情' }, - { cmd: '/skill enable ', desc: '启用技能' }, - { cmd: '/skill disable ', desc: '禁用技能' }, - { cmd: '/memory dream ', desc: '手动触发记忆蒸馏 (可指定天数, 默认3)' }, - { cmd: '/knowledge', desc: '查看知识库统计' }, - { cmd: '/knowledge list', desc: '查看知识库文件树' }, - { cmd: '/knowledge on', desc: '开启知识库' }, - { cmd: '/knowledge off', desc: '关闭知识库' }, - { cmd: '/config', desc: '查看当前配置' }, - { cmd: '/cancel', desc: '中止当前正在运行的 Agent 任务' }, - { cmd: '/logs', desc: '查看最近日志' }, - { cmd: '/version', desc: '查看版本' }, + { cmd: '/help', desc: 'slash_help' }, + { cmd: '/status', desc: 'slash_status' }, + { cmd: '/context', desc: 'slash_context' }, + { cmd: '/context clear', desc: 'slash_context_clear' }, + { cmd: '/skill list', desc: 'slash_skill_list' }, + { cmd: '/skill list --remote', desc: 'slash_skill_list_remote' }, + { cmd: '/skill search ', desc: 'slash_skill_search' }, + { cmd: '/skill install ', desc: 'slash_skill_install' }, + { cmd: '/skill uninstall ', desc: 'slash_skill_uninstall' }, + { cmd: '/skill info ', desc: 'slash_skill_info' }, + { cmd: '/skill enable ', desc: 'slash_skill_enable' }, + { cmd: '/skill disable ', desc: 'slash_skill_disable' }, + { cmd: '/memory dream ', desc: 'slash_memory_dream' }, + { cmd: '/knowledge', desc: 'slash_knowledge' }, + { cmd: '/knowledge list', desc: 'slash_knowledge_list' }, + { cmd: '/knowledge on', desc: 'slash_knowledge_on' }, + { cmd: '/knowledge off', desc: 'slash_knowledge_off' }, + { cmd: '/config', desc: 'slash_config' }, + { cmd: '/cancel', desc: 'slash_cancel' }, + { cmd: '/logs', desc: 'slash_logs' }, + { cmd: '/version', desc: 'slash_version' }, ]; const slashMenu = document.getElementById('slash-menu'); @@ -1373,7 +1435,7 @@ function renderSlashItems() { slashFiltered.map((c, i) => `
` + `${escapeHtml(c.cmd)}` + - `${escapeHtml(c.desc)}
` + `${escapeHtml(t(c.desc))}` ).join(''); const activeEl = slashMenu.querySelector('.slash-menu-item.active'); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 120d8efd..e2c0c5e4 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -21,6 +21,7 @@ from channel.chat_channel import ChatChannel, check_prefix from channel.chat_message import ChatMessage from collections import OrderedDict from common import const +from common import i18n from common.log import logger from common.singleton import singleton from config import conf @@ -98,7 +99,7 @@ def _require_auth(): def _cancel_reply_text(cancelled: int, lang: str) -> str: en = lang.startswith("en") if cancelled > 0: - return "🛑 Cancelled." if en else "🛑 已中止" + return "🛑 Cancelled" if en else "🛑 已中止" return "Nothing to cancel." if en else "当前没有可中止的任务。" @@ -477,7 +478,10 @@ class WebChannel(ChatChannel): ) q.put({ "type": "done", - "content": "(模型未返回任何内容,请重试或换一种方式描述你的需求)", + "content": i18n.t( + "(模型未返回任何内容,请重试或换一种方式描述你的需求)", + "(The model returned no content. Please retry or rephrase your request.)", + ), "request_id": request_id, "timestamp": time.time(), }) @@ -805,13 +809,13 @@ class WebChannel(ChatChannel): if not fpath: continue if ftype == "image": - file_refs.append(f"[图片: {fpath}]") + file_refs.append(f"[{i18n.t('图片', 'Image')}: {fpath}]") elif ftype == "video": - file_refs.append(f"[视频: {fpath}]") + file_refs.append(f"[{i18n.t('视频', 'Video')}: {fpath}]") elif ftype == "directory": - file_refs.append(f"[目录: {fpath}]") + file_refs.append(f"[{i18n.t('目录', 'Directory')}: {fpath}]") else: - file_refs.append(f"[文件: {fpath}]") + file_refs.append(f"[{i18n.t('文件', 'File')}: {fpath}]") if file_refs: prompt = prompt + "\n" + "\n".join(file_refs) logger.info(f"[WebChannel] Attached {len(file_refs)} file(s) to message") @@ -952,7 +956,7 @@ class WebChannel(ChatChannel): if request_id and request_id in self.sse_queues: self.sse_queues[request_id].put({ "type": "cancelled", - "content": "Cancelled" if lang.startswith("en") else "已中止", + "content": "🛑 Cancelled" if lang.startswith("en") else "🛑 已中止", "request_id": request_id, "timestamp": time.time(), }) @@ -1008,7 +1012,10 @@ class WebChannel(ChatChannel): """Serve the chat HTML page.""" file_path = os.path.join(os.path.dirname(__file__), 'chat.html') # 使用绝对路径 with open(file_path, 'r', encoding='utf-8') as f: - return f.read() + html = f.read() + # Inject the backend-resolved default language so the console can use + # it on first load (when the user has no saved cow_lang preference). + return html.replace("{{COW_DEFAULT_LANG}}", i18n.get_language()) def startup(self): configured_host = conf().get("web_host", "") @@ -1388,6 +1395,8 @@ class ChatHandler: cache_bust = str(int(time.time())) html = html.replace('assets/js/console.js', f'assets/js/console.js?v={cache_bust}') html = html.replace('assets/css/console.css', f'assets/css/console.css?v={cache_bust}') + # Inject the backend-resolved default language for first-load fallback. + html = html.replace("{{COW_DEFAULT_LANG}}", i18n.get_language()) return html diff --git a/cli/commands/install.py b/cli/commands/install.py index addec52c..ed22f296 100644 --- a/cli/commands/install.py +++ b/cli/commands/install.py @@ -14,7 +14,7 @@ CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright" # stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None StreamFn = Callable[[str, Optional[str]], None] -# on_phase(msg) — coarse-grained progress for chat channels (Chinese) +# on_phase(msg) — coarse-grained progress for chat channels (localized via i18n) PhaseFn = Callable[[str], None] @@ -112,16 +112,25 @@ def run_install_browser( stream: Optional callback ``(message, fg)`` for each line. ``fg`` is ``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output. on_phase: Optional callback for coarse progress (e.g. push to chat); - messages are short Chinese status lines. + messages are short status lines localized via i18n. Returns: 0 on success, 1 on fatal failure (pip or chromium install failed). """ + from cli.utils import get_cli_language + from common import i18n + + get_cli_language() # resolve cow_lang so i18n.t reflects config + _t = i18n.t + stream = stream or _default_stream python = sys.executable legacy_mode = False - _phase(on_phase, "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…") + _phase(on_phase, _t( + "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…", + "🔧 Installing browser tool dependencies (a few minutes, please wait)…", + )) glibc = _get_glibc_version() if glibc and glibc < GLIBC_THRESHOLD: @@ -136,27 +145,36 @@ def run_install_browser( stream("") _phase( on_phase, - f"ℹ️ 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}。", + _t( + f"ℹ️ 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}。", + f"ℹ️ Detected glibc {glibc_str} (older); installing compatible Playwright {PLAYWRIGHT_LEGACY_VERSION}.", + ), ) target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION - _phase(on_phase, "📦 [1/3] 正在安装 Playwright Python 包…") + _phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…")) stream("[1/3] Installing playwright Python package...", "yellow") ret = _pip_install(f"playwright=={target_version}", stream) if ret != 0: stream("Failed to install playwright package.", "red") - _phase(on_phase, "❌ [1/3] Playwright Python 包安装失败。") + _phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package.")) return 1 installed = _get_installed_version() if installed: stream(f" playwright {installed} installed.", "green") stream("") - _phase(on_phase, f"✅ [1/3] Playwright 包已安装({installed or target_version})。") + _phase(on_phase, _t( + f"✅ [1/3] Playwright 包已安装({installed or target_version})。", + f"✅ [1/3] Playwright package installed ({installed or target_version}).", + )) if sys.platform == "linux": - _phase(on_phase, "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo)…") + _phase(on_phase, _t( + "🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo)…", + "🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…", + )) stream("[2/3] Installing system dependencies (Linux)...", "yellow") ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"]) if ret != 0: @@ -183,14 +201,23 @@ def run_install_browser( stream(" CJK font (wqy-zenhei) installed.", "green") _phase( on_phase, - "✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。", + _t( + "✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。", + "✅ [2/3] Linux deps and font steps executed (on permission issues, check the server log or run the suggested commands manually).", + ), ) else: stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow") - _phase(on_phase, f"ℹ️ [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。") + _phase(on_phase, _t( + f"ℹ️ [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。", + f"ℹ️ [2/3] Skipping Linux-specific deps on this platform ({sys.platform}).", + )) stream("") - _phase(on_phase, "🌐 [3/3] 正在下载并安装 Chromium(体积较大,请耐心等待)…") + _phase(on_phase, _t( + "🌐 [3/3] 正在下载并安装 Chromium(体积较大,请耐心等待)…", + "🌐 [3/3] Downloading and installing Chromium (large download, please wait)…", + )) stream("[3/3] Installing Chromium browser...", "yellow") cmd = [python, "-m", "playwright", "install", "chromium"] @@ -209,27 +236,33 @@ def run_install_browser( if use_mirror: env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR stream(f" (using China mirror: {CHINA_MIRROR})", None) - _phase(on_phase, "📡 检测到国内 pip 源配置,Chromium 将优先走国内镜像下载。") + _phase(on_phase, _t( + "📡 检测到国内 pip 源配置,Chromium 将优先走国内镜像下载。", + "📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.", + )) ret = subprocess.call(cmd, env=env) if ret != 0 and use_mirror: stream(" Mirror download failed, retrying with official CDN...", "yellow") - _phase(on_phase, "⚠️ 镜像下载失败,正在改用官方源重试…") + _phase(on_phase, _t( + "⚠️ 镜像下载失败,正在改用官方源重试…", + "⚠️ Mirror download failed; retrying with the official CDN…", + )) env_no_mirror = os.environ.copy() env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None) ret = subprocess.call(cmd, env=env_no_mirror) if ret != 0: stream("Failed to install Chromium.", "red") - _phase(on_phase, "❌ [3/3] Chromium 安装失败。") + _phase(on_phase, _t("❌ [3/3] Chromium 安装失败。", "❌ [3/3] Failed to install Chromium.")) return 1 stream("") - _phase(on_phase, "✅ [3/3] Chromium 已安装。") + _phase(on_phase, _t("✅ [3/3] Chromium 已安装。", "✅ [3/3] Chromium installed.")) stream("Verifying browser installation...", None) - _phase(on_phase, "🔍 正在验证 Playwright 能否正常加载…") + _phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…")) ret = subprocess.call( [python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"], stderr=subprocess.DEVNULL, @@ -240,14 +273,20 @@ def run_install_browser( " Consider upgrading your OS or using Docker.", "yellow", ) - _phase(on_phase, "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。") + _phase(on_phase, _t( + "⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。", + "⚠️ Verification did not fully pass: the browser tool may still not work here; check the log or upgrade your system.", + )) else: stream(" Verification passed.", "green") - _phase(on_phase, "✅ 验证通过。") + _phase(on_phase, _t("✅ 验证通过。", "✅ Verification passed.")) stream("") stream("Browser tool ready! Restart CowAgent to enable it.", "green") - _phase(on_phase, "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。") + _phase(on_phase, _t( + "🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。", + "🎉 All steps finished. Restart CowAgent to use the browser tool.", + )) return 0 diff --git a/cli/commands/process.py b/cli/commands/process.py index 2176fbf7..6ccffdcb 100644 --- a/cli/commands/process.py +++ b/cli/commands/process.py @@ -275,7 +275,11 @@ def update(ctx): def status(): """Show CowAgent running status.""" from cli import __version__ - from cli.utils import load_config_json + from cli.utils import load_config_json, get_cli_language + from common import i18n + + get_cli_language() # resolve cow_lang so i18n.t reflects config + _t = i18n.t pid = _read_pid() if pid: @@ -283,17 +287,17 @@ def status(): else: click.echo(click.style("● CowAgent is not running", fg="red")) - click.echo(f" 版本: v{__version__}") + click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}")) cfg = load_config_json() if cfg: channel = cfg.get("channel_type", "unknown") if isinstance(channel, list): channel = ", ".join(channel) - click.echo(f" 通道: {channel}") - click.echo(f" 模型: {cfg.get('model', 'unknown')}") + click.echo(_t(f" 通道: {channel}", f" Channel: {channel}")) + click.echo(_t(f" 模型: {cfg.get('model', 'unknown')}", f" Model: {cfg.get('model', 'unknown')}")) mode = "Chat" if cfg.get("agent") is False else "Agent" - click.echo(f" 模式: {mode}") + click.echo(_t(f" 模式: {mode}", f" Mode: {mode}")) @click.command() diff --git a/cli/commands/skill.py b/cli/commands/skill.py index a591ed9c..fa5a3167 100644 --- a/cli/commands/skill.py +++ b/cli/commands/skill.py @@ -517,18 +517,24 @@ def _install_targz_bytes(content: bytes, name: str, skills_dir: str, result: Ins def _print_install_success(name: str, source: str): """Print a unified install success message with description and source.""" + from cli.utils import get_cli_language + from common import i18n + + get_cli_language() # resolve cow_lang so i18n.t reflects config + _t = i18n.t + skills_dir = get_skills_dir() config = load_skills_config() display = config.get(name, {}).get("display_name", "") desc = _read_skill_description(os.path.join(skills_dir, name)) click.echo(click.style(f"✓ {name}", fg="green")) if display and display != name: - click.echo(f" 名称: {display}") + click.echo(_t(f" 名称: {display}", f" Name: {display}")) if desc: if len(desc) > 60: desc = desc[:57] + "…" - click.echo(f" 描述: {desc}") - click.echo(f" 来源: {source}") + click.echo(_t(f" 描述: {desc}", f" Description: {desc}")) + click.echo(_t(f" 来源: {source}", f" Source: {source}")) def _validate_skill_name(name: str): diff --git a/cli/utils.py b/cli/utils.py index b40f8dd5..4dcb5079 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -40,6 +40,22 @@ def load_config_json() -> dict: return {} +def get_cli_language() -> str: + """Resolve the CLI UI language using the shared i18n detector. + + Reads the `cow_lang` field from config.json (defaults to "auto") and runs + the same detection used by the running app, so CLI output matches. + """ + ensure_sys_path() + try: + from common import i18n + + configured = load_config_json().get("cow_lang", "auto") + return i18n.resolve_language(configured) + except Exception: + return "en" + + def load_skills_config() -> dict: """Load skills_config.json from the custom skills directory.""" path = os.path.join(get_skills_dir(), "skills_config.json") diff --git a/common/i18n.py b/common/i18n.py new file mode 100644 index 00000000..8cce5860 --- /dev/null +++ b/common/i18n.py @@ -0,0 +1,177 @@ +# encoding:utf-8 + +"""Lightweight global language detection and resolution. + +This module is the single source of truth for the runtime UI language used +across the CLI, startup logs, error messages, agent prompts and channel +replies. It must NOT import project config (to avoid circular imports) and +must stay dependency-free so it can run at the earliest startup phase. + +Resolution priority (highest first): + 1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any + config key is overridable via its uppercase env var (e.g. COW_LANG=zh), + handled by config.load_config() before resolution. COW_LANG is a private + name to avoid clashing with the gettext-standard LANGUAGE variable. + 2. macOS `defaults read -g AppleLocale` (system-level preference; a Chinese + system locale is a strong signal that beats a shell-default LANG) + 3. Standard locale env vars: LC_ALL > LC_MESSAGES > LANG + 4. Python locale module + 5. Default -> English + +A value of "auto" (the default) triggers detection (steps 2-5). Explicitly +setting "zh" or "en" locks the language and skips detection. +""" + +import os +import subprocess +import sys + +# Supported language codes +ZH = "zh" +EN = "en" +SUPPORTED = (ZH, EN) +DEFAULT_LANG = EN + +# Resolved language cache; None until first resolution. +_resolved_lang = None + + +def _normalize(raw): + """Map an arbitrary locale-ish string to a supported code, or None. + + Only Chinese is detected explicitly; everything else (including unknown + or empty values) yields None so the caller can fall through to the next + detection source. + """ + if not raw: + return None + value = str(raw).strip().lower().replace("_", "-") + if value in ("auto", ""): + return None + # Chinese variants: zh, zh-cn, zh-hans, zh-hans-cn, zh-tw, zh-hk ... + if value.startswith("zh") or value.startswith("chinese"): + return ZH + if value.startswith("en") or value.startswith("english"): + return EN + return None + + +def _detect_from_env(): + """Detect language from standard locale environment variables. + + Note: on macOS, `LANG` is often a shell default (e.g. en_US.UTF-8 set by + .zshrc) that does not reflect the user's real preference, so AppleLocale + is checked first (see detect_language). On Linux these vars are the + primary signal. + + The cow_lang env override (COW_LANG=zh) is intentionally NOT read here: + it sets config["cow_lang"] and is handled via the explicit config path, + not auto-detection. + """ + for key in ("LC_ALL", "LC_MESSAGES", "LANG"): + lang = _normalize(os.environ.get(key)) + if lang: + return lang + return None + + +def _detect_from_macos(): + """macOS fallback: read the system-wide AppleLocale preference. + + On macOS the terminal often does NOT export LANG, yet the system locale + is still meaningful (e.g. a Chinese Mac reports zh_CN). This recovers + that signal so Chinese users are not misdetected as English. + """ + if sys.platform != "darwin": + return None + try: + out = subprocess.run( + ["defaults", "read", "-g", "AppleLocale"], + capture_output=True, + text=True, + timeout=2, + ) + if out.returncode == 0: + return _normalize(out.stdout) + except Exception: + pass + return None + + +def _detect_from_python_locale(): + """Last-resort detection via Python's locale module.""" + try: + import locale + + for value in locale.getlocale(): + lang = _normalize(value) + if lang: + return lang + except Exception: + pass + return None + + +def detect_language(): + """Run full auto-detection and return a supported language code. + + Order (auto-detection only; explicit config["cow_lang"] is resolved + before this is reached): + 1. macOS AppleLocale (system-level preference; a Chinese system locale + is a strong, low-false-positive signal that beats a shell-default + LANG like en_US.UTF-8) + 2. locale env vars LC_ALL / LC_MESSAGES / LANG (primary signal on Linux) + 3. Python locale module + 4. default English + """ + return ( + _detect_from_macos() + or _detect_from_env() + or _detect_from_python_locale() + or DEFAULT_LANG + ) + + +def resolve_language(configured=None): + """Resolve the effective language from a configured value. + + `configured` is the raw `cow_lang` value from config.json (may be None, + "auto", "zh" or "en"). An explicit "zh"/"en" locks the result; "auto" + or empty triggers detection. The result is cached globally. + """ + global _resolved_lang + explicit = _normalize(configured) + if explicit: + _resolved_lang = explicit + else: + _resolved_lang = detect_language() + return _resolved_lang + + +def set_language(lang): + """Force the resolved language (used by tests or per-request overrides).""" + global _resolved_lang + normalized = _normalize(lang) + _resolved_lang = normalized or DEFAULT_LANG + return _resolved_lang + + +def get_language(): + """Return the currently resolved language, detecting lazily if needed.""" + global _resolved_lang + if _resolved_lang is None: + _resolved_lang = detect_language() + return _resolved_lang + + +def is_zh(): + return get_language() == ZH + + +def t(zh_text, en_text): + """Pick a string by the current language. Tiny inline-translation helper. + + Intended for one-off strings where a full message catalog is overkill: + t("已中止", "Cancelled") + """ + return zh_text if get_language() == ZH else en_text diff --git a/config-template.json b/config-template.json index 4e4a7d36..dcd19774 100644 --- a/config-template.json +++ b/config-template.json @@ -1,4 +1,5 @@ { + "cow_lang": "auto", "channel_type": "weixin", "model": "deepseek-v4-flash", "deepseek_api_key": "", diff --git a/config.py b/config.py index 7ad6fa55..ba7936ca 100644 --- a/config.py +++ b/config.py @@ -7,11 +7,17 @@ import os import pickle from common.log import logger +from common import i18n # All available config keys are listed in this dict (use lowercase keys). # The values here are placeholders only; the program does NOT read them. # They merely document the expected format — put real values in config.json. available_setting = { + # global UI language for CLI, startup logs, error messages, agent prompts + # and channel replies. Options: "auto" (detect from system locale, default), + # "zh" (Chinese) or "en" (English). An explicit value locks the language. + # value: auto/en/zh + "cow_lang": "auto", # openai api config "open_ai_api_key": "", # openai api key # openai api base; when use_azure_chatgpt is true, set the matching api base @@ -390,12 +396,17 @@ def load_config(): logger.setLevel(logging.DEBUG) logger.debug("[INIT] set log level to DEBUG") + # Resolve the global UI language as early as possible so that every + # downstream layer (logs, CLI, agent prompts, channel replies) shares it. + resolved_lang = i18n.resolve_language(config.get("cow_lang", "auto")) + logger.info("[INIT] load config: {}".format(drag_sensitive(config))) # print system initialization info logger.info("[INIT] ========================================") logger.info("[INIT] System Initialization") logger.info("[INIT] ========================================") + logger.info("[INIT] Language: {}".format(resolved_lang)) logger.info("[INIT] Channel: {}".format(config.get("channel_type", "unknown"))) logger.info("[INIT] Model: {}".format(config.get("model", "unknown"))) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4d0ec94b..6e5dfde7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,6 +8,7 @@ services: ports: - "9899:9899" environment: + COW_LANG: 'auto' CHANNEL_TYPE: 'weixin' MODEL: 'deepseek-v4-flash' DEEPSEEK_API_KEY: '' diff --git a/models/chatgpt/chat_gpt_bot.py b/models/chatgpt/chat_gpt_bot.py index d5b7703d..999986bc 100644 --- a/models/chatgpt/chat_gpt_bot.py +++ b/models/chatgpt/chat_gpt_bot.py @@ -14,6 +14,7 @@ from models.openai.openai_compat import ( from models.openai.openai_http_client import OpenAIHTTPClient, OpenAIHTTPError import requests from common import const +from common.i18n import t as _t from models.bot import Bot from models.openai_compatible_bot import OpenAICompatibleBot from models.chatgpt.chat_gpt_session import ChatGPTSession @@ -94,13 +95,13 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"]) if query in clear_memory_commands: self.sessions.clear_session(session_id) - reply = Reply(ReplyType.INFO, "记忆已清除") + reply = Reply(ReplyType.INFO, _t("记忆已清除", "Memory cleared")) elif query == "#清除所有": self.sessions.clear_all_session() - reply = Reply(ReplyType.INFO, "所有人记忆已清除") + reply = Reply(ReplyType.INFO, _t("所有人记忆已清除", "All memories cleared")) elif query == "#更新配置": load_config() - reply = Reply(ReplyType.INFO, "配置已更新") + reply = Reply(ReplyType.INFO, _t("配置已更新", "Config updated")) if reply: return reply session = self.sessions.session_query(query, session_id) @@ -148,7 +149,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): reply = self.reply_image(context) return reply else: - reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type)) + reply = Reply(ReplyType.ERROR, _t("Bot不支持处理{}类型的消息", "Bot does not support message type {}").format(context.type)) return reply def reply_image(self, context): @@ -165,7 +166,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): # Check if file exists if not os.path.exists(image_path): logger.error(f"[CHATGPT] Image file not found: {image_path}") - return Reply(ReplyType.ERROR, "图片文件不存在") + return Reply(ReplyType.ERROR, _t("图片文件不存在", "Image file not found")) # Read and encode image with open(image_path, "rb") as f: @@ -232,7 +233,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): logger.error(f"[CHATGPT] Image processing error: {e}") import traceback logger.error(traceback.format_exc()) - return Reply(ReplyType.ERROR, f"图片识别失败: {str(e)}") + return Reply(ReplyType.ERROR, _t("图片识别失败: ", "Image recognition failed: ") + str(e)) def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_count=0) -> dict: """ @@ -277,25 +278,25 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot): def _handle_reply_error(self, e, session, api_key, args, retry_count): """Map exception to user-facing reply with retry/backoff (mirrors SDK behavior).""" need_retry = retry_count < 2 - result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"} + result = {"completion_tokens": 0, "content": _t("我现在有点累了,等会再来吧", "I'm a bit tired right now. Please try again later.")} if isinstance(e, RateLimitError): logger.warn("[CHATGPT] RateLimitError: {}".format(e)) - result["content"] = "提问太快啦,请休息一下再问我吧" + result["content"] = _t("提问太快啦,请休息一下再问我吧", "You're asking too fast. Please take a short break and try again.") if need_retry: time.sleep(20) elif isinstance(e, Timeout): logger.warn("[CHATGPT] Timeout: {}".format(e)) - result["content"] = "我没有收到你的消息" + result["content"] = _t("我没有收到你的消息", "I didn't receive your message") if need_retry: time.sleep(5) elif isinstance(e, APIConnectionError): logger.warn("[CHATGPT] APIConnectionError: {}".format(e)) - result["content"] = "我连接不到你的网络" + result["content"] = _t("我连接不到你的网络", "I can't reach your network") if need_retry: time.sleep(5) elif isinstance(e, APIError): logger.warn("[CHATGPT] Bad Gateway: {}".format(e)) - result["content"] = "请再问我一次" + result["content"] = _t("请再问我一次", "Please ask me again") if need_retry: time.sleep(10) else: @@ -358,7 +359,7 @@ class AzureChatGPTBot(ChatGPTBot): status = "" while (status != "succeeded"): if retry_count > 3: - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") response = requests.get(operation_location, headers=headers) status = response.json()['status'] retry_count += 1 @@ -366,7 +367,7 @@ class AzureChatGPTBot(ChatGPTBot): return True, image_url except Exception as e: logger.error("create image error: {}".format(e)) - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") elif text_to_image_model == "dall-e-3": api_version = conf().get("azure_api_version", "2024-02-15-preview") endpoint = conf().get("azure_openai_dalle_api_base","open_ai_api_base") @@ -389,7 +390,7 @@ class AzureChatGPTBot(ChatGPTBot): else: error_message = "响应中没有图像 URL" logger.error(error_message) - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") except requests.exceptions.RequestException as e: # 捕获所有请求相关的异常 @@ -405,9 +406,9 @@ class AzureChatGPTBot(ChatGPTBot): # 捕获所有其他异常 error_message = f"生成图像时发生错误: {e}" logger.error(error_message) - return False, "图片生成失败" + return False, _t("图片生成失败", "Image generation failed") else: - return False, "图片生成失败,未配置text_to_image参数" + return False, _t("图片生成失败,未配置text_to_image参数", "Image generation failed: text_to_image is not configured") class _AzureChatHTTPClient(OpenAIHTTPClient): diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index f3fee96a..fdd0081f 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -23,6 +23,7 @@ from plugins import Plugin, Event, EventContext, EventAction from bridge.context import ContextType from bridge.reply import Reply, ReplyType from common.log import logger +from common.i18n import t as _t from config import conf from cli import __version__ @@ -280,19 +281,18 @@ class CowCliPlugin(Plugin): @staticmethod def _typo_hint(token: str, suggestion) -> str: - hint = f"未知命令: /{token}" + hint = _t(f"未知命令: /{token}", f"Unknown command: /{token}") if suggestion: - hint += f"\n你是不是想输入 /{suggestion} ?" - hint += "\n发送 /help 查看全部命令。" + hint += _t(f"\n你是不是想输入 /{suggestion} ?", f"\nDid you mean /{suggestion} ?") + hint += _t("\n发送 /help 查看全部命令。", "\nSend /help to see all commands.") return hint @staticmethod def _ambiguous_hint(token: str, candidates) -> str: options = " ".join(f"/{c}" for c in candidates) - return ( - f"命令不明确: /{token}\n" - f"可能想输入: {options}\n" - "发送 /help 查看全部命令。" + return _t( + f"命令不明确: /{token}\n可能想输入: {options}\n发送 /help 查看全部命令。", + f"Ambiguous command: /{token}\nDid you mean: {options}\nSend /help to see all commands.", ) # ------------------------------------------------------------------ @@ -324,7 +324,10 @@ class CowCliPlugin(Plugin): def _dispatch(self, cmd: str, args: str, e_context: EventContext, session_id: str = "") -> str: if cmd in CLI_ONLY_COMMANDS: - return f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}" + return _t( + f"⚠️ `cow {cmd}` 只能在命令行终端中执行。\n请在终端运行: cow {cmd}", + f"⚠️ `cow {cmd}` can only run in a terminal.\nRun it in your shell: cow {cmd}", + ) handler_attr = "_cmd_" + cmd.replace("-", "_") handler = getattr(self, handler_attr, None) @@ -333,42 +336,71 @@ class CowCliPlugin(Plugin): return handler(args, e_context, session_id=session_id) except Exception as e: logger.error(f"[CowCli] command '{cmd}' failed: {e}") - return f"命令执行失败: {e}" + return _t(f"命令执行失败: {e}", f"Command failed: {e}") - return f"未知命令: {cmd}" + return _t(f"未知命令: {cmd}", f"Unknown command: {cmd}") # ------------------------------------------------------------------ # help / version # ------------------------------------------------------------------ def _cmd_help(self, args: str, e_context, **_) -> str: - lines = [ - "📋 CowAgent 命令列表", - "", - " /help 显示此帮助", - " /version 查看版本", - " /status 查看运行状态", - " /cancel 中止当前正在运行的 Agent 任务", - " /logs [N] 查看最近N条日志 (默认20)", - " /context 查看当前对话上下文信息", - " /context clear 清除当前对话上下文", - " /skill list 查看已安装的技能", - " /skill list --remote 浏览技能广场", - " /skill search <关键词> 搜索技能", - " /skill install <名称> 安装技能", - " /skill info <名称> 查看技能详情", - " /config 查看当前配置", - " /config 查看某项配置", - " /config 修改配置", - " /memory status 查看记忆索引状态", - " /memory rebuild-index 清空并重建向量索引 (切换 embedding 模型后必须执行)", - " /memory dream [N] 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", - " /knowledge 查看知识库统计", - " /knowledge list 查看知识库文件树", - " /knowledge on|off 开启/关闭知识库", - "", - "💡 也可以用 cow 代替 /", - ] + if _t("zh", "en") == "en": + lines = [ + "📋 CowAgent Commands", + "", + "/help: Show this help", + "/version: Show version", + "/status: Show running status", + "/cancel: Abort the running Agent task", + "/logs [N]: Show the last N log lines (default 20)", + "/context: Show current conversation context", + "/context clear: Clear current conversation context", + "/skill list: List installed skills", + "/skill list --remote: Browse Skill Hub", + "/skill search : Search skills", + "/skill install : Install a skill", + "/skill info : Show skill details", + "/config: Show current config", + "/config : Show a config item", + "/config : Update a config item", + "/memory status: Show memory index status", + "/memory rebuild-index: Rebuild the vector index (required after switching embedding model)", + "/memory dream [N]: Trigger memory distillation (last N days, default 3, max 30)", + "/knowledge: Show knowledge base stats", + "/knowledge list: Show knowledge base file tree", + "/knowledge on|off: Enable/disable knowledge base", + "", + "💡 You can also use cow instead of /", + ] + else: + lines = [ + "📋 CowAgent 命令列表", + "", + "/help: 显示此帮助", + "/version: 查看版本", + "/status: 查看运行状态", + "/cancel: 中止当前正在运行的 Agent 任务", + "/logs [N]: 查看最近N条日志 (默认20)", + "/context: 查看当前对话上下文信息", + "/context clear: 清除当前对话上下文", + "/skill list: 查看已安装的技能", + "/skill list --remote: 浏览技能广场", + "/skill search <关键词>: 搜索技能", + "/skill install <名称>: 安装技能", + "/skill info <名称>: 查看技能详情", + "/config: 查看当前配置", + "/config : 查看某项配置", + "/config : 修改配置", + "/memory status: 查看记忆索引状态", + "/memory rebuild-index: 清空并重建向量索引 (切换 embedding 模型后必须执行)", + "/memory dream [N]: 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", + "/knowledge: 查看知识库统计", + "/knowledge list: 查看知识库文件树", + "/knowledge on|off: 开启/关闭知识库", + "", + "💡 也可以用 cow 代替 /", + ] return "\n".join(lines) def _cmd_version(self, args: str, e_context, **_) -> str: @@ -405,9 +437,9 @@ class CowCliPlugin(Plugin): cancelled = registry.cancel_session(target_session) if cancelled <= 0: - return "当前没有可中止的任务。" + return _t("当前没有可中止的任务。", "Nothing to cancel.") - return "🛑 已中止" + return _t("🛑 已中止", "🛑 Cancelled") # ------------------------------------------------------------------ # status @@ -417,21 +449,21 @@ class CowCliPlugin(Plugin): from config import conf cfg = conf() - lines = ["📊 CowAgent 运行状态", ""] + lines = [_t("📊 CowAgent 运行状态", "📊 CowAgent Status"), ""] - lines.append(f" 版本: v{__version__}") - lines.append(f" 进程: PID {os.getpid()}") + lines.append(_t(f" 版本: v{__version__}", f" Version: v{__version__}")) + lines.append(_t(f" 进程: PID {os.getpid()}", f" Process: PID {os.getpid()}")) channel = cfg.get("channel_type", "unknown") if isinstance(channel, list): channel = ", ".join(channel) - lines.append(f" 通道: {channel}") + lines.append(_t(f" 通道: {channel}", f" Channel: {channel}")) model_name = cfg.get("model", "unknown") - lines.append(f" 模型: {model_name}") + lines.append(_t(f" 模型: {model_name}", f" Model: {model_name}")) mode = "Chat" if cfg.get("agent") is False else "Agent" - lines.append(f" 模式: {mode}") + lines.append(_t(f" 模式: {mode}", f" Mode: {mode}")) session_id = self._get_session_id(e_context, fallback=session_id) agent = self._get_agent(session_id) @@ -439,7 +471,7 @@ class CowCliPlugin(Plugin): lines.append("") with agent.messages_lock: msg_count = len(agent.messages) - lines.append(f" 会话消息数: {msg_count}") + lines.append(_t(f" 会话消息数: {msg_count}", f" Session messages: {msg_count}")) if agent.skill_manager: total = len(agent.skill_manager.skills) @@ -447,10 +479,10 @@ class CowCliPlugin(Plugin): 1 for v in agent.skill_manager.skills_config.values() if v.get("enabled", True) ) - lines.append(f" 已加载技能: {enabled}/{total}") + lines.append(_t(f" 已加载技能: {enabled}/{total}", f" Loaded skills: {enabled}/{total}")) else: lines.append("") - lines.append(f" Agent: 未初始化 (首次对话后自动创建)") + lines.append(_t(" Agent: 未初始化 (首次对话后自动创建)", " Agent: not initialized (created on first chat)")) return "\n".join(lines) @@ -465,7 +497,7 @@ class CowCliPlugin(Plugin): log_file = self._find_log_file() if not log_file: - return "未找到日志文件" + return _t("未找到日志文件", "No log file found") try: with open(log_file, "r", encoding="utf-8", errors="replace") as f: @@ -473,10 +505,10 @@ class CowCliPlugin(Plugin): tail = all_lines[-num_lines:] content = "".join(tail).strip() if not content: - return "日志为空" - return f"📄 最近 {len(tail)} 条日志:\n\n{content}" + return _t("日志为空", "Log is empty") + return _t(f"📄 最近 {len(tail)} 条日志:\n\n{content}", f"📄 Last {len(tail)} log lines:\n\n{content}") except Exception as e: - return f"读取日志失败: {e}" + return _t(f"读取日志失败: {e}", f"Failed to read log: {e}") def _find_log_file(self) -> str: project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -507,13 +539,13 @@ class CowCliPlugin(Plugin): def _context_info(self, agent, session_id: str) -> str: if not agent: - return "⚠️ Agent 未初始化,暂无上下文信息" + return _t("⚠️ Agent 未初始化,暂无上下文信息", "⚠️ Agent not initialized, no context yet") with agent.messages_lock: messages = agent.messages.copy() if not messages: - return "当前对话上下文为空" + return _t("当前对话上下文为空", "Current conversation context is empty") user_msgs = sum(1 for m in messages if m.get("role") == "user") assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant") @@ -521,29 +553,43 @@ class CowCliPlugin(Plugin): total_chars = sum(len(str(m.get("content", ""))) for m in messages) - lines = [ - "💬 当前对话上下文", - "", - f" 会话: {session_id or 'default'}", - f" 总消息数: {len(messages)}", - f" 用户消息: {user_msgs}", - f" 助手回复: {assistant_msgs}", - f" 工具调用: {tool_msgs}", - f" 内容总长度: ~{total_chars} 字符", - "", - " 发送 /context clear 可清除对话上下文", - ] + if _t("zh", "en") == "en": + lines = [ + "💬 Current Conversation Context", + "", + f" Session: {session_id or 'default'}", + f" Total messages: {len(messages)}", + f" User messages: {user_msgs}", + f" Assistant replies: {assistant_msgs}", + f" Tool calls: {tool_msgs}", + f" Total content length: ~{total_chars} chars", + "", + " Send /context clear to clear the conversation context", + ] + else: + lines = [ + "💬 当前对话上下文", + "", + f" 会话: {session_id or 'default'}", + f" 总消息数: {len(messages)}", + f" 用户消息: {user_msgs}", + f" 助手回复: {assistant_msgs}", + f" 工具调用: {tool_msgs}", + f" 内容总长度: ~{total_chars} 字符", + "", + " 发送 /context clear 可清除对话上下文", + ] return "\n".join(lines) def _context_clear(self, agent, session_id: str) -> str: if not agent: - return "⚠️ Agent 未初始化" + return _t("⚠️ Agent 未初始化", "⚠️ Agent not initialized") with agent.messages_lock: count = len(agent.messages) agent.messages.clear() - return f"✅ 已清除当前对话上下文 ({count} 条消息)" + return _t(f"✅ 已清除当前对话上下文 ({count} 条消息)", f"✅ Conversation context cleared ({count} messages)") # ------------------------------------------------------------------ # config @@ -578,21 +624,24 @@ class CowCliPlugin(Plugin): def _config_show_all(self) -> str: from config import conf cfg = conf() - lines = ["⚙️ 当前配置", ""] + lines = [_t("⚙️ 当前配置", "⚙️ Current Config"), ""] for key in sorted(self._CONFIG_READABLE): val = cfg.get(key, "") lines.append(f" {key}: {val}") lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append("💡 /config 查看配置") - lines.append("💡 /config 修改配置") + lines.append(_t("💡 /config : 查看配置", "💡 /config : Show a config item")) + lines.append(_t("💡 /config : 修改配置", "💡 /config : Update a config item")) return "\n".join(lines) def _config_get(self, key: str) -> str: from config import conf if key not in self._CONFIG_READABLE: available = ", ".join(sorted(self._CONFIG_READABLE)) - return f"不支持查看 '{key}'\n\n可查看的配置项: {available}" + return _t( + f"不支持查看 '{key}'\n\n可查看的配置项: {available}", + f"Cannot show '{key}'\n\nReadable config items: {available}", + ) val = conf().get(key, "") return f"⚙️ {key}: {val}" @@ -602,9 +651,12 @@ class CowCliPlugin(Plugin): if key not in self._CONFIG_WRITABLE: if key in self._CONFIG_READABLE: - return f"⚠️ '{key}' 为只读配置,不支持修改" + return _t(f"⚠️ '{key}' 为只读配置,不支持修改", f"⚠️ '{key}' is read-only and cannot be modified") available = ", ".join(sorted(self._CONFIG_WRITABLE)) - return f"不支持修改 '{key}'\n\n可修改的配置项: {available}" + return _t( + f"不支持修改 '{key}'\n\n可修改的配置项: {available}", + f"Cannot modify '{key}'\n\nWritable config items: {available}", + ) old_val = conf().get(key, "") @@ -637,7 +689,7 @@ class CowCliPlugin(Plugin): with open(config_path, "w", encoding="utf-8") as f: _json.dump(file_config, f, indent=4, ensure_ascii=False) except Exception as e: - return f"写入 config.json 失败: {e}" + return _t(f"写入 config.json 失败: {e}", f"Failed to write config.json: {e}") # Sync updated values to environment variables so that load_config() # won't overwrite the new value with a stale env var (common in Docker). @@ -660,7 +712,7 @@ class CowCliPlugin(Plugin): except Exception as e: logger.warning(f"[CowCli] config reload warning: {e}") - result = f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}" + result = _t(f"✅ 配置已更新\n\n {key}: {old_val} → {new_val}", f"✅ Config updated\n\n {key}: {old_val} → {new_val}") if "bot_type" in updates and updates["bot_type"] != old_bot_type: result += f"\n bot_type: {old_bot_type} → {updates['bot_type']}" return result @@ -725,10 +777,13 @@ class CowCliPlugin(Plugin): from cli.commands.install import run_install_browser if args.strip(): - return ( + return _t( "用法: /install-browser\n\n" "无需参数,等同于终端执行 `cow install-browser`。\n" - "安装过程可能持续数分钟;进度会以多条消息推送,pip 详细输出见服务日志。" + "安装过程可能持续数分钟;进度会以多条消息推送,pip 详细输出见服务日志。", + "Usage: /install-browser\n\n" + "No arguments needed; equivalent to running `cow install-browser` in a terminal.\n" + "Installation may take a few minutes; progress is pushed as multiple messages, and detailed pip output goes to the service log.", ) # Suppress detailed stream in chat; phases go through channel.send @@ -740,11 +795,16 @@ class CowCliPlugin(Plugin): on_phase=lambda m: self._send_install_progress(e_context, m), ) if code != 0: - return ( + return _t( "❌ 安装未成功结束,请查看上方分段提示或服务器日志;" - "也可在终端执行 `cow install-browser`。" + "也可在终端执行 `cow install-browser`。", + "❌ Installation did not finish successfully. Check the messages above or the server log; " + "you can also run `cow install-browser` in a terminal.", ) - return "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。" + return _t( + "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。", + "✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).", + ) # ------------------------------------------------------------------ # skill @@ -770,16 +830,25 @@ class CowCliPlugin(Plugin): elif sub == "disable": return self._skill_set_enabled(sub_args, False) else: - return ( + return _t( "用法: /skill <子命令>\n\n" "子命令:\n" - " list [--remote] 查看技能列表\n" - " search <关键词> 搜索技能\n" - " install <名称> 安装技能\n" - " uninstall <名称> 卸载技能\n" - " info <名称> 查看技能详情\n" - " enable <名称> 启用技能\n" - " disable <名称> 禁用技能" + "list [--remote]: 查看技能列表\n" + "search <关键词>: 搜索技能\n" + "install <名称>: 安装技能\n" + "uninstall <名称>: 卸载技能\n" + "info <名称>: 查看技能详情\n" + "enable <名称>: 启用技能\n" + "disable <名称>: 禁用技能", + "Usage: /skill \n\n" + "Subcommands:\n" + "list [--remote]: List skills\n" + "search : Search skills\n" + "install : Install a skill\n" + "uninstall : Uninstall a skill\n" + "info : Show skill details\n" + "enable : Enable a skill\n" + "disable : Disable a skill", ) def _refresh_skill_manager(self): @@ -813,13 +882,16 @@ class CowCliPlugin(Plugin): if os.path.exists(os.path.join(skill_path, "SKILL.md")): entries.append({"name": name, "source": source, "enabled": True}) if not entries: - return "暂无已安装的技能\n\n💡 /skill list --remote 浏览技能广场" + return _t( + "暂无已安装的技能\n\n💡 /skill list --remote: 浏览技能广场", + "No skills installed yet\n\n💡 /skill list --remote: Browse Skill Hub", + ) config = {e["name"]: e for e in entries} sorted_entries = sorted(config.values(), key=lambda e: e.get("name", "")) enabled_count = sum(1 for e in sorted_entries if e.get("enabled", True)) - lines = [f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", ""] + lines = [_t(f"📦 已安装的技能 ({enabled_count}/{len(sorted_entries)})", f"📦 Installed Skills ({enabled_count}/{len(sorted_entries)})"), ""] for entry in sorted_entries: name = entry.get("name", "") enabled = entry.get("enabled", True) @@ -835,13 +907,13 @@ class CowCliPlugin(Plugin): if desc: line += f"\n {desc}" if source: - line += f"\n 来源: {source}" + line += _t(f"\n 来源: {source}", f"\n Source: {source}") lines.append(line) lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append("💡 /skill list --remote 浏览技能广场") - lines.append("💡 /skill info <名称> 查看详情") + lines.append(_t("💡 /skill list --remote: 浏览技能广场", "💡 /skill list --remote: Browse Skill Hub")) + lines.append(_t("💡 /skill info <名称>: 查看详情", "💡 /skill info : Show details")) return "\n".join(lines) def _skill_list(self, args: str) -> str: @@ -871,43 +943,43 @@ class CowCliPlugin(Plugin): skills = data.get("skills", []) total = data.get("total", len(skills)) except Exception as e: - return f"获取技能广场失败: {e}" + return _t(f"获取技能广场失败: {e}", f"Failed to fetch Skill Hub: {e}") if not skills and page == 1: - return "技能广场暂无可用技能" + return _t("技能广场暂无可用技能", "No skills available on Skill Hub") total_pages = max(1, (total + page_size - 1) // page_size) page = min(page, total_pages) installed = set(load_skills_config().keys()) - lines = ["🌐 技能广场", ""] + lines = [_t("🌐 技能广场", "🌐 Skill Hub"), ""] for s in skills: name = s.get("name", "") display = s.get("display_name", "") or name desc = s.get("description", "") if len(desc) > 50: desc = desc[:47] + "…" - badge = " [已安装]" if name in installed else "" + badge = _t(" [已安装]", " [installed]") if name in installed else "" lines.append(f"📌 {display}{badge}") - lines.append(f" 名称: {name}") + lines.append(_t(f" 名称: {name}", f" Name: {name}")) if desc: lines.append(f" {desc}") lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append(f"📄 第 {page}/{total_pages} 页") + lines.append(_t(f"📄 第 {page}/{total_pages} 页", f"📄 Page {page}/{total_pages}")) if page < total_pages: - lines.append(f"💡 /skill list --remote --page {page + 1} 下一页") + lines.append(_t(f"💡 /skill list --remote --page {page + 1}: 下一页", f"💡 /skill list --remote --page {page + 1}: Next page")) if page > 1: - lines.append(f"💡 /skill list --remote --page {page - 1} 上一页") - lines.append("💡 /skill install <名称> 安装技能") - lines.append("💡 /skill search <关键词> 搜索技能") - lines.append("🌐 https://skills.cowagent.ai 在线浏览全部技能") + lines.append(_t(f"💡 /skill list --remote --page {page - 1}: 上一页", f"💡 /skill list --remote --page {page - 1}: Previous page")) + lines.append(_t("💡 /skill install <名称>: 安装技能", "💡 /skill install : Install a skill")) + lines.append(_t("💡 /skill search <关键词>: 搜索技能", "💡 /skill search : Search skills")) + lines.append(_t("🌐 https://skills.cowagent.ai 在线浏览全部技能", "🌐 https://skills.cowagent.ai Browse all skills online")) return "\n".join(lines) def _skill_search(self, query: str) -> str: if not query: - return "请指定搜索关键词: /skill search <关键词>" + return _t("请指定搜索关键词: /skill search <关键词>", "Please specify a search keyword: /skill search ") import requests from cli.utils import SKILL_HUB_API, load_skills_config @@ -916,35 +988,35 @@ class CowCliPlugin(Plugin): resp.raise_for_status() skills = resp.json().get("skills", []) except Exception as e: - return f"搜索失败: {e}" + return _t(f"搜索失败: {e}", f"Search failed: {e}") if not skills: - return f"未找到与「{query}」相关的技能" + return _t(f"未找到与「{query}」相关的技能", f"No skills found for \"{query}\"") installed = set(load_skills_config().keys()) - lines = [f"🔍 搜索「{query}」({len(skills)} 个结果)", ""] + lines = [_t(f"🔍 搜索「{query}」({len(skills)} 个结果)", f"🔍 Search \"{query}\" ({len(skills)} results)"), ""] for s in skills: name = s.get("name", "") display = s.get("display_name", "") or name desc = s.get("description", "") if len(desc) > 50: desc = desc[:47] + "…" - badge = " [已安装]" if name in installed else "" + badge = _t(" [已安装]", " [installed]") if name in installed else "" lines.append(f"📌 {display}{badge}") - lines.append(f" 名称: {name}") + lines.append(_t(f" 名称: {name}", f" Name: {name}")) if desc: lines.append(f" {desc}") lines.append("") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") - lines.append("💡 /skill install <名称> 安装技能") + lines.append(_t("💡 /skill install <名称>: 安装技能", "💡 /skill install : Install a skill")) return "\n".join(lines) _INSTALL_TIMEOUT = 60 def _skill_install(self, name: str, e_context: EventContext) -> str: if not name: - return "请指定要安装的技能: /skill install <名称>" + return _t("请指定要安装的技能: /skill install <名称>", "Please specify a skill to install: /skill install ") from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from cli.commands.skill import install_skill @@ -955,16 +1027,16 @@ class CowCliPlugin(Plugin): result = future.result(timeout=self._INSTALL_TIMEOUT) if result.error: - return f"安装失败: {result.error}" + return _t(f"安装失败: {result.error}", f"Install failed: {result.error}") if not result.installed: - return "\n".join(result.messages) if result.messages else "未找到可安装的技能" + return "\n".join(result.messages) if result.messages else _t("未找到可安装的技能", "No installable skill found") return self._format_install_result(result) except FuturesTimeout: - return "安装超时,请稍后重试或检查网络连接" + return _t("安装超时,请稍后重试或检查网络连接", "Install timed out. Please retry later or check your network connection.") except Exception as e: - return f"安装失败: {e}" + return _t(f"安装失败: {e}", f"Install failed: {e}") @staticmethod def _format_install_result(result) -> str: @@ -978,20 +1050,20 @@ class CowCliPlugin(Plugin): for skill_name in result.installed: desc = _read_skill_description(os.path.join(skills_dir, skill_name)) display = config.get(skill_name, {}).get("display_name", "") - lines.append(f"✅ 技能安装成功:{skill_name}") + lines.append(_t(f"✅ 技能安装成功:{skill_name}", f"✅ Skill installed: {skill_name}")) if display and display != skill_name: - lines.append(f" 名称:{display}") + lines.append(_t(f" 名称:{display}", f" Name: {display}")) if desc: - lines.append(f" 描述:{desc}") + lines.append(_t(f" 描述:{desc}", f" Description: {desc}")) if len(result.installed) > 1: - lines.append(f"\n共安装 {len(result.installed)} 个技能") + lines.append(_t(f"\n共安装 {len(result.installed)} 个技能", f"\nInstalled {len(result.installed)} skills")) return "\n".join(lines) def _skill_uninstall(self, name: str) -> str: if not name: - return "请指定要卸载的技能: /skill uninstall <名称>" + return _t("请指定要卸载的技能: /skill uninstall <名称>", "Please specify a skill to uninstall: /skill uninstall ") import shutil import json @@ -1004,7 +1076,7 @@ class CowCliPlugin(Plugin): skill_dir = self._resolve_skill_dir(name, skills_dir) if not skill_dir: - return f"技能 '{name}' 未安装" + return _t(f"技能 '{name}' 未安装", f"Skill '{name}' is not installed") shutil.rmtree(skill_dir) @@ -1019,7 +1091,7 @@ class CowCliPlugin(Plugin): except Exception: pass - return f"✅ 技能 '{name}' 已卸载" + return _t(f"✅ 技能 '{name}' 已卸载", f"✅ Skill '{name}' uninstalled") @staticmethod def _resolve_skill_dir(name: str, skills_dir: str): @@ -1055,7 +1127,7 @@ class CowCliPlugin(Plugin): def _skill_info(self, name: str) -> str: if not name: - return "请指定技能名称: /skill info <名称>" + return _t("请指定技能名称: /skill info <名称>", "Please specify a skill name: /skill info ") from cli.utils import get_skills_dir, get_builtin_skills_dir @@ -1078,18 +1150,18 @@ class CowCliPlugin(Plugin): source = "custom" if not skill_dir: - return f"技能 '{name}' 未找到" + return _t(f"技能 '{name}' 未找到", f"Skill '{name}' not found") skill_md = os.path.join(skill_dir, "SKILL.md") if not os.path.exists(skill_md): - return f"技能 '{name}' 没有 SKILL.md 文件" + return _t(f"技能 '{name}' 没有 SKILL.md 文件", f"Skill '{name}' has no SKILL.md file") with open(skill_md, "r", encoding="utf-8") as f: content = f.read() meta, body = self._strip_frontmatter(content) - header_lines = [f"📖 技能: {name} [{source}]", ""] + header_lines = [_t(f"📖 技能: {name} [{source}]", f"📖 Skill: {name} [{source}]"), ""] desc = meta.get("description", "") if desc: header_lines.append(f" {desc}") @@ -1104,8 +1176,10 @@ class CowCliPlugin(Plugin): def _skill_set_enabled(self, name: str, enabled: bool) -> str: if not name: - action = "启用" if enabled else "禁用" - return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>" + return _t( + f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>", + f"Please specify a skill name: /skill {'enable' if enabled else 'disable'} ", + ) import json from cli.utils import get_skills_dir @@ -1114,24 +1188,25 @@ class CowCliPlugin(Plugin): config_path = os.path.join(skills_dir, "skills_config.json") if not os.path.exists(config_path): - return "技能配置文件不存在" + return _t("技能配置文件不存在", "Skills config file not found") try: with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) except Exception as e: - return f"读取配置失败: {e}" + return _t(f"读取配置失败: {e}", f"Failed to read config: {e}") if name not in config: - return f"技能 '{name}' 未在配置中找到" + return _t(f"技能 '{name}' 未在配置中找到", f"Skill '{name}' not found in config") config[name]["enabled"] = enabled with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=4, ensure_ascii=False) - action = "启用" if enabled else "禁用" icon = "✅" if enabled else "⬚" - return f"{icon} 技能 '{name}' 已{action}" + if enabled: + return _t(f"{icon} 技能 '{name}' 已启用", f"{icon} Skill '{name}' enabled") + return _t(f"{icon} 技能 '{name}' 已禁用", f"{icon} Skill '{name}' disabled") # ------------------------------------------------------------------ # memory @@ -1157,13 +1232,19 @@ class CowCliPlugin(Plugin): @staticmethod def _memory_help() -> str: - return ( + return _t( "🧠 记忆管理\n\n" "用法: /memory <子命令>\n\n" "子命令:\n" - " status 查看索引状态 (provider / model / dim / chunks)\n" - " rebuild-index 清空并重建向量索引 (切换 embedding 模型后必须执行)\n" - " dream [N] 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)" + "status: 查看索引状态 (provider / model / dim / chunks)\n" + "rebuild-index: 清空并重建向量索引 (切换 embedding 模型后必须执行)\n" + "dream [N]: 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", + "🧠 Memory Management\n\n" + "Usage: /memory \n\n" + "Subcommands:\n" + "status: Show index status (provider / model / dim / chunks)\n" + "rebuild-index: Rebuild the vector index (required after switching embedding model)\n" + "dream [N]: Trigger memory distillation (last N days, default 3, max 30)", ) def _memory_dream(self, days: int, e_context, session_id: str) -> str: @@ -1178,10 +1259,10 @@ class CowCliPlugin(Plugin): try: flush_mgr = self._create_standalone_flush_manager() except Exception as e: - return f"⚠️ 无法初始化记忆蒸馏: {e}" + return _t(f"⚠️ 无法初始化记忆蒸馏: {e}", f"⚠️ Failed to initialize memory distillation: {e}") if not flush_mgr.llm_model: - return "⚠️ 未配置 LLM 模型,无法执行记忆蒸馏" + return _t("⚠️ 未配置 LLM 模型,无法执行记忆蒸馏", "⚠️ No LLM model configured, cannot run memory distillation") # SaaS (e_context is None): run synchronously, return full result if e_context is None: @@ -1196,13 +1277,16 @@ class CowCliPlugin(Plugin): if result: self._notify(e_context, self._build_dream_result(flush_mgr, is_web)) else: - self._notify(e_context, "💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理") + self._notify(e_context, _t("💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理", "💤 Memory distillation skipped — no new memories to process")) except Exception as e: logger.warning(f"[CowCli] /memory dream failed: {e}") - self._notify(e_context, f"❌ 记忆蒸馏失败: {e}") + self._notify(e_context, _t(f"❌ 记忆蒸馏失败: {e}", f"❌ Memory distillation failed: {e}")) threading.Thread(target=_run, daemon=True).start() - return f"🌙 记忆蒸馏已启动 (整理近 {days} 天的记忆)\n\n整理在后台执行,完成后会通知你。" + return _t( + f"🌙 记忆蒸馏已启动 (整理近 {days} 天的记忆)\n\n整理在后台执行,完成后会通知你。", + f"🌙 Memory distillation started (processing the last {days} days)\n\nRunning in the background; you'll be notified when it's done.", + ) def _memory_dream_sync(self, flush_mgr, days: int) -> str: """Run deep dream synchronously and return the full result.""" @@ -1210,10 +1294,10 @@ class CowCliPlugin(Plugin): result = flush_mgr.deep_dream(lookback_days=days, force=True) if result: return self._build_dream_result(flush_mgr, is_web=True) - return "💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理" + return _t("💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理", "💤 Memory distillation skipped — no new memories to process") except Exception as e: logger.warning(f"[CowCli] /memory dream sync failed: {e}") - return f"❌ 记忆蒸馏失败: {e}" + return _t(f"❌ 记忆蒸馏失败: {e}", f"❌ Memory distillation failed: {e}") @staticmethod def _resolve_active_embedding(): @@ -1255,9 +1339,9 @@ class CowCliPlugin(Plugin): agent = self._get_agent("") memory_manager = agent.memory_manager if agent else None - lines = ["🧠 记忆索引状态", ""] + lines = [_t("🧠 记忆索引状态", "🧠 Memory Index Status"), ""] if not memory_manager: - lines.append(" ⚠️ Agent 尚未初始化,先发一条普通消息再试") + lines.append(_t(" ⚠️ Agent 尚未初始化,先发一条普通消息再试", " ⚠️ Agent not initialized yet, send a normal message first")) return "\n".join(lines) stats = memory_manager.storage.get_stats() @@ -1278,7 +1362,7 @@ class CowCliPlugin(Plugin): lines.append(f" Model : {cfg_model}") lines.append(f" Dim : {cfg_dim if cfg_dim else '?'}") else: - lines.append(" Provider : (未初始化, keyword-only)") + lines.append(_t(" Provider : (未初始化, keyword-only)", " Provider : (not initialized, keyword-only)")) # Health hints — only shown when the user has explicitly opted into # vector search via `embedding_provider`. Legacy users (no explicit @@ -1289,17 +1373,17 @@ class CowCliPlugin(Plugin): if explicitly_opted_in and provider_obj is not None: if chunks > 0 and embedded < chunks: missing = chunks - embedded - warnings.append( - f" ⚠️ {missing}/{chunks} 个 chunk 没有向量;" - f"运行 /memory rebuild-index 后所有记忆才会被向量化检索" - ) + warnings.append(_t( + f" ⚠️ {missing}/{chunks} 个 chunk 没有向量;运行 /memory rebuild-index 后所有记忆才会被向量化检索", + f" ⚠️ {missing}/{chunks} chunks have no vectors; run /memory rebuild-index to enable vector search for all memories", + )) index_dim = detect_index_dim(memory_manager.storage) if index_dim is not None and cfg_dim and index_dim != cfg_dim: - warnings.append( - f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;" - f"运行 /memory rebuild-index 重建后向量检索才会生效" - ) + warnings.append(_t( + f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;运行 /memory rebuild-index 重建后向量检索才会生效", + f" ⚠️ Existing vectors are {index_dim}-dim, mismatching the current {cfg_dim}-dim config; run /memory rebuild-index to make vector search work", + )) if warnings: lines.append("") @@ -1312,9 +1396,11 @@ class CowCliPlugin(Plugin): session_id = self._get_session_id(e_context, fallback=session_id) agent = self._get_agent(session_id) if not agent or not agent.memory_manager: - return ( + return _t( "⚠️ Agent 尚未初始化,无法重建索引。\n" - "请先发送一条普通消息触发 Agent 启动后再试。" + "请先发送一条普通消息触发 Agent 启动后再试。", + "⚠️ Agent not initialized, cannot rebuild the index.\n" + "Send a normal message first to start the Agent, then try again.", ) memory_manager = agent.memory_manager @@ -1328,12 +1414,14 @@ class CowCliPlugin(Plugin): ._init_embedding_provider(memory_manager.config, session_id=session_id) except Exception as e: logger.exception("[CowCli] /memory rebuild-index: build provider failed") - return f"⚠️ 无法根据当前配置构造 embedding provider: {e}" + return _t(f"⚠️ 无法根据当前配置构造 embedding provider: {e}", f"⚠️ Failed to build embedding provider from current config: {e}") if fresh_provider is None: - return ( + return _t( "⚠️ 当前没有可用的 embedding provider。\n" - "请检查 config.json 中的 embedding 相关配置 (provider / api key)。" + "请检查 config.json 中的 embedding 相关配置 (provider / api key)。", + "⚠️ No embedding provider available.\n" + "Check the embedding settings in config.json (provider / api key).", ) memory_manager.embedding_provider = fresh_provider @@ -1353,23 +1441,29 @@ class CowCliPlugin(Plugin): if result.ok: self._notify( e_context, - ( + _t( f"✅ 索引重建完成\n" f" cleared : {result.removed}\n" f" chunks : {result.chunks}\n" - f" files : {result.files}" + f" files : {result.files}", + f"✅ Index rebuild complete\n" + f" cleared : {result.removed}\n" + f" chunks : {result.chunks}\n" + f" files : {result.files}", ), ) else: - self._notify(e_context, f"❌ 索引重建失败: {result.error}") + self._notify(e_context, _t(f"❌ 索引重建失败: {result.error}", f"❌ Index rebuild failed: {result.error}")) except Exception as e: logger.exception("[CowCli] /memory rebuild-index failed") - self._notify(e_context, f"❌ 索引重建失败: {e}") + self._notify(e_context, _t(f"❌ 索引重建失败: {e}", f"❌ Index rebuild failed: {e}")) threading.Thread(target=_run, daemon=True).start() - return ( + return _t( f"🔧 索引重建已启动 (model={model_label}, dim={dim_label})\n\n" - f"将重新向量化所有记忆和知识文件,完成后会通知你。" + f"将重新向量化所有记忆和知识文件,完成后会通知你。", + f"🔧 Index rebuild started (model={model_label}, dim={dim_label})\n\n" + f"Re-vectorizing all memory and knowledge files; you'll be notified when done.", ) @staticmethod @@ -1380,15 +1474,19 @@ class CowCliPlugin(Plugin): result = rebuild_in_process(memory_manager) except Exception as e: logger.exception("[CowCli] /memory rebuild-index sync failed") - return f"❌ 索引重建失败: {e}" + return _t(f"❌ 索引重建失败: {e}", f"❌ Index rebuild failed: {e}") if not result.ok: - return f"❌ 索引重建失败: {result.error}" - return ( + return _t(f"❌ 索引重建失败: {result.error}", f"❌ Index rebuild failed: {result.error}") + return _t( f"✅ 索引重建完成 (model={model_label}, dim={dim_label})\n" f" cleared : {result.removed}\n" f" chunks : {result.chunks}\n" - f" files : {result.files}" + f" files : {result.files}", + f"✅ Index rebuild complete (model={model_label}, dim={dim_label})\n" + f" cleared : {result.removed}\n" + f" chunks : {result.chunks}\n" + f" files : {result.files}", ) @staticmethod @@ -1418,7 +1516,7 @@ class CowCliPlugin(Plugin): def _build_dream_result(flush_mgr, is_web: bool) -> str: """Build dream completion message with diary content.""" from datetime import datetime - lines = ["✅ 记忆蒸馏完成"] + lines = [_t("✅ 记忆蒸馏完成", "✅ Memory distillation complete")] # Read today's dream diary today = datetime.now().strftime("%Y-%m-%d") @@ -1433,9 +1531,9 @@ class CowCliPlugin(Plugin): lines.append(f"\n{diary}") if is_web: - lines.append("\n[MEMORY.md](/memory/MEMORY.md) | [梦境日记](/memory/dreams)") + lines.append(_t("\n[MEMORY.md](/memory/MEMORY.md) | [梦境日记](/memory/dreams)", "\n[MEMORY.md](/memory/MEMORY.md) | [Dream Diary](/memory/dreams)")) else: - lines.append("\nMEMORY.md 已更新") + lines.append(_t("\nMEMORY.md 已更新", "\nMEMORY.md updated")) return "\n".join(lines) @@ -1485,11 +1583,17 @@ class CowCliPlugin(Plugin): with open(config_path, "w", encoding="utf-8") as f: _json.dump(file_config, f, indent=4, ensure_ascii=False) except Exception as e: - return f"⚠️ 内存中已切换,但写入 config.json 失败: {e}" + return _t(f"⚠️ 内存中已切换,但写入 config.json 失败: {e}", f"⚠️ Switched in memory, but failed to write config.json: {e}") - status = "开启 ✅" if enabled else "关闭 ❌" - note = "知识库将在下次对话中生效" if enabled else "知识库系统已停用,不再注入提示词和索引知识文件" - return f"📚 知识库已{status}\n\n{note}" + if enabled: + return _t( + "📚 知识库已开启 ✅\n\n知识库将在下次对话中生效", + "📚 Knowledge base enabled ✅\n\nIt will take effect in the next conversation", + ) + return _t( + "📚 知识库已关闭 ❌\n\n知识库系统已停用,不再注入提示词和索引知识文件", + "📚 Knowledge base disabled ❌\n\nThe knowledge system is off; no prompt injection or file indexing", + ) def _knowledge_stats(self) -> str: from config import conf @@ -1499,7 +1603,7 @@ class CowCliPlugin(Plugin): "knowledge" ) if not os.path.isdir(knowledge_dir): - return "📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on" + return _t("📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on", "📚 Knowledge base directory not found\n\n💡 Enable it: /knowledge on") enabled = conf().get("knowledge", True) total_files = 0 @@ -1516,13 +1620,13 @@ class CowCliPlugin(Plugin): total_bytes += os.path.getsize(os.path.join(root, f)) cat_count[category] = cat_count.get(category, 0) + 1 - status = "✅ 已开启" if enabled else "❌ 已关闭" + status = _t("✅ 已开启", "✅ Enabled") if enabled else _t("❌ 已关闭", "❌ Disabled") lines = [ - "📚 知识库统计", + _t("📚 知识库统计", "📚 Knowledge Base Stats"), "", - f"状态: {status}", - f"页面: {total_files} 篇", - f"大小: {total_bytes / 1024:.1f} KB", + _t(f"状态: {status}", f"Status: {status}"), + _t(f"页面: {total_files} 篇", f"Pages: {total_files}"), + _t(f"大小: {total_bytes / 1024:.1f} KB", f"Size: {total_bytes / 1024:.1f} KB"), "", ] if cat_count: @@ -1530,12 +1634,12 @@ class CowCliPlugin(Plugin): lines.append(f"- {cat}/ ({cat_count[cat]} pages)") lines.append("") - lines.append(f"路径: {knowledge_dir}") + lines.append(_t(f"路径: {knowledge_dir}", f"Path: {knowledge_dir}")) lines.extend([ "", "━━━━━━━━━━━━━━━━━━━━━━━━━━", - "💡 /knowledge list 查看文件树", - "💡 /knowledge on|off 开关知识库", + _t("💡 /knowledge list: 查看文件树", "💡 /knowledge list: Show file tree"), + _t("💡 /knowledge on|off: 开关知识库", "💡 /knowledge on|off: Toggle knowledge base"), ]) return "\n".join(lines) @@ -1547,7 +1651,7 @@ class CowCliPlugin(Plugin): "knowledge" ) if not os.path.isdir(knowledge_dir): - return "📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on" + return _t("📚 知识库目录不存在\n\n💡 开启知识库: /knowledge on", "📚 Knowledge base directory not found\n\n💡 Enable it: /knowledge on") tree = ["knowledge/"] @@ -1577,7 +1681,7 @@ class CowCliPlugin(Plugin): tree.append(f"{child_prefix}└── ... +{len(md_files) - max_show} more") if not subdirs: - tree.append("(空)") + tree.append(_t("(空)", "(empty)")) return "```\n" + "\n".join(tree) + "\n```" @@ -1602,4 +1706,4 @@ class CowCliPlugin(Plugin): return None def get_help_text(self, **kwargs): - return "在对话中使用 /help 或 cow help 查看可用命令" + return _t("在对话中使用 /help 或 cow help 查看可用命令", "Use /help or cow help in chat to see available commands")