feat(i18n): add global language resolution and localize user-facing text

This commit is contained in:
zhayujie
2026-05-31 16:49:35 +08:00
parent 2ec6ea8045
commit fcf4eb78dc
15 changed files with 748 additions and 289 deletions

View File

@@ -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.protocol.message_utils import sanitize_claude_messages, compress_turn_to_text_only
from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.base_tool import BaseTool, ToolResult
from common.log import logger 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). # Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content).
try: try:
@@ -317,7 +318,10 @@ class AgentStreamExecutor:
# Hard stop at 8 failures - abort with critical message # Hard stop at 8 failures - abort with critical message
if same_tool_failures >= 8: 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 # Warning at 6 failures
if same_tool_failures >= 6: if same_tool_failures >= 6:
@@ -436,14 +440,16 @@ class AgentStreamExecutor:
elif not assistant_msg: elif not assistant_msg:
# Still empty (no text and no tool_calls): use fallback # Still empty (no text and no tool_calls): use fallback
logger.warning(f"[Agent] Still empty after explicit request") 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") logger.info(f"Generated fallback response for empty LLM output")
else: else:
# 第一轮就空回复,直接 fallback # First-turn empty reply, fall back directly
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") logger.info(f"Generated fallback response for empty LLM output")
else: else:
@@ -514,7 +520,7 @@ class AgentStreamExecutor:
# Check for critical error - abort entire conversation # Check for critical error - abort entire conversation
if result.get("status") == "critical_error": if result.get("status") == "critical_error":
logger.error(f"💥 检测到严重错误,终止对话") logger.error(f"💥 检测到严重错误,终止对话")
final_response = result.get('result', '任务执行失败') final_response = result.get('result') or _t("任务执行失败", "Task execution failed")
return final_response return final_response
# Log tool result in compact format # 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 ''}") logger.info(f"💭 Summary: {summary_response[:150]}{'...' if len(summary_response) > 150 else ''}")
else: else:
# Fallback if model still doesn't respond # Fallback if model still doesn't respond
final_response = ( final_response = _t(
f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" 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: except Exception as e:
logger.warning(f"Failed to get summary from LLM: {e}") logger.warning(f"Failed to get summary from LLM: {e}")
final_response = ( final_response = _t(
f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。" 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: finally:
# Remove the injected user prompt from history to avoid polluting # Remove the injected user prompt from history to avoid polluting
@@ -953,13 +959,15 @@ class AgentStreamExecutor:
self.messages.clear() self.messages.clear()
self._clear_session_db() self._clear_session_db()
if is_context_overflow: 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: 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) # Check if error is rate limit (429)
is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower

View File

@@ -10,6 +10,7 @@ from bridge.reply import *
from channel.channel import Channel from channel.channel import Channel
from common.dequeue import Dequeue from common.dequeue import Dequeue
from common import memory from common import memory
from common.i18n import t as _t
from plugins import * from plugins import *
try: try:
@@ -265,7 +266,7 @@ class ChatChannel(Channel):
if reply.type in self.NOT_SUPPORT_REPLYTYPE: if reply.type in self.NOT_SUPPORT_REPLYTYPE:
logger.error("[chat_channel]reply type not support: " + str(reply.type)) logger.error("[chat_channel]reply type not support: " + str(reply.type))
reply.type = ReplyType.ERROR reply.type = ReplyType.ERROR
reply.content = "不支持发送的消息类型: " + str(reply.type) reply.content = _t("不支持发送的消息类型: ", "Unsupported message type: ") + str(reply.type)
if reply.type == ReplyType.TEXT: if reply.type == ReplyType.TEXT:
reply_text = reply.content reply_text = reply.content
@@ -476,9 +477,9 @@ class ChatChannel(Channel):
cancelled = get_cancel_registry().cancel_session(session_id) cancelled = get_cancel_registry().cancel_session(session_id)
text = ( text = (
"🛑 已中止" _t("🛑 已中止", "🛑 Cancelled")
if cancelled > 0 if cancelled > 0
else "当前没有可中止的任务。" else _t("当前没有可中止的任务。", "Nothing to cancel.")
) )
logger.info( logger.info(
f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}" f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}"

View File

@@ -47,11 +47,30 @@
This runs synchronously in <head> so the correct class is on <html> This runs synchronously in <head> so the correct class is on <html>
before any CSS or body rendering occurs. --> before any CSS or body rendering occurs. -->
<script> <script>
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'en',
// or '' when unrecognized so callers can fall through to the next source.
window.__cowNormalizeLang__ = function(raw) {
if (!raw) return '';
var v = String(raw).trim().toLowerCase();
if (v === 'auto') return '';
if (v.indexOf('zh') === 0) return 'zh';
if (v.indexOf('en') === 0) return 'en';
return '';
};
// Resolve the console language by priority:
// user choice (localStorage) -> backend-detected -> browser -> 'zh'.
window.__cowResolveLang__ = function() {
return window.__cowNormalizeLang__(localStorage.getItem('cow_lang'))
|| window.__cowNormalizeLang__(window.__COW_DEFAULT_LANG__)
|| window.__cowNormalizeLang__(navigator.language || (navigator.languages && navigator.languages[0]))
|| 'zh';
};
(function() { (function() {
// Backend-resolved default language (from cow_lang config / auto-detect).
window.__COW_DEFAULT_LANG__ = '{{COW_DEFAULT_LANG}}';
var theme = localStorage.getItem('cow_theme') || 'dark'; var theme = localStorage.getItem('cow_theme') || 'dark';
if (theme === 'dark') document.documentElement.classList.add('dark'); if (theme === 'dark') document.documentElement.classList.add('dark');
var lang = localStorage.getItem('cow_lang') || 'zh'; document.documentElement.setAttribute('lang', window.__cowResolveLang__());
document.documentElement.setAttribute('lang', lang);
})(); })();
</script> </script>
</head> </head>

View File

@@ -91,6 +91,27 @@ const I18N = {
example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况', example_knowledge_title: '知识库', example_knowledge_text: '查看知识库当前文档情况',
example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能', example_skill_title: '技能系统', example_skill_text: '查看所有支持的工具和技能',
example_web_title: '指令中心', example_web_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: '输入消息,或输入 / 使用指令', input_placeholder: '输入消息,或输入 / 使用指令',
config_title: '配置管理', config_desc: '管理模型和 Agent 配置', config_title: '配置管理', config_desc: '管理模型和 Agent 配置',
config_model: '模型配置', config_agent: '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_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_skill_title: 'Skills', example_skill_text: 'Show current tools and skills',
example_web_title: 'Commands', example_web_text: 'Show all commands', 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', input_placeholder: 'Type a message, or press / for commands',
config_title: 'Configuration', config_desc: 'Manage model and agent settings', config_title: 'Configuration', config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration', config_agent: 'Agent Configuration', 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) { function t(key) {
return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || 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); }); chatInput.addEventListener('compositionend', () => { setTimeout(() => { isComposing = false; }, 100); });
// ── Slash Command Menu ─────────────────────────────────────── // ── 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 = [ const SLASH_COMMANDS = [
{ cmd: '/help', desc: '显示命令帮助' }, { cmd: '/help', desc: 'slash_help' },
{ cmd: '/status', desc: '查看运行状态' }, { cmd: '/status', desc: 'slash_status' },
{ cmd: '/context', desc: '查看对话上下文' }, { cmd: '/context', desc: 'slash_context' },
{ cmd: '/context clear', desc: '清除对话上下文' }, { cmd: '/context clear', desc: 'slash_context_clear' },
{ cmd: '/skill list', desc: '查看已安装技能' }, { cmd: '/skill list', desc: 'slash_skill_list' },
{ cmd: '/skill list --remote', desc: '浏览技能广场' }, { cmd: '/skill list --remote', desc: 'slash_skill_list_remote' },
{ cmd: '/skill search ', desc: '搜索技能' }, { cmd: '/skill search ', desc: 'slash_skill_search' },
{ cmd: '/skill install ', desc: '安装技能 (名称或 GitHub URL)' }, { cmd: '/skill install ', desc: 'slash_skill_install' },
{ cmd: '/skill uninstall ', desc: '卸载技能' }, { cmd: '/skill uninstall ', desc: 'slash_skill_uninstall' },
{ cmd: '/skill info ', desc: '查看技能详情' }, { cmd: '/skill info ', desc: 'slash_skill_info' },
{ cmd: '/skill enable ', desc: '启用技能' }, { cmd: '/skill enable ', desc: 'slash_skill_enable' },
{ cmd: '/skill disable ', desc: '禁用技能' }, { cmd: '/skill disable ', desc: 'slash_skill_disable' },
{ cmd: '/memory dream ', desc: '手动触发记忆蒸馏 (可指定天数, 默认3)' }, { cmd: '/memory dream ', desc: 'slash_memory_dream' },
{ cmd: '/knowledge', desc: '查看知识库统计' }, { cmd: '/knowledge', desc: 'slash_knowledge' },
{ cmd: '/knowledge list', desc: '查看知识库文件树' }, { cmd: '/knowledge list', desc: 'slash_knowledge_list' },
{ cmd: '/knowledge on', desc: '开启知识库' }, { cmd: '/knowledge on', desc: 'slash_knowledge_on' },
{ cmd: '/knowledge off', desc: '关闭知识库' }, { cmd: '/knowledge off', desc: 'slash_knowledge_off' },
{ cmd: '/config', desc: '查看当前配置' }, { cmd: '/config', desc: 'slash_config' },
{ cmd: '/cancel', desc: '中止当前正在运行的 Agent 任务' }, { cmd: '/cancel', desc: 'slash_cancel' },
{ cmd: '/logs', desc: '查看最近日志' }, { cmd: '/logs', desc: 'slash_logs' },
{ cmd: '/version', desc: '查看版本' }, { cmd: '/version', desc: 'slash_version' },
]; ];
const slashMenu = document.getElementById('slash-menu'); const slashMenu = document.getElementById('slash-menu');
@@ -1373,7 +1435,7 @@ function renderSlashItems() {
slashFiltered.map((c, i) => slashFiltered.map((c, i) =>
`<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` + `<div class="slash-menu-item${i === slashActiveIdx ? ' active' : ''}" data-idx="${i}">` +
`<span class="cmd">${escapeHtml(c.cmd)}</span>` + `<span class="cmd">${escapeHtml(c.cmd)}</span>` +
`<span class="desc">${escapeHtml(c.desc)}</span></div>` `<span class="desc">${escapeHtml(t(c.desc))}</span></div>`
).join(''); ).join('');
const activeEl = slashMenu.querySelector('.slash-menu-item.active'); const activeEl = slashMenu.querySelector('.slash-menu-item.active');

View File

@@ -21,6 +21,7 @@ from channel.chat_channel import ChatChannel, check_prefix
from channel.chat_message import ChatMessage from channel.chat_message import ChatMessage
from collections import OrderedDict from collections import OrderedDict
from common import const from common import const
from common import i18n
from common.log import logger from common.log import logger
from common.singleton import singleton from common.singleton import singleton
from config import conf from config import conf
@@ -98,7 +99,7 @@ def _require_auth():
def _cancel_reply_text(cancelled: int, lang: str) -> str: def _cancel_reply_text(cancelled: int, lang: str) -> str:
en = lang.startswith("en") en = lang.startswith("en")
if cancelled > 0: if cancelled > 0:
return "🛑 Cancelled." if en else "🛑 已中止" return "🛑 Cancelled" if en else "🛑 已中止"
return "Nothing to cancel." if en else "当前没有可中止的任务。" return "Nothing to cancel." if en else "当前没有可中止的任务。"
@@ -477,7 +478,10 @@ class WebChannel(ChatChannel):
) )
q.put({ q.put({
"type": "done", "type": "done",
"content": "(模型未返回任何内容,请重试或换一种方式描述你的需求)", "content": i18n.t(
"(模型未返回任何内容,请重试或换一种方式描述你的需求)",
"(The model returned no content. Please retry or rephrase your request.)",
),
"request_id": request_id, "request_id": request_id,
"timestamp": time.time(), "timestamp": time.time(),
}) })
@@ -805,13 +809,13 @@ class WebChannel(ChatChannel):
if not fpath: if not fpath:
continue continue
if ftype == "image": if ftype == "image":
file_refs.append(f"[图片: {fpath}]") file_refs.append(f"[{i18n.t('图片', 'Image')}: {fpath}]")
elif ftype == "video": elif ftype == "video":
file_refs.append(f"[视频: {fpath}]") file_refs.append(f"[{i18n.t('视频', 'Video')}: {fpath}]")
elif ftype == "directory": elif ftype == "directory":
file_refs.append(f"[目录: {fpath}]") file_refs.append(f"[{i18n.t('目录', 'Directory')}: {fpath}]")
else: else:
file_refs.append(f"[文件: {fpath}]") file_refs.append(f"[{i18n.t('文件', 'File')}: {fpath}]")
if file_refs: if file_refs:
prompt = prompt + "\n" + "\n".join(file_refs) prompt = prompt + "\n" + "\n".join(file_refs)
logger.info(f"[WebChannel] Attached {len(file_refs)} file(s) to message") 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: if request_id and request_id in self.sse_queues:
self.sse_queues[request_id].put({ self.sse_queues[request_id].put({
"type": "cancelled", "type": "cancelled",
"content": "Cancelled" if lang.startswith("en") else "已中止", "content": "🛑 Cancelled" if lang.startswith("en") else "🛑 已中止",
"request_id": request_id, "request_id": request_id,
"timestamp": time.time(), "timestamp": time.time(),
}) })
@@ -1008,7 +1012,10 @@ class WebChannel(ChatChannel):
"""Serve the chat HTML page.""" """Serve the chat HTML page."""
file_path = os.path.join(os.path.dirname(__file__), 'chat.html') # 使用绝对路径 file_path = os.path.join(os.path.dirname(__file__), 'chat.html') # 使用绝对路径
with open(file_path, 'r', encoding='utf-8') as f: 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): def startup(self):
configured_host = conf().get("web_host", "") configured_host = conf().get("web_host", "")
@@ -1388,6 +1395,8 @@ class ChatHandler:
cache_bust = str(int(time.time())) 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/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}') 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 return html

View File

@@ -14,7 +14,7 @@ CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
# stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None # stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None
StreamFn = Callable[[str, Optional[str]], 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] PhaseFn = Callable[[str], None]
@@ -112,16 +112,25 @@ def run_install_browser(
stream: Optional callback ``(message, fg)`` for each line. ``fg`` is stream: Optional callback ``(message, fg)`` for each line. ``fg`` is
``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output. ``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output.
on_phase: Optional callback for coarse progress (e.g. push to chat); 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: Returns:
0 on success, 1 on fatal failure (pip or chromium install failed). 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 stream = stream or _default_stream
python = sys.executable python = sys.executable
legacy_mode = False legacy_mode = False
_phase(on_phase, "🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…") _phase(on_phase, _t(
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
"🔧 Installing browser tool dependencies (a few minutes, please wait)…",
))
glibc = _get_glibc_version() glibc = _get_glibc_version()
if glibc and glibc < GLIBC_THRESHOLD: if glibc and glibc < GLIBC_THRESHOLD:
@@ -136,27 +145,36 @@ def run_install_browser(
stream("") stream("")
_phase( _phase(
on_phase, on_phase,
_t(
f" 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}", 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 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") stream("[1/3] Installing playwright Python package...", "yellow")
ret = _pip_install(f"playwright=={target_version}", stream) ret = _pip_install(f"playwright=={target_version}", stream)
if ret != 0: if ret != 0:
stream("Failed to install playwright package.", "red") 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 return 1
installed = _get_installed_version() installed = _get_installed_version()
if installed: if installed:
stream(f" playwright {installed} installed.", "green") stream(f" playwright {installed} installed.", "green")
stream("") 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": 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") stream("[2/3] Installing system dependencies (Linux)...", "yellow")
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"]) ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
if ret != 0: if ret != 0:
@@ -183,14 +201,23 @@ def run_install_browser(
stream(" CJK font (wqy-zenhei) installed.", "green") stream(" CJK font (wqy-zenhei) installed.", "green")
_phase( _phase(
on_phase, on_phase,
_t(
"✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。", "✅ [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: else:
stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow") 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("") 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") stream("[3/3] Installing Chromium browser...", "yellow")
cmd = [python, "-m", "playwright", "install", "chromium"] cmd = [python, "-m", "playwright", "install", "chromium"]
@@ -209,27 +236,33 @@ def run_install_browser(
if use_mirror: if use_mirror:
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
stream(f" (using China mirror: {CHINA_MIRROR})", None) 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) ret = subprocess.call(cmd, env=env)
if ret != 0 and use_mirror: if ret != 0 and use_mirror:
stream(" Mirror download failed, retrying with official CDN...", "yellow") 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 = os.environ.copy()
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None) env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
ret = subprocess.call(cmd, env=env_no_mirror) ret = subprocess.call(cmd, env=env_no_mirror)
if ret != 0: if ret != 0:
stream("Failed to install Chromium.", "red") 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 return 1
stream("") stream("")
_phase(on_phase, "✅ [3/3] Chromium 已安装。") _phase(on_phase, _t("✅ [3/3] Chromium 已安装。", "✅ [3/3] Chromium installed."))
stream("Verifying browser installation...", None) stream("Verifying browser installation...", None)
_phase(on_phase, "🔍 正在验证 Playwright 能否正常加载…") _phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
ret = subprocess.call( ret = subprocess.call(
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"], [python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -240,14 +273,20 @@ def run_install_browser(
" Consider upgrading your OS or using Docker.", " Consider upgrading your OS or using Docker.",
"yellow", "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: else:
stream(" Verification passed.", "green") stream(" Verification passed.", "green")
_phase(on_phase, "✅ 验证通过。") _phase(on_phase, _t("✅ 验证通过。", "✅ Verification passed."))
stream("") stream("")
stream("Browser tool ready! Restart CowAgent to enable it.", "green") 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 return 0

View File

@@ -275,7 +275,11 @@ def update(ctx):
def status(): def status():
"""Show CowAgent running status.""" """Show CowAgent running status."""
from cli import __version__ 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() pid = _read_pid()
if pid: if pid:
@@ -283,17 +287,17 @@ def status():
else: else:
click.echo(click.style("● CowAgent is not running", fg="red")) 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() cfg = load_config_json()
if cfg: if cfg:
channel = cfg.get("channel_type", "unknown") channel = cfg.get("channel_type", "unknown")
if isinstance(channel, list): if isinstance(channel, list):
channel = ", ".join(channel) channel = ", ".join(channel)
click.echo(f" 通道: {channel}") click.echo(_t(f" 通道: {channel}", f" Channel: {channel}"))
click.echo(f" 模型: {cfg.get('model', 'unknown')}") click.echo(_t(f" 模型: {cfg.get('model', 'unknown')}", f" Model: {cfg.get('model', 'unknown')}"))
mode = "Chat" if cfg.get("agent") is False else "Agent" mode = "Chat" if cfg.get("agent") is False else "Agent"
click.echo(f" 模式: {mode}") click.echo(_t(f" 模式: {mode}", f" Mode: {mode}"))
@click.command() @click.command()

View File

@@ -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): def _print_install_success(name: str, source: str):
"""Print a unified install success message with description and source.""" """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() skills_dir = get_skills_dir()
config = load_skills_config() config = load_skills_config()
display = config.get(name, {}).get("display_name", "") display = config.get(name, {}).get("display_name", "")
desc = _read_skill_description(os.path.join(skills_dir, name)) desc = _read_skill_description(os.path.join(skills_dir, name))
click.echo(click.style(f"{name}", fg="green")) click.echo(click.style(f"{name}", fg="green"))
if display and display != name: if display and display != name:
click.echo(f" 名称: {display}") click.echo(_t(f" 名称: {display}", f" Name: {display}"))
if desc: if desc:
if len(desc) > 60: if len(desc) > 60:
desc = desc[:57] + "" desc = desc[:57] + ""
click.echo(f" 描述: {desc}") click.echo(_t(f" 描述: {desc}", f" Description: {desc}"))
click.echo(f" 来源: {source}") click.echo(_t(f" 来源: {source}", f" Source: {source}"))
def _validate_skill_name(name: str): def _validate_skill_name(name: str):

View File

@@ -40,6 +40,22 @@ def load_config_json() -> dict:
return {} 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: def load_skills_config() -> dict:
"""Load skills_config.json from the custom skills directory.""" """Load skills_config.json from the custom skills directory."""
path = os.path.join(get_skills_dir(), "skills_config.json") path = os.path.join(get_skills_dir(), "skills_config.json")

177
common/i18n.py Normal file
View File

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

View File

@@ -1,4 +1,5 @@
{ {
"cow_lang": "auto",
"channel_type": "weixin", "channel_type": "weixin",
"model": "deepseek-v4-flash", "model": "deepseek-v4-flash",
"deepseek_api_key": "", "deepseek_api_key": "",

View File

@@ -7,11 +7,17 @@ import os
import pickle import pickle
from common.log import logger from common.log import logger
from common import i18n
# All available config keys are listed in this dict (use lowercase keys). # All available config keys are listed in this dict (use lowercase keys).
# The values here are placeholders only; the program does NOT read them. # The values here are placeholders only; the program does NOT read them.
# They merely document the expected format — put real values in config.json. # They merely document the expected format — put real values in config.json.
available_setting = { 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 # openai api config
"open_ai_api_key": "", # openai api key "open_ai_api_key": "", # openai api key
# openai api base; when use_azure_chatgpt is true, set the matching api base # 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.setLevel(logging.DEBUG)
logger.debug("[INIT] set log level to 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))) logger.info("[INIT] load config: {}".format(drag_sensitive(config)))
# print system initialization info # print system initialization info
logger.info("[INIT] ========================================") logger.info("[INIT] ========================================")
logger.info("[INIT] System Initialization") logger.info("[INIT] System Initialization")
logger.info("[INIT] ========================================") logger.info("[INIT] ========================================")
logger.info("[INIT] Language: {}".format(resolved_lang))
logger.info("[INIT] Channel: {}".format(config.get("channel_type", "unknown"))) logger.info("[INIT] Channel: {}".format(config.get("channel_type", "unknown")))
logger.info("[INIT] Model: {}".format(config.get("model", "unknown"))) logger.info("[INIT] Model: {}".format(config.get("model", "unknown")))

View File

@@ -8,6 +8,7 @@ services:
ports: ports:
- "9899:9899" - "9899:9899"
environment: environment:
COW_LANG: 'auto'
CHANNEL_TYPE: 'weixin' CHANNEL_TYPE: 'weixin'
MODEL: 'deepseek-v4-flash' MODEL: 'deepseek-v4-flash'
DEEPSEEK_API_KEY: '' DEEPSEEK_API_KEY: ''

View File

@@ -14,6 +14,7 @@ from models.openai.openai_compat import (
from models.openai.openai_http_client import OpenAIHTTPClient, OpenAIHTTPError from models.openai.openai_http_client import OpenAIHTTPClient, OpenAIHTTPError
import requests import requests
from common import const from common import const
from common.i18n import t as _t
from models.bot import Bot from models.bot import Bot
from models.openai_compatible_bot import OpenAICompatibleBot from models.openai_compatible_bot import OpenAICompatibleBot
from models.chatgpt.chat_gpt_session import ChatGPTSession 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", ["#清除记忆"]) clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
if query in clear_memory_commands: if query in clear_memory_commands:
self.sessions.clear_session(session_id) self.sessions.clear_session(session_id)
reply = Reply(ReplyType.INFO, "记忆已清除") reply = Reply(ReplyType.INFO, _t("记忆已清除", "Memory cleared"))
elif query == "#清除所有": elif query == "#清除所有":
self.sessions.clear_all_session() self.sessions.clear_all_session()
reply = Reply(ReplyType.INFO, "所有人记忆已清除") reply = Reply(ReplyType.INFO, _t("所有人记忆已清除", "All memories cleared"))
elif query == "#更新配置": elif query == "#更新配置":
load_config() load_config()
reply = Reply(ReplyType.INFO, "配置已更新") reply = Reply(ReplyType.INFO, _t("配置已更新", "Config updated"))
if reply: if reply:
return reply return reply
session = self.sessions.session_query(query, session_id) session = self.sessions.session_query(query, session_id)
@@ -148,7 +149,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
reply = self.reply_image(context) reply = self.reply_image(context)
return reply return reply
else: 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 return reply
def reply_image(self, context): def reply_image(self, context):
@@ -165,7 +166,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
# Check if file exists # Check if file exists
if not os.path.exists(image_path): if not os.path.exists(image_path):
logger.error(f"[CHATGPT] Image file not found: {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 # Read and encode image
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
@@ -232,7 +233,7 @@ class ChatGPTBot(Bot, OpenAIImage, OpenAICompatibleBot):
logger.error(f"[CHATGPT] Image processing error: {e}") logger.error(f"[CHATGPT] Image processing error: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) 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: 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): def _handle_reply_error(self, e, session, api_key, args, retry_count):
"""Map exception to user-facing reply with retry/backoff (mirrors SDK behavior).""" """Map exception to user-facing reply with retry/backoff (mirrors SDK behavior)."""
need_retry = retry_count < 2 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): if isinstance(e, RateLimitError):
logger.warn("[CHATGPT] RateLimitError: {}".format(e)) 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: if need_retry:
time.sleep(20) time.sleep(20)
elif isinstance(e, Timeout): elif isinstance(e, Timeout):
logger.warn("[CHATGPT] Timeout: {}".format(e)) logger.warn("[CHATGPT] Timeout: {}".format(e))
result["content"] = "我没有收到你的消息" result["content"] = _t("我没有收到你的消息", "I didn't receive your message")
if need_retry: if need_retry:
time.sleep(5) time.sleep(5)
elif isinstance(e, APIConnectionError): elif isinstance(e, APIConnectionError):
logger.warn("[CHATGPT] APIConnectionError: {}".format(e)) logger.warn("[CHATGPT] APIConnectionError: {}".format(e))
result["content"] = "我连接不到你的网络" result["content"] = _t("我连接不到你的网络", "I can't reach your network")
if need_retry: if need_retry:
time.sleep(5) time.sleep(5)
elif isinstance(e, APIError): elif isinstance(e, APIError):
logger.warn("[CHATGPT] Bad Gateway: {}".format(e)) logger.warn("[CHATGPT] Bad Gateway: {}".format(e))
result["content"] = "请再问我一次" result["content"] = _t("请再问我一次", "Please ask me again")
if need_retry: if need_retry:
time.sleep(10) time.sleep(10)
else: else:
@@ -358,7 +359,7 @@ class AzureChatGPTBot(ChatGPTBot):
status = "" status = ""
while (status != "succeeded"): while (status != "succeeded"):
if retry_count > 3: if retry_count > 3:
return False, "图片生成失败" return False, _t("图片生成失败", "Image generation failed")
response = requests.get(operation_location, headers=headers) response = requests.get(operation_location, headers=headers)
status = response.json()['status'] status = response.json()['status']
retry_count += 1 retry_count += 1
@@ -366,7 +367,7 @@ class AzureChatGPTBot(ChatGPTBot):
return True, image_url return True, image_url
except Exception as e: except Exception as e:
logger.error("create image error: {}".format(e)) logger.error("create image error: {}".format(e))
return False, "图片生成失败" return False, _t("图片生成失败", "Image generation failed")
elif text_to_image_model == "dall-e-3": elif text_to_image_model == "dall-e-3":
api_version = conf().get("azure_api_version", "2024-02-15-preview") api_version = conf().get("azure_api_version", "2024-02-15-preview")
endpoint = conf().get("azure_openai_dalle_api_base","open_ai_api_base") endpoint = conf().get("azure_openai_dalle_api_base","open_ai_api_base")
@@ -389,7 +390,7 @@ class AzureChatGPTBot(ChatGPTBot):
else: else:
error_message = "响应中没有图像 URL" error_message = "响应中没有图像 URL"
logger.error(error_message) logger.error(error_message)
return False, "图片生成失败" return False, _t("图片生成失败", "Image generation failed")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
# 捕获所有请求相关的异常 # 捕获所有请求相关的异常
@@ -405,9 +406,9 @@ class AzureChatGPTBot(ChatGPTBot):
# 捕获所有其他异常 # 捕获所有其他异常
error_message = f"生成图像时发生错误: {e}" error_message = f"生成图像时发生错误: {e}"
logger.error(error_message) logger.error(error_message)
return False, "图片生成失败" return False, _t("图片生成失败", "Image generation failed")
else: else:
return False, "图片生成失败未配置text_to_image参数" return False, _t("图片生成失败未配置text_to_image参数", "Image generation failed: text_to_image is not configured")
class _AzureChatHTTPClient(OpenAIHTTPClient): class _AzureChatHTTPClient(OpenAIHTTPClient):

View File

@@ -23,6 +23,7 @@ from plugins import Plugin, Event, EventContext, EventAction
from bridge.context import ContextType from bridge.context import ContextType
from bridge.reply import Reply, ReplyType from bridge.reply import Reply, ReplyType
from common.log import logger from common.log import logger
from common.i18n import t as _t
from config import conf from config import conf
from cli import __version__ from cli import __version__
@@ -280,19 +281,18 @@ class CowCliPlugin(Plugin):
@staticmethod @staticmethod
def _typo_hint(token: str, suggestion) -> str: def _typo_hint(token: str, suggestion) -> str:
hint = f"未知命令: /{token}" hint = _t(f"未知命令: /{token}", f"Unknown command: /{token}")
if suggestion: if suggestion:
hint += f"\n你是不是想输入 /{suggestion} ?" hint += _t(f"\n你是不是想输入 /{suggestion} ?", f"\nDid you mean /{suggestion} ?")
hint += "\n发送 /help 查看全部命令。" hint += _t("\n发送 /help 查看全部命令。", "\nSend /help to see all commands.")
return hint return hint
@staticmethod @staticmethod
def _ambiguous_hint(token: str, candidates) -> str: def _ambiguous_hint(token: str, candidates) -> str:
options = " ".join(f"/{c}" for c in candidates) options = " ".join(f"/{c}" for c in candidates)
return ( return _t(
f"命令不明确: /{token}\n" f"命令不明确: /{token}\n可能想输入: {options}\n发送 /help 查看全部命令。",
f"可能想输入: {options}\n" f"Ambiguous command: /{token}\nDid you mean: {options}\nSend /help to see all commands.",
"发送 /help 查看全部命令。"
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -324,7 +324,10 @@ class CowCliPlugin(Plugin):
def _dispatch(self, cmd: str, args: str, e_context: EventContext, session_id: str = "") -> str: def _dispatch(self, cmd: str, args: str, e_context: EventContext, session_id: str = "") -> str:
if cmd in CLI_ONLY_COMMANDS: 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_attr = "_cmd_" + cmd.replace("-", "_")
handler = getattr(self, handler_attr, None) handler = getattr(self, handler_attr, None)
@@ -333,39 +336,68 @@ class CowCliPlugin(Plugin):
return handler(args, e_context, session_id=session_id) return handler(args, e_context, session_id=session_id)
except Exception as e: except Exception as e:
logger.error(f"[CowCli] command '{cmd}' failed: {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 # help / version
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _cmd_help(self, args: str, e_context, **_) -> str: def _cmd_help(self, args: str, e_context, **_) -> str:
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 <keyword>: Search skills",
"/skill install <name>: Install a skill",
"/skill info <name>: Show skill details",
"/config: Show current config",
"/config <key>: Show a config item",
"/config <key> <val>: 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 <command> instead of /<command>",
]
else:
lines = [ lines = [
"📋 CowAgent 命令列表", "📋 CowAgent 命令列表",
"", "",
" /help 显示此帮助", "/help: 显示此帮助",
" /version 查看版本", "/version: 查看版本",
" /status 查看运行状态", "/status: 查看运行状态",
" /cancel 中止当前正在运行的 Agent 任务", "/cancel: 中止当前正在运行的 Agent 任务",
" /logs [N] 查看最近N条日志 (默认20)", "/logs [N]: 查看最近N条日志 (默认20)",
" /context 查看当前对话上下文信息", "/context: 查看当前对话上下文信息",
" /context clear 清除当前对话上下文", "/context clear: 清除当前对话上下文",
" /skill list 查看已安装的技能", "/skill list: 查看已安装的技能",
" /skill list --remote 浏览技能广场", "/skill list --remote: 浏览技能广场",
" /skill search <关键词> 搜索技能", "/skill search <关键词>: 搜索技能",
" /skill install <名称> 安装技能", "/skill install <名称>: 安装技能",
" /skill info <名称> 查看技能详情", "/skill info <名称>: 查看技能详情",
" /config 查看当前配置", "/config: 查看当前配置",
" /config <key> 查看某项配置", "/config <key>: 查看某项配置",
" /config <key> <val> 修改配置", "/config <key> <val>: 修改配置",
" /memory status 查看记忆索引状态", "/memory status: 查看记忆索引状态",
" /memory rebuild-index 清空并重建向量索引 (切换 embedding 模型后必须执行)", "/memory rebuild-index: 清空并重建向量索引 (切换 embedding 模型后必须执行)",
" /memory dream [N] 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)", "/memory dream [N]: 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)",
" /knowledge 查看知识库统计", "/knowledge: 查看知识库统计",
" /knowledge list 查看知识库文件树", "/knowledge list: 查看知识库文件树",
" /knowledge on|off 开启/关闭知识库", "/knowledge on|off: 开启/关闭知识库",
"", "",
"💡 也可以用 cow <command> 代替 /<command>", "💡 也可以用 cow <command> 代替 /<command>",
] ]
@@ -405,9 +437,9 @@ class CowCliPlugin(Plugin):
cancelled = registry.cancel_session(target_session) cancelled = registry.cancel_session(target_session)
if cancelled <= 0: if cancelled <= 0:
return "当前没有可中止的任务。" return _t("当前没有可中止的任务。", "Nothing to cancel.")
return "🛑 已中止" return _t("🛑 已中止", "🛑 Cancelled")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# status # status
@@ -417,21 +449,21 @@ class CowCliPlugin(Plugin):
from config import conf from config import conf
cfg = conf() cfg = conf()
lines = ["📊 CowAgent 运行状态", ""] lines = [_t("📊 CowAgent 运行状态", "📊 CowAgent Status"), ""]
lines.append(f" 版本: v{__version__}") lines.append(_t(f" 版本: v{__version__}", f" Version: v{__version__}"))
lines.append(f" 进程: PID {os.getpid()}") lines.append(_t(f" 进程: PID {os.getpid()}", f" Process: PID {os.getpid()}"))
channel = cfg.get("channel_type", "unknown") channel = cfg.get("channel_type", "unknown")
if isinstance(channel, list): if isinstance(channel, list):
channel = ", ".join(channel) channel = ", ".join(channel)
lines.append(f" 通道: {channel}") lines.append(_t(f" 通道: {channel}", f" Channel: {channel}"))
model_name = cfg.get("model", "unknown") 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" 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) session_id = self._get_session_id(e_context, fallback=session_id)
agent = self._get_agent(session_id) agent = self._get_agent(session_id)
@@ -439,7 +471,7 @@ class CowCliPlugin(Plugin):
lines.append("") lines.append("")
with agent.messages_lock: with agent.messages_lock:
msg_count = len(agent.messages) 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: if agent.skill_manager:
total = len(agent.skill_manager.skills) total = len(agent.skill_manager.skills)
@@ -447,10 +479,10 @@ class CowCliPlugin(Plugin):
1 for v in agent.skill_manager.skills_config.values() 1 for v in agent.skill_manager.skills_config.values()
if v.get("enabled", True) if v.get("enabled", True)
) )
lines.append(f" 已加载技能: {enabled}/{total}") lines.append(_t(f" 已加载技能: {enabled}/{total}", f" Loaded skills: {enabled}/{total}"))
else: else:
lines.append("") lines.append("")
lines.append(f" Agent: 未初始化 (首次对话后自动创建)") lines.append(_t(" Agent: 未初始化 (首次对话后自动创建)", " Agent: not initialized (created on first chat)"))
return "\n".join(lines) return "\n".join(lines)
@@ -465,7 +497,7 @@ class CowCliPlugin(Plugin):
log_file = self._find_log_file() log_file = self._find_log_file()
if not log_file: if not log_file:
return "未找到日志文件" return _t("未找到日志文件", "No log file found")
try: try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f: with open(log_file, "r", encoding="utf-8", errors="replace") as f:
@@ -473,10 +505,10 @@ class CowCliPlugin(Plugin):
tail = all_lines[-num_lines:] tail = all_lines[-num_lines:]
content = "".join(tail).strip() content = "".join(tail).strip()
if not content: if not content:
return "日志为空" return _t("日志为空", "Log is empty")
return f"📄 最近 {len(tail)} 条日志:\n\n{content}" return _t(f"📄 最近 {len(tail)} 条日志:\n\n{content}", f"📄 Last {len(tail)} log lines:\n\n{content}")
except Exception as e: except Exception as e:
return f"读取日志失败: {e}" return _t(f"读取日志失败: {e}", f"Failed to read log: {e}")
def _find_log_file(self) -> str: def _find_log_file(self) -> str:
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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: def _context_info(self, agent, session_id: str) -> str:
if not agent: if not agent:
return "⚠️ Agent 未初始化,暂无上下文信息" return _t("⚠️ Agent 未初始化,暂无上下文信息", "⚠️ Agent not initialized, no context yet")
with agent.messages_lock: with agent.messages_lock:
messages = agent.messages.copy() messages = agent.messages.copy()
if not messages: if not messages:
return "当前对话上下文为空" return _t("当前对话上下文为空", "Current conversation context is empty")
user_msgs = sum(1 for m in messages if m.get("role") == "user") 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") assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
@@ -521,6 +553,20 @@ class CowCliPlugin(Plugin):
total_chars = sum(len(str(m.get("content", ""))) for m in messages) total_chars = sum(len(str(m.get("content", ""))) for m in messages)
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 = [ lines = [
"💬 当前对话上下文", "💬 当前对话上下文",
"", "",
@@ -537,13 +583,13 @@ class CowCliPlugin(Plugin):
def _context_clear(self, agent, session_id: str) -> str: def _context_clear(self, agent, session_id: str) -> str:
if not agent: if not agent:
return "⚠️ Agent 未初始化" return _t("⚠️ Agent 未初始化", "⚠️ Agent not initialized")
with agent.messages_lock: with agent.messages_lock:
count = len(agent.messages) count = len(agent.messages)
agent.messages.clear() agent.messages.clear()
return f"✅ 已清除当前对话上下文 ({count} 条消息)" return _t(f"✅ 已清除当前对话上下文 ({count} 条消息)", f"✅ Conversation context cleared ({count} messages)")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# config # config
@@ -578,21 +624,24 @@ class CowCliPlugin(Plugin):
def _config_show_all(self) -> str: def _config_show_all(self) -> str:
from config import conf from config import conf
cfg = conf() cfg = conf()
lines = ["⚙️ 当前配置", ""] lines = [_t("⚙️ 当前配置", "⚙️ Current Config"), ""]
for key in sorted(self._CONFIG_READABLE): for key in sorted(self._CONFIG_READABLE):
val = cfg.get(key, "") val = cfg.get(key, "")
lines.append(f" {key}: {val}") lines.append(f" {key}: {val}")
lines.append("") lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /config <key> 查看配置") lines.append(_t("💡 /config <key>: 查看配置", "💡 /config <key>: Show a config item"))
lines.append("💡 /config <key> <val> 修改配置") lines.append(_t("💡 /config <key> <val>: 修改配置", "💡 /config <key> <val>: Update a config item"))
return "\n".join(lines) return "\n".join(lines)
def _config_get(self, key: str) -> str: def _config_get(self, key: str) -> str:
from config import conf from config import conf
if key not in self._CONFIG_READABLE: if key not in self._CONFIG_READABLE:
available = ", ".join(sorted(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, "") val = conf().get(key, "")
return f"⚙️ {key}: {val}" return f"⚙️ {key}: {val}"
@@ -602,9 +651,12 @@ class CowCliPlugin(Plugin):
if key not in self._CONFIG_WRITABLE: if key not in self._CONFIG_WRITABLE:
if key in self._CONFIG_READABLE: 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)) 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, "") old_val = conf().get(key, "")
@@ -637,7 +689,7 @@ class CowCliPlugin(Plugin):
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
_json.dump(file_config, f, indent=4, ensure_ascii=False) _json.dump(file_config, f, indent=4, ensure_ascii=False)
except Exception as e: 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() # Sync updated values to environment variables so that load_config()
# won't overwrite the new value with a stale env var (common in Docker). # 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: except Exception as e:
logger.warning(f"[CowCli] config reload warning: {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: if "bot_type" in updates and updates["bot_type"] != old_bot_type:
result += f"\n bot_type: {old_bot_type}{updates['bot_type']}" result += f"\n bot_type: {old_bot_type}{updates['bot_type']}"
return result return result
@@ -725,10 +777,13 @@ class CowCliPlugin(Plugin):
from cli.commands.install import run_install_browser from cli.commands.install import run_install_browser
if args.strip(): if args.strip():
return ( return _t(
"用法: /install-browser\n\n" "用法: /install-browser\n\n"
"无需参数,等同于终端执行 `cow install-browser`。\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 # 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), on_phase=lambda m: self._send_install_progress(e_context, m),
) )
if code != 0: 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 _t(
"✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。",
"✅ Installation finished. Restart CowAgent to use the browser tool (see messages above for progress).",
) )
return "✅ 安装流程已结束。请重启 CowAgent 后使用 browser 工具(进度见上方消息)。"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# skill # skill
@@ -770,16 +830,25 @@ class CowCliPlugin(Plugin):
elif sub == "disable": elif sub == "disable":
return self._skill_set_enabled(sub_args, False) return self._skill_set_enabled(sub_args, False)
else: else:
return ( return _t(
"用法: /skill <子命令>\n\n" "用法: /skill <子命令>\n\n"
"子命令:\n" "子命令:\n"
" list [--remote] 查看技能列表\n" "list [--remote]: 查看技能列表\n"
" search <关键词> 搜索技能\n" "search <关键词>: 搜索技能\n"
" install <名称> 安装技能\n" "install <名称>: 安装技能\n"
" uninstall <名称> 卸载技能\n" "uninstall <名称>: 卸载技能\n"
" info <名称> 查看技能详情\n" "info <名称>: 查看技能详情\n"
" enable <名称> 启用技能\n" "enable <名称>: 启用技能\n"
" disable <名称> 禁用技能" "disable <名称>: 禁用技能",
"Usage: /skill <subcommand>\n\n"
"Subcommands:\n"
"list [--remote]: List skills\n"
"search <keyword>: Search skills\n"
"install <name>: Install a skill\n"
"uninstall <name>: Uninstall a skill\n"
"info <name>: Show skill details\n"
"enable <name>: Enable a skill\n"
"disable <name>: Disable a skill",
) )
def _refresh_skill_manager(self): def _refresh_skill_manager(self):
@@ -813,13 +882,16 @@ class CowCliPlugin(Plugin):
if os.path.exists(os.path.join(skill_path, "SKILL.md")): if os.path.exists(os.path.join(skill_path, "SKILL.md")):
entries.append({"name": name, "source": source, "enabled": True}) entries.append({"name": name, "source": source, "enabled": True})
if not entries: 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} config = {e["name"]: e for e in entries}
sorted_entries = sorted(config.values(), key=lambda e: e.get("name", "")) 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)) 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: for entry in sorted_entries:
name = entry.get("name", "") name = entry.get("name", "")
enabled = entry.get("enabled", True) enabled = entry.get("enabled", True)
@@ -835,13 +907,13 @@ class CowCliPlugin(Plugin):
if desc: if desc:
line += f"\n {desc}" line += f"\n {desc}"
if source: if source:
line += f"\n 来源: {source}" line += _t(f"\n 来源: {source}", f"\n Source: {source}")
lines.append(line) lines.append(line)
lines.append("") lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /skill list --remote 浏览技能广场") lines.append(_t("💡 /skill list --remote: 浏览技能广场", "💡 /skill list --remote: Browse Skill Hub"))
lines.append("💡 /skill info <名称> 查看详情") lines.append(_t("💡 /skill info <名称>: 查看详情", "💡 /skill info <name>: Show details"))
return "\n".join(lines) return "\n".join(lines)
def _skill_list(self, args: str) -> str: def _skill_list(self, args: str) -> str:
@@ -871,43 +943,43 @@ class CowCliPlugin(Plugin):
skills = data.get("skills", []) skills = data.get("skills", [])
total = data.get("total", len(skills)) total = data.get("total", len(skills))
except Exception as e: except Exception as e:
return f"获取技能广场失败: {e}" return _t(f"获取技能广场失败: {e}", f"Failed to fetch Skill Hub: {e}")
if not skills and page == 1: 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) total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages) page = min(page, total_pages)
installed = set(load_skills_config().keys()) installed = set(load_skills_config().keys())
lines = ["🌐 技能广场", ""] lines = [_t("🌐 技能广场", "🌐 Skill Hub"), ""]
for s in skills: for s in skills:
name = s.get("name", "") name = s.get("name", "")
display = s.get("display_name", "") or name display = s.get("display_name", "") or name
desc = s.get("description", "") desc = s.get("description", "")
if len(desc) > 50: if len(desc) > 50:
desc = desc[:47] + "" 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"📌 {display}{badge}")
lines.append(f" 名称: {name}") lines.append(_t(f" 名称: {name}", f" Name: {name}"))
if desc: if desc:
lines.append(f" {desc}") lines.append(f" {desc}")
lines.append("") lines.append("")
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: 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: if page > 1:
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}: Previous page"))
lines.append("💡 /skill install <名称> 安装技能") lines.append(_t("💡 /skill install <名称>: 安装技能", "💡 /skill install <name>: Install a skill"))
lines.append("💡 /skill search <关键词> 搜索技能") lines.append(_t("💡 /skill search <关键词>: 搜索技能", "💡 /skill search <keyword>: Search skills"))
lines.append("🌐 https://skills.cowagent.ai 在线浏览全部技能") lines.append(_t("🌐 https://skills.cowagent.ai 在线浏览全部技能", "🌐 https://skills.cowagent.ai Browse all skills online"))
return "\n".join(lines) return "\n".join(lines)
def _skill_search(self, query: str) -> str: def _skill_search(self, query: str) -> str:
if not query: if not query:
return "请指定搜索关键词: /skill search <关键词>" return _t("请指定搜索关键词: /skill search <关键词>", "Please specify a search keyword: /skill search <keyword>")
import requests import requests
from cli.utils import SKILL_HUB_API, load_skills_config from cli.utils import SKILL_HUB_API, load_skills_config
@@ -916,35 +988,35 @@ class CowCliPlugin(Plugin):
resp.raise_for_status() resp.raise_for_status()
skills = resp.json().get("skills", []) skills = resp.json().get("skills", [])
except Exception as e: except Exception as e:
return f"搜索失败: {e}" return _t(f"搜索失败: {e}", f"Search failed: {e}")
if not skills: if not skills:
return f"未找到与「{query}」相关的技能" return _t(f"未找到与「{query}」相关的技能", f"No skills found for \"{query}\"")
installed = set(load_skills_config().keys()) 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: for s in skills:
name = s.get("name", "") name = s.get("name", "")
display = s.get("display_name", "") or name display = s.get("display_name", "") or name
desc = s.get("description", "") desc = s.get("description", "")
if len(desc) > 50: if len(desc) > 50:
desc = desc[:47] + "" 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"📌 {display}{badge}")
lines.append(f" 名称: {name}") lines.append(_t(f" 名称: {name}", f" Name: {name}"))
if desc: if desc:
lines.append(f" {desc}") lines.append(f" {desc}")
lines.append("") lines.append("")
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━") lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━")
lines.append("💡 /skill install <名称> 安装技能") lines.append(_t("💡 /skill install <名称>: 安装技能", "💡 /skill install <name>: Install a skill"))
return "\n".join(lines) return "\n".join(lines)
_INSTALL_TIMEOUT = 60 _INSTALL_TIMEOUT = 60
def _skill_install(self, name: str, e_context: EventContext) -> str: def _skill_install(self, name: str, e_context: EventContext) -> str:
if not name: if not name:
return "请指定要安装的技能: /skill install <名称>" return _t("请指定要安装的技能: /skill install <名称>", "Please specify a skill to install: /skill install <name>")
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from cli.commands.skill import install_skill from cli.commands.skill import install_skill
@@ -955,16 +1027,16 @@ class CowCliPlugin(Plugin):
result = future.result(timeout=self._INSTALL_TIMEOUT) result = future.result(timeout=self._INSTALL_TIMEOUT)
if result.error: if result.error:
return f"安装失败: {result.error}" return _t(f"安装失败: {result.error}", f"Install failed: {result.error}")
if not result.installed: 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) return self._format_install_result(result)
except FuturesTimeout: except FuturesTimeout:
return "安装超时,请稍后重试或检查网络连接" return _t("安装超时,请稍后重试或检查网络连接", "Install timed out. Please retry later or check your network connection.")
except Exception as e: except Exception as e:
return f"安装失败: {e}" return _t(f"安装失败: {e}", f"Install failed: {e}")
@staticmethod @staticmethod
def _format_install_result(result) -> str: def _format_install_result(result) -> str:
@@ -978,20 +1050,20 @@ class CowCliPlugin(Plugin):
for skill_name in result.installed: for skill_name in result.installed:
desc = _read_skill_description(os.path.join(skills_dir, skill_name)) desc = _read_skill_description(os.path.join(skills_dir, skill_name))
display = config.get(skill_name, {}).get("display_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: if display and display != skill_name:
lines.append(f" 名称:{display}") lines.append(_t(f" 名称:{display}", f" Name: {display}"))
if desc: if desc:
lines.append(f" 描述:{desc}") lines.append(_t(f" 描述:{desc}", f" Description: {desc}"))
if len(result.installed) > 1: 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) return "\n".join(lines)
def _skill_uninstall(self, name: str) -> str: def _skill_uninstall(self, name: str) -> str:
if not name: if not name:
return "请指定要卸载的技能: /skill uninstall <名称>" return _t("请指定要卸载的技能: /skill uninstall <名称>", "Please specify a skill to uninstall: /skill uninstall <name>")
import shutil import shutil
import json import json
@@ -1004,7 +1076,7 @@ class CowCliPlugin(Plugin):
skill_dir = self._resolve_skill_dir(name, skills_dir) skill_dir = self._resolve_skill_dir(name, skills_dir)
if not skill_dir: if not skill_dir:
return f"技能 '{name}' 未安装" return _t(f"技能 '{name}' 未安装", f"Skill '{name}' is not installed")
shutil.rmtree(skill_dir) shutil.rmtree(skill_dir)
@@ -1019,7 +1091,7 @@ class CowCliPlugin(Plugin):
except Exception: except Exception:
pass pass
return f"✅ 技能 '{name}' 已卸载" return _t(f"✅ 技能 '{name}' 已卸载", f"✅ Skill '{name}' uninstalled")
@staticmethod @staticmethod
def _resolve_skill_dir(name: str, skills_dir: str): def _resolve_skill_dir(name: str, skills_dir: str):
@@ -1055,7 +1127,7 @@ class CowCliPlugin(Plugin):
def _skill_info(self, name: str) -> str: def _skill_info(self, name: str) -> str:
if not name: if not name:
return "请指定技能名称: /skill info <名称>" return _t("请指定技能名称: /skill info <名称>", "Please specify a skill name: /skill info <name>")
from cli.utils import get_skills_dir, get_builtin_skills_dir from cli.utils import get_skills_dir, get_builtin_skills_dir
@@ -1078,18 +1150,18 @@ class CowCliPlugin(Plugin):
source = "custom" source = "custom"
if not skill_dir: 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") skill_md = os.path.join(skill_dir, "SKILL.md")
if not os.path.exists(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: with open(skill_md, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
meta, body = self._strip_frontmatter(content) 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", "") desc = meta.get("description", "")
if desc: if desc:
header_lines.append(f" {desc}") header_lines.append(f" {desc}")
@@ -1104,8 +1176,10 @@ class CowCliPlugin(Plugin):
def _skill_set_enabled(self, name: str, enabled: bool) -> str: def _skill_set_enabled(self, name: str, enabled: bool) -> str:
if not name: if not name:
action = "启用" if enabled else "禁用" return _t(
return f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>" f"请指定技能名称: /skill {'enable' if enabled else 'disable'} <名称>",
f"Please specify a skill name: /skill {'enable' if enabled else 'disable'} <name>",
)
import json import json
from cli.utils import get_skills_dir from cli.utils import get_skills_dir
@@ -1114,24 +1188,25 @@ class CowCliPlugin(Plugin):
config_path = os.path.join(skills_dir, "skills_config.json") config_path = os.path.join(skills_dir, "skills_config.json")
if not os.path.exists(config_path): if not os.path.exists(config_path):
return "技能配置文件不存在" return _t("技能配置文件不存在", "Skills config file not found")
try: try:
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
except Exception as e: except Exception as e:
return f"读取配置失败: {e}" return _t(f"读取配置失败: {e}", f"Failed to read config: {e}")
if name not in config: if name not in config:
return f"技能 '{name}' 未在配置中找到" return _t(f"技能 '{name}' 未在配置中找到", f"Skill '{name}' not found in config")
config[name]["enabled"] = enabled config[name]["enabled"] = enabled
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False) json.dump(config, f, indent=4, ensure_ascii=False)
action = "启用" if enabled else "禁用"
icon = "" 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 # memory
@@ -1157,13 +1232,19 @@ class CowCliPlugin(Plugin):
@staticmethod @staticmethod
def _memory_help() -> str: def _memory_help() -> str:
return ( return _t(
"🧠 记忆管理\n\n" "🧠 记忆管理\n\n"
"用法: /memory <子命令>\n\n" "用法: /memory <子命令>\n\n"
"子命令:\n" "子命令:\n"
" status 查看索引状态 (provider / model / dim / chunks)\n" "status: 查看索引状态 (provider / model / dim / chunks)\n"
" rebuild-index 清空并重建向量索引 (切换 embedding 模型后必须执行)\n" "rebuild-index: 清空并重建向量索引 (切换 embedding 模型后必须执行)\n"
" dream [N] 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)" "dream [N]: 手动触发记忆蒸馏 (整理近N天, 默认3, 最多30)",
"🧠 Memory Management\n\n"
"Usage: /memory <subcommand>\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: def _memory_dream(self, days: int, e_context, session_id: str) -> str:
@@ -1178,10 +1259,10 @@ class CowCliPlugin(Plugin):
try: try:
flush_mgr = self._create_standalone_flush_manager() flush_mgr = self._create_standalone_flush_manager()
except Exception as e: except Exception as e:
return f"⚠️ 无法初始化记忆蒸馏: {e}" return _t(f"⚠️ 无法初始化记忆蒸馏: {e}", f"⚠️ Failed to initialize memory distillation: {e}")
if not flush_mgr.llm_model: 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 # SaaS (e_context is None): run synchronously, return full result
if e_context is None: if e_context is None:
@@ -1196,13 +1277,16 @@ class CowCliPlugin(Plugin):
if result: if result:
self._notify(e_context, self._build_dream_result(flush_mgr, is_web)) self._notify(e_context, self._build_dream_result(flush_mgr, is_web))
else: else:
self._notify(e_context, "💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理") self._notify(e_context, _t("💤 记忆蒸馏跳过 — 没有新的记忆内容需要整理", "💤 Memory distillation skipped — no new memories to process"))
except Exception as e: except Exception as e:
logger.warning(f"[CowCli] /memory dream failed: {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() 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: def _memory_dream_sync(self, flush_mgr, days: int) -> str:
"""Run deep dream synchronously and return the full result.""" """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) result = flush_mgr.deep_dream(lookback_days=days, force=True)
if result: if result:
return self._build_dream_result(flush_mgr, is_web=True) 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: except Exception as e:
logger.warning(f"[CowCli] /memory dream sync failed: {e}") logger.warning(f"[CowCli] /memory dream sync failed: {e}")
return f"❌ 记忆蒸馏失败: {e}" return _t(f"❌ 记忆蒸馏失败: {e}", f"❌ Memory distillation failed: {e}")
@staticmethod @staticmethod
def _resolve_active_embedding(): def _resolve_active_embedding():
@@ -1255,9 +1339,9 @@ class CowCliPlugin(Plugin):
agent = self._get_agent("") agent = self._get_agent("")
memory_manager = agent.memory_manager if agent else None memory_manager = agent.memory_manager if agent else None
lines = ["🧠 记忆索引状态", ""] lines = [_t("🧠 记忆索引状态", "🧠 Memory Index Status"), ""]
if not memory_manager: if not memory_manager:
lines.append(" ⚠️ Agent 尚未初始化,先发一条普通消息再试") lines.append(_t(" ⚠️ Agent 尚未初始化,先发一条普通消息再试", " ⚠️ Agent not initialized yet, send a normal message first"))
return "\n".join(lines) return "\n".join(lines)
stats = memory_manager.storage.get_stats() stats = memory_manager.storage.get_stats()
@@ -1278,7 +1362,7 @@ class CowCliPlugin(Plugin):
lines.append(f" Model : {cfg_model}") lines.append(f" Model : {cfg_model}")
lines.append(f" Dim : {cfg_dim if cfg_dim else '?'}") lines.append(f" Dim : {cfg_dim if cfg_dim else '?'}")
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 # Health hints — only shown when the user has explicitly opted into
# vector search via `embedding_provider`. Legacy users (no explicit # 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 explicitly_opted_in and provider_obj is not None:
if chunks > 0 and embedded < chunks: if chunks > 0 and embedded < chunks:
missing = chunks - embedded missing = chunks - embedded
warnings.append( warnings.append(_t(
f" ⚠️ {missing}/{chunks} 个 chunk 没有向量;" f" ⚠️ {missing}/{chunks} 个 chunk 没有向量;运行 /memory rebuild-index 后所有记忆才会被向量化检索",
f"运行 /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) index_dim = detect_index_dim(memory_manager.storage)
if index_dim is not None and cfg_dim and index_dim != cfg_dim: if index_dim is not None and cfg_dim and index_dim != cfg_dim:
warnings.append( warnings.append(_t(
f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;" f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;运行 /memory rebuild-index 重建后向量检索才会生效",
f"运行 /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: if warnings:
lines.append("") lines.append("")
@@ -1312,9 +1396,11 @@ class CowCliPlugin(Plugin):
session_id = self._get_session_id(e_context, fallback=session_id) session_id = self._get_session_id(e_context, fallback=session_id)
agent = self._get_agent(session_id) agent = self._get_agent(session_id)
if not agent or not agent.memory_manager: if not agent or not agent.memory_manager:
return ( return _t(
"⚠️ Agent 尚未初始化,无法重建索引。\n" "⚠️ 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 memory_manager = agent.memory_manager
@@ -1328,12 +1414,14 @@ class CowCliPlugin(Plugin):
._init_embedding_provider(memory_manager.config, session_id=session_id) ._init_embedding_provider(memory_manager.config, session_id=session_id)
except Exception as e: except Exception as e:
logger.exception("[CowCli] /memory rebuild-index: build provider failed") 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: if fresh_provider is None:
return ( return _t(
"⚠️ 当前没有可用的 embedding provider。\n" "⚠️ 当前没有可用的 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 memory_manager.embedding_provider = fresh_provider
@@ -1353,23 +1441,29 @@ class CowCliPlugin(Plugin):
if result.ok: if result.ok:
self._notify( self._notify(
e_context, e_context,
( _t(
f"✅ 索引重建完成\n" f"✅ 索引重建完成\n"
f" cleared : {result.removed}\n" f" cleared : {result.removed}\n"
f" chunks : {result.chunks}\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: 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: except Exception as e:
logger.exception("[CowCli] /memory rebuild-index failed") 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() threading.Thread(target=_run, daemon=True).start()
return ( return _t(
f"🔧 索引重建已启动 (model={model_label}, dim={dim_label})\n\n" 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 @staticmethod
@@ -1380,15 +1474,19 @@ class CowCliPlugin(Plugin):
result = rebuild_in_process(memory_manager) result = rebuild_in_process(memory_manager)
except Exception as e: except Exception as e:
logger.exception("[CowCli] /memory rebuild-index sync failed") logger.exception("[CowCli] /memory rebuild-index sync failed")
return f"❌ 索引重建失败: {e}" return _t(f"❌ 索引重建失败: {e}", f"❌ Index rebuild failed: {e}")
if not result.ok: if not result.ok:
return f"❌ 索引重建失败: {result.error}" return _t(f"❌ 索引重建失败: {result.error}", f"❌ Index rebuild failed: {result.error}")
return ( return _t(
f"✅ 索引重建完成 (model={model_label}, dim={dim_label})\n" f"✅ 索引重建完成 (model={model_label}, dim={dim_label})\n"
f" cleared : {result.removed}\n" f" cleared : {result.removed}\n"
f" chunks : {result.chunks}\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 @staticmethod
@@ -1418,7 +1516,7 @@ class CowCliPlugin(Plugin):
def _build_dream_result(flush_mgr, is_web: bool) -> str: def _build_dream_result(flush_mgr, is_web: bool) -> str:
"""Build dream completion message with diary content.""" """Build dream completion message with diary content."""
from datetime import datetime from datetime import datetime
lines = ["✅ 记忆蒸馏完成"] lines = [_t("✅ 记忆蒸馏完成", "✅ Memory distillation complete")]
# Read today's dream diary # Read today's dream diary
today = datetime.now().strftime("%Y-%m-%d") today = datetime.now().strftime("%Y-%m-%d")
@@ -1433,9 +1531,9 @@ class CowCliPlugin(Plugin):
lines.append(f"\n{diary}") lines.append(f"\n{diary}")
if is_web: 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: else:
lines.append("\nMEMORY.md 已更新") lines.append(_t("\nMEMORY.md 已更新", "\nMEMORY.md updated"))
return "\n".join(lines) return "\n".join(lines)
@@ -1485,11 +1583,17 @@ class CowCliPlugin(Plugin):
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
_json.dump(file_config, f, indent=4, ensure_ascii=False) _json.dump(file_config, f, indent=4, ensure_ascii=False)
except Exception as e: 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 "关闭 ❌" if enabled:
note = "知识库将在下次对话中生效" if enabled else "知识库系统已停用,不再注入提示词和索引知识文件" return _t(
return f"📚 知识库已{status}\n\n{note}" "📚 知识库已开启 ✅\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: def _knowledge_stats(self) -> str:
from config import conf from config import conf
@@ -1499,7 +1603,7 @@ class CowCliPlugin(Plugin):
"knowledge" "knowledge"
) )
if not os.path.isdir(knowledge_dir): 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) enabled = conf().get("knowledge", True)
total_files = 0 total_files = 0
@@ -1516,13 +1620,13 @@ class CowCliPlugin(Plugin):
total_bytes += os.path.getsize(os.path.join(root, f)) total_bytes += os.path.getsize(os.path.join(root, f))
cat_count[category] = cat_count.get(category, 0) + 1 cat_count[category] = cat_count.get(category, 0) + 1
status = "✅ 已开启" if enabled else "❌ 已关闭" status = _t("✅ 已开启", "✅ Enabled") if enabled else _t("❌ 已关闭", "❌ Disabled")
lines = [ lines = [
"📚 知识库统计", _t("📚 知识库统计", "📚 Knowledge Base Stats"),
"", "",
f"状态: {status}", _t(f"状态: {status}", f"Status: {status}"),
f"页面: {total_files}", _t(f"页面: {total_files}", f"Pages: {total_files}"),
f"大小: {total_bytes / 1024:.1f} KB", _t(f"大小: {total_bytes / 1024:.1f} KB", f"Size: {total_bytes / 1024:.1f} KB"),
"", "",
] ]
if cat_count: if cat_count:
@@ -1530,12 +1634,12 @@ class CowCliPlugin(Plugin):
lines.append(f"- {cat}/ ({cat_count[cat]} pages)") lines.append(f"- {cat}/ ({cat_count[cat]} pages)")
lines.append("") lines.append("")
lines.append(f"路径: {knowledge_dir}") lines.append(_t(f"路径: {knowledge_dir}", f"Path: {knowledge_dir}"))
lines.extend([ lines.extend([
"", "",
"━━━━━━━━━━━━━━━━━━━━━━━━━━", "━━━━━━━━━━━━━━━━━━━━━━━━━━",
"💡 /knowledge list 查看文件树", _t("💡 /knowledge list: 查看文件树", "💡 /knowledge list: Show file tree"),
"💡 /knowledge on|off 开关知识库", _t("💡 /knowledge on|off: 开关知识库", "💡 /knowledge on|off: Toggle knowledge base"),
]) ])
return "\n".join(lines) return "\n".join(lines)
@@ -1547,7 +1651,7 @@ class CowCliPlugin(Plugin):
"knowledge" "knowledge"
) )
if not os.path.isdir(knowledge_dir): 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/"] tree = ["knowledge/"]
@@ -1577,7 +1681,7 @@ class CowCliPlugin(Plugin):
tree.append(f"{child_prefix}└── ... +{len(md_files) - max_show} more") tree.append(f"{child_prefix}└── ... +{len(md_files) - max_show} more")
if not subdirs: if not subdirs:
tree.append("(空)") tree.append(_t("(空)", "(empty)"))
return "```\n" + "\n".join(tree) + "\n```" return "```\n" + "\n".join(tree) + "\n```"
@@ -1602,4 +1706,4 @@ class CowCliPlugin(Plugin):
return None return None
def get_help_text(self, **kwargs): 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")