feat(engine): 添加MCP连接管理和工具集成功能

- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
This commit is contained in:
2026-05-14 09:43:48 +08:00
parent 8a12c30141
commit 30ab74ffb2
149 changed files with 12293 additions and 2812 deletions

View File

@ -0,0 +1,621 @@
'use client';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import React, { useMemo, useState } from 'react';
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, MessageSquare, RefreshCw, RotateCcw, Trash2, User, XCircle } from 'lucide-react';
import { OfficeStatusBadge, formatOfficeDuration, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { cancelDelegation, deleteBackendTask, getBackendTask, getFileUrl, retryDelegation, submitChatFeedback } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildOfficeView, isOfficeTaskTerminal, type OfficeTaskView } from '@/lib/office';
import { useChatStore } from '@/lib/store';
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
function taskVisibleStatus(task: OfficeTaskView, locale: 'zh-CN' | 'en-US') {
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
return task.stageLabel || task.status;
}
function downloadText(filename: string, content: string) {
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export default function TaskDetailPage() {
const { locale } = useAppI18n();
const router = useRouter();
const params = useParams<{ taskId: string }>();
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
const sessions = useChatStore((state) => state.sessions);
const processRuns = useChatStore((state) => state.processRuns);
const processEvents = useChatStore((state) => state.processEvents);
const processArtifacts = useChatStore((state) => state.processArtifacts);
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
const task = useMemo(
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
);
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
const [revision, setRevision] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState<string | null>(null);
React.useEffect(() => {
setSelectedRunId(task?.rootRunId ?? null);
}, [task?.rootRunId]);
React.useEffect(() => {
let cancelled = false;
if (task || !taskId) {
setBackendTask(null);
return () => {
cancelled = true;
};
}
setBackendTaskLoading(true);
getBackendTask(taskId)
.then((item) => {
if (!cancelled) setBackendTask(item);
})
.catch(() => {
if (!cancelled) setBackendTask(null);
})
.finally(() => {
if (!cancelled) setBackendTaskLoading(false);
});
return () => {
cancelled = true;
};
}, [task, taskId]);
const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]);
const artifacts = useMemo(
() => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)),
[processArtifacts, runIds]
);
const eventsByRun = useMemo(() => {
const map = new Map<string, ProcessEvent[]>();
for (const event of processEvents) {
if (!runIds.has(event.run_id)) continue;
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
}
return map;
}, [processEvents, runIds]);
const artifactsByRun = useMemo(() => {
const map = new Map<string, ProcessArtifact[]>();
for (const artifact of artifacts) {
map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]);
}
return map;
}, [artifacts]);
const phaseGroups = useMemo(() => {
const groups = new Map<string, OfficeTaskView[]>();
for (const item of task?.tasks ?? []) {
const label = item.stageLabel || taskVisibleStatus(item, locale);
groups.set(label, [...(groups.get(label) ?? []), item]);
}
return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes }));
}, [locale, task?.tasks]);
const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null;
const runAction = async (key: string, action: () => Promise<unknown>) => {
setActionBusy(key);
setActionError(null);
try {
await action();
} catch (err: any) {
setActionError(err.message || pickAppText(locale, '操作失败', 'Action failed'));
} finally {
setActionBusy(null);
}
};
const deleteCurrentBackendTask = async () => {
if (!backendTask) return;
const title = backendTask.short_title || backendTask.description || backendTask.goal || backendTask.task_id;
if (!window.confirm(pickAppText(locale, `删除任务“${title}”?`, `Delete task "${title}"?`))) {
return;
}
await runAction('delete-backend-task', async () => {
await deleteBackendTask(backendTask.task_id);
router.push('/tasks');
});
};
if (!task && backendTask) {
const validation = backendTask.validation_result;
const accepted = Boolean(validation?.accepted);
const validationIssues = [
...arrayOfStrings(validation?.issues),
...arrayOfStrings(validation?.missing_requirements),
];
const feedbackItems = backendTask.feedback || [];
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button asChild variant="outline" className="w-fit">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
</Link>
</Button>
<div className="flex items-center gap-2">
{backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
<Badge>{humanTaskStatus(backendTask.status, locale)}</Badge>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={Boolean(actionBusy)}
onClick={() => void deleteCurrentBackendTask()}
>
<Trash2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '删除任务', 'Delete task')}
</Button>
</div>
</div>
<Card>
<CardContent className="p-5">
<h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1>
{backendTask.description ? (
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p>
) : null}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
<span>{pickAppText(locale, '更新', 'Updated')}: {formatOfficeTime(backendTask.updated_at, locale)}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{(backendTask.runs ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div>
) : (
(backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />)
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, '验证和反馈', 'Validation and feedback')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="rounded-lg border border-border bg-muted/25 p-4">
<div className="flex items-center gap-2">
{validation ? (
accepted ? <CheckCircle2 className="h-5 w-5 text-[#657162]" /> : <XCircle className="h-5 w-5 text-destructive" />
) : (
<HelpCircle className="h-5 w-5 text-muted-foreground" />
)}
<div className="font-medium">
{validation
? accepted
? pickAppText(locale, '验证通过', 'Validation passed')
: pickAppText(locale, '需要继续修改', 'Needs revision')
: pickAppText(locale, '尚未验证', 'Not validated yet')}
</div>
</div>
{validation ? (
<div className="mt-2 text-muted-foreground">
{pickAppText(locale, '评分', 'Score')}: {String(validation.score ?? '-')} · {pickAppText(locale, '验证器', 'Validator')}: {String(validation.validator ?? '-')}
</div>
) : null}
{validationIssues.length > 0 && (
<ul className="mt-3 list-disc space-y-1 pl-5 text-muted-foreground">
{validationIssues.map((item, index) => <li key={`${item}:${index}`}>{item}</li>)}
</ul>
)}
{typeof validation?.recommended_revision_prompt === 'string' && validation.recommended_revision_prompt && (
<p className="mt-3 rounded-md bg-background p-3 text-muted-foreground">{validation.recommended_revision_prompt}</p>
)}
</div>
<div className="space-y-2">
<div className="font-medium">{pickAppText(locale, '用户反馈', 'User feedback')}</div>
{feedbackItems.length === 0 ? (
<p className="text-muted-foreground">{pickAppText(locale, '还没有用户反馈。', 'No user feedback yet.')}</p>
) : (
feedbackItems.map((item, index) => (
<div key={index} className="rounded-md border border-border p-3">
<div className="font-medium">{humanFeedback(String(item.feedback_type || ''), locale)}</div>
{item.comment ? <p className="mt-1 text-muted-foreground">{String(item.comment)}</p> : null}
{item.created_at ? <p className="mt-1 text-xs text-muted-foreground">{formatOfficeTime(String(item.created_at), locale)}</p> : null}
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
);
}
if (!task) {
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<Button asChild variant="outline" className="w-fit">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
</Link>
</Button>
<Card className="border-dashed">
<CardContent className="py-16 text-center">
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{backendTaskLoading
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
: pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')}
</p>
</CardContent>
</Card>
</div>
);
}
const progressValue = progressPercent(task.progress.value, task.progress.max);
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务', 'Back to tasks')}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '对话', 'Chat')}
</Link>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={Boolean(actionBusy) || isOfficeTaskTerminal(task.status)}
onClick={() => void runAction('cancel', () => cancelDelegation(task.rootRunId))}
>
<XCircle className="mr-2 h-4 w-4" />
{pickAppText(locale, '取消任务', 'Cancel task')}
</Button>
<Button
size="sm"
disabled={Boolean(actionBusy) || !isOfficeTaskTerminal(task.status)}
onClick={() => void runAction('retry', () => retryDelegation(task.rootRunId))}
>
<RotateCcw className="mr-2 h-4 w-4" />
{pickAppText(locale, '重试任务', 'Retry task')}
</Button>
</div>
</div>
<Card>
<CardContent className="p-5">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
<OfficeStatusBadge status={task.status} />
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
<span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(task.createdAt, locale)}</span>
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(task.durationMs, locale)}</span>
</div>
</div>
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.alerts.length)} />
</div>
</div>
<div className="mt-5 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{task.progress.label}</span>
<span className="font-medium">{progressValue}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div className="h-full bg-primary" style={{ width: `${progressValue}%` }} />
</div>
</div>
</CardContent>
</Card>
{actionError && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{actionError}
</CardContent>
</Card>
)}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{phaseGroups.map((phase, index) => (
<div key={`${phase.label}:${index}`} className="flex items-center gap-2">
<div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
<div className="font-medium">{phase.label}</div>
<div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div>
</div>
{index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null}
</div>
))}
</div>
</CardContent>
</Card>
{phaseGroups.map((phase) => (
<Card key={phase.label}>
<CardHeader>
<CardTitle className="text-base">{phase.label}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
{phase.nodes.map((node) => (
<button
key={node.runId}
type="button"
onClick={() => setSelectedRunId(node.runId)}
className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{node.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
</div>
<OfficeStatusBadge status={node.status} />
</div>
<div className="mt-3 text-sm text-muted-foreground">
{node.summary || taskVisibleStatus(node, locale)}
</div>
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
<span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span>
<span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span>
</div>
</button>
))}
</CardContent>
</Card>
))}
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle>
</CardHeader>
<CardContent>
{selectedNode ? (
<div className="space-y-4">
<div>
<div className="font-medium">{selectedNode.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
</div>
<OfficeStatusBadge status={selectedNode.status} />
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
<div className="space-y-2">
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
<div className="font-medium">{event.kind}</div>
<div className="mt-1 text-muted-foreground">{event.text || formatOfficeTime(event.created_at, locale)}</div>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{pickAppText(locale, '修订意见', 'Revision')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={revision}
onChange={(event) => setRevision(event.target.value)}
placeholder={pickAppText(locale, '直接写下需要调整的地方...', 'Describe what should change...')}
/>
<Button
className="w-full"
disabled={!revision.trim() || Boolean(actionBusy)}
onClick={() =>
void runAction('revision', async () => {
updateMessageFeedback(task.rootRunId, 'revise');
await submitChatFeedback({
sessionId: task.sessionId || 'web:default',
runId: task.rootRunId,
feedbackType: 'revise',
comment: revision.trim(),
});
setRevision('');
})
}
>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '提交修订', 'Submit revision')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
<Button
size="sm"
variant="outline"
disabled={artifacts.length === 0}
onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))}
>
<Download className="mr-2 h-4 w-4" />
{pickAppText(locale, '全部下载', 'Download all')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2">
{artifacts.length === 0 ? (
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
) : (
artifacts.map((artifact) => (
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{artifact.title}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
</div>
{artifact.url || artifact.file_id ? (
<Button asChild size="sm" variant="outline">
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-3.5 w-3.5" />
{pickAppText(locale, '下载', 'Download')}
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))}
>
<Download className="mr-2 h-3.5 w-3.5" />
{pickAppText(locale, '下载', 'Download')}
</Button>
)}
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
</div>
);
}
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
const { locale } = useAppI18n();
return (
<div className="rounded-lg border border-border bg-background p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{run.started_at ? formatOfficeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
</div>
</div>
<Badge variant={run.success === false ? 'destructive' : 'secondary'}>
{run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')}
</Badge>
</div>
{run.messages.length === 0 ? (
<p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p>
) : (
<div className="space-y-3">
{run.messages.map((message, messageIndex) => {
const isAssistant = message.role === 'assistant';
const isTool = message.role === 'tool';
const Icon = isAssistant ? Bot : isTool ? FileText : User;
return (
<div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3">
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
{message.created_at ? <span>{formatOfficeTime(message.created_at, locale)}</span> : null}
</div>
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
{message.content}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
const map: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
validating: ['验证中', 'Validating'],
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
};
const item = map[status];
return item ? pickAppText(locale, item[0], item[1]) : status;
}
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
if (type === 'satisfied') return pickAppText(locale, '满意', 'Satisfied');
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
return type || pickAppText(locale, '反馈', 'Feedback');
}
function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed');
if (reason === 'error') return pickAppText(locale, '执行出错', 'Error');
if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
return reason;
}
function arrayOfStrings(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
}

View File

@ -0,0 +1,466 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import React, { useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowRight, Clock3, FolderDown, ListTodo, Loader2, Play, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { formatOfficeTime } from '@/components/office/OfficeShared';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { addCronJob, deleteBackendTask, listBackendTasks, listCronJobs, removeCronJob, runCronJob, toggleCronJob } from '@/lib/api';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { BackendTask, CronJob } from '@/types';
export default function TasksPage() {
const { locale } = useAppI18n();
const searchParams = useSearchParams();
const tab = searchParams.get('tab') === 'scheduled' ? 'scheduled' : 'ordinary';
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal">{pickAppText(locale, '任务', 'Tasks')}</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
{pickAppText(locale, '普通任务展示用户发起或接入的工作任务;定时任务触发后会先进入通知,需要修改时再接入 Task。', 'Ordinary tasks show user-started or engaged work. Scheduled jobs first create notifications and only become Tasks when revised.')}
</p>
</div>
<TaskManagementTabs />
</div>
{tab === 'scheduled' ? <ScheduledTasks /> : <OrdinaryTasks />}
</div>
);
}
function OrdinaryTasks() {
const { locale } = useAppI18n();
const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]);
const [error, setError] = useState<string | null>(null);
const visibleTasks = useMemo(
() => backendTasks.filter((task) => {
if (task.creator !== 'cron') return true;
return Boolean(task.metadata?.user_engaged || task.metadata?.requires_followup);
}),
[backendTasks]
);
const loadBackendTasks = React.useCallback(() => {
let cancelled = false;
listBackendTasks()
.then((items) => {
if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []);
})
.catch(() => {
if (!cancelled) setBackendTasks([]);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => loadBackendTasks(), [loadBackendTasks]);
const handleDeleteBackendTask = async (task: BackendTask) => {
const title = task.short_title || task.description || task.goal || task.task_id;
if (!window.confirm(pickAppText(locale, `删除任务“${title}”?`, `Delete task "${title}"?`))) {
return;
}
setError(null);
try {
await deleteBackendTask(task.task_id);
setBackendTasks((items) => items.filter((item) => item.task_id !== task.task_id));
} catch (err: any) {
setError(err.message || pickAppText(locale, '删除任务失败', 'Failed to delete task'));
}
};
if (visibleTasks.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<ListTodo className="h-10 w-10 text-muted-foreground/50" />
<h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '暂无普通任务', 'No ordinary tasks yet')}</h2>
<p className="mt-2 max-w-xl text-sm text-muted-foreground">
{pickAppText(locale, '从对话页发起复杂任务后,这里会保留普通任务列表;即使来源会话被归档,任务仍会显示。', 'Complex tasks created from chat appear here. Tasks remain visible even when their source session is archived.')}
</p>
<Button asChild className="mt-6">
<Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{pickAppText(locale, '任务', 'Task')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
<TableHead>{pickAppText(locale, '运行次数', 'Runs')}</TableHead>
<TableHead>{pickAppText(locale, '使用技能', 'Skills')}</TableHead>
<TableHead>{pickAppText(locale, '更新时间', 'Updated')}</TableHead>
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleTasks.map((task) => (
<TableRow key={task.task_id}>
<TableCell>
<div className="max-w-[360px]">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium">{task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id}</div>
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{task.description || task.session_id} · {task.creator}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={task.status === 'awaiting_feedback' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">{taskSourceLabel(task, locale)}</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{task.run_ids.length}</TableCell>
<TableCell className="text-sm text-muted-foreground">{task.skill_names.length}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatOfficeTime(task.updated_at, locale)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button asChild size="sm" variant="outline">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入', 'Open')}
<ArrowRight className="ml-2 h-3.5 w-3.5" />
</Link>
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => void handleDeleteBackendTask(task)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
validating: ['验证中', 'Validating'],
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
};
const label = labels[status];
return label ? pickAppText(locale, label[0], label[1]) : status;
}
function taskSourceLabel(task: BackendTask, locale: 'zh-CN' | 'en-US') {
if (task.metadata?.source === 'scheduled_run') {
return pickAppText(locale, '定时通知修改', 'Scheduled notification revision');
}
if (task.metadata?.source === 'scheduled_cron') {
return pickAppText(locale, '定时任务', 'Scheduled task');
}
if (task.creator === 'cron') {
return pickAppText(locale, '定时任务', 'Scheduled task');
}
return pickAppText(locale, '对话任务', 'Chat task');
}
function ScheduledTasks() {
const { locale } = useAppI18n();
const sessionId = useChatStore((state) => state.sessionId);
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default';
const loadJobs = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setJobs(await listCronJobs(true));
} catch (err: any) {
setError(err.message || pickAppText(locale, '加载定时任务失败', 'Failed to load scheduled tasks'));
} finally {
setLoading(false);
}
}, [locale]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
const formatTime = (ms: number | null) => {
if (!ms) return '-';
return new Date(ms).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
const runJobAction = async (action: () => Promise<unknown>) => {
try {
await action();
await loadJobs();
} catch (err: any) {
setError(err.message || pickAppText(locale, '操作失败', 'Action failed'));
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock3 className="h-4 w-4" />
{pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')}
</div>
<div className="flex items-center gap-2">
<Button onClick={() => void loadJobs()} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '新建定时任务', 'New scheduled task')}
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</CardContent>
</Card>
)}
{showAdd && (
<AddJobForm
targetSessionKey={targetSessionKey}
onCancel={() => setShowAdd(false)}
onAdd={(params) =>
runJobAction(async () => {
await addCronJob({ ...params, session_key: targetSessionKey, mode: 'notification' });
setShowAdd(false);
})
}
/>
)}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{pickAppText(locale, '加载中', 'Loading')}
</div>
) : jobs.length === 0 ? (
<div className="py-14 text-center text-muted-foreground">
<Clock3 className="mx-auto mb-3 h-10 w-10 opacity-40" />
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
<TableHead>{pickAppText(locale, '运行历史', 'History')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-28">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch checked={job.enabled} onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))} />
</TableCell>
<TableCell>
<div className="font-medium">{job.name}</div>
<div className="text-xs text-muted-foreground">{job.id}</div>
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{job.schedule_display}</code>
<div className="mt-1 text-xs text-muted-foreground">{pickAppText(locale, '下次', 'Next')}: {formatTime(job.next_run_at_ms)}</div>
</TableCell>
<TableCell>
<span className="block max-w-[260px] truncate text-sm">{job.message}</span>
</TableCell>
<TableCell>
<Button asChild size="sm" variant="outline" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}>
<FolderDown className="mr-2 h-3.5 w-3.5" />
{formatTime(job.last_run_at_ms)}
</Link>
</Button>
</TableCell>
<TableCell>
{job.last_status === 'ok' ? (
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
) : job.last_status === 'error' ? (
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void runJobAction(() => runCronJob(job.id))}>
<Play className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => void runJobAction(() => removeCronJob(job.id))}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function AddJobForm({
targetSessionKey,
onAdd,
onCancel,
}: {
targetSessionKey: string;
onAdd: (params: { name: string; message: string; every_seconds?: number; cron_expr?: string; at_iso?: string }) => void;
onCancel: () => void;
}) {
const { locale } = useAppI18n();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron' | 'at'>('every');
const [everySeconds, setEverySeconds] = useState('3600');
const [cronExpr, setCronExpr] = useState('0 9 * * *');
const [atIso, setAtIso] = useState('');
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!name.trim() || !message.trim()) return;
if (scheduleType === 'every') {
onAdd({ name: name.trim(), message: message.trim(), every_seconds: Number.parseInt(everySeconds, 10) || 3600 });
return;
}
if (scheduleType === 'cron') {
onAdd({ name: name.trim(), message: message.trim(), cron_expr: cronExpr.trim() });
return;
}
onAdd({ name: name.trim(), message: message.trim(), at_iso: atIso });
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="job-name">{pickAppText(locale, '任务名称', 'Task name')}</Label>
<Input id="job-name" value={name} onChange={(event) => setName(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
<Select value={scheduleType} onValueChange={(value) => setScheduleType(value as 'every' | 'cron' | 'at')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="every">{pickAppText(locale, '固定间隔', 'Fixed interval')}</SelectItem>
<SelectItem value="cron">Cron</SelectItem>
<SelectItem value="at">{pickAppText(locale, '一次性', 'One-time')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scheduleType === 'every' ? (
<div className="space-y-2">
<Label htmlFor="every-seconds">{pickAppText(locale, '间隔秒数', 'Interval seconds')}</Label>
<Input id="every-seconds" type="number" min="10" value={everySeconds} onChange={(event) => setEverySeconds(event.target.value)} />
</div>
) : scheduleType === 'cron' ? (
<div className="space-y-2">
<Label htmlFor="cron-expr">Cron</Label>
<Input id="cron-expr" value={cronExpr} onChange={(event) => setCronExpr(event.target.value)} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="at-iso">{pickAppText(locale, '触发时间', 'Run at')}</Label>
<Input id="at-iso" type="datetime-local" value={atIso} onChange={(event) => setAtIso(event.target.value)} />
</div>
)}
<div className="space-y-2">
<Label htmlFor="job-message">{pickAppText(locale, '任务消息', 'Task message')}</Label>
<Input id="job-message" value={message} onChange={(event) => setMessage(event.target.value)} />
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '触发后会发送到固定通知 session需要修改时再接入 Task。来源会话', 'Triggers are sent to the fixed notification session; connect to a Task for revisions. Source session: ')}
<code className="rounded bg-muted px-1 py-0.5">{targetSessionKey}</code>
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '创建', 'Create')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}