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(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