Files
beaver_project/app-instance/frontend/app/(app)/tasks/page.tsx
steven_li 30ab74ffb2 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方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
2026-05-14 09:43:48 +08:00

467 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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