移除了所有Hermes相关的命名引用,包括: - 从.gitignore中清理相关构建缓存文件 - 将README中的beaver-home路径配置更新 - 完善backend/README.md文档说明Beaver后端主线实现 - 移除Hermes风格的相关注释和兼容性代码 - 清理nanobot环境变量兼容性处理 - 删除技能迁移和服务迁移相关功能代码 - 更新测试用例中相关命名和函数名 BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
467 lines
21 KiB
TypeScript
467 lines
21 KiB
TypeScript
'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_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">{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'],
|
||
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>
|
||
);
|
||
}
|