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

@ -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);
}