Files
beaver_project/app-instance/frontend/app/(app)/tasks/page.tsx
steven_li 6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00

466 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 { formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
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_acceptance' || 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">{formatTaskRuntimeTime(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'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
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>
);
}