Files
beaver_project/app-instance/frontend/app/(app)/tasks/page.tsx
steven_li 3b0af173cc refactor(beaver): 移除Hermes相关引用和迁移代码,完善Beaver后端主线实现
移除了所有Hermes相关的命名引用,包括:
- 从.gitignore中清理相关构建缓存文件
- 将README中的beaver-home路径配置更新
- 完善backend/README.md文档说明Beaver后端主线实现
- 移除Hermes风格的相关注释和兼容性代码
- 清理nanobot环境变量兼容性处理
- 删除技能迁移和服务迁移相关功能代码
- 更新测试用例中相关命名和函数名

BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
2026-05-14 17:20:32 +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 { 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>
);
}