feat: 支持多语言提示词本地化和界面优化

- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化
- 移除内置 agents 配置以简化系统架构
- 更新 ContextBuilder 使用动态提示词模板而非硬编码内容
- 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数
- 添加输出语言指令确保用户界面内容按指定语言生成
- 扩展前端 LanguageSwitcher 组件支持三种语言选项
- 优化 Header 和侧边栏组件的响应式布局和文本截断处理
- 更新测试用例验证不同语言环境下的提示词正确性
This commit is contained in:
2026-06-10 16:11:05 +08:00
parent 9cc3334ea7
commit fc9fd93c36
51 changed files with 7493 additions and 619 deletions

View File

@ -1,145 +1,4 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-27T05:25:11.756341+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756349+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-27T05:25:11.756351+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756353+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-27T05:25:11.756355+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756356+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-27T05:25:11.756358+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756358+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-27T05:25:11.756360+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756360+00:00"
}
],
"agents": [],
"version": 1
}

View File

@ -27,13 +27,7 @@ from dataclasses import dataclass, field
from typing import Any
from beaver.memory.curated.snapshot import MemorySnapshot
BEAVER_USER_ASSISTANT_IDENTITY_PROMPT = (
"You are 海狸 (Beaver), an AI assistant developed by 博维资讯系统有限公司. "
"When communicating with users, keep this identity consistent. "
"If users ask who you are, say that you are 海狸 (Beaver), 博维资讯系统有限公司研发的 AI 助手."
)
from beaver.prompts import get_main_agent_prompt
@dataclass(slots=True)
@ -113,6 +107,7 @@ class ContextBuildInput:
"""
base_system_prompt: str = ""
prompt_locale: str | None = None
history: list[dict[str, Any]] = field(default_factory=list)
current_user_input: str | list[dict[str, Any]] | None = None
memory_snapshot: MemorySnapshot | None = None
@ -171,7 +166,7 @@ class ContextBuilder:
- activated skill 正文放到显式消息里,避免 system prompt 持续膨胀
"""
sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT]
sections: list[str] = [get_main_agent_prompt(build_input.prompt_locale)]
base_system_prompt = (build_input.base_system_prompt or "").strip()
if base_system_prompt:

View File

@ -224,6 +224,7 @@ class AgentLoop:
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
prompt_locale: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
@ -275,6 +276,7 @@ class AgentLoop:
title=title,
execution_context=execution_context,
skill_selection_context=skill_selection_context,
prompt_locale=prompt_locale,
model=model,
provider_name=provider_name,
api_key=api_key,
@ -314,6 +316,7 @@ class AgentLoop:
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
prompt_locale: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
@ -572,6 +575,7 @@ class AgentLoop:
build_input = ContextBuildInput(
base_system_prompt=self.profile.system_prompt,
prompt_locale=prompt_locale,
history=session_manager.get_history(
resolved_session_id,
max_messages=max(1, self.profile.max_context_messages),

View File

@ -2463,6 +2463,7 @@ def create_app(
"user_id": payload.user_id,
"title": payload.title,
"execution_context": payload.execution_context,
"prompt_locale": payload.prompt_locale,
"model": payload.model,
"provider_name": payload.provider_name,
"embedding_model": payload.embedding_model,
@ -2578,6 +2579,7 @@ def create_app(
"user_id": _clean_text(payload.get("user_id")) or None,
"title": _clean_text(payload.get("title")) or None,
"execution_context": _clean_text(payload.get("execution_context")) or None,
"prompt_locale": _clean_text(payload.get("prompt_locale")) or None,
"model": _clean_text(payload.get("model")) or None,
"provider_name": _clean_text(payload.get("provider_name")) or None,
"embedding_model": _clean_text(payload.get("embedding_model")) or None,

View File

@ -55,6 +55,7 @@ class WebChatRequest(BaseModel):
user_id: str | None = None
title: str | None = None
execution_context: str | None = None
prompt_locale: str | None = None
model: str | None = None
provider_name: str | None = None
embedding_model: str | None = None

View File

@ -0,0 +1,5 @@
"""Prompt templates used by Beaver runtime components."""
from .main_agent import get_main_agent_prompt
__all__ = ["get_main_agent_prompt"]

View File

@ -0,0 +1,55 @@
"""Locale-aware main agent prompt loading."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
DEFAULT_MAIN_AGENT_PROMPT_LOCALE = "zh-Hans"
_PROMPT_FILES = {
"zh-Hans": "zh-Hans.md",
"zh-Hant": "zh-Hant.md",
"en": "en.md",
}
_LOCALE_ALIASES = {
"zh": "zh-Hans",
"zh-cn": "zh-Hans",
"zh-hans": "zh-Hans",
"zh-sg": "zh-Hans",
"zh-hant": "zh-Hant",
"zh-tw": "zh-Hant",
"zh-hk": "zh-Hant",
"zh-mo": "zh-Hant",
"en": "en",
"en-us": "en",
"en-gb": "en",
}
def get_main_agent_prompt(locale: str | None = None) -> str:
"""Return the main-agent identity prompt for a prompt locale."""
prompt_locale = normalize_main_agent_prompt_locale(locale)
return _load_main_agent_prompt(prompt_locale)
def normalize_main_agent_prompt_locale(locale: str | None = None) -> str:
cleaned = (locale or DEFAULT_MAIN_AGENT_PROMPT_LOCALE).strip()
if not cleaned:
return DEFAULT_MAIN_AGENT_PROMPT_LOCALE
normalized = _LOCALE_ALIASES.get(cleaned.lower())
if normalized:
return normalized
return cleaned if cleaned in _PROMPT_FILES else DEFAULT_MAIN_AGENT_PROMPT_LOCALE
@lru_cache(maxsize=len(_PROMPT_FILES))
def _load_main_agent_prompt(locale: str) -> str:
filename = _PROMPT_FILES.get(locale, _PROMPT_FILES[DEFAULT_MAIN_AGENT_PROMPT_LOCALE])
path = Path(__file__).with_name("main_agent") / filename
if not path.exists():
fallback_path = Path(__file__).with_name("main_agent") / _PROMPT_FILES[DEFAULT_MAIN_AGENT_PROMPT_LOCALE]
return fallback_path.read_text(encoding="utf-8").strip()
return path.read_text(encoding="utf-8").strip()

View File

@ -0,0 +1,7 @@
You are Beaver, an AI assistant developed by Boway Information Systems Co., Ltd.
When communicating with users, keep this identity consistent. If users ask who you are, say that you are Beaver, an AI assistant developed by Boway Information Systems Co., Ltd.
# Language
Use English for user-facing replies, task titles, summaries, plans, and final reports while this prompt is active. If the user explicitly asks for another language, follow that request.

View File

@ -0,0 +1,7 @@
你是海狸 (Beaver),博维资讯系统有限公司研发的 AI 助手。
与用户沟通时,保持这个身份一致。用户问你是谁时,说明你是海狸 (Beaver),博维资讯系统有限公司研发的 AI 助手。
# 语言
使用简体中文进行面向用户的回复、任务标题、摘要、计划和最终报告。若用户明确要求其他语言,则按用户要求执行。

View File

@ -0,0 +1,7 @@
你是海狸 (Beaver),博維資訊系統有限公司研發的 AI 助手。
與使用者溝通時,保持這個身份一致。使用者問你是誰時,說明你是海狸 (Beaver),博維資訊系統有限公司研發的 AI 助手。
# 語言
使用繁體中文進行面向使用者的回覆、任務標題、摘要、計劃和最終報告。若使用者明確要求其他語言,則按使用者要求執行。

View File

@ -22,6 +22,7 @@ from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
from beaver.engine.providers import make_provider_bundle
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.foundation.models import CronJob, CronRunRecord
from beaver.prompts.main_agent import normalize_main_agent_prompt_locale
from beaver.tasks import (
EvidenceBuilder,
MainAgentRouter,
@ -622,6 +623,7 @@ class AgentService:
session_id=session_id,
description=message,
metadata={
"prompt_locale": normalize_main_agent_prompt_locale(kwargs.get("prompt_locale")),
"router_reason": decision.reason,
**({"short_title": decision.short_title} if decision.short_title else {}),
},
@ -749,6 +751,8 @@ class AgentService:
session_manager = self._require_loaded(loaded, "session_manager")
base_execution_context = kwargs.get("execution_context")
prompt_locale = kwargs.get("prompt_locale") or task.metadata.get("prompt_locale")
output_language_instruction = self._output_language_instruction(prompt_locale)
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
kwargs = dict(kwargs)
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
@ -843,8 +847,11 @@ class AgentService:
"allow_candidate_generation": False,
}
)
if team_execution_context:
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
attempt_kwargs["execution_context"] = self._join_context(
base_execution_context,
output_language_instruction,
team_execution_context,
)
if plan.is_team and team_execution_context:
attempt_kwargs["include_tools"] = False
attempt_kwargs["max_tool_iterations"] = 0
@ -979,6 +986,24 @@ class AgentService:
"short_title": decision.short_title,
}
@staticmethod
def _output_language_instruction(prompt_locale: str | None) -> str:
locale = normalize_main_agent_prompt_locale(prompt_locale)
if locale == "en":
return (
"Output language: English. Use English for user-facing task titles, summaries, plans, "
"and final answers unless the user explicitly requests another language."
)
if locale == "zh-Hant":
return (
"輸出語言:繁體中文。除非使用者明確要求其他語言,所有面向使用者的任務標題、摘要、"
"計劃與最終回答都使用繁體中文。"
)
return (
"输出语言:简体中文。除非用户明确要求其他语言,所有面向用户的任务标题、摘要、"
"计划与最终回答都使用简体中文。"
)
@staticmethod
def _skill_names_for_run(loaded: Any, run_id: str) -> list[str]:
store = getattr(loaded, "run_memory_store", None)

View File

@ -26,3 +26,26 @@ def test_context_builder_injects_current_date_and_time() -> None:
assert "Local UTC offset: +08:00" in system_prompt
assert '"today", "tomorrow", "now", "this week", and "next month"' in system_prompt
assert result.messages[-1] == {"role": "user", "content": "今天几号?"}
def test_context_builder_uses_simplified_main_agent_prompt_by_default() -> None:
system_prompt = ContextBuilder().build_system_prompt(ContextBuildInput())
assert "你是海狸 (Beaver)" in system_prompt
assert "博维资讯系统有限公司研发的 AI 助手" in system_prompt
assert "使用简体中文进行面向用户的回复" in system_prompt
def test_context_builder_uses_traditional_main_agent_prompt_for_zh_hant() -> None:
system_prompt = ContextBuilder().build_system_prompt(ContextBuildInput(prompt_locale="zh-Hant"))
assert "你是海狸 (Beaver)" in system_prompt
assert "博維資訊系統有限公司研發的 AI 助手" in system_prompt
assert "使用繁體中文進行面向使用者的回覆" in system_prompt
def test_context_builder_uses_english_main_agent_prompt_for_en() -> None:
system_prompt = ContextBuilder().build_system_prompt(ContextBuildInput(prompt_locale="en"))
assert "You are Beaver, an AI assistant developed by Boway Information Systems Co., Ltd." in system_prompt
assert "Use English for user-facing replies" in system_prompt

View File

@ -15,6 +15,7 @@ class StubProvider(LLMProvider):
def __init__(self, responses: list[LLMResponse]) -> None:
super().__init__()
self._responses = list(responses)
self.seen_messages: list[list[dict]] = []
async def chat(
self,
@ -26,6 +27,7 @@ class StubProvider(LLMProvider):
) -> LLMResponse:
if not self._responses:
raise AssertionError("No stubbed provider responses left")
self.seen_messages.append(messages)
return self._responses.pop(0)
def get_default_model(self) -> str:
@ -99,6 +101,52 @@ def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> N
assert "validated" not in event_types
def test_task_mode_injects_prompt_locale_output_language(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
main_provider = StubProvider(
[
LLMResponse(
content="Done",
finish_reason="stop",
provider_name="stub",
model="stub-model",
)
]
)
bundle = ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=main_provider,
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
auxiliary_provider=StubProvider([_route_response("new_task", "Product summary")]),
)
result = asyncio.run(
service.process_direct(
"Summarize the uploaded report in English",
session_id="web:locale-task",
prompt_locale="en",
provider_bundle=bundle,
)
)
assert result.task_id
assert main_provider.seen_messages
system_prompt = main_provider.seen_messages[-1][0]["content"]
assert "Use English for user-facing replies" in system_prompt
assert "Output language: English." in system_prompt
task_service = service.create_loop().boot().task_service
assert task_service is not None
task = task_service.get_task(result.task_id)
assert task is not None
assert task.metadata["prompt_locale"] == "en"
def test_unrelated_simple_chat_auto_accepts_active_task(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(

View File

@ -73,6 +73,7 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
{
"type": "message",
"content": "hello",
"prompt_locale": "zh-Hant",
"metadata": {"source": "test"},
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
}
@ -89,6 +90,7 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
"user_id": None,
"title": None,
"execution_context": None,
"prompt_locale": "zh-Hant",
"model": None,
"provider_name": None,
"embedding_model": None,
@ -134,6 +136,7 @@ def test_websocket_message_uses_direct_processing_when_loop_is_not_running() ->
"user_id": None,
"title": None,
"execution_context": None,
"prompt_locale": None,
"model": None,
"provider_name": None,
"embedding_model": None,
@ -149,7 +152,10 @@ def test_rest_chat_uses_direct_processing_when_loop_is_not_running() -> None:
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.post("/api/chat", json={"session_id": "web:alpha", "message": "hello"})
response = client.post(
"/api/chat",
json={"session_id": "web:alpha", "message": "hello", "prompt_locale": "en"},
)
assert response.status_code == 200
assert service.calls == [
@ -160,6 +166,7 @@ def test_rest_chat_uses_direct_processing_when_loop_is_not_running() -> None:
"user_id": None,
"title": None,
"execution_context": None,
"prompt_locale": "en",
"model": None,
"provider_name": None,
"embedding_model": None,

View File

@ -23,6 +23,7 @@ import {
getSession,
getSessionProcess,
listSessions,
promptLocaleForAppLocale,
sendMessage,
submitChatFeedback,
uploadFile,
@ -44,7 +45,7 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
return data.type === 'session_updated' && typeof data.session_id === 'string';
}
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
function activeTaskStatusLabel(status: string, locale: string) {
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
if (status === 'awaiting_acceptance') return pickAppText(locale, '待验收', 'Awaiting acceptance');
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
@ -140,8 +141,9 @@ export default function ChatPage() {
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
locale,
}),
[activeTaskDetail, processArtifacts, processEvents, processRuns]
[activeTaskDetail, locale, processArtifacts, processEvents, processRuns]
);
const loadSessions = useCallback(async () => {
@ -400,6 +402,7 @@ export default function ChatPage() {
type: 'message',
content: msgContent,
thinking_enabled: thinkingModeEnabled,
prompt_locale: promptLocaleForAppLocale(locale),
};
if (attachments.length > 0) {
wsPayload.attachments = attachments;

View File

@ -97,8 +97,9 @@ export default function TaskDetailPage() {
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
locale,
}),
[backendTask, processArtifacts, processEvents, processRuns]
[backendTask, locale, processArtifacts, processEvents, processRuns]
);
const timelineCards = timelineView?.cards ?? [];

View File

@ -222,7 +222,7 @@ function OrdinaryTaskCard({
onDelete,
}: {
task: BackendTask;
locale: 'zh-CN' | 'en-US';
locale: string;
onDelete: () => void;
}) {
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
@ -284,7 +284,7 @@ function OrdinaryTaskCard({
);
}
function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
function taskStatusLabel(status: string, locale: string) {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
@ -297,7 +297,7 @@ function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
return label ? pickAppText(locale, label[0], label[1]) : status;
}
function taskSourceLabel(task: BackendTask, locale: 'zh-CN' | 'en-US') {
function taskSourceLabel(task: BackendTask, locale: string) {
if (task.metadata?.source === 'scheduled_run') {
return pickAppText(locale, '定时通知修改', 'Scheduled notification revision');
}
@ -520,7 +520,7 @@ function ScheduledJobCard({
onRemove,
}: {
job: CronJob;
locale: 'zh-CN' | 'en-US';
locale: string;
formatTime: (ms: number | null) => string;
onToggle: (checked: boolean) => void;
onRun: () => void;

View File

@ -155,7 +155,7 @@ const Header = () => {
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] 2xl:hidden"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] min-[1800px]:hidden"
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
aria-expanded={mobileMenuOpen}
aria-controls="app-primary-mobile-nav"
@ -170,7 +170,7 @@ const Header = () => {
</Link>
</div>
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] 2xl:flex">
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] min-[1800px]:flex">
{renderNavLinks(false)}
</nav>
@ -185,7 +185,7 @@ const Header = () => {
<PopoverTrigger asChild>
<button
type="button"
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4] sm:w-auto sm:justify-start sm:px-2"
className="flex h-11 w-11 min-w-0 items-center justify-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4] sm:w-auto sm:max-w-[11rem] sm:justify-start sm:px-2"
aria-label={pickAppText(locale, '打开账号菜单', 'Open account menu')}
>
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
@ -193,7 +193,7 @@ const Header = () => {
{userInitial}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
<span className="hidden min-w-0 max-w-24 truncate sm:block">{user.username}</span>
<ChevronDown className="hidden h-4 w-4 text-muted-foreground sm:block" />
</button>
</PopoverTrigger>
@ -245,14 +245,14 @@ const Header = () => {
<>
<button
type="button"
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 2xl:hidden"
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 min-[1800px]:hidden"
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
onClick={() => setMobileMenuOpen(false)}
/>
<nav
id="app-primary-mobile-nav"
aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 2xl:hidden"
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 min-[1800px]:hidden"
>
<div className="min-h-full bg-background px-4 py-5">
<div className="grid gap-2 bg-background">

View File

@ -2,40 +2,49 @@
import { Languages } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils';
const OPTIONS = [
{ value: 'zh-CN', label: 'ZH' },
{ value: 'en-US', label: 'EN' },
{ value: 'zh-CN', label: '中文', shortLabel: '中' },
{ value: 'en-US', label: 'English', shortLabel: 'EN' },
{ value: 'zh-Hant', label: '繁體中文', shortLabel: '繁' },
] as const;
export function LanguageSwitcher({ className }: { className?: string }) {
const { locale, setLocale } = useAppI18n();
const selectedOption = OPTIONS.find((option) => option.value === locale) ?? OPTIONS[0];
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-md border border-border bg-muted/30 p-1',
className
)}
>
<Languages className="h-3.5 w-3.5 text-muted-foreground" />
{OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setLocale(option.value)}
className={cn(
'h-11 w-11 rounded text-xs font-medium transition-colors',
locale === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{option.label}
</button>
))}
</div>
<Select value={locale} onValueChange={(value) => setLocale(value as AppLocale)}>
<SelectTrigger
className={cn('h-11 w-[92px] gap-1.5 bg-muted/30 px-2 sm:w-[138px] sm:gap-2 sm:px-3', className)}
aria-label={pickAppText(locale, '选择语言', 'Select language')}
>
<Languages className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<SelectValue aria-label={selectedOption.label}>
<span className="min-w-0 flex-1 truncate text-left">
<span className="sm:hidden">{selectedOption.shortLabel}</span>
<span className="hidden sm:inline">{selectedOption.label}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -6,6 +6,7 @@ import { CheckCircle2, Loader2, Sparkles } from 'lucide-react';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Badge } from '@/components/ui/badge';
import { appArtifactPreview, appFeedRoleLabel, appStatusLabel } from '@/lib/i18n/common';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils';
@ -84,7 +85,7 @@ function buildFeed(
run: ProcessRun,
events: ProcessEvent[],
artifacts: ProcessArtifact[],
locale: 'zh-CN' | 'en-US',
locale: AppLocale,
): AgentFeedItem[] {
const items: AgentFeedItem[] = [];
let hasLeadBubble = false;
@ -152,7 +153,7 @@ function buildFeed(
.slice(-8);
}
function runSummary(run: ProcessRun, feed: AgentFeedItem[], locale: 'zh-CN' | 'en-US'): string {
function runSummary(run: ProcessRun, feed: AgentFeedItem[], locale: AppLocale): string {
if (run.summary?.trim()) {
return run.summary.trim();
}
@ -262,7 +263,7 @@ function AgentBubble({
locale,
}: {
item: AgentFeedItem;
locale: 'zh-CN' | 'en-US';
locale: AppLocale;
}) {
return (
<div
@ -297,7 +298,7 @@ function LiveAgentCard({
phase: RunCardPhase;
accentIndex: number;
onSelect: () => void;
locale: 'zh-CN' | 'en-US';
locale: AppLocale;
}) {
const showSpinner = !TERMINAL_STATUSES.has(run.status);
const accent = accentFor(accentIndex);
@ -370,7 +371,7 @@ function ResultCard({
selected: boolean;
accentIndex: number;
onSelect: () => void;
locale: 'zh-CN' | 'en-US';
locale: AppLocale;
}) {
const accent = accentFor(accentIndex);

View File

@ -18,7 +18,7 @@ function artifactIcon(type: ProcessArtifact['artifact_type']) {
return <FileOutput className="w-4 h-4" />;
}
function renderArtifactBody(artifact: ProcessArtifact, locale: 'zh-CN' | 'en-US') {
function renderArtifactBody(artifact: ProcessArtifact, locale: string) {
if (artifact.artifact_type === 'json' && artifact.data !== undefined) {
return (
<pre className="text-[11px] leading-5 whitespace-pre-wrap break-words rounded-md bg-background/70 p-3 overflow-x-auto">

View File

@ -21,17 +21,19 @@ function ProgressPanel({
const { locale } = useAppI18n();
return (
<div className="flex h-full flex-col bg-[#FBFAF9]">
<div className="flex h-16 shrink-0 items-center justify-between border-b border-[#E6E1DE] px-5">
<div>
<h2 className="text-base font-semibold text-foreground">
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-[#FBFAF9]">
<div className="flex h-16 min-w-0 shrink-0 items-center justify-between gap-3 border-b border-[#E6E1DE] px-5">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-foreground">
{pickAppText(locale, '当前会话的运行进度', 'Current Session Progress')}
</h2>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<p className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{isLive ? <Activity className="h-3.5 w-3.5" /> : null}
{isLive
? pickAppText(locale, '任务时间线实时更新', 'Task timeline updates live')
: pickAppText(locale, '任务详情时间线一致', 'Matches the Task detail timeline')}
<span className="truncate">
{isLive
? pickAppText(locale, '任务时间线实时更新', 'Task timeline updates live')
: pickAppText(locale, '与任务详情时间线一致', 'Matches the Task detail timeline')}
</span>
</p>
</div>
{onClose ? (
@ -46,8 +48,8 @@ function ProgressPanel({
) : null}
</div>
<ScrollArea className="min-h-0 flex-1 px-4 py-4">
<div className="pb-6">
<ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden px-4 py-4">
<div className="min-w-0 max-w-full pb-6">
<TaskTimeline cards={cards} isLive={isLive} showHeader={false} />
</div>
</ScrollArea>
@ -67,7 +69,7 @@ export function CurrentSessionProgressSidebar({
return (
<>
<aside className="hidden h-full w-[380px] shrink-0 border-l border-[#E6E1DE] xl:flex">
<aside className="hidden h-full w-[380px] min-w-0 shrink-0 overflow-hidden border-l border-[#E6E1DE] xl:flex">
<ProgressPanel cards={cards} isLive={isLive} />
</aside>
@ -88,7 +90,7 @@ export function CurrentSessionProgressSidebar({
onClick={() => setMobileOpen(false)}
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
/>
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] border-l border-[#E6E1DE] shadow-2xl">
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] min-w-0 overflow-hidden border-l border-[#E6E1DE] shadow-2xl">
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
</div>
</div>

View File

@ -55,14 +55,14 @@ function feedbackKind(item: TaskFeedbackItem): string {
return String(item.acceptance_type || item.feedback_type || '');
}
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
function humanFeedback(type: string, locale: string) {
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
return type || pickAppText(locale, '验收', 'Acceptance');
}
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
function humanTaskStatus(status: string, locale: string) {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],

View File

@ -24,7 +24,7 @@ function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
return RUNTIME_STATUSES.has(status);
}
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
function humanTaskStatus(status: string, locale: string) {
const map: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],

View File

@ -26,7 +26,7 @@ function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
return RUNTIME_STATUSES.has(status);
}
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
function humanTaskStatus(status: string, locale: string) {
const map: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
@ -47,7 +47,7 @@ function latestFeedback(task: BackendTask): Record<string, unknown> | null {
return [...(task.feedback ?? [])].reverse()[0] ?? null;
}
function acceptanceState(task: BackendTask, locale: 'zh-CN' | 'en-US'): string {
function acceptanceState(task: BackendTask, locale: string): string {
const feedback = latestFeedback(task);
const kind = String(feedback?.acceptance_type || feedback?.feedback_type || '');
if (kind) return humanTaskStatus(kind, locale);

View File

@ -93,7 +93,7 @@ function detailsJson(details: Record<string, unknown>): string {
}
}
function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
function cardTypeLabel(type: TaskTimelineCardType, locale: string) {
const labels: Record<TaskTimelineCardType, [string, string]> = {
task_created: ['任务', 'Task'],
plan: ['计划', 'Plan'],
@ -114,7 +114,7 @@ function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
return pickAppText(locale, label[0], label[1]);
}
function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
function humanStatus(status: string, locale: string) {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
@ -137,7 +137,7 @@ function historyVersions(details: Record<string, unknown> | undefined): Array<Re
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
}
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
function renderHistoryStatus(version: Record<string, unknown>, locale: string) {
const status = String(version.acceptanceType || version.status || '');
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
}
@ -184,30 +184,30 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
return (
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="min-w-0 max-w-full scroll-mt-44 overflow-hidden rounded-md">
<CardContent className="p-4">
<div className="flex gap-3">
<div className="flex min-w-0 gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold">{card.title}</h3>
<Badge variant="secondary" className="shrink-0 text-[11px]">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1 basis-44">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className={`min-w-0 flex-1 basis-32 text-sm font-semibold ${containedLongTextClass}`}>{card.title}</h3>
<Badge variant="secondary" className="max-w-full text-[11px]">
{cardTypeLabel(card.type, locale)}
</Badge>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{card.actorName ? <span className={containedLongTextClass}>{card.actorName}</span> : null}
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
<div className="mt-1 flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{card.actorName ? <span className={`max-w-full ${containedLongTextClass}`}>{card.actorName}</span> : null}
<span className="max-w-full">{formatTaskRuntimeTime(card.createdAt, locale)}</span>
{card.runId ? <span className={`max-w-full font-mono ${containedLongTextClass}`}>{card.runId.slice(0, 8)}</span> : null}
</div>
</div>
{card.status ? (
isRuntimeStatus(card.status) ? (
<TaskRuntimeStatusBadge status={card.status} />
<TaskRuntimeStatusBadge status={card.status} className={`max-w-full ${containedLongTextClass}`} />
) : (
<Badge variant="outline" className="shrink-0 text-[11px]">
<Badge variant="outline" className={`max-w-full text-[11px] ${containedLongTextClass}`}>
{humanStatus(card.status, locale)}
</Badge>
)
@ -224,7 +224,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<summary className="flex min-h-[44px] cursor-pointer select-none items-center font-medium text-muted-foreground">
<summary className="flex min-h-[44px] min-w-0 cursor-pointer select-none items-center font-medium text-muted-foreground">
{pickAppText(locale, '详情 JSON', 'Details JSON')}
</summary>
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>

View File

@ -35,7 +35,7 @@ export function TaskRuntimeStatusBadge({
);
}
export function formatTaskRuntimeTime(value?: string | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
export function formatTaskRuntimeTime(value?: string | null, locale: string = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
@ -47,7 +47,7 @@ export function formatTaskRuntimeTime(value?: string | null, locale: 'zh-CN' | '
}).format(date);
}
export function formatTaskRuntimeDuration(durationMs: number | null, locale: 'zh-CN' | 'en-US' = 'zh-CN'): string {
export function formatTaskRuntimeDuration(durationMs: number | null, locale: string = 'zh-CN'): string {
if (durationMs === null || durationMs < 0) return '-';
if (durationMs < 1000) return locale === 'en-US' ? '<1s' : '<1秒';

View File

@ -88,7 +88,7 @@ const SelectContent = React.forwardRef<
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
'w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}

View File

@ -51,7 +51,7 @@ import type {
UiMcpServerDescriptor,
WsEvent,
} from '@/types';
import { getCurrentAppLocale, pickAppText } from '@/lib/i18n/core';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
const API_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
@ -62,6 +62,15 @@ const REQUEST_TIMEOUT_MS = 8000;
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
export type PromptLocale = 'zh-Hans' | 'zh-Hant' | 'en';
export function promptLocaleForAppLocale(locale: AppLocale): PromptLocale {
if (locale === 'zh-Hant') {
return 'zh-Hant';
}
return locale === 'en-US' ? 'en' : 'zh-Hans';
}
function isBrowser(): boolean {
return typeof window !== 'undefined';
}
@ -271,6 +280,7 @@ export async function sendMessage(
replyToScheduledRunId?: string;
scheduledReplyIntent?: 'revise_once' | 'update_future' | 'continue_task';
thinkingEnabled?: boolean;
promptLocale?: PromptLocale;
}
): Promise<{
response?: string;
@ -281,7 +291,11 @@ export async function sendMessage(
task_status?: string | null;
evidence_status?: string | null;
}> {
const body: Record<string, unknown> = { message, session_id: sessionId };
const body: Record<string, unknown> = {
message,
session_id: sessionId,
prompt_locale: options?.promptLocale || promptLocaleForAppLocale(getCurrentAppLocale()),
};
if (attachments && attachments.length > 0) {
body.attachments = attachments;
}
@ -356,7 +370,11 @@ export function streamMessage(
const res = await fetch(buildApiUrl('/api/chat/stream'), {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ message, session_id: sessionId }),
body: JSON.stringify({
message,
session_id: sessionId,
prompt_locale: promptLocaleForAppLocale(getCurrentAppLocale()),
}),
signal: controller.signal,
});

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { isAppLocale, normalizeAppLocale, pickAppText } from '@/lib/i18n/core';
describe('app locale normalization', () => {
it('accepts simplified Chinese, English, and traditional Chinese locales', () => {
expect(isAppLocale('zh-CN')).toBe(true);
expect(isAppLocale('en-US')).toBe(true);
expect(isAppLocale('zh-Hant')).toBe(true);
});
it('normalizes common traditional Chinese locale tags', () => {
expect(normalizeAppLocale('zh-TW')).toBe('zh-Hant');
expect(normalizeAppLocale('zh-HK')).toBe('zh-Hant');
expect(normalizeAppLocale('zh-Hant')).toBe('zh-Hant');
});
});
describe('app text picker', () => {
it('returns simplified Chinese text for zh-CN', () => {
expect(pickAppText('zh-CN', '任务状态', 'Task status')).toBe('任务状态');
});
it('returns English text for en-US', () => {
expect(pickAppText('en-US', '任务状态', 'Task status')).toBe('Task status');
});
it('returns traditional Chinese text for zh-Hant', () => {
expect(pickAppText('zh-Hant', '任务状态', 'Task status')).toBe('任務狀態');
expect(pickAppText('zh-Hant', '智能体结果', 'Agent results')).toBe('智慧體結果');
});
});

View File

@ -1,12 +1,12 @@
export const APP_LOCALE_COOKIE = 'beaver_locale';
export const APP_LOCALE_STORAGE_KEY = 'beaver_locale';
export const APP_LOCALES = ['zh-CN', 'en-US'] as const;
export const APP_LOCALES = ['zh-CN', 'en-US', 'zh-Hant'] as const;
export type AppLocale = (typeof APP_LOCALES)[number];
export function isAppLocale(value: string | null | undefined): value is AppLocale {
return value === 'zh-CN' || value === 'en-US';
return value === 'zh-CN' || value === 'en-US' || value === 'zh-Hant';
}
export function normalizeAppLocale(value?: string | null): AppLocale {
@ -14,6 +14,14 @@ export function normalizeAppLocale(value?: string | null): AppLocale {
if (probe.startsWith('en')) {
return 'en-US';
}
if (
probe === 'zh-hant' ||
probe.startsWith('zh-tw') ||
probe.startsWith('zh-hk') ||
probe.startsWith('zh-mo')
) {
return 'zh-Hant';
}
return 'zh-CN';
}
@ -71,6 +79,507 @@ export function getCurrentAppLocale(): AppLocale {
return readBrowserAppLocale();
}
export function pickAppText<T>(locale: AppLocale, zhValue: T, enValue: T): T {
return locale === 'en-US' ? enValue : zhValue;
export function pickAppText<T>(locale: string | null | undefined, zhValue: T, enValue: T): T {
const appLocale = normalizeAppLocale(locale);
if (appLocale === 'en-US') {
return enValue;
}
if (appLocale === 'zh-Hant') {
return toTraditionalValue(zhValue);
}
return zhValue;
}
function toTraditionalValue<T>(value: T): T {
return typeof value === 'string' ? (toTraditionalChinese(value) as T) : value;
}
const SIMPLIFIED_TO_TRADITIONAL_PHRASES: Array<[string, string]> = [
['智能体', '智慧體'],
['Agent Team', 'Agent Team'],
];
const SIMPLIFIED_TO_TRADITIONAL_CHARS: Record<string, string> = {
: '個',
: '為',
: '麼',
: '義',
: '習',
: '書',
: '了',
: '於',
: '雲',
: '產',
: '僅',
: '從',
: '倉',
: '儀',
: '們',
: '優',
: '會',
: '傳',
: '體',
: '餘',
: '側',
: '偵',
: '促',
: '倆',
: '值',
: '假',
: '做',
: '停',
: '儲',
: '像',
: '兒',
: '先',
: '光',
: '關',
: '興',
: '具',
: '內',
: '冊',
: '寫',
: '軍',
: '農',
: '況',
: '凍',
: '淨',
: '準',
: '幾',
: '擊',
: '劃',
: '則',
: '創',
: '初',
: '刪',
: '別',
: '到',
: '製',
: '劑',
: '剩',
: '辦',
: '功',
: '加',
: '務',
: '動',
: '助',
: '勢',
: '包',
: '區',
: '協',
: '單',
: '賣',
: '佔',
: '卡',
: '歷',
: '壓',
: '廁',
: '廂',
: '縣',
: '參',
: '雙',
: '發',
: '變',
: '疊',
: '號',
: '後',
: '向',
: '嗎',
: '啟',
: '員',
: '命',
: '諮',
: '啞',
: '響',
: '喚',
: '問',
: '單',
: '餵',
: '器',
: '團',
: '園',
: '困',
: '圖',
: '場',
: '塊',
: '壞',
: '址',
: '堅',
: '壇',
: '型',
: '垃',
: '域',
: '堆',
: '填',
: '增',
: '牆',
: '聲',
: '處',
: '備',
: '復',
: '夠',
: '頭',
: '獎',
: '好',
: '如',
: '始',
: '委',
: '存',
: '學',
: '寧',
: '它',
: '安',
: '完',
: '實',
: '審',
: '客',
: '憲',
: '寬',
: '對',
: '導',
: '將',
: '爾',
: '嘗',
: '層',
: '屬',
: '歲',
: '島',
: '州',
: '工',
: '幣',
: '師',
: '帳',
: '帶',
: '幫',
: '乾',
: '並',
广: '廣',
: '慶',
: '庫',
: '應',
: '廢',
: '開',
: '異',
: '棄',
: '張',
: '強',
: '歸',
: '當',
: '錄',
: '徹',
: '徑',
: '待',
: '循',
: '憶',
: '誌',
: '憂',
: '念',
: '態',
: '總',
: '恢',
: '息',
: '您',
: '情',
: '想',
: '意',
: '願',
: '戲',
: '戰',
: '戶',
: '執',
: '擴',
: '掃',
: '揚',
: '批',
: '找',
: '技',
: '報',
: '護',
: '抽',
: '擔',
: '擁',
: '擇',
: '按',
: '揮',
: '換',
: '損',
: '據',
: '授',
: '掉',
: '接',
: '控',
: '推',
: '提',
: '插',
: '揭',
: '搜',
: '攜',
: '攝',
: '摘',
: '播',
: '操',
: '支',
: '收',
: '改',
: '放',
: '效',
: '數',
: '文',
: '斷',
: '新',
: '無',
: '時',
: '明',
: '顯',
: '智',
: '暫',
: '更',
: '替',
: '術',
: '機',
: '權',
: '條',
: '來',
: '極',
: '構',
: '標',
: '欄',
: '樹',
: '樣',
: '核',
: '案',
: '檔',
: '檢',
: '樓',
: '次',
: '款',
: '步',
: '殘',
: '段',
: '畢',
: '氣',
: '匯',
: '漢',
: '沒',
: '法',
: '註',
: '洩',
: '測',
: '瀏',
: '消',
: '涉',
: '漲',
: '潤',
: '添',
: '清',
: '渠',
: '渲',
: '溫',
: '滾',
: '滿',
: '漏',
: '演',
: '點',
: '煩',
: '熱',
: '然',
: '照',
: '愛',
: '父',
: '片',
: '版',
: '狀',
: '獨',
: '環',
: '現',
: '理',
: '畫',
: '暢',
: '療',
: '登',
: '監',
: '盤',
: '碼',
: '礎',
: '確',
: '礙',
: '禮',
: '離',
: '種',
: '稱',
: '穩',
: '窗',
: '筆',
: '簽',
: '簡',
: '算',
: '管',
: '類',
: '黏',
: '精',
: '系',
: '級',
线: '線',
: '組',
: '細',
: '終',
: '經',
: '結',
: '絕',
: '統',
: '維',
: '緩',
: '編',
: '縮',
: '缺',
: '網',
: '置',
: '聯',
: '聊',
: '肅',
: '背',
: '能',
: '腳',
: '脫',
: '腦',
: '自動',
: '艦',
: '藝',
: '節',
: '範',
: '薦',
: '獲',
: '營',
: '落',
: '著',
: '藏',
: '慮',
: '虛',
: '雖',
: '行',
: '補',
: '表',
: '裝',
: '規',
: '視',
: '覺',
: '覽',
: '計',
: '訂',
: '認',
: '議',
: '訊',
: '記',
: '講',
: '許',
: '論',
: '設',
访: '訪',
: '證',
: '評',
: '識',
: '訴',
: '試',
: '話',
: '詳',
: '語',
: '誤',
: '請',
: '讀',
: '調',
: '談',
: '謝',
: '谷',
: '帳',
: '負',
: '責',
: '敗',
: '貨',
: '質',
: '資',
: '贓',
: '起',
: '超',
: '躍',
: '路',
: '蹤',
: '車',
: '輪',
: '軟',
: '載',
: '輯',
: '輸',
: '邊',
: '達',
: '過',
: '還',
: '這',
: '進',
: '遠',
: '連',
: '遲',
: '適',
: '選',
: '遞',
: '通',
: '邏',
: '遺',
: '遙',
: '邀',
: '郵',
: '部',
: '配',
: '釋',
: '重',
: '針',
: '鑰',
: '鐘',
: '鈕',
: '錢',
: '鏈',
: '錯',
: '鍵',
: '鏡',
: '長',
: '門',
: '閉',
: '間',
: '隊',
: '階',
: '陽',
: '陰',
: '陳',
: '際',
: '隱',
: '難',
: '雛',
: '需',
: '面',
: '頁',
: '項',
: '順',
: '須',
: '預',
: '題',
: '顏',
: '風',
: '飛',
: '館',
: '驗',
: '高',
: '魚',
: '鮮',
: '鳥',
: '麥',
: '黃',
};
export function toTraditionalChinese(value: string): string {
let converted = value;
for (const [source, target] of SIMPLIFIED_TO_TRADITIONAL_PHRASES) {
converted = converted.split(source).join(target);
}
return Array.from(converted)
.map((char) => SIMPLIFIED_TO_TRADITIONAL_CHARS[char] ?? char)
.join('');
}

View File

@ -40,9 +40,11 @@ describe('buildTaskTimelineView', () => {
const view = buildTaskTimelineView({
task: task(),
liveEvents,
locale: 'en-US',
});
expect(view?.cards.map((card) => card.type)).toEqual(['task_created', 'plan']);
expect(view?.cards.map((card) => card.title)).toEqual(['Task created', 'Execution plan']);
expect(view?.process.events.map((event) => event.event_id)).toEqual(['plan']);
});

View File

@ -1,9 +1,11 @@
import { selectTaskProcess, type SelectTaskProcessInput, type TaskProcessSelection } from '@/lib/task-process';
import { buildTaskTimelineCards } from '@/lib/task-timeline';
import type { AppLocale } from '@/lib/i18n/core';
import type { BackendTask, TaskTimelineCard } from '@/types';
export type BuildTaskTimelineViewInput = Omit<SelectTaskProcessInput, 'task'> & {
task: BackendTask | null;
locale?: AppLocale | string;
};
export type TaskTimelineView = {
@ -16,6 +18,7 @@ export function buildTaskTimelineView({
liveRuns,
liveEvents,
liveArtifacts,
locale,
}: BuildTaskTimelineViewInput): TaskTimelineView | null {
if (!task) return null;
@ -32,6 +35,7 @@ export function buildTaskTimelineView({
processRuns: process.runs,
processEvents: process.events,
processArtifacts: process.artifacts,
locale,
}),
};
}

View File

@ -143,6 +143,48 @@ describe('buildTaskTimelineCards', () => {
expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']);
});
it('localizes generated milestone titles for English and Traditional Chinese', () => {
const task = makeTask();
const processEvents: ProcessEvent[] = [
{
event_id: 'evt-plan',
run_id: 'run-main',
parent_run_id: null,
kind: 'task_planned',
actor_type: 'agent',
actor_id: 'planner',
actor_name: 'Task Planner',
text: 'Plan created.',
created_at: '2026-05-26T10:01:00.000Z',
},
{
event_id: 'evt-tool-start',
run_id: 'run-main',
parent_run_id: null,
kind: 'tool_call_started',
actor_type: 'mcp',
actor_id: 'user_files_list',
actor_name: 'user_files_list',
text: 'Calling tool: user_files_list.',
created_at: '2026-05-26T10:02:00.000Z',
},
];
const englishCards = buildTaskTimelineCards({ task, processEvents, locale: 'en-US' });
const traditionalCards = buildTaskTimelineCards({ task, processEvents, locale: 'zh-Hant' });
expect(englishCards.map((card) => card.title)).toEqual([
'Task created',
'Execution plan',
'Calling tool: user_files_list',
]);
expect(traditionalCards.map((card) => card.title)).toEqual([
'任務已創建',
'執行計劃',
'調用工具user_files_list',
]);
});
it('appends result and acceptance cards for closed tasks with feedback', () => {
const task = makeTask({
is_open: false,

View File

@ -6,12 +6,14 @@ import type {
TaskTimelineCard,
TaskTimelineCardType,
} from '@/types';
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
export type BuildTaskTimelineCardsInput = {
task: BackendTask;
processRuns?: ProcessRun[];
processEvents?: ProcessEvent[];
processArtifacts?: ProcessArtifact[];
locale?: AppLocale | string;
};
const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
@ -110,36 +112,40 @@ function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
}
}
function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
function titleForCard(type: TaskTimelineCardType, actorName?: string, locale: AppLocale | string = getCurrentAppLocale()): string {
switch (type) {
case 'task_created':
return '任务已创建';
return pickAppText(locale, '任务已创建', 'Task created');
case 'plan':
return '执行计划';
return pickAppText(locale, '执行计划', 'Execution plan');
case 'skill':
return '选择 Skill';
return pickAppText(locale, '选择 Skill', 'Skill selected');
case 'tool_call':
return actorName ? `调用工具:${actorName}` : '调用工具';
return actorName
? pickAppText(locale, `调用工具:${actorName}`, `Calling tool: ${actorName}`)
: pickAppText(locale, '调用工具', 'Tool call');
case 'tool_result':
return actorName ? `工具结果:${actorName}` : '工具结果';
return actorName
? pickAppText(locale, `工具结果:${actorName}`, `Tool result: ${actorName}`)
: pickAppText(locale, '工具结果', 'Tool result');
case 'next_step':
return '下一步';
return pickAppText(locale, '下一步', 'Next step');
case 'agent_team':
return '启动 Agent Team';
return pickAppText(locale, '启动 Agent Team', 'Agent team started');
case 'agent_progress':
return actorName || 'Agent 进展';
return actorName || pickAppText(locale, 'Agent 进展', 'Agent progress');
case 'agent_handoff':
return 'Agent 交接';
return pickAppText(locale, 'Agent 交接', 'Agent handoff');
case 'artifact':
return '生成产物';
return pickAppText(locale, '生成产物', 'Artifact generated');
case 'error':
return '执行遇到问题';
return pickAppText(locale, '执行遇到问题', 'Execution issue');
case 'result':
return '本轮结果';
return pickAppText(locale, '本轮结果', 'Run result');
case 'result_history':
return '历史结果版本';
return pickAppText(locale, '历史结果版本', 'Previous result versions');
case 'acceptance':
return '任务验收';
return pickAppText(locale, '任务验收', 'Task acceptance');
}
}
@ -286,7 +292,12 @@ function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map<string,
return statuses;
}
function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[]): TaskTimelineCard {
function buildResultHistoryCard(
task: BackendTask,
resultCards: TaskTimelineCard[],
acceptanceCards: TaskTimelineCard[],
locale: AppLocale | string,
): TaskTimelineCard {
const versions = resultCards.map((resultCard) => {
const acceptanceCard = acceptanceCards
.filter((card) => card.runId === resultCard.runId)
@ -307,14 +318,18 @@ function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard
id: `${task.task_id}:result-history`,
taskId: task.task_id,
type: 'result_history',
title: titleForCard('result_history'),
summary: `${resultCards.length} 历史结果版本`,
title: titleForCard('result_history', undefined, locale),
summary: pickAppText(
locale,
`${resultCards.length} 历史结果版本`,
`${resultCards.length} previous result ${resultCards.length === 1 ? 'version' : 'versions'}`,
),
createdAt: resultCards[0]?.createdAt ?? task.created_at,
details: { versions },
};
}
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[]): TaskTimelineCard[] {
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskTimelineCard[] {
const resultCards = cards.filter((card) => card.type === 'result');
if (resultCards.length <= 1) return cards;
@ -334,7 +349,7 @@ function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[])
.filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId))
.sort((a, b) => cardTime(a) - cardTime(b));
const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id));
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances);
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances, locale);
const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id);
const output: TaskTimelineCard[] = [];
@ -352,6 +367,7 @@ function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[])
export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
const { task } = input;
const locale = input.locale ?? getCurrentAppLocale();
const processRuns = input.processRuns ?? task.process_runs ?? [];
const processEvents = input.processEvents ?? task.process_events ?? [];
const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? [];
@ -365,7 +381,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
id: `${task.task_id}:created`,
taskId: task.task_id,
type: 'task_created',
title: titleForCard('task_created'),
title: titleForCard('task_created', undefined, locale),
summary: firstString(task.short_title, task.description, task.goal),
actorName: task.creator,
status: task.status,
@ -396,7 +412,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
runId: event.run_id,
parentRunId: event.parent_run_id,
type,
title: titleForCard(type, event.actor_name),
title: titleForCard(type, event.actor_name, locale),
summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event),
actorName: event.actor_name,
status:
@ -418,7 +434,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
runId: run.run_id,
parentRunId: run.parent_run_id,
type: 'agent_progress',
title: titleForCard('agent_progress', run.actor_name),
title: titleForCard('agent_progress', run.actor_name, locale),
summary: firstString(run.summary, run.title),
actorName: run.actor_name,
status: run.status,
@ -435,7 +451,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
runId: artifact.run_id,
parentRunId: run?.parent_run_id,
type: 'artifact',
title: titleForCard('artifact'),
title: titleForCard('artifact', undefined, locale),
summary: firstString(artifact.title),
actorName: artifact.actor_name,
createdAt: artifact.created_at,
@ -454,7 +470,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
taskId: task.task_id,
runId: lastItem(task.run_ids),
type: 'result',
title: titleForCard('result'),
title: titleForCard('result', undefined, locale),
summary: fallbackResultSummary(task),
status: task.status,
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
@ -473,7 +489,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
taskId: task.task_id,
runId,
type: 'acceptance',
title: titleForCard('acceptance'),
title: titleForCard('acceptance', undefined, locale),
summary: feedbackSummary(feedback),
status: firstString(feedback.acceptance_type),
createdAt,
@ -486,5 +502,5 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
.sort(compareCardsByCreatedAt)
.map(({ card }) => card);
return collapseHistoricalResults(task, sortedCards);
return collapseHistoricalResults(task, sortedCards, locale);
}