Files
beaver_project/app-instance/frontend/app/(app)/cron/page.tsx
steven_li 0c180f48f2 feat(delegation): 添加直连模式下的委托公告回调机制
- 引入 DirectAnnouncementCallback 类型用于处理直连模式下的公告
- 在 DelegationManager 中添加 _direct_announcement_callback 属性和设置方法
- 实现 _notify_direct_announcement 方法用于在非总线模式下将公告回写到本地会话
- 在委托取消、完成和分组完成时添加对直连公告的通知逻辑

feat(web): 增加 WebSocket 广播器支持实时会话更新通知

- 创建 WebSocketBroadcaster 类用于跟踪认证的 WebSocket 连接并广播 JSON 事件
- 在应用启动时初始化 websocket_broadcaster 实例
- 实现连接注册、注销和消息广播功能
- 添加过期连接清理机制

feat(agent): 新增系统公告处理方法支持本地处理

- 在 AgentLoop 中添加 process_system_announcement 方法用于在无常驻 run() 场景下处理系统公告
- 创建 InboundMessage 并通过 _process_message 进行处理

feat(cron): 改进定时任务的会话路由解析和实时更新

- 添加 _resolve_cron_session_key 和 _infer_cron_route_from_session_key 辅助函数
- 在 cron 任务执行完成后通过 WebSocket 广播会话更新事件
- 在添加定时任务时自动推断目标会话的渠道和聊天 ID

refactor: 项目名称从 Boardware Genius 统一改为 Boardware Agent Sandbox

- 更新前端页面标题和描述文本中的产品名称
- 添加新的品牌 Logo 图片资源
- 在前端布局中使用新的 Logo 显示
- 更新授权门户中的品牌信息和 Logo 显示

feat(frontend): 添加会话更新事件监听实现消息自动刷新

- 定义 SessionUpdatedEvent 类型接口
- 在 ChatPage 中添加会话更新事件的处理逻辑
- 当收到会话更新事件时自动重新加载会话列表和当前会话消息

feat(api): 扩展定时任务 API 支持会话键参数

- 在 addCronJob API 参数中添加 session_key 字段
- 更新前端 Cron 页面的表单处理以传递当前会话键
2026-03-18 14:34:25 +08:00

413 lines
13 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 React, { useEffect, useState } from 'react';
import {
Clock,
Plus,
Trash2,
Play,
RefreshCw,
Loader2,
AlertCircle,
X,
} from 'lucide-react';
import {
listCronJobs,
addCronJob,
removeCronJob,
toggleCronJob,
runCronJob,
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useChatStore } from '@/lib/store';
import type { CronJob } from '@/types';
export default function CronPage() {
const sessionId = useChatStore((s) => s.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 = async () => {
setLoading(true);
setError(null);
try {
const data = await listCronJobs(true);
setJobs(data);
} catch (err: any) {
setError(err.message || '加载任务失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadJobs();
}, []);
const handleToggle = async (jobId: string, enabled: boolean) => {
try {
await toggleCronJob(jobId, enabled);
loadJobs();
} catch {
// ignore
}
};
const handleDelete = async (jobId: string) => {
try {
await removeCronJob(jobId);
loadJobs();
} catch {
// ignore
}
};
const handleRun = async (jobId: string) => {
try {
await runCronJob(jobId);
loadJobs();
} catch {
// ignore
}
};
const handleAdd = async (params: {
name: string;
message: string;
every_seconds?: number;
cron_expr?: string;
}) => {
try {
await addCronJob({
...params,
session_key: targetSessionKey,
});
setShowAdd(false);
loadJobs();
} catch (err: any) {
setError(err.message);
}
};
const formatTime = (ms: number | null) => {
if (!ms) return '-';
return new Date(ms).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Clock className="w-6 h-6" />
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadJobs} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Add Job Form */}
{showAdd && (
<AddJobForm
targetSessionKey={targetSessionKey}
onAdd={handleAdd}
onCancel={() => setShowAdd(false)}
/>
)}
{/* Jobs Table */}
<Card>
<CardContent className="p-0">
{jobs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch
checked={job.enabled}
onCheckedChange={(checked) =>
handleToggle(job.id, checked)
}
/>
</TableCell>
<TableCell className="font-medium">
<div>
<span>{job.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{job.id}
</span>
</div>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{job.schedule_display}
</code>
</TableCell>
<TableCell>
<span className="text-sm truncate max-w-[200px] block">
{job.message}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(job.last_run_at_ms)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(job.next_run_at_ms)}
</TableCell>
<TableCell>
{job.last_status === 'ok' && (
<Badge variant="default" className="text-xs bg-green-600">
OK
</Badge>
)}
{job.last_status === 'error' && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
{!job.last_status && (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleRun(job.id)}
title="立即执行"
>
<Play className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(job.id)}
title="删除"
>
<Trash2 className="w-3.5 h-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;
}) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
const [everySeconds, setEverySeconds] = useState('3600');
const [cronExpr, setCronExpr] = useState('0 9 * * *');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !message.trim()) return;
const params: any = { name: name.trim(), message: message.trim() };
if (scheduleType === 'every') {
params.every_seconds = parseInt(everySeconds, 10) || 3600;
} else {
params.cron_expr = cronExpr.trim();
}
onAdd(params);
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:日报汇总"
/>
</div>
<div className="space-y-2">
<Label htmlFor="schedule-type"></Label>
<Select
value={scheduleType}
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="every"> N </SelectItem>
<SelectItem value="cron">Cron </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scheduleType === 'every' ? (
<div className="space-y-2">
<Label htmlFor="every"></Label>
<Input
id="every"
type="number"
value={everySeconds}
onChange={(e) => setEverySeconds(e.target.value)}
min="10"
placeholder="3600"
/>
<p className="text-xs text-muted-foreground">
{parseInt(everySeconds, 10) >= 3600
? `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}`
: parseInt(everySeconds, 10) >= 60
? `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}`
: ''}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="cron">Cron </Label>
<Input
id="cron"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
placeholder="0 9 * * *"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="message"></Label>
<Input
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="例如:检查我的邮件并生成摘要"
/>
<p className="text-xs text-muted-foreground">
Web <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</form>
</CardContent>
</Card>
);
}