feat: 支持多语言提示词本地化和界面优化
- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化 - 移除内置 agents 配置以简化系统架构 - 更新 ContextBuilder 使用动态提示词模板而非硬编码内容 - 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数 - 添加输出语言指令确保用户界面内容按指定语言生成 - 扩展前端 LanguageSwitcher 组件支持三种语言选项 - 优化 Header 和侧边栏组件的响应式布局和文本截断处理 - 更新测试用例验证不同语言环境下的提示词正确性
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
5
app-instance/backend/beaver/prompts/__init__.py
Normal file
5
app-instance/backend/beaver/prompts/__init__.py
Normal 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"]
|
||||
55
app-instance/backend/beaver/prompts/main_agent.py
Normal file
55
app-instance/backend/beaver/prompts/main_agent.py
Normal 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()
|
||||
7
app-instance/backend/beaver/prompts/main_agent/en.md
Normal file
7
app-instance/backend/beaver/prompts/main_agent/en.md
Normal 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.
|
||||
@ -0,0 +1,7 @@
|
||||
你是海狸 (Beaver),博维资讯系统有限公司研发的 AI 助手。
|
||||
|
||||
与用户沟通时,保持这个身份一致。用户问你是谁时,说明你是海狸 (Beaver),博维资讯系统有限公司研发的 AI 助手。
|
||||
|
||||
# 语言
|
||||
|
||||
使用简体中文进行面向用户的回复、任务标题、摘要、计划和最终报告。若用户明确要求其他语言,则按用户要求执行。
|
||||
@ -0,0 +1,7 @@
|
||||
你是海狸 (Beaver),博維資訊系統有限公司研發的 AI 助手。
|
||||
|
||||
與使用者溝通時,保持這個身份一致。使用者問你是誰時,說明你是海狸 (Beaver),博維資訊系統有限公司研發的 AI 助手。
|
||||
|
||||
# 語言
|
||||
|
||||
使用繁體中文進行面向使用者的回覆、任務標題、摘要、計劃和最終報告。若使用者明確要求其他語言,則按使用者要求執行。
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ?? [];
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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秒';
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
32
app-instance/frontend/lib/i18n/core.test.ts
Normal file
32
app-instance/frontend/lib/i18n/core.test.ts
Normal 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('智慧體結果');
|
||||
});
|
||||
});
|
||||
@ -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('');
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user