Files
beaver_project/app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx
steven_li fc9fd93c36 feat: 支持多语言提示词本地化和界面优化
- 添加 prompt_locale 参数支持简体中文、繁体中文和英文提示词本地化
- 移除内置 agents 配置以简化系统架构
- 更新 ContextBuilder 使用动态提示词模板而非硬编码内容
- 在 AgentLoop、Web 接口和 AgentService 中传递 locale 参数
- 添加输出语言指令确保用户界面内容按指定语言生成
- 扩展前端 LanguageSwitcher 组件支持三种语言选项
- 优化 Header 和侧边栏组件的响应式布局和文本截断处理
- 更新测试用例验证不同语言环境下的提示词正确性
2026-06-10 16:11:05 +08:00

249 lines
9.5 KiB
TypeScript

'use client';
import * as React from 'react';
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
export type TaskFeedbackItem = {
acceptance_type?: unknown;
feedback_type?: unknown;
comment?: unknown;
created_at?: unknown;
run_id?: unknown;
};
type Props = {
sessionId: string;
runId: string | null;
taskStatus: string;
feedbackItems: TaskFeedbackItem[];
actionBusy: string | null;
revision?: string;
onRevisionChange?: (value: string) => void;
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
};
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
const READY_FOR_ACCEPTANCE_STATUSES = new Set<string>(['awaiting_acceptance', 'needs_revision']);
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
return RUNTIME_STATUSES.has(status);
}
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
if (!runId) return null;
return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null;
}
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
return [...items].reverse()[0] ?? null;
}
function feedbackKind(item: TaskFeedbackItem): string {
return String(item.acceptance_type || item.feedback_type || '');
}
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: string) {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
accept: ['接受', 'Accepted'],
satisfied: ['接受', 'Accepted'],
revise: ['请求修改', 'Revision requested'],
abandon: ['放弃任务', 'Abandoned'],
};
const label = labels[status];
return label ? pickAppText(locale, label[0], label[1]) : status;
}
function FeedbackButton({
type,
icon,
label,
actionBusy,
disabled,
onClick,
}: {
type: TaskFeedbackType;
icon: React.ReactNode;
label: string;
actionBusy: string | null;
disabled: boolean;
onClick: () => void;
}) {
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
return (
<Button type="button" variant="outline" className="h-11 w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
{label}
</Button>
);
}
export function TaskAcceptanceCard({
sessionId,
runId,
taskStatus,
feedbackItems,
actionBusy,
revision,
onRevisionChange,
onSubmit,
}: Props) {
const { locale } = useAppI18n();
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
{isRuntimeStatus(taskStatus) ? (
<TaskRuntimeStatusBadge status={taskStatus} />
) : (
<Badge variant="outline" className="text-[11px]">
{humanTaskStatus(taskStatus, locale)}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<TaskAcceptanceControls
sessionId={sessionId}
runId={runId}
taskStatus={taskStatus}
feedbackItems={feedbackItems}
actionBusy={actionBusy}
revision={revision}
onRevisionChange={onRevisionChange}
onSubmit={onSubmit}
/>
</CardContent>
</Card>
);
}
export function TaskAcceptanceControls({
sessionId,
runId,
taskStatus,
feedbackItems,
actionBusy,
revision,
onRevisionChange,
onSubmit,
}: Props) {
const { locale } = useAppI18n();
const commentId = React.useId();
const [localComment, setLocalComment] = React.useState('');
const comment = revision ?? localComment;
const setComment = onRevisionChange ?? setLocalComment;
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
const trimmedComment = comment.trim();
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
if (!runId || !canSubmit) return;
void onSubmit(feedbackType, nextComment);
};
return (
<div className="space-y-4">
{recordedFeedback ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div className="flex items-center gap-2 font-medium">
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
</div>
{recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
{recordedFeedback.created_at ? (
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
) : null}
</div>
) : isFinalized ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
</div>
) : !isReadyForAcceptance ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
{pickAppText(locale, '任务还在执行,完成后才能验收。', 'The task is still running. Acceptance becomes available when a result is ready.')}
</div>
) : !runId ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
</div>
) : null}
<div className="grid gap-2 sm:grid-cols-3">
<FeedbackButton
type="accept"
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
label={pickAppText(locale, '接受', 'Accept')}
actionBusy={actionBusy}
disabled={!canSubmit}
onClick={() => submit('accept', trimmedComment || undefined)}
/>
<FeedbackButton
type="revise"
icon={<RefreshCw className="mr-2 h-4 w-4" />}
label={pickAppText(locale, '需要修改', 'Needs revision')}
actionBusy={actionBusy}
disabled={!canSubmit || !trimmedComment}
onClick={() => submit('revise', trimmedComment)}
/>
<FeedbackButton
type="abandon"
icon={<XCircle className="mr-2 h-4 w-4" />}
label={pickAppText(locale, '放弃', 'Abandon')}
actionBusy={actionBusy}
disabled={!canSubmit}
onClick={() => submit('abandon', trimmedComment || undefined)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={commentId}>{pickAppText(locale, '验收说明', 'Acceptance note')}</Label>
<Textarea
id={commentId}
value={comment}
onChange={(event) => setComment(event.target.value)}
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
/>
</div>
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
<span className="font-mono">{runId || '-'}</span>
<span className="mx-1">·</span>
{pickAppText(locale, '会话:', 'Session: ')}
<span className="font-mono">{sessionId}</span>
</div>
</div>
);
}