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

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