- 新增 modules/i18n.py:tr() + 进程级语言缓存 + modules/i18n_messages/ 域文案包自动聚合 - 新增 29 个域文案包,共 1153 条双语 key;90+ 源文件 1146 处用户可见消息 tr 化 - ui_locale 存入 personalization.json(用户级共享),前后端双向同步 - 前端匹配点双语兼容(history/shared/ChatArea/taskPolling/upload 等正则) - 修复语言判等陷阱:审批等待加稳定 code 字段;conversation.py 不存在判等改双语 helper - 边界:日志/prompt 注入/子智能体工具回填/容器内嵌脚本不迁移
227 lines
8.1 KiB
Python
227 lines
8.1 KiB
Python
"""Backend i18n for user-visible messages.
|
||
|
||
Scope rule(与用户确认的边界):只有「真正发送到前端显示」的消息走本模块
|
||
(toast 错误、系统通知包装、默认对话标题、子智能体/后台任务通知包装等)。
|
||
日志、内部异常、给模型的 prompt 注入文案不做多语言。
|
||
|
||
语言来源:personalization.json 的 ui_locale 字段(用户级共享,多工作区软链)。
|
||
读取侧零 IO:load/save personalization 时由 personalization_manager 调
|
||
sync_from_config() 把 ui_locale 推进进程级缓存;消息生成处直接 tr()。
|
||
|
||
注意:消息文本被前端正则/判等识别(如子智能体摘要前缀、上传错误),
|
||
改动任一文案必须同步前端匹配逻辑(见各处的 \"i18n-match\" 注释)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
from typing import Any, Dict
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SUPPORTED_LOCALES = ("zh-CN", "en-US")
|
||
DEFAULT_LOCALE = "zh-CN"
|
||
|
||
_LOCALE_LOCK = threading.RLock()
|
||
_current_locale = DEFAULT_LOCALE
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 文案表:key -> {locale: text}
|
||
# 插值用 str.format 命名参数:tr("key", name="...")。
|
||
# ---------------------------------------------------------------------------
|
||
_MESSAGES: Dict[str, Dict[str, str]] = {
|
||
# ── 对话 ──
|
||
"conversation.default_title": {
|
||
"zh-CN": "新对话",
|
||
"en-US": "New Chat",
|
||
},
|
||
|
||
# ── 子智能体完成通知包装(注入对话历史;前端 history.ts / ui/shared.ts /
|
||
# ChatArea.vue 有对应识别正则,改动格式必须同步前端) ──
|
||
"sub_agent.summary_completed": {
|
||
"zh-CN": "✅ 子智能体{agent_id} 任务摘要:{summary} 已完成。",
|
||
"en-US": "✅ Sub-agent {agent_id} task summary: {summary} Completed.",
|
||
},
|
||
"sub_agent.summary_timeout": {
|
||
"zh-CN": "⏱️ 子智能体{agent_id} 任务摘要:{summary} 超时未完成。",
|
||
"en-US": "⏱️ Sub-agent {agent_id} task summary: {summary} Timed out.",
|
||
},
|
||
"sub_agent.summary_failed": {
|
||
"zh-CN": "❌ 子智能体{agent_id} 任务摘要:{summary} 执行失败。",
|
||
"en-US": "❌ Sub-agent {agent_id} task summary: {summary} Failed.",
|
||
},
|
||
"sub_agent.done_label": {
|
||
# ChatArea.vue SUB_AGENT_DONE_LABEL_RE 识别此格式
|
||
"zh-CN": "子智能体{agent_id} 任务完成",
|
||
"en-US": "Sub-agent {agent_id} task done",
|
||
},
|
||
"sub_agent.timeout_terminated_note": {
|
||
"zh-CN": "等待超时,子智能体已被终止。",
|
||
"en-US": "Wait timed out; the sub-agent has been terminated.",
|
||
},
|
||
|
||
# ── 后台 run_command 完成通知包装(前端 history.ts / ui/shared.ts 有识别正则) ──
|
||
"background_command.done_header": {
|
||
"zh-CN": "[后台 run_command 完成]",
|
||
"en-US": "[Background run_command finished]",
|
||
},
|
||
|
||
# ── 后台任务派发通知(chat_flow_task_main 注入对话的通知包装) ──
|
||
"background_tasks.done_header": {
|
||
"zh-CN": "后台指令已完成。",
|
||
"en-US": "Background commands finished.",
|
||
},
|
||
"background_tasks.command_line": {
|
||
"zh-CN": "命令:{command}",
|
||
"en-US": "Command: {command}",
|
||
},
|
||
"background_tasks.return_code_line": {
|
||
"zh-CN": "退出码:{code}",
|
||
"en-US": "Exit code: {code}",
|
||
},
|
||
"background_tasks.output_section": {
|
||
"zh-CN": "输出:",
|
||
"en-US": "Output:",
|
||
},
|
||
|
||
# ── 上传安全(API 错误 → 前端 toast;stores/upload.ts 有识别正则) ──
|
||
"upload.security_rejected": {
|
||
"zh-CN": "安全审核未通过",
|
||
"en-US": "Security check failed",
|
||
},
|
||
"upload.type_not_allowed": {
|
||
"zh-CN": "文件类型不在允许列表中",
|
||
"en-US": "File type is not in the allowed list",
|
||
},
|
||
"upload.size_exceeded": {
|
||
"zh-CN": "文件大小 {size} 超过上限 {max} 字节",
|
||
"en-US": "File size {size} exceeds the limit of {max} bytes",
|
||
},
|
||
|
||
# ── 工具调用(注入对话的工具错误;taskPolling/lifecycle.ts 有识别正则) ──
|
||
"tool.param_parse_failed": {
|
||
"zh-CN": "工具参数解析失败: {error}",
|
||
"en-US": "Failed to parse tool arguments: {error}",
|
||
},
|
||
|
||
# ── 多智能体消息包装(前端 ChatArea.vue MULTI_AGENT_MESSAGE_RE 识别) ──
|
||
"multi_agent.from_directed": {
|
||
# 来自 {display_name} 向 {target} 的{type_label}
|
||
"zh-CN": "来自 {display_name} 向 {target} 的{type_label}",
|
||
"en-US": "From {display_name} to {target}: {type_label}",
|
||
},
|
||
"multi_agent.from_plain": {
|
||
# 来自 {display_name} 的{type_label}
|
||
"zh-CN": "来自 {display_name} 的{type_label}",
|
||
"en-US": "From {display_name}: {type_label}",
|
||
},
|
||
"multi_agent.type_task": {
|
||
"zh-CN": "任务发布",
|
||
"en-US": "task dispatch",
|
||
},
|
||
"multi_agent.type_output": {
|
||
"zh-CN": "任务进度输出",
|
||
"en-US": "task progress",
|
||
},
|
||
"multi_agent.type_ask": {
|
||
"zh-CN": "提问",
|
||
"en-US": "question",
|
||
},
|
||
"multi_agent.type_answer": {
|
||
"zh-CN": "回答",
|
||
"en-US": "answer",
|
||
},
|
||
"multi_agent.type_message": {
|
||
"zh-CN": "消息",
|
||
"en-US": "message",
|
||
},
|
||
}
|
||
|
||
|
||
def set_locale(locale: Any) -> None:
|
||
"""Set the process-level current locale (invalid values fall back to default)."""
|
||
global _current_locale
|
||
if not isinstance(locale, str):
|
||
return
|
||
normalized = locale.strip()
|
||
if normalized not in SUPPORTED_LOCALES:
|
||
return
|
||
with _LOCALE_LOCK:
|
||
_current_locale = normalized
|
||
|
||
|
||
def get_locale() -> str:
|
||
"""Return the process-level current locale."""
|
||
with _LOCALE_LOCK:
|
||
return _current_locale
|
||
|
||
|
||
def sync_from_config(config: Any) -> None:
|
||
"""Push personalization config's ui_locale into the process-level cache.
|
||
|
||
Called by personalization_manager on every successful load/save so that
|
||
all message-generation sites see a fresh locale without doing file IO.
|
||
"""
|
||
if not isinstance(config, dict):
|
||
return
|
||
locale = config.get("ui_locale")
|
||
if isinstance(locale, str) and locale in SUPPORTED_LOCALES:
|
||
set_locale(locale)
|
||
|
||
|
||
def tr(key: str, **params: Any) -> str:
|
||
"""Translate a message key into the current locale with named params.
|
||
|
||
Unknown keys fall back to the key itself (visible in UI, easy to spot in dev).
|
||
"""
|
||
bundle = _MESSAGES.get(key)
|
||
if bundle is None:
|
||
return key
|
||
locale = get_locale()
|
||
text = bundle.get(locale) or bundle.get(DEFAULT_LOCALE) or key
|
||
if params:
|
||
try:
|
||
return text.format(**params)
|
||
except (KeyError, IndexError, ValueError):
|
||
return text
|
||
return text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 域消息包自动聚合
|
||
#
|
||
# 各域消息表放在 modules/i18n_messages/<domain>.py,每个文件暴露模块级
|
||
# `MESSAGES` dict(结构与 _MESSAGES 相同)。本模块 import 时自动合并,
|
||
# 调用方始终只用本模块的 tr()。新增域 = 往 modules/i18n_messages/ 丢一个
|
||
# 新文件,无需注册。消息包模块必须是纯数据(禁止 import modules.i18n)。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _load_domain_messages() -> None:
|
||
import importlib
|
||
import pkgutil
|
||
|
||
try:
|
||
import modules.i18n_messages as _pack
|
||
except ImportError:
|
||
return
|
||
for mod_info in pkgutil.iter_modules(_pack.__path__):
|
||
try:
|
||
mod = importlib.import_module(f"modules.i18n_messages.{mod_info.name}")
|
||
except Exception as exc:
|
||
logger.error(f"[i18n] failed to load message pack {mod_info.name}: {exc}")
|
||
continue
|
||
domain_messages = getattr(mod, "MESSAGES", None)
|
||
if not isinstance(domain_messages, dict):
|
||
continue
|
||
for key, value in domain_messages.items():
|
||
if key in _MESSAGES:
|
||
logger.warning(
|
||
f"[i18n] duplicate message key overridden by pack {mod_info.name}: {key}"
|
||
)
|
||
_MESSAGES[key] = value
|
||
|
||
|
||
_load_domain_messages()
|