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方法重新构建全文搜索索引 - 优化索引触发器和表的维护流程
This commit is contained in:
@ -1,419 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
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 { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||||
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 { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { CronJob } from '@/types';
|
||||
|
||||
export default function CronPage() {
|
||||
const { locale } = useAppI18n();
|
||||
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 || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
|
||||
} 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">
|
||||
<TaskManagementTabs />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadJobs} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAdd(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '新建任务', 'New job')}
|
||||
</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">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</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, '上次运行', 'Last run')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
|
||||
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
|
||||
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</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">
|
||||
{pickAppText(locale, '错误', 'Error')}
|
||||
</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={pickAppText(locale, '立即执行', 'Run now')}
|
||||
>
|
||||
<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={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<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 { locale } = useAppI18n();
|
||||
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">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</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">{pickAppText(locale, '任务名称', 'Job name')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
|
||||
<Select
|
||||
value={scheduleType}
|
||||
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
|
||||
<SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleType === 'every' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</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
|
||||
? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)} 分`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`)
|
||||
: parseInt(everySeconds, 10) >= 60
|
||||
? pickAppText(locale, `约 ${Math.floor(parseInt(everySeconds, 10) / 60)} 分 ${parseInt(everySeconds, 10) % 60} 秒`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
|
||||
<Input
|
||||
id="cron"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
placeholder="0 9 * * *"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <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}>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !message.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '创建任务', 'Create job')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
export default function CronRedirectPage() {
|
||||
redirect('/tasks?tab=scheduled');
|
||||
}
|
||||
|
||||
@ -1,21 +1,9 @@
|
||||
import Header from '@/components/Header';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<AuthGuard>
|
||||
<AppRuntimeBridge />
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
205
app-instance/frontend/app/(app)/logs/page.tsx
Normal file
205
app-instance/frontend/app/(app)/logs/page.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, Bot, Braces, ChevronDown, Loader2, MessageSquare, RefreshCw, TerminalSquare } from 'lucide-react';
|
||||
|
||||
import { getChatLogs } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { ChatLogEvent, ChatLogSession } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
function eventLabel(event: ChatLogEvent): string {
|
||||
return event.event_type || event.role || 'event';
|
||||
}
|
||||
|
||||
function eventIcon(event: ChatLogEvent) {
|
||||
if (event.event_type === 'llm_request_snapshotted') return Braces;
|
||||
if (event.role === 'assistant') return Bot;
|
||||
if (event.role === 'tool') return TerminalSquare;
|
||||
return MessageSquare;
|
||||
}
|
||||
|
||||
function formatPayload(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function eventBody(event: ChatLogEvent): string {
|
||||
const content = event.content?.trim();
|
||||
if (content) return content;
|
||||
return formatPayload(event.event_payload);
|
||||
}
|
||||
|
||||
function timestampLabel(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [sessions, setSessions] = useState<ChatLogSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
sessions.flatMap((session) =>
|
||||
session.runs.map((run) => ({
|
||||
...run,
|
||||
sessionTitle: session.title || session.session_id,
|
||||
}))
|
||||
),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getChatLogs(80);
|
||||
setSessions(data.sessions || []);
|
||||
const firstRun = data.sessions?.[0]?.runs?.[0]?.run_id;
|
||||
if (firstRun) {
|
||||
setExpandedRuns((current) => (current.size ? current : new Set([firstRun])));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '读取日志失败', 'Failed to load logs'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
|
||||
const toggleRun = (runId: string) => {
|
||||
setExpandedRuns((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(runId)) next.delete(runId);
|
||||
else next.add(runId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-6 space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '运行日志', 'Runtime Logs')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'按每一次用户输入分组,查看 LLM 请求、回复、工具结果和隐藏运行快照。',
|
||||
'Grouped by each user input, with LLM requests, responses, tool results, and hidden runtime snapshots.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={loadLogs} variant="outline" size="sm" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-start gap-3 pt-6 text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '无法读取日志', 'Unable to load logs')}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading && !runs.length ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !runs.length && !error ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '还没有可展示的运行日志。', 'No runtime logs are available yet.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{runs.map((run) => {
|
||||
const expanded = expandedRuns.has(run.run_id);
|
||||
return (
|
||||
<Card key={`${run.session_id}:${run.run_id}`} className="overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRun(run.run_id)}
|
||||
className="flex w-full items-start justify-between gap-4 border-b px-5 py-4 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="min-w-0 space-y-2">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={run.task_mode ? 'default' : 'secondary'}>
|
||||
{run.task_mode ? 'Task' : 'Chat'}
|
||||
</Badge>
|
||||
{run.source ? <Badge variant="outline">{run.source}</Badge> : null}
|
||||
{run.attempt_index ? <Badge variant="outline">attempt {run.attempt_index}</Badge> : null}
|
||||
<span className="text-xs text-muted-foreground">{timestampLabel(run.started_at)}</span>
|
||||
</span>
|
||||
<span className="block truncate text-sm font-semibold text-foreground">
|
||||
{run.user_input || run.title || run.run_id}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{run.sessionTitle} · {run.run_id}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<CardContent className="space-y-3 p-5">
|
||||
{run.events.map((event, index) => {
|
||||
const Icon = eventIcon(event);
|
||||
const body = eventBody(event);
|
||||
return (
|
||||
<div
|
||||
key={`${event.message_id ?? index}:${event.event_type}`}
|
||||
className="rounded-lg border border-border bg-background"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">{eventLabel(event)}</span>
|
||||
<Badge variant={event.context_visible ? 'secondary' : 'outline'}>
|
||||
{event.context_visible ? 'visible' : 'hidden'}
|
||||
</Badge>
|
||||
{event.tool_name ? <Badge variant="outline">{event.tool_name}</Badge> : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
||||
</div>
|
||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
|
||||
{body || formatPayload(event)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,439 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Check, Download, Loader2, Search, Star } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Store,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Download,
|
||||
Check,
|
||||
X,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
listMarketplaces,
|
||||
addMarketplace,
|
||||
removeMarketplace,
|
||||
updateMarketplace,
|
||||
listMarketplacePlugins,
|
||||
installMarketplacePlugin,
|
||||
uninstallPlugin,
|
||||
getSkillHubDetail,
|
||||
getSkillHubVersion,
|
||||
installSkillHubSkill,
|
||||
searchSkillHubSkills,
|
||||
} from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Marketplace, MarketplacePlugin } from '@/types';
|
||||
import type { SkillHubSearchItem, SkillHubVersionResponse } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
type SortMode = 'relevance' | 'downloads' | 'newest';
|
||||
|
||||
function publishedVersion(skill: SkillHubSearchItem | null): string {
|
||||
return skill?.publishedVersion?.version || skill?.headlineVersion?.version || '';
|
||||
}
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
|
||||
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
|
||||
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
|
||||
const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sort, setSort] = useState<SortMode>('newest');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [items, setItems] = useState<SkillHubSearchItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pluginsLoading, setPluginsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addSource, setAddSource] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [actionPlugin, setActionPlugin] = useState<string | null>(null);
|
||||
const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<SkillHubSearchItem | null>(null);
|
||||
const [versionDetail, setVersionDetail] = useState<SkillHubVersionResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
const loadMarketplaces = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listMarketplaces();
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setMarketplaces(list);
|
||||
// Auto-select first marketplace if none selected or selected was removed
|
||||
if (list.length > 0) {
|
||||
setSelectedMarketplace((prev) => {
|
||||
if (prev && list.some((m) => m.name === prev)) return prev;
|
||||
return list[0].name;
|
||||
});
|
||||
} else {
|
||||
setSelectedMarketplace(null);
|
||||
setPlugins([]);
|
||||
}
|
||||
const result = await searchSkillHubSkills({ q: query, sort, page, size: 12 });
|
||||
const nextItems = Array.isArray(result.items) ? result.items : [];
|
||||
setItems(starredOnly ? nextItems.filter((item) => (item.starCount || 0) > 0) : nextItems);
|
||||
setTotal(result.total || 0);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces'));
|
||||
setError(err.message || t('加载 SkillHub 失败', 'Failed to load SkillHub'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPlugins = useCallback(async (marketplaceName: string) => {
|
||||
setPluginsLoading(true);
|
||||
try {
|
||||
const data = await listMarketplacePlugins(marketplaceName);
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
|
||||
} finally {
|
||||
setPluginsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, query, sort, starredOnly, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMarketplaces();
|
||||
}, [loadMarketplaces]);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMarketplace) {
|
||||
loadPlugins(selectedMarketplace);
|
||||
}
|
||||
}, [selectedMarketplace, loadPlugins]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addSource.trim()) return;
|
||||
setAdding(true);
|
||||
const openDetail = async (item: SkillHubSearchItem) => {
|
||||
setSelected(item);
|
||||
setVersionDetail(null);
|
||||
setDetailLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const marketplace = await addMarketplace(addSource.trim());
|
||||
setAddSource('');
|
||||
setShowAddForm(false);
|
||||
await loadMarketplaces();
|
||||
setSelectedMarketplace(marketplace.name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await removeMarketplace(name);
|
||||
if (selectedMarketplace === name) {
|
||||
setSelectedMarketplace(null);
|
||||
setPlugins([]);
|
||||
}
|
||||
await loadMarketplaces();
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMarketplace = async (name: string) => {
|
||||
setUpdatingMarketplace(name);
|
||||
setError(null);
|
||||
try {
|
||||
await updateMarketplace(name);
|
||||
await loadPlugins(name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
|
||||
} finally {
|
||||
setUpdatingMarketplace(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (marketplaceName: string, pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await uninstallPlugin(pluginName);
|
||||
if (selectedMarketplace) {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
const detail = await getSkillHubDetail(item.namespace, item.slug);
|
||||
setSelected(detail);
|
||||
const version = publishedVersion(detail);
|
||||
if (version) {
|
||||
setVersionDetail(await getSkillHubVersion(detail.namespace, detail.slug, version));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin'));
|
||||
setError(err.message || t('加载技能详情失败', 'Failed to load skill details'));
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await loadMarketplaces();
|
||||
if (selectedMarketplace) {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
const installSelected = async () => {
|
||||
if (!selected) return;
|
||||
setInstalling(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await installSkillHubSkill(selected.namespace, selected.slug, publishedVersion(selected));
|
||||
setSelected({ ...selected, installed: true, installed_version: result.version });
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || t('安装技能失败', 'Failed to install skill'));
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Store className="w-6 h-6" />
|
||||
{pickAppText(locale, '插件市场', 'Plugin marketplace')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setShowAddForm((v) => !v)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '添加市场', 'Add marketplace')}
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mx-auto mb-10 max-w-4xl">
|
||||
<form
|
||||
className="flex gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setPage(0);
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('搜索技能...', 'Search skills...')}
|
||||
className="h-14 rounded-2xl pl-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="h-14 rounded-2xl px-10 text-base">
|
||||
{t('搜索', 'Search')}
|
||||
</Button>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between gap-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selected ? (
|
||||
<div className="space-y-5">
|
||||
<Button variant="ghost" onClick={() => setSelected(null)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('返回搜索', 'Back to search')}
|
||||
</Button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant="outline">@{selected.namespace}</Badge>
|
||||
{selected.installed && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('已安装', 'Installed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{selected.displayName || selected.slug}</CardTitle>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">{selected.summary}</p>
|
||||
</div>
|
||||
<Button onClick={installSelected} disabled={installing || detailLoading}>
|
||||
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add marketplace form */}
|
||||
{showAddForm && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...)', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')}
|
||||
value={addSource}
|
||||
onChange={(e) => setAddSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
}}
|
||||
disabled={adding}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm">
|
||||
{adding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '添加', 'Add')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setAddSource('');
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Marketplace tabs */}
|
||||
{marketplaces.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{marketplaces.map((marketplace) => (
|
||||
<div key={marketplace.name} className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMarketplace(marketplace.name)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{marketplace.type === 'git' ? (
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{marketplace.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
|
||||
disabled={updatingMarketplace === marketplace.name}
|
||||
onClick={() => handleUpdateMarketplace(marketplace.name)}
|
||||
title={pickAppText(locale, '更新市场', 'Update marketplace')}
|
||||
>
|
||||
{updatingMarketplace === marketplace.name ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(marketplace.name)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{marketplaces.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p>
|
||||
<p className="text-sm mt-2 max-w-sm mx-auto">
|
||||
{pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plugin list */}
|
||||
{selectedMarketplace && (
|
||||
<>
|
||||
{pluginsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
|
||||
<p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
<Card key={plugin.name}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{plugin.name}
|
||||
</CardTitle>
|
||||
{plugin.installed && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
{pickAppText(locale, '已安装', 'Installed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{plugin.installed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() =>
|
||||
handleUpdatePlugin(plugin.marketplace_name, plugin.name)
|
||||
}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '更新', 'Update')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() => handleUninstall(plugin.name)}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '卸载', 'Uninstall')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() =>
|
||||
handleInstall(plugin.marketplace_name, plugin.name)
|
||||
}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{pickAppText(locale, '安装', 'Install')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{detailLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">v{publishedVersion(selected) || '-'}</Badge>
|
||||
<span>{t('下载', 'Downloads')}: {selected.downloadCount || 0}</span>
|
||||
<span>{t('收藏', 'Stars')}: {selected.starCount || 0}</span>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-2 text-sm font-medium">SKILL.md</div>
|
||||
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap text-xs">
|
||||
{versionDetail?.detail?.parsedMetadataJson || t('暂无预览', 'No preview available')}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 text-sm font-medium">{t('版本文件', 'Version files')}</div>
|
||||
<div className="space-y-2">
|
||||
{(versionDetail?.files || []).map((file) => (
|
||||
<div key={file.filePath} className="flex items-center justify-between gap-3 rounded-md bg-background px-3 py-2 text-xs">
|
||||
<span className="break-all font-mono">{file.filePath}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{file.fileSize} B</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('排序:', 'Sort:')}</span>
|
||||
{([
|
||||
['relevance', t('相关性', 'Relevance')],
|
||||
['downloads', t('下载量', 'Downloads')],
|
||||
['newest', t('最新', 'Newest')],
|
||||
] as Array<[SortMode, string]>).map(([value, label]) => (
|
||||
<Button key={value} size="sm" variant={sort === value ? 'default' : 'outline'} onClick={() => { setSort(value); setPage(0); }}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
|
||||
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
{t('只看已收藏', 'Starred only')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
|
||||
<Badge variant="outline">@{item.namespace}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
|
||||
<span>{item.downloadCount || 0}</span>
|
||||
<span>{item.starCount || 0}</span>
|
||||
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
|
||||
{t('上一页', 'Previous')}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{page + 1} / {totalPages}</span>
|
||||
<Button variant="outline" disabled={page + 1 >= totalPages} onClick={() => setPage((value) => value + 1)}>
|
||||
{t('下一页', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -116,6 +116,7 @@ export default function MCPPage() {
|
||||
const [form, setForm] = useState(createEmptyForm());
|
||||
const [authzStatus, setAuthzStatus] = useState<AuthzStatus | null>(null);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||
const [toolTab, setToolTab] = useState<'local' | 'online'>('local');
|
||||
|
||||
const load = useCallback(async (background = false) => {
|
||||
if (background) {
|
||||
@ -262,6 +263,7 @@ export default function MCPPage() {
|
||||
const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
|
||||
const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null;
|
||||
const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null;
|
||||
const visibleServers = servers.filter((server) => (server.kind || (server.transport === 'stdio' ? 'local' : 'online')) === toolTab);
|
||||
let authzHint = t(
|
||||
'无需手动填写。Audience 会按 MCP ID 自动生成,Scopes 按 AuthZ 当前权限动态决定。',
|
||||
'No manual input is required. The audience is generated from the MCP ID and scopes follow current AuthZ permissions.'
|
||||
@ -305,10 +307,10 @@ export default function MCPPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ServerCog className="w-6 h-6" />
|
||||
{t('MCP 服务', 'MCP servers')}
|
||||
{t('工具', 'Tools')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')}
|
||||
{t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -323,7 +325,7 @@ export default function MCPPage() {
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('新增 MCP', 'Add MCP')}
|
||||
{t('新增工具服务', 'Add tool server')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
@ -484,9 +486,19 @@ export default function MCPPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs value={toolTab} onValueChange={(value) => {
|
||||
setToolTab(value as 'local' | 'online');
|
||||
setSelectedServerId(null);
|
||||
}} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="local">{t('本地工具', 'Local tools')}</TabsTrigger>
|
||||
<TabsTrigger value="online">{t('在线工具', 'Online tools')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] gap-4">
|
||||
<div className="space-y-4">
|
||||
{servers.map((server) => (
|
||||
{visibleServers.map((server) => (
|
||||
<Card
|
||||
key={server.id}
|
||||
role="button"
|
||||
@ -511,6 +523,8 @@ export default function MCPPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
|
||||
<Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
|
||||
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>}
|
||||
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
|
||||
{serverStatusLabel(server.status, locale)}
|
||||
</Badge>
|
||||
@ -534,12 +548,14 @@ export default function MCPPage() {
|
||||
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(server);
|
||||
}}>
|
||||
{t('编辑', 'Edit')}
|
||||
</Button>
|
||||
{!server.managed && (
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(server);
|
||||
}}>
|
||||
{t('编辑', 'Edit')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleTest(server.id);
|
||||
@ -547,21 +563,23 @@ export default function MCPPage() {
|
||||
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
|
||||
{t('测试', 'Test')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(server.id);
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
{!server.managed && (
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(server.id);
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
{visibleServers.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{t('暂无 MCP 服务。', 'There are no MCP servers yet.')}
|
||||
{toolTab === 'local' ? t('暂无本地工具服务。', 'There are no local tool servers yet.') : t('暂无在线工具服务。', 'There are no online tool servers yet.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -571,7 +589,7 @@ export default function MCPPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" />
|
||||
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('MCP 工具', 'MCP tools')}
|
||||
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('工具详情', 'Tool details')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeft, Check, Loader2, RefreshCw, Send, Settings2 } from 'lucide-react';
|
||||
|
||||
import { getNotification, sendMessage } from '@/lib/api';
|
||||
import type { ChatMessage, NotificationDetail } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
|
||||
type ReplyIntent = 'revise_once' | 'update_future';
|
||||
|
||||
export default function NotificationDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const params = useParams<{ scheduledRunId: string }>();
|
||||
const scheduledRunId = decodeURIComponent(params.scheduledRunId);
|
||||
const [detail, setDetail] = useState<NotificationDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [intent, setIntent] = useState<ReplyIntent | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setDetail(await getNotification(scheduledRunId));
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载通知详情失败', 'Failed to load notification detail'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [locale, scheduledRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const messages = useMemo<ChatMessage[]>(() => {
|
||||
if (!detail) return [];
|
||||
const runId = detail.run_id;
|
||||
const scoped = detail.detail.messages.filter((message) => !runId || message.run_id === runId || message.scheduled_run_id === detail.scheduled_run_id);
|
||||
if (scoped.length > 0) return scoped;
|
||||
return [
|
||||
{ role: 'user', content: detail.message, timestamp: detail.started_at || undefined },
|
||||
{ role: 'assistant', content: detail.output || detail.error || '', timestamp: detail.finished_at || detail.started_at || undefined, run_id: detail.run_id || undefined, task_id: detail.task_id || null },
|
||||
];
|
||||
}, [detail]);
|
||||
|
||||
const formatTime = (value?: string | null) => {
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleString(locale);
|
||||
};
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!detail || !intent || !input.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await sendMessage(
|
||||
input.trim(),
|
||||
detail.notification_session_id,
|
||||
undefined,
|
||||
{
|
||||
replyToScheduledRunId: detail.scheduled_run_id,
|
||||
scheduledReplyIntent: intent,
|
||||
}
|
||||
);
|
||||
setInput('');
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '提交修改失败', 'Failed to submit revision'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex h-[calc(100vh-4rem)] items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{pickAppText(locale, '加载中', 'Loading')}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-6 py-8">
|
||||
<Link href="/notifications" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{pickAppText(locale, '返回通知', 'Back to notifications')}
|
||||
</Link>
|
||||
<p className="mt-8 text-destructive">{error || pickAppText(locale, '通知不存在', 'Notification not found')}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-[calc(100vh-4rem)] flex-col bg-background">
|
||||
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-6 py-4">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link href="/notifications" className="mb-2 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{pickAppText(locale, '通知列表', 'Notifications')}
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{detail.title || detail.job_name}</h1>
|
||||
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'}>{detail.status}</Badge>
|
||||
{detail.engaged && <Badge>{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '生成时间', 'Generated')}: {formatTime(detail.started_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
{detail.task_id && (
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/tasks/${encodeURIComponent(detail.task_id)}`}>{pickAppText(locale, '查看任务', 'Open task')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mx-auto w-full max-w-6xl px-6 pt-3 text-sm text-destructive">{error}</div>}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatWorkbench
|
||||
messages={messages}
|
||||
isThinking={submitting}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messageViewportRef={viewportRef}
|
||||
processRuns={[]}
|
||||
processEvents={[]}
|
||||
processArtifacts={[]}
|
||||
selectedRunId={null}
|
||||
onSelectRun={() => {}}
|
||||
onCancelRun={() => {}}
|
||||
onFeedback={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#E6E1DE] bg-background px-6 py-4">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={intent === 'revise_once' ? 'default' : 'outline'}
|
||||
onClick={() => setIntent('revise_once')}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '修改这次', 'Revise this')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={intent === 'update_future' ? 'default' : 'outline'}
|
||||
onClick={() => setIntent('update_future')}
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '以后按这样', 'Apply going forward')}
|
||||
</Button>
|
||||
{detail.engaged && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '这条通知已经接入 Task', 'This notification is linked to a Task')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{intent && (
|
||||
<div className="rounded-[20px] border border-[#E6E1DE] bg-white p-3 shadow-[0_6px_18px_rgba(0,0,0,0.06)]">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={
|
||||
intent === 'update_future'
|
||||
? pickAppText(locale, '告诉我以后这类通知要怎么调整...', 'Describe how future notifications should change...')
|
||||
: pickAppText(locale, '告诉我这次内容要怎么改...', 'Describe how this result should change...')
|
||||
}
|
||||
className="block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
{pickAppText(locale, '发送', 'Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
109
app-instance/frontend/app/(app)/notifications/page.tsx
Normal file
109
app-instance/frontend/app/(app)/notifications/page.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { AlertCircle, Bell, Clock3, Loader2, RefreshCw, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { listNotifications } from '@/lib/api';
|
||||
import type { NotificationRun } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [items, setItems] = useState<NotificationRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setItems(await listNotifications());
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '加载通知失败', 'Failed to load notifications'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const formatTime = (value?: string | null) => {
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleString(locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-6 py-8">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Bell className="h-5 w-5" />
|
||||
{pickAppText(locale, '通知', 'Notifications')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '定时任务生成的日报、提醒和总结会固定出现在这里。', 'Scheduled reports, reminders, and summaries appear here.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void load()} variant="outline" size="sm">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="mb-4 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-[#E6E1DE] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{pickAppText(locale, '加载中', 'Loading')}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Bell className="mb-3 h-10 w-10 opacity-40" />
|
||||
<p className="font-medium">{pickAppText(locale, '暂无通知', 'No notifications yet')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[#E6E1DE]">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.scheduled_run_id}
|
||||
href={`/notifications/${encodeURIComponent(item.scheduled_run_id)}`}
|
||||
className="grid gap-3 px-5 py-4 transition-colors hover:bg-[#F7F6F5] md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{item.title || item.job_name}</span>
|
||||
{item.engaged && <Badge variant="secondary">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
|
||||
{item.status === 'error' && <Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{item.output || item.message}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
{formatTime(item.started_at)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{item.job_name}</div>
|
||||
<ArrowRight className="hidden h-4 w-4 self-center text-muted-foreground md:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,653 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
FolderOutput,
|
||||
ListTree,
|
||||
MessageSquare,
|
||||
PanelRightOpen,
|
||||
Siren,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
OfficeStatusBadge,
|
||||
formatOfficeDuration,
|
||||
formatOfficeTime,
|
||||
progressPercent,
|
||||
} from '@/components/office/OfficeShared';
|
||||
import { OfficePhaserCanvas } from '@/components/office/OfficePhaserCanvas';
|
||||
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office';
|
||||
import { appEventKindLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
function traceMetadataLabels(locale: 'zh-CN' | 'en-US'): Record<string, string> {
|
||||
return {
|
||||
stage_label: pickAppText(locale, '阶段', 'Stage'),
|
||||
source: pickAppText(locale, '来源', 'Source'),
|
||||
phase: 'Phase',
|
||||
step: 'Step',
|
||||
selection_mode: pickAppText(locale, '选人方式', 'Selection mode'),
|
||||
selected_mode: pickAppText(locale, '选中模式', 'Selected mode'),
|
||||
execution_mode: pickAppText(locale, '执行模式', 'Execution mode'),
|
||||
selected_targets: pickAppText(locale, '成员', 'Members'),
|
||||
selected_count: pickAppText(locale, '成员数', 'Member count'),
|
||||
requested_targets: pickAppText(locale, '请求成员', 'Requested targets'),
|
||||
planned_targets: pickAppText(locale, '计划成员', 'Planned targets'),
|
||||
matched_procedure_id: pickAppText(locale, '命中 Procedure', 'Matched procedure'),
|
||||
candidate_procedure_id: pickAppText(locale, '候选 Procedure', 'Candidate procedure'),
|
||||
announcement_path: pickAppText(locale, '回流路径', 'Announcement path'),
|
||||
announcement_sender_id: pickAppText(locale, '回流 Sender', 'Announcement sender'),
|
||||
announcement_category: pickAppText(locale, '回流类别', 'Announcement category'),
|
||||
external_fallback_reason: pickAppText(locale, '外部回退原因', 'External fallback reason'),
|
||||
failure_type: pickAppText(locale, '失败分类', 'Failure type'),
|
||||
failure_reason: pickAppText(locale, '失败原因', 'Failure reason'),
|
||||
error: pickAppText(locale, '错误', 'Error'),
|
||||
origin_channel: pickAppText(locale, '来源 Channel', 'Origin channel'),
|
||||
origin_chat_id: pickAppText(locale, '来源 Chat', 'Origin chat'),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTraceValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((item) => formatTraceValue(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function traceMetadataEntries(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
labels: Record<string, string>
|
||||
): Array<{ key: string; label: string; value: string }> {
|
||||
if (!metadata) return [];
|
||||
|
||||
const entries: Array<{ key: string; label: string; value: string }> = [];
|
||||
const used = new Set<string>();
|
||||
|
||||
for (const [key, label] of Object.entries(labels)) {
|
||||
const value = formatTraceValue(metadata[key]);
|
||||
if (!value) continue;
|
||||
used.add(key);
|
||||
entries.push({ key, label, value });
|
||||
}
|
||||
|
||||
for (const [key, rawValue] of Object.entries(metadata)) {
|
||||
if (used.has(key)) continue;
|
||||
const value = formatTraceValue(rawValue);
|
||||
if (!value) continue;
|
||||
entries.push({ key, label: key, value });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function PixelPanel({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-none border-4 border-[#0e1119] bg-[#141722] p-4 text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
|
||||
<div className="flex items-center gap-2 font-mono text-sm font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
|
||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-2 text-xs text-slate-400">{subtitle}</div>
|
||||
) : null}
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardPanel({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="rounded-none border-4 border-[#0e1119] bg-[#141722] text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
|
||||
<CardHeader className="border-b border-[#262a3d] pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-[#fef3c7]">
|
||||
<Icon className="h-4 w-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description ? <CardDescription className="text-slate-400">{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const params = useParams<{ taskId: string }>();
|
||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||||
|
||||
const sessions = useChatStore((state) => state.sessions);
|
||||
const processRuns = useChatStore((state) => state.processRuns);
|
||||
const processEvents = useChatStore((state) => state.processEvents);
|
||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||
|
||||
const office = React.useMemo(
|
||||
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
|
||||
);
|
||||
const metadataLabels = React.useMemo(() => traceMetadataLabels(locale), [locale]);
|
||||
|
||||
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedRunId(office?.rootRunId ?? null);
|
||||
setDetailOpen(false);
|
||||
}, [office?.rootRunId]);
|
||||
|
||||
const selectedTask = React.useMemo(
|
||||
() => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null,
|
||||
[office?.tasks, selectedRunId]
|
||||
);
|
||||
const selectedRun = React.useMemo(
|
||||
() => processRuns.find((run) => run.run_id === selectedTask?.runId) ?? null,
|
||||
[processRuns, selectedTask?.runId]
|
||||
);
|
||||
const selectedRunMetadata = React.useMemo(
|
||||
() => traceMetadataEntries(selectedRun?.metadata, metadataLabels),
|
||||
[metadataLabels, selectedRun?.metadata]
|
||||
);
|
||||
|
||||
const selectedEvents = React.useMemo(
|
||||
() => processEvents
|
||||
.filter((event) => event.run_id === selectedTask?.runId)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 16),
|
||||
[processEvents, selectedTask?.runId]
|
||||
);
|
||||
|
||||
const selectedArtifacts = React.useMemo(
|
||||
() => processArtifacts
|
||||
.filter((artifact) => artifact.run_id === selectedTask?.runId)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()),
|
||||
[processArtifacts, selectedTask?.runId]
|
||||
);
|
||||
|
||||
const openRunDetail = React.useCallback((runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
if (!office) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/office">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回 Office 列表', 'Back to office list')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'当前 store 中没有这个 task 的运行数据。先从对话页发起任务,或者回到 Office 列表查看当前可用任务。',
|
||||
'The current store does not contain runtime data for this task yet. Start it from chat first, or return to the office list to inspect available tasks.'
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressValue = progressPercent(office.progress.value, office.progress.max);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1720px] space-y-6 p-6">
|
||||
<TaskManagementTabs />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/office">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回 Office', 'Back to office')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '回到对话', 'Back to chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="mx-auto max-w-[1280px] rounded-none border-4 border-[#0e1119] bg-[#141522] p-4 shadow-[0_0_0_2px_#241d36_inset]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="truncate font-mono text-3xl font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
|
||||
{office.title}
|
||||
</h1>
|
||||
<OfficeStatusBadge status={office.status} className="bg-black/20" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400">
|
||||
<span>{pickAppText(locale, '负责人', 'Lead')}: {office.rootActorName}</span>
|
||||
<span>{pickAppText(locale, '会话', 'Session')}: {office.sourceSessionLabel}</span>
|
||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(office.createdAt, locale)}</span>
|
||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(office.durationMs, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
|
||||
<MetricTile label={pickAppText(locale, '运行实例', 'Runs')} value={String(office.stats.totalRuns)} />
|
||||
<MetricTile label={pickAppText(locale, '参与成员', 'Members')} value={String(office.stats.memberCount)} />
|
||||
<MetricTile label={pickAppText(locale, '产物数量', 'Artifacts')} value={String(office.stats.artifactCount)} />
|
||||
<MetricTile label={pickAppText(locale, '告警数量', 'Alerts')} value={String(office.alerts.length)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[1280px]">
|
||||
<OfficePhaserCanvas
|
||||
office={office}
|
||||
selectedRunId={selectedTask?.runId ?? null}
|
||||
onRunSelect={openRunDetail}
|
||||
showMetaBar={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]">
|
||||
<PixelPanel
|
||||
title={pickAppText(locale, '昨日小记', 'Yesterday notes')}
|
||||
subtitle={pickAppText(locale, '用任务摘要、告警和最近更新来替代原版 memo 区。', 'Use task summaries, alerts, and recent updates instead of the original memo area.')}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-6 text-slate-300">
|
||||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
|
||||
{selectedTask?.summary || pickAppText(locale, '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。', 'The selected task has no summary yet. Pick a specific run from the board on the right to inspect the floor.')}
|
||||
</div>
|
||||
{office.alerts.slice(0, 2).map((alert) => (
|
||||
<button
|
||||
key={alert.id}
|
||||
type="button"
|
||||
disabled={!alert.runId}
|
||||
onClick={() => alert.runId && openRunDetail(alert.runId)}
|
||||
className="block w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
|
||||
>
|
||||
<div className="font-medium text-rose-200">{alert.title}</div>
|
||||
{alert.description ? <div className="mt-1 text-xs text-slate-400">{alert.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PixelPanel>
|
||||
|
||||
<PixelPanel
|
||||
title={pickAppText(locale, '任务控制台', 'Task console')}
|
||||
subtitle={pickAppText(locale, '保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。', 'Keep the original center console position, but back it with real task runtime data.')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MiniMetric label={pickAppText(locale, '当前阶段', 'Current stage')} value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
|
||||
<MiniMetric label={pickAppText(locale, '活跃实例', 'Active runs')} value={String(office.stats.activeRuns)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">
|
||||
<span>{office.progress.label}</span>
|
||||
<span>{progressValue}%</span>
|
||||
</div>
|
||||
<div className="h-4 rounded-none border-2 border-[#263144] bg-[#0f1420] p-[2px]">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#22d3ee,#fde047,#fb7185)] transition-all"
|
||||
style={{ width: `${progressValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTask ? (
|
||||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{pickAppText(locale, '当前聚焦', 'Current focus')}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{selectedTask.actorName} · {selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]"
|
||||
>
|
||||
{pickAppText(locale, '打开详情', 'Open details')}
|
||||
<PanelRightOpen className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]"
|
||||
>
|
||||
<Link href="/">
|
||||
{pickAppText(locale, '回到对话', 'Back to chat')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOfficeTaskTerminal(office.status) ? (
|
||||
<div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200">
|
||||
{pickAppText(locale, '任务已结束,办公室已解散,但现场记录仍可回看。', 'The task has ended and the office has dissolved, but the floor record is still available for review.')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PixelPanel>
|
||||
|
||||
<PixelPanel
|
||||
title={pickAppText(locale, '办公人员名单', 'Roster')}
|
||||
subtitle={pickAppText(locale, '原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。', 'Replacement for the original visitor area, showing the agents currently participating in this task.')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{office.members.map((member) => (
|
||||
<button
|
||||
key={member.memberId}
|
||||
type="button"
|
||||
onClick={() => openRunDetail(member.currentRunId)}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left transition-colors hover:border-[#64748b]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-100">{member.actorName}</div>
|
||||
<div className="truncate text-xs text-slate-400">{member.currentTitle}</div>
|
||||
</div>
|
||||
<OfficeStatusBadge status={member.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PixelPanel>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]">
|
||||
<BoardPanel
|
||||
icon={ListTree}
|
||||
title={pickAppText(locale, '任务看板', 'Task board')}
|
||||
description={pickAppText(locale, '当前 task 下所有 run 的结构化列表。', 'Structured list of all runs under this task.')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{office.tasks.map((task) => (
|
||||
<button
|
||||
key={task.runId}
|
||||
type="button"
|
||||
onClick={() => openRunDetail(task.runId)}
|
||||
className={`w-full rounded-none border-2 px-4 py-3 text-left transition-colors ${
|
||||
selectedTask?.runId === task.runId
|
||||
? 'border-[#facc15] bg-[#201922]'
|
||||
: 'border-[#2d3348] bg-[#0f1420] hover:border-[#64748b]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-slate-100">{task.title}</span>
|
||||
{task.isRoot ? (
|
||||
<span className="rounded-none border border-[#4a3c17] bg-[#3b2f12] px-2 py-0.5 font-mono text-[10px] text-[#fef3c7]">
|
||||
ROOT
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
|
||||
<span>{task.actorName}</span>
|
||||
<span>{formatOfficeTime(task.updatedAt, locale)}</span>
|
||||
<span>{pickAppText(locale, `${task.artifactCount} 个产物`, `${task.artifactCount} artifacts`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<OfficeStatusBadge status={task.status} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</BoardPanel>
|
||||
|
||||
<div className="space-y-5">
|
||||
<BoardPanel
|
||||
icon={Boxes}
|
||||
title={pickAppText(locale, '分工关系', 'Assignments')}
|
||||
description={pickAppText(locale, '主 Agent 到子 Agent 的委派关系。', 'Delegation links from the lead agent to sub-agents.')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{office.assignments.length === 0 ? (
|
||||
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
|
||||
{pickAppText(locale, '当前没有可见的子任务分工。', 'No visible subtask assignments yet.')}
|
||||
</div>
|
||||
) : (
|
||||
office.assignments.map((assignment) => (
|
||||
<button
|
||||
key={assignment.ownerRunId}
|
||||
type="button"
|
||||
onClick={() => openRunDetail(assignment.ownerRunId)}
|
||||
className="w-full rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left text-sm transition-colors hover:border-[#64748b]"
|
||||
>
|
||||
<div className="font-medium text-slate-100">{assignment.label}</div>
|
||||
<div className="mt-1 text-slate-400">{assignment.assigneeActorNames.join(' / ')}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</BoardPanel>
|
||||
|
||||
<BoardPanel
|
||||
icon={Siren}
|
||||
title={pickAppText(locale, '现场告警', 'Live alerts')}
|
||||
description={pickAppText(locale, '优先展示失败、阻塞和较高风险的任务信号。', 'Prioritize failed, blocked, and higher-risk task signals.')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{office.alerts.length === 0 ? (
|
||||
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
|
||||
{pickAppText(locale, '当前没有高优先级告警。', 'There are no high-priority alerts right now.')}
|
||||
</div>
|
||||
) : (
|
||||
office.alerts.map((alert) => (
|
||||
<button
|
||||
key={alert.id}
|
||||
type="button"
|
||||
disabled={!alert.runId}
|
||||
onClick={() => alert.runId && openRunDetail(alert.runId)}
|
||||
className="w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
|
||||
>
|
||||
<div className="font-medium text-rose-200">{alert.title}</div>
|
||||
{alert.description ? <div className="mt-1 text-sm text-slate-400">{alert.description}</div> : null}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</BoardPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
|
||||
<SheetHeader className="pr-8">
|
||||
<SheetTitle>{selectedTask?.title ?? pickAppText(locale, '任务详情', 'Task details')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedTask
|
||||
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}`
|
||||
: pickAppText(locale, '当前没有选中的任务实例。', 'No task run is currently selected.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{!selectedTask ? (
|
||||
<div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '当前没有可展示的任务详情。', 'There are no task details to display right now.')}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="rounded-xl border border-border/60 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{selectedTask.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{selectedTask.actorName}</div>
|
||||
</div>
|
||||
<OfficeStatusBadge status={selectedTask.status} />
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{pickAppText(locale, '开始时间', 'Started')}</span>
|
||||
<span>{formatOfficeTime(selectedTask.startedAt, locale)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{pickAppText(locale, '最近更新', 'Last update')}</span>
|
||||
<span>{formatOfficeTime(selectedTask.updatedAt, locale)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{pickAppText(locale, '阶段', 'Stage')}</span>
|
||||
<span>{selectedTask.stageLabel ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedRunMetadata.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '链路上下文', 'Trace context')}</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{selectedRunMetadata.map((item) => (
|
||||
<div key={item.key} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]">
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span className="break-words font-mono text-foreground/90">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.summary ? (
|
||||
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
|
||||
{selectedTask.summary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '产物', 'Artifacts')}</div>
|
||||
<div className="space-y-2 p-4">
|
||||
{selectedArtifacts.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有产物。', 'There are no artifacts for this task.')}</div>
|
||||
) : (
|
||||
selectedArtifacts.map((artifact) => (
|
||||
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3">
|
||||
<div className="font-medium">{artifact.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/60">
|
||||
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '最近事件', 'Recent events')}</div>
|
||||
<div className="space-y-2 p-4">
|
||||
{selectedEvents.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有事件。', 'There are no events for this task.')}</div>
|
||||
) : (
|
||||
selectedEvents.map((event) => {
|
||||
const metadataEntries = traceMetadataEntries(event.metadata, metadataLabels);
|
||||
return (
|
||||
<div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{appEventKindLabel(event.kind, locale)}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at, locale)}</div>
|
||||
</div>
|
||||
{event.status ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">{pickAppText(locale, '状态', 'Status')}: {event.status}</div>
|
||||
) : null}
|
||||
<div className="mt-2 text-sm text-foreground/90">
|
||||
{event.text || pickAppText(locale, '结构化更新', 'Structured update')}
|
||||
</div>
|
||||
{metadataEntries.length > 0 ? (
|
||||
<div className="mt-3 rounded-md bg-muted/20 px-3 py-2">
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '事件上下文', 'Event context')}</div>
|
||||
<div className="space-y-1.5">
|
||||
{metadataEntries.map((item) => (
|
||||
<div key={`${event.event_id}:${item.key}`} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]">
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span className="break-words font-mono text-foreground/90">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-4 py-4 text-slate-100">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-xl font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-slate-100">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
export default function OfficeTaskRedirectPage({ params }: { params: { taskId: string } }) {
|
||||
redirect(`/tasks/${params.taskId}`);
|
||||
}
|
||||
|
||||
@ -1,284 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Clock3,
|
||||
FolderKanban,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { OfficeStatusBadge, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared';
|
||||
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
|
||||
import { appConnectionStatusLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
function TaskCard({
|
||||
taskId,
|
||||
title,
|
||||
sessionLabel,
|
||||
rootActorName,
|
||||
status,
|
||||
updatedAt,
|
||||
memberCount,
|
||||
activeRuns,
|
||||
artifactCount,
|
||||
errorCount,
|
||||
currentStageLabel,
|
||||
progressLabel,
|
||||
progressValue,
|
||||
locale,
|
||||
}: {
|
||||
taskId: string;
|
||||
title: string;
|
||||
sessionLabel: string;
|
||||
rootActorName: string;
|
||||
status: Parameters<typeof OfficeStatusBadge>[0]['status'];
|
||||
updatedAt: string;
|
||||
memberCount: number;
|
||||
activeRuns: number;
|
||||
artifactCount: number;
|
||||
errorCount: number;
|
||||
currentStageLabel: string | null;
|
||||
progressLabel: string;
|
||||
progressValue: number;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/80 transition-colors hover:border-primary/30">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-lg">{title}</CardTitle>
|
||||
<CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>{pickAppText(locale, '会话', 'Session')}: {sessionLabel}</span>
|
||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {rootActorName}</span>
|
||||
<span>{pickAppText(locale, '更新于', 'Updated')} {formatOfficeTime(updatedAt, locale)}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<OfficeStatusBadge status={status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Metric icon={Users} label={pickAppText(locale, '成员', 'Members')} value={String(memberCount)} />
|
||||
<Metric icon={Activity} label={pickAppText(locale, '活跃', 'Active')} value={String(activeRuns)} />
|
||||
<Metric icon={FolderKanban} label={pickAppText(locale, '产物', 'Artifacts')} value={String(artifactCount)} />
|
||||
<Metric icon={Sparkles} label={pickAppText(locale, '异常', 'Alerts')} value={String(errorCount)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate text-muted-foreground">{progressLabel}</span>
|
||||
{currentStageLabel ? <span className="truncate font-medium">{currentStageLabel}</span> : null}
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${progressValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/office/${encodeURIComponent(taskId)}`}>
|
||||
{pickAppText(locale, '进入办公室', 'Open office')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficeListPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const sessionId = useChatStore((state) => state.sessionId);
|
||||
const sessions = useChatStore((state) => state.sessions);
|
||||
const processRuns = useChatStore((state) => state.processRuns);
|
||||
const processEvents = useChatStore((state) => state.processEvents);
|
||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||||
|
||||
const tasks = React.useMemo(
|
||||
() => buildOfficeTaskList({
|
||||
sessionId,
|
||||
sessions,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
}, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
);
|
||||
|
||||
const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status));
|
||||
const recentTasks = tasks.filter((task) => isOfficeTaskTerminal(task.status));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<TaskManagementTabs />
|
||||
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Office</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'基于当前会话的真实运行数据,展示主 Agent 与子 Agent 的任务现场。任务结束后会从活跃现场移除,但保留回看入口。',
|
||||
'Show the live task floor for the lead agent and its sub-agents using real runtime data from the current session. Finished tasks leave the active floor but remain available for review.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="min-w-[280px] border-border/70">
|
||||
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{pickAppText(locale, '当前会话', 'Current session')}</div>
|
||||
<div className="mt-1 font-medium">{sessionId}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">{pickAppText(locale, '连接状态', 'Connection')}</div>
|
||||
<div className="mt-1 font-medium">{appConnectionStatusLabel(wsStatus, wsStatus === 'connected' ? true : null, locale)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{wsStatus === 'connecting' && tasks.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{pickAppText(locale, '正在等待运行时数据...', 'Waiting for runtime data...')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Clock3 className="h-10 w-10 text-muted-foreground/50" />
|
||||
<h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '当前没有可展示的任务现场', 'No task floor is available yet')}</h2>
|
||||
<p className="mt-2 max-w-xl text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'先回到对话页发起一次主 Agent 任务。开始执行后,这里会出现活跃的 office 卡片。',
|
||||
'Start a lead-agent task from the chat page first. Once it begins running, active office cards will appear here.'
|
||||
)}
|
||||
</p>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{pickAppText(locale, '活跃 Office', 'Active office')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '正在运行中的任务现场会优先显示。', 'Running task floors are shown first.')}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, `${activeTasks.length} 个任务`, `${activeTasks.length} tasks`)}</div>
|
||||
</div>
|
||||
{activeTasks.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '当前没有活跃任务,下面可以查看最近结束的任务。', 'There are no active tasks right now. Recent finished tasks are listed below.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.taskId}
|
||||
taskId={task.taskId}
|
||||
title={task.title}
|
||||
sessionLabel={task.sessionLabel}
|
||||
rootActorName={task.rootActorName}
|
||||
status={task.status}
|
||||
updatedAt={task.updatedAt}
|
||||
memberCount={task.memberCount}
|
||||
activeRuns={task.activeRuns}
|
||||
artifactCount={task.artifactCount}
|
||||
errorCount={task.errorCount}
|
||||
currentStageLabel={task.currentStageLabel}
|
||||
progressLabel={task.progress.label}
|
||||
progressValue={progressPercent(task.progress.value, task.progress.max)}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{pickAppText(locale, '最近结束', 'Recently finished')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '已完成、失败或取消的任务仍保留回看入口。', 'Completed, failed, or cancelled tasks remain available for review.')}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, `${recentTasks.length} 个任务`, `${recentTasks.length} tasks`)}</div>
|
||||
</div>
|
||||
{recentTasks.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '还没有历史任务。', 'There is no task history yet.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{recentTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.taskId}
|
||||
taskId={task.taskId}
|
||||
title={task.title}
|
||||
sessionLabel={task.sessionLabel}
|
||||
rootActorName={task.rootActorName}
|
||||
status={task.status}
|
||||
updatedAt={task.updatedAt}
|
||||
memberCount={task.memberCount}
|
||||
activeRuns={task.activeRuns}
|
||||
artifactCount={task.artifactCount}
|
||||
errorCount={task.errorCount}
|
||||
currentStageLabel={task.currentStageLabel}
|
||||
progressLabel={task.progress.label}
|
||||
progressValue={progressPercent(task.progress.value, task.progress.max)}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export default function OfficeRedirectPage() {
|
||||
redirect('/tasks');
|
||||
}
|
||||
|
||||
@ -2,31 +2,27 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowRight, Building2, MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { OfficeStatusBadge } from '@/components/office/OfficeShared';
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
cancelDelegation,
|
||||
archiveSession,
|
||||
createSession,
|
||||
deleteSession,
|
||||
getActiveTask,
|
||||
getSession,
|
||||
getSessionProcess,
|
||||
listCommands,
|
||||
listSessions,
|
||||
sendMessage,
|
||||
submitChatFeedback,
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
||||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
|
||||
function messageFingerprint(msg: ChatMessage): string {
|
||||
const attachmentKey = (msg.attachments ?? [])
|
||||
@ -62,6 +58,23 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
|
||||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||||
}
|
||||
|
||||
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
|
||||
if (status === 'awaiting_feedback') return pickAppText(locale, '待反馈', 'Awaiting feedback');
|
||||
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
|
||||
return pickAppText(locale, '进行中', 'Active');
|
||||
}
|
||||
|
||||
const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled';
|
||||
|
||||
function loadThinkingModePreference(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY);
|
||||
return stored == null ? true : stored !== 'false';
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const {
|
||||
@ -86,30 +99,19 @@ export default function ChatPage() {
|
||||
} = useChatStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [commands, setCommands] = useState<SlashCommand[]>([]);
|
||||
const [showCommandPicker, setShowCommandPicker] = useState(false);
|
||||
const [pickerIndex, setPickerIndex] = useState(0);
|
||||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadSessionReqSeq = useRef(0);
|
||||
const commandsLoadedRef = useRef(false);
|
||||
const refreshSessionOnReconnectRef = useRef(false);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const shouldSnapToLatestRef = useRef(true);
|
||||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!input.startsWith('/') || input.includes(' ')) return [];
|
||||
const filter = input.slice(1).toLowerCase();
|
||||
return commands.filter(
|
||||
(command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter))
|
||||
);
|
||||
}, [commands, input]);
|
||||
|
||||
const sessionProcessRuns = useMemo(
|
||||
() => processRuns.filter((run) => run.session_id === sessionId),
|
||||
[processRuns, sessionId]
|
||||
@ -132,19 +134,6 @@ export default function ChatPage() {
|
||||
|
||||
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
|
||||
|
||||
const officeTasks = useMemo(
|
||||
() => buildOfficeTaskList({
|
||||
sessionId,
|
||||
sessions,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
}, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
|
||||
);
|
||||
|
||||
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listSessions();
|
||||
@ -154,6 +143,17 @@ export default function ChatPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadActiveTask = useCallback(async (key: string) => {
|
||||
try {
|
||||
if (useChatStore.getState().sessionId !== key) return;
|
||||
setActiveTask(await getActiveTask(key));
|
||||
} catch {
|
||||
if (useChatStore.getState().sessionId === key) {
|
||||
setActiveTask(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSessionMessages = useCallback(async (key: string) => {
|
||||
const reqSeq = ++loadSessionReqSeq.current;
|
||||
const localSnapshot = useChatStore.getState().messages;
|
||||
@ -168,6 +168,7 @@ export default function ChatPage() {
|
||||
if (process) {
|
||||
setSessionProcess(key, process);
|
||||
}
|
||||
void loadActiveTask(key);
|
||||
const nextMessages = waitingForReply
|
||||
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
||||
: detail.messages;
|
||||
@ -182,36 +183,16 @@ export default function ChatPage() {
|
||||
if (reqSeq !== loadSessionReqSeq.current) return;
|
||||
if (useChatStore.getState().sessionId !== key) return;
|
||||
}
|
||||
}, [setIsLoading, setIsThinking, setMessages, setSessionProcess]);
|
||||
|
||||
const loadCommands = useCallback(async () => {
|
||||
if (commandsLoadedRef.current) return;
|
||||
commandsLoadedRef.current = true;
|
||||
try {
|
||||
const nextCommands = await listCommands();
|
||||
setCommands(nextCommands);
|
||||
} catch {
|
||||
commandsLoadedRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (input.startsWith('/') && !input.includes(' ')) {
|
||||
void loadCommands();
|
||||
}
|
||||
}, [input, loadCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowCommandPicker(filteredCommands.length > 0);
|
||||
setPickerIndex(0);
|
||||
}, [filteredCommands]);
|
||||
}, [loadActiveTask, setIsLoading, setIsThinking, setMessages, setSessionProcess]);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages();
|
||||
setIsLoading(false);
|
||||
setIsThinking(false);
|
||||
setActiveTask(null);
|
||||
void loadSessionMessages(sessionId);
|
||||
}, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||
void loadActiveTask(sessionId);
|
||||
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wsStatus === 'connected') {
|
||||
@ -260,6 +241,7 @@ export default function ChatPage() {
|
||||
validation_status: validationStatus,
|
||||
});
|
||||
void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||||
void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||||
loadSessions();
|
||||
}
|
||||
});
|
||||
@ -267,7 +249,7 @@ export default function ChatPage() {
|
||||
return () => {
|
||||
unsubMessage();
|
||||
};
|
||||
}, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isThinking) {
|
||||
@ -311,18 +293,6 @@ export default function ChatPage() {
|
||||
shouldSnapToLatestRef.current = false;
|
||||
}, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandPicker || !pickerRef.current) return;
|
||||
const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [pickerIndex, showCommandPicker]);
|
||||
|
||||
const selectCommand = useCallback((command: SlashCommand) => {
|
||||
setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`);
|
||||
setShowCommandPicker(false);
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if ((!text && pendingFiles.length === 0) || isLoading) return;
|
||||
@ -337,7 +307,6 @@ export default function ChatPage() {
|
||||
|
||||
setInput('');
|
||||
setPendingFiles([]);
|
||||
setShowCommandPicker(false);
|
||||
|
||||
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
|
||||
addMessage({
|
||||
@ -350,14 +319,20 @@ export default function ChatPage() {
|
||||
setIsThinking(false);
|
||||
|
||||
if (wsManager.getStatus() === 'connected') {
|
||||
const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent };
|
||||
const wsPayload: Record<string, unknown> = {
|
||||
type: 'message',
|
||||
content: msgContent,
|
||||
thinking_enabled: thinkingModeEnabled,
|
||||
};
|
||||
if (attachments.length > 0) {
|
||||
wsPayload.attachments = attachments;
|
||||
}
|
||||
wsManager.sendRaw(wsPayload);
|
||||
} else {
|
||||
try {
|
||||
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined);
|
||||
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined, {
|
||||
thinkingEnabled: thinkingModeEnabled,
|
||||
});
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
if (result.response) {
|
||||
@ -377,9 +352,11 @@ export default function ChatPage() {
|
||||
: 'unknown',
|
||||
});
|
||||
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
||||
void loadActiveTask(sessionId);
|
||||
loadSessions();
|
||||
} else {
|
||||
await loadSessionMessages(sessionId);
|
||||
void loadActiveTask(sessionId);
|
||||
loadSessions();
|
||||
}
|
||||
} catch {
|
||||
@ -395,48 +372,27 @@ export default function ChatPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess]);
|
||||
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled]);
|
||||
|
||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => {
|
||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
||||
updateMessageFeedback(runId, feedbackType);
|
||||
try {
|
||||
await submitChatFeedback({
|
||||
sessionId,
|
||||
runId,
|
||||
feedbackType,
|
||||
comment,
|
||||
});
|
||||
void loadSessionMessages(sessionId);
|
||||
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
||||
void loadActiveTask(sessionId);
|
||||
void loadSessions();
|
||||
} catch (err: any) {
|
||||
updateMessageFeedback(runId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed'));
|
||||
}
|
||||
}, [loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
|
||||
}, [loadActiveTask, loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showCommandPicker && filteredCommands.length > 0) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) {
|
||||
e.preventDefault();
|
||||
selectCommand(filteredCommands[pickerIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowCommandPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
@ -470,6 +426,7 @@ export default function ChatPage() {
|
||||
const id = `web:${Date.now()}`;
|
||||
setSessionId(id);
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
clearMessages();
|
||||
useChatStore.getState().resetProcessState();
|
||||
try {
|
||||
@ -480,23 +437,30 @@ export default function ChatPage() {
|
||||
void loadSessions();
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
|
||||
const handleArchiveSession = async (key: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteSession(key);
|
||||
await archiveSession(key);
|
||||
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
|
||||
if (key === sessionId) {
|
||||
setSessionId('web:default');
|
||||
setActiveTask(null);
|
||||
clearMessages();
|
||||
useChatStore.getState().resetProcessState();
|
||||
}
|
||||
loadSessions();
|
||||
void loadSessions();
|
||||
} catch {
|
||||
// ignore transient errors
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: pickAppText(locale, '归档会话失败,请稍后重试。', 'Failed to archive the session. Please try again later.'),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = (key: string) => {
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setSessionId(key);
|
||||
};
|
||||
|
||||
@ -516,6 +480,16 @@ export default function ChatPage() {
|
||||
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
|
||||
}, []);
|
||||
|
||||
const toggleThinkingMode = useCallback(() => {
|
||||
setThinkingModeEnabled((current) => {
|
||||
const next = !current;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(THINKING_MODE_STORAGE_KEY, String(next));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const formatSessionName = (key: string) => {
|
||||
if (key.startsWith('web:')) {
|
||||
const id = key.slice(4);
|
||||
@ -535,37 +509,42 @@ export default function ChatPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
|
||||
<div className="w-64 border-r border-border flex flex-col bg-card">
|
||||
<div className="p-3">
|
||||
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
|
||||
<Plus className="w-4 h-4" />
|
||||
<div className="flex h-[calc(100vh-4rem)] bg-background">
|
||||
<aside className="flex w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5]">
|
||||
<div className="px-5 pb-5 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSession}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{pickAppText(locale, '新对话', 'New chat')}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="space-y-3 px-3 pb-6">
|
||||
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
|
||||
{sessions.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-4 text-center">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
|
||||
)}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.key}
|
||||
onClick={() => handleSelectSession(session.key)}
|
||||
className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${
|
||||
className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${
|
||||
session.key === sessionId
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
? 'bg-[#EFEEED] text-foreground'
|
||||
: 'text-foreground hover:bg-[#EFEEED]/70'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<div className="truncate">
|
||||
<span className="truncate">{formatSessionName(session.key)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => handleDeleteSession(session.key, event)}
|
||||
<button
|
||||
onClick={(event) => handleArchiveSession(session.key, event)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
|
||||
title={pickAppText(locale, '归档会话', 'Archive session')}
|
||||
aria-label={pickAppText(locale, '归档会话', 'Archive session')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@ -573,40 +552,9 @@ export default function ChatPage() {
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{currentOfficeTask ? (
|
||||
<div className="border-b border-border bg-background/90 px-4 py-3 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{pickAppText(locale, '当前任务现场', 'Current task floor')}
|
||||
</div>
|
||||
<OfficeStatusBadge status={currentOfficeTask.status} />
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm text-muted-foreground">
|
||||
{currentOfficeTask.title}
|
||||
<span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
|
||||
{pickAppText(locale, '查看任务现场', 'Open task floor')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatWorkbench
|
||||
messages={messages}
|
||||
@ -623,8 +571,23 @@ export default function ChatPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-4 bg-background/95 backdrop-blur">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-background px-8 pb-8 pt-4">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{activeTask && (
|
||||
<div className="mb-2 flex">
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||||
title={activeTask.description}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{pickAppText(locale, '当前任务', 'Current task')}:</span>
|
||||
<span className="truncate font-medium">{activeTask.short_title}</span>
|
||||
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{activeTaskStatusLabel(activeTask.status, locale)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{pendingFiles.map((item, index) => (
|
||||
@ -640,7 +603,7 @@ export default function ChatPage() {
|
||||
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-green-500 text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
|
||||
<span className="text-[#657162] text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
|
||||
)}
|
||||
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
@ -650,62 +613,18 @@ export default function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex gap-2">
|
||||
{showCommandPicker && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50"
|
||||
>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<button
|
||||
key={command.name}
|
||||
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${
|
||||
index === pickerIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 text-foreground'
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
selectCommand(command);
|
||||
}}
|
||||
onMouseEnter={() => setPickerIndex(index)}
|
||||
>
|
||||
<span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span>
|
||||
{command.argument_hint && (
|
||||
<span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
|
||||
{command.plugin_name !== 'builtin' && (
|
||||
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
|
||||
{command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
||||
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
title={pickAppText(locale, '添加附件', 'Add attachment')}
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={pickAppText(locale, '输入消息或 / 呼出命令…(回车发送,Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')}
|
||||
placeholder={pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ minHeight: '40px', maxHeight: '200px' }}
|
||||
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ minHeight: '72px', maxHeight: '200px' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
@ -713,14 +632,44 @@ export default function ChatPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5 text-[15px] text-muted-foreground">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground"
|
||||
title={pickAppText(locale, '添加附件', 'Add attachment')}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThinkingMode}
|
||||
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
|
||||
thinkingModeEnabled
|
||||
? 'border-primary/40 bg-[#F1EFEE] text-foreground'
|
||||
: 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
title={
|
||||
thinkingModeEnabled
|
||||
? pickAppText(locale, '思考模式已开启', 'Thinking mode is on')
|
||||
: pickAppText(locale, '思考模式已关闭', 'Thinking mode is off')
|
||||
}
|
||||
aria-pressed={thinkingModeEnabled}
|
||||
>
|
||||
<Brain className="h-4 w-4" />
|
||||
{pickAppText(locale, '思考', 'Think')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#85817E] text-white transition-colors hover:bg-primary disabled:opacity-40"
|
||||
aria-label={pickAppText(locale, '发送', 'Send')}
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
app-instance/frontend/app/(app)/settings/page.tsx
Normal file
1
app-instance/frontend/app/(app)/settings/page.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default } from '../status/page';
|
||||
@ -14,7 +14,6 @@ import {
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload,
|
||||
Wand2,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
@ -27,11 +26,11 @@ import {
|
||||
listSkillCandidates,
|
||||
listSkillDrafts,
|
||||
listSkills,
|
||||
migrateSkills,
|
||||
publishSkillDraft,
|
||||
regenerateSkillDraft,
|
||||
rejectSkillDraft,
|
||||
rollbackPublishedSkill,
|
||||
runSkillLearningOnce,
|
||||
submitSkillDraft,
|
||||
synthesizeSkillDraft,
|
||||
uploadSkill,
|
||||
@ -52,6 +51,9 @@ import type { Skill, SkillDraft, SkillLearningCandidate } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
@ -62,7 +64,7 @@ export default function SkillsPage() {
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [ignoredCandidates, setIgnoredCandidates] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -100,12 +102,11 @@ export default function SkillsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async (name: string) => {
|
||||
await runAction(`delete:${name}`, async () => {
|
||||
await deleteSkill(name);
|
||||
setDeleting(null);
|
||||
});
|
||||
};
|
||||
const hiddenCandidateStatuses = new Set(['rejected', 'superseded', 'published']);
|
||||
const visibleCandidates = candidates.filter(
|
||||
(candidate) => !ignoredCandidates.has(candidate.candidate_id) && !hiddenCandidateStatuses.has(candidate.status)
|
||||
);
|
||||
const visibleDrafts = drafts.filter((draft) => !TERMINAL_DRAFT_STATUSES.has(draft.status));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -127,22 +128,18 @@ export default function SkillsPage() {
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowUpload(true)} size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('上传技能', 'Upload skill')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void runAction('learning:run-once', () => runSkillLearningOnce())}
|
||||
onClick={() => void runAction('migrate-skills', () => migrateSkills())}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={Boolean(actionId)}
|
||||
>
|
||||
{actionId === 'learning:run-once' ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('运行学习', 'Run learning')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowUpload(true)} size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('上传技能', 'Upload skill')}
|
||||
{actionId === 'migrate-skills' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />}
|
||||
{t('迁移旧技能', 'Migrate legacy skills')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -169,36 +166,18 @@ export default function SkillsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleting && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-4 pt-6">
|
||||
<p className="text-sm">
|
||||
{t('确定删除技能', 'Delete skill')} <strong>{deleting}</strong>?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleting(null)}>
|
||||
{t('取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void confirmDelete(deleting)}>
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="published" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger>
|
||||
<TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger>
|
||||
<TabsTrigger value="drafts">{t('草稿/评审', 'Drafts')}</TabsTrigger>
|
||||
<TabsTrigger value="drafts">{t('草稿评审', 'Draft review')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="published">
|
||||
<PublishedSkillsTable
|
||||
skills={skills}
|
||||
onDownload={(name) => downloadSkill(name).catch((err) => setError(err.message))}
|
||||
onDelete={(name) => setDeleting(name)}
|
||||
onDelete={(name) => void runAction(`delete:${name}`, () => deleteSkill(name))}
|
||||
onDisable={(name) =>
|
||||
runAction(`disable:${name}`, () => disablePublishedSkill(name, t('人工禁用', 'Manual disable')))
|
||||
}
|
||||
@ -219,11 +198,11 @@ export default function SkillsPage() {
|
||||
<CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{candidates.length === 0 ? (
|
||||
<EmptyState icon={<Wand2 className="h-8 w-8" />} text={t('暂无学习候选', 'No learning candidates yet')} />
|
||||
{visibleCandidates.length === 0 ? (
|
||||
<EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无学习候选', 'No learning candidates yet')} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{candidates.map((candidate) => (
|
||||
{visibleCandidates.map((candidate) => (
|
||||
<div key={candidate.candidate_id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@ -252,6 +231,14 @@ export default function SkillsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={Boolean(actionId)}
|
||||
onClick={() => setIgnoredCandidates((prev) => new Set(prev).add(candidate.candidate_id))}
|
||||
>
|
||||
{t('忽略', 'Ignore')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={Boolean(actionId)}
|
||||
@ -299,11 +286,11 @@ export default function SkillsPage() {
|
||||
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{drafts.length === 0 ? (
|
||||
{visibleDrafts.length === 0 ? (
|
||||
<EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无草稿', 'No drafts yet')} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{drafts.map((draft) => (
|
||||
{visibleDrafts.map((draft) => (
|
||||
<DraftCard
|
||||
key={`${draft.skill_name}:${draft.draft_id}`}
|
||||
draft={draft}
|
||||
@ -323,15 +310,11 @@ export default function SkillsPage() {
|
||||
rejectSkillDraft(draft.skill_name, draft.draft_id)
|
||||
)
|
||||
}
|
||||
onPublish={() =>
|
||||
runAction(`publish:${draft.draft_id}`, async () => {
|
||||
const confirmHighRisk = draft.safety_report?.risk_level === 'high';
|
||||
if (confirmHighRisk && !window.confirm(t('这是高风险草稿,确认发布?', 'This is a high-risk draft. Publish anyway?'))) {
|
||||
return;
|
||||
}
|
||||
await publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk);
|
||||
})
|
||||
}
|
||||
onPublish={(confirmHighRisk) =>
|
||||
runAction(`publish:${draft.draft_id}`, () =>
|
||||
publishSkillDraft(draft.skill_name, draft.draft_id, '', confirmHighRisk)
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -442,7 +425,7 @@ function DraftCard({
|
||||
onSubmit: () => Promise<unknown>;
|
||||
onApprove: () => Promise<unknown>;
|
||||
onReject: () => Promise<unknown>;
|
||||
onPublish: () => Promise<unknown>;
|
||||
onPublish: (confirmHighRisk: boolean) => Promise<unknown>;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
@ -452,9 +435,27 @@ function DraftCard({
|
||||
const publishBlocked =
|
||||
draft.status !== 'approved'
|
||||
|| !safety
|
||||
|| !safety.passed
|
||||
|| safety.risk_level === 'critical'
|
||||
|| (evalReport?.status !== 'skipped_provider_unavailable' && evalReport?.passed === false);
|
||||
const isHighRisk = safety?.risk_level === 'high';
|
||||
const highRiskReason = [
|
||||
...(safety?.blocked_reasons ?? []),
|
||||
...(safety?.issues ?? []),
|
||||
safety?.suggested_fix,
|
||||
].filter(Boolean).join('\n');
|
||||
const safetyBlocksReview = Boolean(safety && (!safety.passed || safety.risk_level === 'critical'));
|
||||
const submitBlocked = draft.status !== 'draft' || safetyBlocksReview;
|
||||
const approveBlocked = draft.status !== 'in_review' || safetyBlocksReview;
|
||||
const rejectBlocked = !REJECTABLE_DRAFT_STATUSES.has(draft.status);
|
||||
const handlePublish = () => {
|
||||
if (isHighRisk) {
|
||||
const confirmed = window.confirm(
|
||||
t('该草稿被标记为高风险。确认发布?', 'This draft is marked high risk. Publish anyway?')
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
void onPublish(isHighRisk);
|
||||
};
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@ -478,33 +479,47 @@ function DraftCard({
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('base', 'base')}: {draft.base_version || '-'}
|
||||
</p>
|
||||
{isHighRisk && (
|
||||
<div className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
<div className="font-medium">{t('高风险理由来自 safety report', 'High-risk reason from safety report')}</div>
|
||||
<pre className="mt-2 whitespace-pre-wrap font-sans">{highRiskReason || t('未提供具体理由', 'No concrete reason provided')}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || draft.status !== 'draft'} onClick={() => void onSubmit()}>
|
||||
<Button variant="outline" size="sm" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('送审', 'Submit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} onClick={() => void onApprove()}>
|
||||
<Button variant="outline" size="sm" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{t('批准', 'Approve')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} onClick={() => void onReject()}>
|
||||
<Button variant="outline" size="sm" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t('拒绝', 'Reject')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={busy || publishBlocked} onClick={() => void onPublish()}>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{t('发布', 'Publish')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{t('发布', 'Publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<pre className="max-h-52 overflow-auto rounded-md bg-muted/50 p-3 text-xs">
|
||||
{JSON.stringify(draft.proposed_frontmatter, null, 2)}
|
||||
</pre>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs">
|
||||
{draft.proposed_content}
|
||||
</pre>
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">{t('当前版本', 'Current version')}</div>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap text-xs">
|
||||
{draft.base_version ? `${t('基线版本', 'Base version')}: ${draft.base_version}` : t('无基线版本,视为新增技能', 'No base version, treated as a new skill')}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">{t('草稿变更', 'Draft changes')}</div>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap text-xs">
|
||||
{JSON.stringify(draft.proposed_frontmatter, null, 2)}
|
||||
{'\n\n---\n\n'}
|
||||
{draft.proposed_content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<ReportBlock
|
||||
@ -595,6 +610,9 @@ function UploadSkillForm({
|
||||
accept=".zip"
|
||||
className="block w-full cursor-pointer text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary-foreground hover:file:bg-primary/90"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '上传后进入草稿评审,并自动运行 safety 和 eval。', 'After upload, the skill enters draft review and runs safety and eval automatically.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
Key,
|
||||
Loader2,
|
||||
Settings2,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { getStatus, restartSystem, updateProviderConfig } from '@/lib/api';
|
||||
import {
|
||||
@ -189,65 +191,116 @@ export default function StatusPage() {
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const settingsLinks = [
|
||||
{
|
||||
href: '/logs',
|
||||
icon: ScrollText,
|
||||
title: pickAppText(locale, '运行日志', 'Runtime Logs'),
|
||||
description: pickAppText(locale, '查看每次对话和后台任务的运行日志。', 'Inspect chat and background runtime logs.'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '系统状态', 'System status')}</h1>
|
||||
<div className="mx-auto max-w-6xl p-6 space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'集中管理模型、工具、集成和实例运行状态。Task 和通知只在各自页面处理。',
|
||||
'Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={loadStatus} variant="outline" size="sm" disabled={restarting}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{settingsLinks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="group flex min-h-[116px] items-start gap-4 rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/50 hover:bg-muted/40"
|
||||
>
|
||||
<span className="mt-0.5 rounded-md border border-border bg-muted p-2 text-muted-foreground group-hover:text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span className="block text-sm font-semibold text-foreground">{item.title}</span>
|
||||
<span className="block text-sm leading-6 text-muted-foreground">{item.description}</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Server className="w-4 h-4" />
|
||||
{pickAppText(locale, '系统信息', 'System information')}
|
||||
{pickAppText(locale, '实例运行', 'Instance runtime')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_auto] lg:items-center">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '重启当前实例', 'Restart current instance')}</p>
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '运行与调试', 'Runtime and debugging')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{restarting
|
||||
? pickAppText(locale, '正在重启当前 docker,服务恢复后页面会自动刷新。', 'Restarting the current Docker container. The page will refresh automatically once the service is back.')
|
||||
: pickAppText(locale, '会重启当前 docker 容器。重启完成后需要重新登录。', 'This restarts the current Docker container. You will need to sign in again afterwards.')}
|
||||
: pickAppText(locale, '查看每次对话的运行日志,或重启当前 docker 容器。重启完成后需要重新登录。', 'Inspect per-chat runtime logs or restart the current Docker container. You will need to sign in again afterwards.')}
|
||||
</p>
|
||||
{restartError ? (
|
||||
<p className="text-sm text-destructive">{restartError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<AlertDialog open={restartDialogOpen} onOpenChange={setRestartDialogOpen}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Restart
|
||||
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/logs">
|
||||
<ScrollText className="w-4 h-4 mr-2" />
|
||||
{pickAppText(locale, '运行日志', 'Runtime Logs')}
|
||||
</Link>
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestart} disabled={restarting}>
|
||||
{restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog open={restartDialogOpen} onOpenChange={setRestartDialogOpen}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Restart
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestart} disabled={restarting}>
|
||||
{restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 border-t pt-5 md:grid-cols-2">
|
||||
<InfoRow label={pickAppText(locale, '配置文件', 'Config file')} value={status.config_path} />
|
||||
<InfoRow label={pickAppText(locale, '工作区', 'Workspace')} value={status.workspace} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -436,23 +489,6 @@ export default function StatusPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cron Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{pickAppText(locale, '调度器', 'Scheduler')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow
|
||||
label={pickAppText(locale, '状态', 'Status')}
|
||||
value={status.cron.enabled ? pickAppText(locale, '运行中', 'Running') : pickAppText(locale, '已停止', 'Stopped')}
|
||||
ok={status.cron.enabled}
|
||||
/>
|
||||
<InfoRow label={pickAppText(locale, '任务数', 'Jobs')} value={String(status.cron.jobs)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
621
app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx
Normal file
621
app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx
Normal file
@ -0,0 +1,621 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, MessageSquare, RefreshCw, RotateCcw, Trash2, User, XCircle } from 'lucide-react';
|
||||
|
||||
import { OfficeStatusBadge, formatOfficeDuration, formatOfficeTime, progressPercent } from '@/components/office/OfficeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cancelDelegation, deleteBackendTask, getBackendTask, getFileUrl, retryDelegation, submitChatFeedback } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildOfficeView, isOfficeTaskTerminal, type OfficeTaskView } from '@/lib/office';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent } from '@/types';
|
||||
|
||||
function taskVisibleStatus(task: OfficeTaskView, locale: 'zh-CN' | 'en-US') {
|
||||
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
|
||||
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return task.stageLabel || task.status;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ taskId: string }>();
|
||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||||
const sessions = useChatStore((state) => state.sessions);
|
||||
const processRuns = useChatStore((state) => state.processRuns);
|
||||
const processEvents = useChatStore((state) => state.processEvents);
|
||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
|
||||
|
||||
const task = useMemo(
|
||||
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
|
||||
);
|
||||
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
|
||||
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
||||
const [revision, setRevision] = useState('');
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedRunId(task?.rootRunId ?? null);
|
||||
}, [task?.rootRunId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (task || !taskId) {
|
||||
setBackendTask(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setBackendTaskLoading(true);
|
||||
getBackendTask(taskId)
|
||||
.then((item) => {
|
||||
if (!cancelled) setBackendTask(item);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBackendTask(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBackendTaskLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [task, taskId]);
|
||||
|
||||
const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]);
|
||||
const artifacts = useMemo(
|
||||
() => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)),
|
||||
[processArtifacts, runIds]
|
||||
);
|
||||
const eventsByRun = useMemo(() => {
|
||||
const map = new Map<string, ProcessEvent[]>();
|
||||
for (const event of processEvents) {
|
||||
if (!runIds.has(event.run_id)) continue;
|
||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
||||
}
|
||||
return map;
|
||||
}, [processEvents, runIds]);
|
||||
const artifactsByRun = useMemo(() => {
|
||||
const map = new Map<string, ProcessArtifact[]>();
|
||||
for (const artifact of artifacts) {
|
||||
map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]);
|
||||
}
|
||||
return map;
|
||||
}, [artifacts]);
|
||||
const phaseGroups = useMemo(() => {
|
||||
const groups = new Map<string, OfficeTaskView[]>();
|
||||
for (const item of task?.tasks ?? []) {
|
||||
const label = item.stageLabel || taskVisibleStatus(item, locale);
|
||||
groups.set(label, [...(groups.get(label) ?? []), item]);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes }));
|
||||
}, [locale, task?.tasks]);
|
||||
const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null;
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
||||
setActionBusy(key);
|
||||
setActionError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err: any) {
|
||||
setActionError(err.message || pickAppText(locale, '操作失败', 'Action failed'));
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCurrentBackendTask = async () => {
|
||||
if (!backendTask) return;
|
||||
const title = backendTask.short_title || backendTask.description || backendTask.goal || backendTask.task_id;
|
||||
if (!window.confirm(pickAppText(locale, `删除任务“${title}”?`, `Delete task "${title}"?`))) {
|
||||
return;
|
||||
}
|
||||
await runAction('delete-backend-task', async () => {
|
||||
await deleteBackendTask(backendTask.task_id);
|
||||
router.push('/tasks');
|
||||
});
|
||||
};
|
||||
|
||||
if (!task && backendTask) {
|
||||
const validation = backendTask.validation_result;
|
||||
const accepted = Boolean(validation?.accepted);
|
||||
const validationIssues = [
|
||||
...arrayOfStrings(validation?.issues),
|
||||
...arrayOfStrings(validation?.missing_requirements),
|
||||
];
|
||||
const feedbackItems = backendTask.feedback || [];
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
|
||||
<Badge>{humanTaskStatus(backendTask.status, locale)}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionBusy)}
|
||||
onClick={() => void deleteCurrentBackendTask()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1>
|
||||
{backendTask.description ? (
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
|
||||
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
|
||||
<span>{pickAppText(locale, '更新', 'Updated')}: {formatOfficeTime(backendTask.updated_at, locale)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{(backendTask.runs ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div>
|
||||
) : (
|
||||
(backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '验证和反馈', 'Validation and feedback')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div className="rounded-lg border border-border bg-muted/25 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{validation ? (
|
||||
accepted ? <CheckCircle2 className="h-5 w-5 text-[#657162]" /> : <XCircle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="font-medium">
|
||||
{validation
|
||||
? accepted
|
||||
? pickAppText(locale, '验证通过', 'Validation passed')
|
||||
: pickAppText(locale, '需要继续修改', 'Needs revision')
|
||||
: pickAppText(locale, '尚未验证', 'Not validated yet')}
|
||||
</div>
|
||||
</div>
|
||||
{validation ? (
|
||||
<div className="mt-2 text-muted-foreground">
|
||||
{pickAppText(locale, '评分', 'Score')}: {String(validation.score ?? '-')} · {pickAppText(locale, '验证器', 'Validator')}: {String(validation.validator ?? '-')}
|
||||
</div>
|
||||
) : null}
|
||||
{validationIssues.length > 0 && (
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-muted-foreground">
|
||||
{validationIssues.map((item, index) => <li key={`${item}:${index}`}>{item}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{typeof validation?.recommended_revision_prompt === 'string' && validation.recommended_revision_prompt && (
|
||||
<p className="mt-3 rounded-md bg-background p-3 text-muted-foreground">{validation.recommended_revision_prompt}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">{pickAppText(locale, '用户反馈', 'User feedback')}</div>
|
||||
{feedbackItems.length === 0 ? (
|
||||
<p className="text-muted-foreground">{pickAppText(locale, '还没有用户反馈。', 'No user feedback yet.')}</p>
|
||||
) : (
|
||||
feedbackItems.map((item, index) => (
|
||||
<div key={index} className="rounded-md border border-border p-3">
|
||||
<div className="font-medium">{humanFeedback(String(item.feedback_type || ''), locale)}</div>
|
||||
{item.comment ? <p className="mt-1 text-muted-foreground">{String(item.comment)}</p> : null}
|
||||
{item.created_at ? <p className="mt-1 text-xs text-muted-foreground">{formatOfficeTime(String(item.created_at), locale)}</p> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-16 text-center">
|
||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{backendTaskLoading
|
||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||
: pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressValue = progressPercent(task.progress.value, task.progress.max);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={Boolean(actionBusy) || isOfficeTaskTerminal(task.status)}
|
||||
onClick={() => void runAction('cancel', () => cancelDelegation(task.rootRunId))}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '取消任务', 'Cancel task')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={Boolean(actionBusy) || !isOfficeTaskTerminal(task.status)}
|
||||
onClick={() => void runAction('retry', () => retryDelegation(task.rootRunId))}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '重试任务', 'Retry task')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
|
||||
<OfficeStatusBadge status={task.status} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
|
||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
|
||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(task.createdAt, locale)}</span>
|
||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(task.durationMs, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
|
||||
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
|
||||
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
|
||||
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
|
||||
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.alerts.length)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{task.progress.label}</span>
|
||||
<span className="font-medium">{progressValue}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="h-full bg-primary" style={{ width: `${progressValue}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{actionError && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{actionError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phaseGroups.map((phase, index) => (
|
||||
<div key={`${phase.label}:${index}`} className="flex items-center gap-2">
|
||||
<div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
||||
<div className="font-medium">{phase.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div>
|
||||
</div>
|
||||
{index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{phaseGroups.map((phase) => (
|
||||
<Card key={phase.label}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{phase.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
{phase.nodes.map((node) => (
|
||||
<button
|
||||
key={node.runId}
|
||||
type="button"
|
||||
onClick={() => setSelectedRunId(node.runId)}
|
||||
className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{node.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
|
||||
</div>
|
||||
<OfficeStatusBadge status={node.status} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
{node.summary || taskVisibleStatus(node, locale)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span>
|
||||
<span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="font-medium">{selectedNode.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
|
||||
</div>
|
||||
<OfficeStatusBadge status={selectedNode.status} />
|
||||
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
|
||||
<div className="space-y-2">
|
||||
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
|
||||
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
|
||||
<div className="font-medium">{event.kind}</div>
|
||||
<div className="mt-1 text-muted-foreground">{event.text || formatOfficeTime(event.created_at, locale)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '修订意见', 'Revision')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={revision}
|
||||
onChange={(event) => setRevision(event.target.value)}
|
||||
placeholder={pickAppText(locale, '直接写下需要调整的地方...', 'Describe what should change...')}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!revision.trim() || Boolean(actionBusy)}
|
||||
onClick={() =>
|
||||
void runAction('revision', async () => {
|
||||
updateMessageFeedback(task.rootRunId, 'revise');
|
||||
await submitChatFeedback({
|
||||
sessionId: task.sessionId || 'web:default',
|
||||
runId: task.rootRunId,
|
||||
feedbackType: 'revise',
|
||||
comment: revision.trim(),
|
||||
});
|
||||
setRevision('');
|
||||
})
|
||||
}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '提交修订', 'Submit revision')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={artifacts.length === 0}
|
||||
onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '全部下载', 'Download all')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||||
) : (
|
||||
artifacts.map((artifact) => (
|
||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{artifact.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
|
||||
</div>
|
||||
{artifact.url || artifact.file_id ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))}
|
||||
>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
||||
const { locale } = useAppI18n();
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{run.started_at ? formatOfficeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
|
||||
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={run.success === false ? 'destructive' : 'secondary'}>
|
||||
{run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{run.messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{run.messages.map((message, messageIndex) => {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isTool = message.role === 'tool';
|
||||
const Icon = isAssistant ? Bot : isTool ? FileText : User;
|
||||
return (
|
||||
<div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3">
|
||||
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
|
||||
{message.created_at ? <span>{formatOfficeTime(message.created_at, locale)}</span> : null}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const map: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
running: ['执行中', 'Running'],
|
||||
validating: ['验证中', 'Validating'],
|
||||
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (type === 'satisfied') return pickAppText(locale, '满意', 'Satisfied');
|
||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||||
return type || pickAppText(locale, '反馈', 'Feedback');
|
||||
}
|
||||
|
||||
function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
|
||||
if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed');
|
||||
if (reason === 'error') return pickAppText(locale, '执行出错', 'Error');
|
||||
if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
||||
return reason;
|
||||
}
|
||||
|
||||
function arrayOfStrings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
|
||||
}
|
||||
466
app-instance/frontend/app/(app)/tasks/page.tsx
Normal file
466
app-instance/frontend/app/(app)/tasks/page.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -18,31 +18,31 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--background: 0 0% 99%;
|
||||
--foreground: 0 0% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 4%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 4%;
|
||||
--primary: 15 16% 10%;
|
||||
--primary-foreground: 0 0% 99%;
|
||||
--secondary: 30 10% 94%;
|
||||
--secondary-foreground: 15 16% 10%;
|
||||
--muted: 24 9% 91%;
|
||||
--muted-foreground: 20 8% 46%;
|
||||
--accent: 30 8% 95%;
|
||||
--accent-foreground: 15 16% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--border: 24 8% 88%;
|
||||
--input: 0 0% 100%;
|
||||
--ring: 18 9% 52%;
|
||||
--chart-1: 17 9% 51%;
|
||||
--chart-2: 107 9% 55%;
|
||||
--chart-3: 216 12% 59%;
|
||||
--chart-4: 18 8% 68%;
|
||||
--chart-5: 102 12% 74%;
|
||||
--radius: 1rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
@ -78,8 +78,13 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC",
|
||||
"Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: "Public Sans", Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||
"Noto Sans SC", "Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
font-family: "Lora", Georgia, "Times New Roman", serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { AppI18nProvider } from '@/lib/i18n/provider';
|
||||
import { getServerAppLocale } from '@/lib/i18n/server';
|
||||
|
||||
@ -17,9 +18,29 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = getServerAppLocale();
|
||||
const taupeTheme = {
|
||||
'--background': '0 0% 99%',
|
||||
'--foreground': '0 0% 4%',
|
||||
'--card': '0 0% 100%',
|
||||
'--card-foreground': '0 0% 4%',
|
||||
'--popover': '0 0% 100%',
|
||||
'--popover-foreground': '0 0% 4%',
|
||||
'--primary': '15 16% 10%',
|
||||
'--primary-foreground': '0 0% 99%',
|
||||
'--secondary': '30 10% 94%',
|
||||
'--secondary-foreground': '15 16% 10%',
|
||||
'--muted': '24 9% 91%',
|
||||
'--muted-foreground': '20 8% 46%',
|
||||
'--accent': '30 8% 95%',
|
||||
'--accent-foreground': '15 16% 10%',
|
||||
'--border': '24 8% 88%',
|
||||
'--input': '0 0% 100%',
|
||||
'--ring': '18 9% 52%',
|
||||
'--radius': '1rem',
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<html lang={locale} style={taupeTheme}>
|
||||
<body className="bg-background text-foreground">
|
||||
<AppI18nProvider initialLocale={locale}>{children}</AppI18nProvider>
|
||||
</body>
|
||||
|
||||
@ -78,8 +78,7 @@ export function AppRuntimeBridge() {
|
||||
|
||||
React.useEffect(() => {
|
||||
resetProcessState();
|
||||
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
|
||||
wsManager.connect(wsSessionId);
|
||||
wsManager.connect(sessionId);
|
||||
}, [resetProcessState, sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
21
app-instance/frontend/components/AppShell.tsx
Normal file
21
app-instance/frontend/components/AppShell.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import Header from '@/components/Header';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<AuthGuard>
|
||||
<AppRuntimeBridge />
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,9 +2,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { MessageSquare, Activity, Clock, Puzzle, Blocks, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, ChevronDown } from 'lucide-react';
|
||||
import { Bell, Bot, ChevronDown, ListTodo, LogOut, Mail, MessageSquare, PackageOpen, Puzzle, Settings, Store, Wrench } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
@ -16,17 +15,7 @@ import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
type NavItem = {
|
||||
key:
|
||||
| 'chat'
|
||||
| 'status'
|
||||
| 'office'
|
||||
| 'skills'
|
||||
| 'plugins'
|
||||
| 'agents'
|
||||
| 'mcp'
|
||||
| 'outlook'
|
||||
| 'marketplace'
|
||||
| 'files';
|
||||
key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'plugins' | 'settings';
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
matchPrefixes?: string[];
|
||||
@ -34,22 +23,22 @@ type NavItem = {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'chat', href: '/', icon: MessageSquare },
|
||||
{ key: 'status', href: '/status', icon: Activity },
|
||||
{ key: 'office', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
|
||||
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] },
|
||||
{ key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] },
|
||||
{ key: 'skills', href: '/skills', icon: Puzzle },
|
||||
{ key: 'plugins', href: '/plugins', icon: Blocks },
|
||||
{ key: 'agents', href: '/agents', icon: Bot },
|
||||
{ key: 'mcp', href: '/mcp', icon: ServerCog },
|
||||
{ key: 'outlook', href: '/outlook', icon: Mail },
|
||||
{ key: 'marketplace', href: '/marketplace', icon: Store },
|
||||
{ key: 'files', href: '/files', icon: FolderOpen },
|
||||
{ key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] },
|
||||
{ key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] },
|
||||
{ key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] },
|
||||
{ key: 'marketplace', href: '/marketplace', icon: Store, matchPrefixes: ['/marketplace'] },
|
||||
{ key: 'plugins', href: '/plugins', icon: PackageOpen, matchPrefixes: ['/plugins'] },
|
||||
{
|
||||
key: 'settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
matchPrefixes: ['/settings', '/status', '/logs'],
|
||||
},
|
||||
];
|
||||
|
||||
const AUTH_ITEMS = [
|
||||
{ key: 'login', href: '/login', icon: LogIn },
|
||||
{ key: 'register', href: '/register', icon: UserPlus },
|
||||
] as const;
|
||||
|
||||
function ConnectionDot() {
|
||||
const { locale } = useAppI18n();
|
||||
const wsStatus = useChatStore((s) => s.wsStatus);
|
||||
@ -61,10 +50,10 @@ function ConnectionDot() {
|
||||
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
|
||||
|
||||
const color = isOnline
|
||||
? 'bg-green-500'
|
||||
? 'bg-[#869683]'
|
||||
: isConnecting
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500';
|
||||
? 'bg-[#8B7E77]'
|
||||
: 'bg-[#5F5550]';
|
||||
|
||||
const label = appConnectionStatusLabel(wsStatus, nanobotReady, locale);
|
||||
|
||||
@ -86,23 +75,17 @@ const Header = () => {
|
||||
|
||||
const navLabel = React.useCallback((key: NavItem['key']) => {
|
||||
if (key === 'chat') return pickAppText(locale, '对话', 'Chat');
|
||||
if (key === 'status') return pickAppText(locale, '状态', 'Status');
|
||||
if (key === 'office') return pickAppText(locale, '任务管理', 'Tasks');
|
||||
if (key === 'tasks') return 'Task';
|
||||
if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications');
|
||||
if (key === 'skills') return pickAppText(locale, '技能', 'Skills');
|
||||
if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins');
|
||||
if (key === 'tools') return pickAppText(locale, '工具', 'Tools');
|
||||
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
|
||||
if (key === 'mcp') return 'MCP';
|
||||
if (key === 'outlook') return 'Outlook';
|
||||
if (key === 'marketplace') return pickAppText(locale, '市场', 'Marketplace');
|
||||
return pickAppText(locale, '文件', 'Files');
|
||||
if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins');
|
||||
return pickAppText(locale, '配置', 'Settings');
|
||||
}, [locale]);
|
||||
|
||||
const authLabel = React.useCallback((key: 'login' | 'register') => (
|
||||
key === 'login'
|
||||
? pickAppText(locale, '登录', 'Sign In')
|
||||
: pickAppText(locale, '注册', 'Sign Up')
|
||||
), [locale]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
@ -113,24 +96,16 @@ const Header = () => {
|
||||
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50">
|
||||
<div className="max-w-[1720px] mx-auto px-5 sm:px-6 lg:px-8 xl:px-10">
|
||||
<div className="flex items-center h-16 gap-6">
|
||||
<Link href="/" className="flex shrink-0 items-center gap-3 pr-2">
|
||||
<Image
|
||||
src="/boardware-logo.jpg"
|
||||
alt="Boardware logo"
|
||||
width={40}
|
||||
height={32}
|
||||
className="h-8 w-10 shrink-0 rounded-sm bg-white object-contain p-0.5"
|
||||
/>
|
||||
<span className="whitespace-nowrap text-[1.05rem] font-semibold leading-none tracking-tight sm:text-[1.15rem]">
|
||||
Boardware Agent Sandbox
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
|
||||
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid h-16 grid-cols-[minmax(120px,1fr)_auto_minmax(120px,1fr)] items-center gap-4">
|
||||
<Link href="/" className="flex shrink-0 items-center">
|
||||
<span className="font-serif text-[28px] font-semibold leading-none text-[#0B0B0B]">
|
||||
Beaver
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-3">
|
||||
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<nav className="flex items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
@ -141,10 +116,10 @@ const Header = () => {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
@ -154,26 +129,30 @@ const Header = () => {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-l border-border pl-4">
|
||||
<div className="flex min-w-0 items-center justify-end gap-3">
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<ConnectionDot />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{user ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full border border-border/70 bg-background px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="flex items-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-2 py-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4]"
|
||||
>
|
||||
<Avatar className="h-8 w-8 border border-border/60">
|
||||
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
|
||||
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Avatar>
|
||||
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
|
||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-b from-slate-50 via-slate-50 to-white">
|
||||
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
|
||||
<div className="border-b border-border/60 px-6 py-5">
|
||||
<p className="truncate text-center text-sm font-medium text-muted-foreground">
|
||||
{user.email}
|
||||
@ -210,30 +189,7 @@ const Header = () => {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : !isAuthLoading ? (
|
||||
AUTH_ITEMS.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{authLabel(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-l border-border pl-4">
|
||||
<ConnectionDot />
|
||||
) : !isAuthLoading ? null : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -25,28 +25,28 @@ const TERMINAL_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cance
|
||||
|
||||
const AGENT_ACCENTS = [
|
||||
{
|
||||
frame: 'border-sky-500/25 bg-sky-500/[0.05]',
|
||||
title: 'text-sky-300',
|
||||
dot: 'bg-sky-400',
|
||||
result: 'border-sky-500/25 bg-sky-500/[0.08]',
|
||||
frame: 'border-[#BCC4CE] bg-[#E4E7EB]/45',
|
||||
title: 'text-[#697281]',
|
||||
dot: 'bg-[#8C96A3]',
|
||||
result: 'border-[#BCC4CE] bg-[#E4E7EB]/55',
|
||||
},
|
||||
{
|
||||
frame: 'border-emerald-500/25 bg-emerald-500/[0.05]',
|
||||
title: 'text-emerald-300',
|
||||
dot: 'bg-emerald-400',
|
||||
result: 'border-emerald-500/25 bg-emerald-500/[0.08]',
|
||||
frame: 'border-[#B7C2B5] bg-[#E3E8E2]/45',
|
||||
title: 'text-[#657162]',
|
||||
dot: 'bg-[#869683]',
|
||||
result: 'border-[#B7C2B5] bg-[#E3E8E2]/55',
|
||||
},
|
||||
{
|
||||
frame: 'border-amber-500/25 bg-amber-500/[0.05]',
|
||||
title: 'text-amber-300',
|
||||
dot: 'bg-amber-400',
|
||||
result: 'border-amber-500/25 bg-amber-500/[0.08]',
|
||||
frame: 'border-[#B8AEA8] bg-[#E7E2DE]/55',
|
||||
title: 'text-[#5F5550]',
|
||||
dot: 'bg-[#8B7E77]',
|
||||
result: 'border-[#B8AEA8] bg-[#E7E2DE]/65',
|
||||
},
|
||||
{
|
||||
frame: 'border-fuchsia-500/25 bg-fuchsia-500/[0.05]',
|
||||
title: 'text-fuchsia-300',
|
||||
dot: 'bg-fuchsia-400',
|
||||
result: 'border-fuchsia-500/25 bg-fuchsia-500/[0.08]',
|
||||
frame: 'border-[#D8D2CE] bg-[#ECE8E5]/70',
|
||||
title: 'text-[#4F4642]',
|
||||
dot: 'bg-[#6A5E58]',
|
||||
result: 'border-[#D8D2CE] bg-[#ECE8E5]/80',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@ -55,12 +55,12 @@ function accentFor(index: number) {
|
||||
}
|
||||
|
||||
function statusTone(status: ProcessRun['status']) {
|
||||
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300';
|
||||
if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
|
||||
if (status === 'cancelled') return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300';
|
||||
if (status === 'waiting') return 'border-amber-500/20 bg-amber-500/10 text-amber-300';
|
||||
if (status === 'queued') return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
|
||||
return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
|
||||
if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]';
|
||||
if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]';
|
||||
if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]';
|
||||
if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]';
|
||||
if (status === 'queued') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]';
|
||||
return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]';
|
||||
}
|
||||
|
||||
function feedTone(role: AgentFeedItem['role']) {
|
||||
@ -166,8 +166,8 @@ function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) {
|
||||
const rawEphemeral = metadata?.ephemeral_skill_names;
|
||||
const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : [];
|
||||
const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : [];
|
||||
const draftId = typeof metadata?.generated_skill_draft_id === 'string' ? metadata.generated_skill_draft_id : '';
|
||||
if (selected.length === 0 && ephemeral.length === 0 && !draftId) {
|
||||
const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : '';
|
||||
if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -182,9 +182,9 @@ function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) {
|
||||
ephemeral:{name}
|
||||
</Badge>
|
||||
))}
|
||||
{draftId && (
|
||||
{guidanceId && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
draft:{draftId.slice(0, 8)}
|
||||
guidance:{guidanceId.slice(0, 8)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@ -390,7 +390,7 @@ function ResultCard({
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">{pickAppText(locale, '结果', 'Result')}</div>
|
||||
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
|
||||
</div>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
|
||||
@ -28,7 +28,7 @@ function renderArtifactBody(artifact: ProcessArtifact, locale: 'zh-CN' | 'en-US'
|
||||
}
|
||||
if (artifact.artifact_type === 'link' && artifact.url) {
|
||||
return (
|
||||
<a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-sky-300 underline break-all">
|
||||
<a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-[#5F5550] underline break-all">
|
||||
{artifact.url}
|
||||
</a>
|
||||
);
|
||||
|
||||
@ -3,12 +3,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MessageList } from '@/components/chat-workbench/MessageList';
|
||||
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
|
||||
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export function ChatWorkbench({
|
||||
messages,
|
||||
@ -33,58 +28,10 @@ export function ChatWorkbench({
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [isDesktop, setIsDesktop] = React.useState(() =>
|
||||
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 1024px)').matches
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
const updateLayout = () => setIsDesktop(mediaQuery.matches);
|
||||
updateLayout();
|
||||
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', updateLayout);
|
||||
return () => mediaQuery.removeEventListener('change', updateLayout);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(updateLayout);
|
||||
return () => mediaQuery.removeListener(updateLayout);
|
||||
}, []);
|
||||
|
||||
const selectedRun = selectedRunId
|
||||
? processRuns.find((item) => item.run_id === selectedRunId) || null
|
||||
: null;
|
||||
const selectedRunEvents = selectedRun
|
||||
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: [];
|
||||
const selectedRunArtifacts = selectedRun
|
||||
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: [];
|
||||
const hasResultsPanel = Boolean(
|
||||
selectedRun &&
|
||||
(
|
||||
selectedRun.summary ||
|
||||
selectedRunEvents.length > 0 ||
|
||||
selectedRunArtifacts.length > 0
|
||||
)
|
||||
);
|
||||
const hasProcessPanel = processRuns.length > 0;
|
||||
const desktopColumns = hasProcessPanel && hasResultsPanel
|
||||
? 'grid-cols-[minmax(0,1fr)_340px_360px]'
|
||||
: hasProcessPanel
|
||||
? 'grid-cols-[minmax(0,1fr)_340px]'
|
||||
: hasResultsPanel
|
||||
? 'grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'grid-cols-[minmax(0,1fr)]';
|
||||
|
||||
const messageList = (
|
||||
return (
|
||||
<div className="h-full">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isThinking={isThinking}
|
||||
@ -93,81 +40,11 @@ export function ChatWorkbench({
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
onFeedback={onFeedback}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className={`grid h-full ${desktopColumns}`}>
|
||||
<div className="min-h-0">
|
||||
{messageList}
|
||||
</div>
|
||||
{hasProcessPanel && (
|
||||
<div className="min-h-0">
|
||||
<ProcessLane
|
||||
runs={processRuns}
|
||||
events={processEvents}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasResultsPanel && (
|
||||
<div className="min-h-0">
|
||||
<ArtifactSidebar
|
||||
selectedRun={selectedRun}
|
||||
events={processEvents}
|
||||
artifacts={processArtifacts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{!hasResultsPanel && !hasProcessPanel ? (
|
||||
messageList
|
||||
) : (
|
||||
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-3 border-b border-border">
|
||||
<TabsList className={`grid w-full ${hasResultsPanel ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
<TabsTrigger value="chat">{pickAppText(locale, '聊天', 'Chat')}</TabsTrigger>
|
||||
<TabsTrigger value="process">{pickAppText(locale, '过程', 'Process')}</TabsTrigger>
|
||||
{hasResultsPanel && (
|
||||
<TabsTrigger value="results">{pickAppText(locale, '结果', 'Results')}</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="chat" className="flex-1 min-h-0 mt-0">
|
||||
{messageList}
|
||||
</TabsContent>
|
||||
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
|
||||
<ProcessLane
|
||||
runs={processRuns}
|
||||
events={processEvents}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</TabsContent>
|
||||
{hasResultsPanel && (
|
||||
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
|
||||
<ArtifactSidebar
|
||||
selectedRun={selectedRun}
|
||||
events={processEvents}
|
||||
artifacts={processArtifacts}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,34 +5,34 @@ import remarkGfm from 'remark-gfm';
|
||||
|
||||
export function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-3 overflow-x-auto rounded-lg border border-border">
|
||||
<div className="my-3 overflow-x-auto rounded-lg border border-[#D8D2CE]">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }) => (
|
||||
<thead className="bg-muted/60" {...props}>
|
||||
<thead className="bg-[#ECE8E5]" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th className="px-3 py-2 text-left font-semibold text-foreground border-b border-border" {...props}>
|
||||
<th className="border-b border-[#D8D2CE] px-3 py-2 text-left font-semibold text-[#0B0B0B]" {...props}>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td className="px-3 py-2 border-b border-border/50" {...props}>
|
||||
<td className="border-b border-[#E7E2DE] px-3 py-2 text-[#1D1715]" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
tr: ({ children, ...props }) => (
|
||||
<tr className="hover:bg-muted/30 transition-colors" {...props}>
|
||||
<tr className="transition-colors hover:bg-[#F7F5F4]" {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Bot, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
|
||||
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
@ -44,24 +45,31 @@ function MessageBubble({
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
canSendFeedback: boolean;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const isUser = message.role === 'user';
|
||||
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | 'revise' | null>(null);
|
||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
||||
const validationFailed = message.validation_status === 'failed';
|
||||
const validationDetails =
|
||||
validationFailed
|
||||
? pickAppText(locale, '详细原因会在任务验证区展示;展开任务可查看验证报告。', 'Detailed reasons are shown in the task validation area. Open the task to inspect the validation report.')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
{!isUser && (
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-[#F1EFEE]">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`rounded-xl px-4 py-3 max-w-[88%] shadow-sm ${
|
||||
className={`max-w-[88%] px-4 py-3 ${
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-card border border-border/80'
|
||||
? 'rounded-[28px] bg-primary text-primary-foreground'
|
||||
: 'rounded-none bg-transparent text-[#1D1715]'
|
||||
}`}
|
||||
>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
@ -110,42 +118,107 @@ function MessageBubble({
|
||||
) : (
|
||||
<MarkdownContent content={textContent} />
|
||||
)}
|
||||
{!isUser && message.task_id && (
|
||||
<div className="mt-3 rounded-md border border-border bg-muted/35 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase text-muted-foreground">Task</div>
|
||||
<div className="mt-1 truncate text-sm font-medium">
|
||||
{pickAppText(locale, '已创建任务', 'Task created')}: {message.task_id}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(message.task_id)}`}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{pickAppText(locale, '查看任务', 'Open task')}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isUser && validationFailed && (
|
||||
<details className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<summary className="cursor-pointer text-base font-semibold text-destructive">
|
||||
{pickAppText(locale, '验证失败', 'Validation failed')}
|
||||
</summary>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
|
||||
</details>
|
||||
)}
|
||||
{!isUser && canSendFeedback && message.run_id && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/70 pt-2">
|
||||
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
||||
{message.feedback_state ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.feedback_state === 'satisfied'
|
||||
? pickAppText(locale, '已标记满意', 'Marked satisfied')
|
||||
: message.feedback_state === 'revise'
|
||||
? pickAppText(locale, '已请求修改', 'Revision requested')
|
||||
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{message.feedback_state === 'satisfied'
|
||||
? pickAppText(locale, '已标记满意', 'Marked satisfied')
|
||||
: message.feedback_state === 'revise'
|
||||
? pickAppText(locale, '已请求修改', 'Revision requested')
|
||||
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, 'satisfied')}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '满意', 'Satisfied')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, 'revise')}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '需要修改', 'Revise')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, 'abandon')}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '放弃', 'Abandon')}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackMode('satisfied')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '满意', 'Satisfied')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackMode('revise')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '需要修改', 'Revise')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, 'abandon')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '放弃', 'Abandon')}
|
||||
</button>
|
||||
</div>
|
||||
{feedbackMode && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-background p-2">
|
||||
<textarea
|
||||
value={feedbackComment}
|
||||
onChange={(event) => setFeedbackComment(event.target.value)}
|
||||
placeholder={
|
||||
feedbackMode === 'revise'
|
||||
? pickAppText(locale, '写下需要修改的地方...', 'Describe what needs to change...')
|
||||
: pickAppText(locale, '可选:补充说明...', 'Optional note...')
|
||||
}
|
||||
className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFeedbackMode(null);
|
||||
setFeedbackComment('');
|
||||
}}
|
||||
className="h-8 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)}
|
||||
className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{pickAppText(locale, '提交', 'Submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{message.validation_status && message.validation_status !== 'unknown' && (
|
||||
@ -162,7 +235,7 @@ function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
{isUser && (
|
||||
<div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary">
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
@ -269,7 +342,7 @@ export function MessageList({
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
|
||||
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const visibleMessages = React.useMemo(
|
||||
@ -311,12 +384,12 @@ export function MessageList({
|
||||
.find((message) => message.role === 'assistant' && message.run_id && message.task_id)?.run_id;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
||||
<div className="max-w-6xl mx-auto py-4 space-y-4">
|
||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 py-10">
|
||||
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
|
||||
<p className="text-lg font-medium text-foreground">Beaver</p>
|
||||
<p className="text-sm">{pickAppText(locale, '发送消息开始对话', 'Send a message to start the conversation')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -13,11 +13,11 @@ import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === 'done') return 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20';
|
||||
if (status === 'error') return 'bg-rose-500/10 text-rose-300 border-rose-500/20';
|
||||
if (status === 'cancelled') return 'bg-zinc-500/10 text-zinc-300 border-zinc-500/20';
|
||||
if (status === 'waiting') return 'bg-amber-500/10 text-amber-300 border-amber-500/20';
|
||||
return 'bg-sky-500/10 text-sky-300 border-sky-500/20';
|
||||
if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]';
|
||||
if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]';
|
||||
if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]';
|
||||
if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]';
|
||||
return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]';
|
||||
}
|
||||
|
||||
function actorIcon(run: ProcessRun) {
|
||||
@ -147,7 +147,7 @@ export function ProcessLane({
|
||||
</div>
|
||||
))}
|
||||
{run.status === 'error' && (
|
||||
<div className="flex items-center gap-2 text-xs text-rose-300">
|
||||
<div className="flex items-center gap-2 text-xs text-[#5F5550]">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{pickAppText(locale, '此任务执行失败。', 'This task failed.')}
|
||||
</div>
|
||||
@ -168,8 +168,8 @@ function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) {
|
||||
const rawEphemeral = metadata?.ephemeral_skill_names;
|
||||
const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : [];
|
||||
const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : [];
|
||||
const draftId = typeof metadata?.generated_skill_draft_id === 'string' ? metadata.generated_skill_draft_id : '';
|
||||
if (selected.length === 0 && ephemeral.length === 0 && !draftId) {
|
||||
const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : '';
|
||||
if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -184,9 +184,9 @@ function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) {
|
||||
ephemeral:{name}
|
||||
</Badge>
|
||||
))}
|
||||
{draftId && (
|
||||
{guidanceId && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
draft:{draftId.slice(0, 8)}
|
||||
guidance:{guidanceId.slice(0, 8)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -20,13 +20,13 @@ export function OfficeStatusBadge({
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'border text-[11px]',
|
||||
status === 'done' && 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
|
||||
status === 'running' && 'border-sky-500/30 bg-sky-500/10 text-sky-700',
|
||||
status === 'waiting' && 'border-amber-500/30 bg-amber-500/10 text-amber-700',
|
||||
status === 'blocked' && 'border-orange-500/30 bg-orange-500/10 text-orange-700',
|
||||
status === 'queued' && 'border-slate-500/30 bg-slate-500/10 text-slate-700',
|
||||
status === 'error' && 'border-rose-500/30 bg-rose-500/10 text-rose-700',
|
||||
status === 'cancelled' && 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700',
|
||||
status === 'done' && 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]',
|
||||
status === 'running' && 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]',
|
||||
status === 'waiting' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]',
|
||||
status === 'blocked' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]',
|
||||
status === 'queued' && 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]',
|
||||
status === 'error' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]',
|
||||
status === 'cancelled' && 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@ -70,10 +70,10 @@ export function zonePanelClassName(zone: OfficeZoneView): string {
|
||||
return cn(
|
||||
'relative min-h-[220px] overflow-hidden rounded-2xl border p-4 shadow-sm',
|
||||
'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_40%)]',
|
||||
zone.tone === 'info' && 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.95),rgba(224,242,254,0.7))]',
|
||||
zone.tone === 'warn' && 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.95),rgba(254,243,199,0.72))]',
|
||||
zone.tone === 'danger' && 'border-rose-200 bg-[linear-gradient(180deg,rgba(255,241,242,0.96),rgba(255,228,230,0.76))]',
|
||||
zone.tone === 'success' && 'border-emerald-200 bg-[linear-gradient(180deg,rgba(236,253,245,0.96),rgba(209,250,229,0.74))]',
|
||||
zone.tone === 'info' && 'border-[#BCC4CE] bg-[#E4E7EB]/70',
|
||||
zone.tone === 'warn' && 'border-[#B8AEA8] bg-[#E7E2DE]/70',
|
||||
zone.tone === 'danger' && 'border-[#B8AEA8] bg-[#E7E2DE]/80',
|
||||
zone.tone === 'success' && 'border-[#B7C2B5] bg-[#E3E8E2]/75',
|
||||
zone.tone === 'neutral' && 'border-border bg-card'
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Building2, Clock3 } from 'lucide-react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Clock3, ListTodo } from 'lucide-react';
|
||||
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
@ -10,28 +10,30 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const TASK_MANAGEMENT_TABS = [
|
||||
{
|
||||
label: 'Office',
|
||||
href: '/office',
|
||||
icon: Building2,
|
||||
match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'),
|
||||
label: 'ordinary',
|
||||
href: '/tasks',
|
||||
icon: ListTodo,
|
||||
match: (pathname: string, tab: string | null) => pathname.startsWith('/tasks') && tab !== 'scheduled',
|
||||
},
|
||||
{
|
||||
label: 'Scheduled tasks',
|
||||
href: '/cron',
|
||||
label: 'scheduled',
|
||||
href: '/tasks?tab=scheduled',
|
||||
icon: Clock3,
|
||||
match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'),
|
||||
match: (pathname: string, tab: string | null) => pathname.startsWith('/tasks') && tab === 'scheduled',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function TaskManagementTabs() {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const activeTab = searchParams.get('tab');
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{TASK_MANAGEMENT_TABS.map((tab) => {
|
||||
const isActive = tab.match(pathname);
|
||||
const isActive = tab.match(pathname, activeTab);
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
@ -46,9 +48,9 @@ export function TaskManagementTabs() {
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.href === '/cron'
|
||||
{tab.label === 'scheduled'
|
||||
? pickAppText(locale, '定时任务', 'Scheduled tasks')
|
||||
: pickAppText(locale, '办公室', 'Office')}
|
||||
: pickAppText(locale, '普通任务', 'Ordinary tasks')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.03)] ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-transparent bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
|
||||
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
'rounded-2xl border border-black/[0.04] bg-card/70 text-card-foreground shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.03)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-10 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[80px] w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -6,11 +6,16 @@ import type {
|
||||
AuthzRegisterBackendResponse,
|
||||
AuthzStatus,
|
||||
AuthUser,
|
||||
ActiveTask,
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
CronJob,
|
||||
FileAttachment,
|
||||
Marketplace,
|
||||
MarketplacePlugin,
|
||||
NotificationDetail,
|
||||
NotificationRun,
|
||||
PluginInfo,
|
||||
ProviderConfigPayload,
|
||||
Session,
|
||||
@ -19,6 +24,10 @@ import type {
|
||||
SkillDraft,
|
||||
SkillDraftEvalReport,
|
||||
SkillDraftSafetyReport,
|
||||
SkillHubInstallResponse,
|
||||
SkillHubSearchItem,
|
||||
SkillHubSearchResponse,
|
||||
SkillHubVersionResponse,
|
||||
SkillLearningCandidate,
|
||||
SkillReviewRecord,
|
||||
SlashCommand,
|
||||
@ -252,7 +261,12 @@ export async function getMe(): Promise<AuthUser> {
|
||||
export async function sendMessage(
|
||||
message: string,
|
||||
sessionId: string = 'web:default',
|
||||
attachments?: FileAttachment[]
|
||||
attachments?: FileAttachment[],
|
||||
options?: {
|
||||
replyToScheduledRunId?: string;
|
||||
scheduledReplyIntent?: 'revise_once' | 'update_future' | 'continue_task';
|
||||
thinkingEnabled?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
response?: string;
|
||||
status?: string;
|
||||
@ -266,6 +280,13 @@ export async function sendMessage(
|
||||
if (attachments && attachments.length > 0) {
|
||||
body.attachments = attachments;
|
||||
}
|
||||
if (options?.replyToScheduledRunId) {
|
||||
body.reply_to_scheduled_run_id = options.replyToScheduledRunId;
|
||||
body.scheduled_reply_intent = options.scheduledReplyIntent || 'revise_once';
|
||||
}
|
||||
if (typeof options?.thinkingEnabled === 'boolean') {
|
||||
body.thinking_enabled = options.thinkingEnabled;
|
||||
}
|
||||
const result = await fetchJSON<{
|
||||
response?: string;
|
||||
status?: string;
|
||||
@ -583,8 +604,14 @@ export async function getSessionProcess(key: string): Promise<SessionProcessProj
|
||||
return fetchJSON(`/api/sessions/${encodeURIComponent(key)}/process`);
|
||||
}
|
||||
|
||||
export async function deleteSession(key: string): Promise<void> {
|
||||
await fetchJSON(`/api/sessions/${encodeURIComponent(key)}`, { method: 'DELETE' });
|
||||
export async function getChatLogs(limit = 50): Promise<ChatLogsResponse> {
|
||||
return fetchJSON(`/api/debug/chat-logs?limit=${encodeURIComponent(String(limit))}`, {
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveSession(key: string): Promise<void> {
|
||||
await fetchJSON(`/api/sessions/${encodeURIComponent(key)}/archive`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -629,7 +656,10 @@ export async function addCronJob(params: {
|
||||
every_seconds?: number;
|
||||
cron_expr?: string;
|
||||
at_iso?: string;
|
||||
tz?: string;
|
||||
session_key?: string;
|
||||
mode?: 'notification' | 'task';
|
||||
requires_followup?: boolean;
|
||||
}): Promise<CronJob> {
|
||||
return fetchJSON('/api/cron/jobs', {
|
||||
method: 'POST',
|
||||
@ -652,6 +682,40 @@ export async function runCronJob(jobId: string): Promise<void> {
|
||||
await fetchJSON(`/api/cron/jobs/${jobId}/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function listNotifications(): Promise<NotificationRun[]> {
|
||||
return fetchJSON('/api/notifications');
|
||||
}
|
||||
|
||||
export async function getNotification(scheduledRunId: string): Promise<NotificationDetail> {
|
||||
return fetchJSON(`/api/notifications/${encodeURIComponent(scheduledRunId)}`);
|
||||
}
|
||||
|
||||
export async function engageNotification(
|
||||
scheduledRunId: string,
|
||||
intent: 'revise_once' | 'update_future' | 'continue_task'
|
||||
): Promise<{ ok: boolean; task_id: string; intent: string }> {
|
||||
return fetchJSON(`/api/notifications/${encodeURIComponent(scheduledRunId)}/engage`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ intent }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBackendTasks(): Promise<BackendTask[]> {
|
||||
return fetchJSON('/api/tasks');
|
||||
}
|
||||
|
||||
export async function getBackendTask(taskId: string): Promise<BackendTask> {
|
||||
return fetchJSON(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
export async function deleteBackendTask(taskId: string): Promise<void> {
|
||||
await fetchJSON(`/api/tasks/${encodeURIComponent(taskId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getActiveTask(sessionId: string): Promise<ActiveTask | null> {
|
||||
return fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}/active-task`);
|
||||
}
|
||||
|
||||
export async function ping(): Promise<{ message: string }> {
|
||||
return fetchJSON('/api/ping');
|
||||
}
|
||||
@ -877,6 +941,12 @@ export async function cancelDelegation(runId: string): Promise<{ ok: boolean; ru
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
|
||||
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMcpServers(): Promise<UiMcpServerDescriptor[]> {
|
||||
return fetchJSON('/api/mcp/servers');
|
||||
}
|
||||
@ -1190,6 +1260,62 @@ export async function uploadSkill(file: File): Promise<Skill> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function migrateSkills(): Promise<{ included: Array<Record<string, unknown>>; skipped: Array<Record<string, unknown>> }> {
|
||||
return fetchJSON('/api/skills/migrate', { method: 'POST', timeoutMs: 45000 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillHub marketplace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchSkillHubSkills(params: {
|
||||
q?: string;
|
||||
sort?: 'relevance' | 'downloads' | 'newest';
|
||||
page?: number;
|
||||
size?: number;
|
||||
namespace?: string;
|
||||
} = {}): Promise<SkillHubSearchResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.q) search.set('q', params.q);
|
||||
if (params.sort) search.set('sort', params.sort);
|
||||
if (typeof params.page === 'number') search.set('page', String(params.page));
|
||||
if (typeof params.size === 'number') search.set('size', String(params.size));
|
||||
if (params.namespace) search.set('namespace', params.namespace);
|
||||
const suffix = search.toString();
|
||||
return fetchJSON(`/api/marketplaces/skills/search${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getSkillHubDetail(namespace: string, slug: string): Promise<SkillHubSearchItem> {
|
||||
return fetchJSON(
|
||||
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSkillHubVersion(
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version: string
|
||||
): Promise<SkillHubVersionResponse> {
|
||||
return fetchJSON(
|
||||
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function installSkillHubSkill(
|
||||
namespace: string,
|
||||
slug: string,
|
||||
version?: string
|
||||
): Promise<SkillHubInstallResponse> {
|
||||
return fetchJSON(
|
||||
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/install`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version }),
|
||||
timeoutMs: 45000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marketplace (proxied)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -51,6 +51,10 @@ export interface ChatMessage {
|
||||
validation_status?: 'passed' | 'failed' | 'unknown';
|
||||
feedback_state?: 'satisfied' | 'revise' | 'abandon';
|
||||
feedback_error?: string;
|
||||
message_type?: string | null;
|
||||
scheduled_job_id?: string | null;
|
||||
scheduled_run_id?: string | null;
|
||||
cron_job_name?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
@ -67,6 +71,52 @@ export interface SessionDetail {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatLogEvent {
|
||||
message_id?: number | null;
|
||||
run_id?: string | null;
|
||||
role: string;
|
||||
event_type?: string | null;
|
||||
content?: string | null;
|
||||
timestamp?: string;
|
||||
context_visible?: boolean;
|
||||
tool_name?: string | null;
|
||||
tool_call_id?: string | null;
|
||||
tool_calls?: Array<Record<string, unknown>> | null;
|
||||
finish_reason?: string | null;
|
||||
reasoning?: string | null;
|
||||
reasoning_details?: unknown;
|
||||
codex_reasoning_items?: unknown;
|
||||
event_payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ChatLogRun {
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
title?: string | null;
|
||||
source?: string | null;
|
||||
task_id?: string | null;
|
||||
attempt_index?: number | null;
|
||||
task_mode?: boolean | null;
|
||||
user_input?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string | null;
|
||||
finish_reason?: string | null;
|
||||
events: ChatLogEvent[];
|
||||
}
|
||||
|
||||
export interface ChatLogSession {
|
||||
session_id: string;
|
||||
source?: string | null;
|
||||
title?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
runs: ChatLogRun[];
|
||||
}
|
||||
|
||||
export interface ChatLogsResponse {
|
||||
sessions: ChatLogSession[];
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
id?: string;
|
||||
name: string;
|
||||
@ -168,14 +218,125 @@ export interface CronJob {
|
||||
schedule_expr: string | null;
|
||||
schedule_every_ms: number | null;
|
||||
message: string;
|
||||
mode?: 'notification' | 'task';
|
||||
requires_followup?: boolean;
|
||||
deliver: boolean;
|
||||
channel: string | null;
|
||||
to: string | null;
|
||||
session_key?: string | null;
|
||||
next_run_at_ms: number | null;
|
||||
last_run_at_ms: number | null;
|
||||
last_status: string | null;
|
||||
last_error: string | null;
|
||||
last_scheduled_run_id?: string | null;
|
||||
last_task_id?: string | null;
|
||||
last_run_id?: string | null;
|
||||
history?: Array<{
|
||||
started_at_ms: number;
|
||||
finished_at_ms?: number | null;
|
||||
status: string;
|
||||
mode?: 'notification' | 'task';
|
||||
notification_session_id?: string | null;
|
||||
output?: string | null;
|
||||
task_id?: string | null;
|
||||
run_id?: string | null;
|
||||
error?: string | null;
|
||||
scheduled_run_id?: string;
|
||||
engaged?: boolean;
|
||||
engage_intent?: string | null;
|
||||
}>;
|
||||
created_at_ms: number;
|
||||
updated_at_ms?: number;
|
||||
}
|
||||
|
||||
export interface NotificationRun {
|
||||
scheduled_run_id: string;
|
||||
job_id: string;
|
||||
job_name: string;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
mode: 'notification' | 'task';
|
||||
started_at_ms: number;
|
||||
finished_at_ms?: number | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
output?: string | null;
|
||||
error?: string | null;
|
||||
notification_session_id: string;
|
||||
task_id?: string | null;
|
||||
run_id?: string | null;
|
||||
engaged?: boolean;
|
||||
engage_intent?: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationDetail extends NotificationRun {
|
||||
detail: SessionDetail;
|
||||
}
|
||||
|
||||
export interface BackendTaskEvent {
|
||||
event_id: string;
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
run_id?: string | null;
|
||||
event_type: string;
|
||||
created_at: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BackendTaskRunMessage {
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
created_at?: string;
|
||||
tool_name?: string | null;
|
||||
}
|
||||
|
||||
export interface BackendTaskRun {
|
||||
run_id: string;
|
||||
title: string;
|
||||
session_id: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
success?: boolean | null;
|
||||
finish_reason?: string | null;
|
||||
attempt_index?: number | null;
|
||||
task_text?: string;
|
||||
messages: BackendTaskRunMessage[];
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BackendTask {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
parent_task_id?: string | null;
|
||||
description: string;
|
||||
short_title?: string | null;
|
||||
is_open?: boolean;
|
||||
goal: string;
|
||||
constraints: string[];
|
||||
priority: number;
|
||||
status: string;
|
||||
creator: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at?: string | null;
|
||||
close_reason?: string | null;
|
||||
satisfaction?: number | null;
|
||||
run_ids: string[];
|
||||
skill_names: string[];
|
||||
feedback: Array<Record<string, unknown>>;
|
||||
validation_result?: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown>;
|
||||
events?: BackendTaskEvent[];
|
||||
runs?: BackendTaskRun[];
|
||||
}
|
||||
|
||||
export interface ActiveTask {
|
||||
task_id: string;
|
||||
status: string;
|
||||
short_title: string;
|
||||
description: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Marketplace {
|
||||
@ -191,6 +352,75 @@ export interface MarketplacePlugin {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export interface SkillHubVersionRef {
|
||||
id?: number;
|
||||
version: string;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
publishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillHubSearchItem {
|
||||
id?: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
namespace: string;
|
||||
downloadCount: number;
|
||||
starCount: number;
|
||||
ratingAvg?: number;
|
||||
ratingCount?: number;
|
||||
headlineVersion?: SkillHubVersionRef | null;
|
||||
publishedVersion?: SkillHubVersionRef | null;
|
||||
installed?: boolean;
|
||||
installed_version?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface SkillHubSearchResponse {
|
||||
items: SkillHubSearchItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface SkillHubFileInfo {
|
||||
id?: number;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
contentType?: string | null;
|
||||
sha256?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillHubVersionDetail {
|
||||
id?: number;
|
||||
version?: string;
|
||||
status?: string;
|
||||
fileCount?: number;
|
||||
totalSize?: number;
|
||||
parsedMetadataJson?: string | null;
|
||||
manifestJson?: string | null;
|
||||
createdAt?: string;
|
||||
publishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillHubVersionResponse {
|
||||
detail: SkillHubVersionDetail;
|
||||
files: SkillHubFileInfo[];
|
||||
}
|
||||
|
||||
export interface SkillHubInstallResponse {
|
||||
ok: boolean;
|
||||
skill_name: string;
|
||||
version: string;
|
||||
source: 'skillhub';
|
||||
namespace: string;
|
||||
slug: string;
|
||||
installed_path: string;
|
||||
already_installed?: boolean;
|
||||
}
|
||||
|
||||
export type ProcessActorType = 'agent' | 'mcp' | 'system';
|
||||
export type ProcessRunStatus =
|
||||
| 'queued'
|
||||
@ -251,6 +481,10 @@ export interface UiMcpServerDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: 'stdio' | 'http';
|
||||
kind?: 'local' | 'online';
|
||||
category?: string;
|
||||
managed?: boolean;
|
||||
source?: string;
|
||||
url?: string | null;
|
||||
command?: string | null;
|
||||
args?: string[];
|
||||
|
||||
968
app-instance/frontend/前端需求讨论.md
Normal file
968
app-instance/frontend/前端需求讨论.md
Normal file
@ -0,0 +1,968 @@
|
||||
# 前端需求讨论
|
||||
|
||||
当前讨论到第二页:**Task 管理界面**。
|
||||
|
||||
这份文档先不展开其它页面。每次只把一页讲清楚:页面定位、用户目标、必须展示什么、不要塞什么、争议点是什么。等这一页定下来,再开下一页。
|
||||
|
||||
相关参考:
|
||||
|
||||
- 后端运行结构:`../backend/flow.md`
|
||||
- 后端施工状态:`../backend/施工指南.md`
|
||||
- 长期蓝图:`../backend/change.md`
|
||||
- 旧系统蓝图:`../backend-old/change.md`
|
||||
|
||||
---
|
||||
|
||||
## 第 1 页:对话页
|
||||
|
||||
路径:`/`
|
||||
|
||||
一句话定位:
|
||||
|
||||
> 对话页是用户和主 Agent 交互的主工作台。它负责提交问题/任务、展示最终回答、展示必要的运行状态,并收集用户对 Task 结果的反馈。
|
||||
|
||||
它承担一部分轻量后台管理工作,主要是会话管理和任务入口。但它不应该变成完整后台管理台,也不应该让用户直接配置 team strategy、sub-agent、MCP、插件市场等系统能力。
|
||||
|
||||
已确认的方向:
|
||||
|
||||
1. session 不做“删除”,改成“归档”。
|
||||
- 归档后的 session 不再出现在前端会话列表。
|
||||
- 归档不等于抹除记忆;相关记忆和历史数据仍然存在。
|
||||
2. 附件是对话页第一版必须能力。
|
||||
3. Slash command 不保留。
|
||||
4. 复杂任务创建后,对话中显示 Task 创建回复,点击跳转到 Task 管理界面。
|
||||
5. 原 `Office` 概念改名为 `Task`,并删除图形化办公室展示,改为更合理的任务交互过程与链条展示。
|
||||
6. “需要修改”走类似 Codex plan mode 的选择/评论框;用户也可以继续在主聊天框说话,此时默认上一轮结果满意。
|
||||
7. 验证失败时,大字显示状态,详细原因小字展示并默认折叠,可展开查看。
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户打开对话页是为了什么
|
||||
|
||||
| 用户目标 | 页面需要支持 |
|
||||
|---|---|
|
||||
| 问一个简单问题 | 快速输入、快速看到 assistant 回答,不制造 Task 负担 |
|
||||
| 提交一个复杂任务 | 输入任务后进入运行中状态,最终看到主 Agent synthesis 的结果 |
|
||||
| 知道系统是不是还在工作 | 明确显示 thinking/running/失败状态 |
|
||||
| 看到必要过程 | 能看到规划、子任务、验证、重试等摘要,但不被原始事件淹没 |
|
||||
| 判断结果是否可接受 | 最新 Task 结果下提供“满意 / 需要修改 / 放弃” |
|
||||
| 继续上下文 | 会话列表、切换历史会话、加载历史消息 |
|
||||
| 修改结果 | 反馈“需要修改”后,下一条消息应自然复用未关闭 Task |
|
||||
| 管理会话可见性 | 支持归档 session,使它不再出现在前端列表 |
|
||||
| 进入复杂任务工作台 | Task 创建后,从对话消息中的 Task 链接跳转到 Task 管理界面 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前对话页已有元素
|
||||
|
||||
| 元素 | 当前情况 | 本页讨论点 |
|
||||
|---|---|---|
|
||||
| 会话列表 | 左侧已有新建、切换、删除会话 | 删除应改为归档;归档后前端不再显示 |
|
||||
| 消息流 | 中间展示 user / assistant 消息 | 是否需要显示模型、run_id、token、工具次数等元信息 |
|
||||
| 输入框 | 支持文本输入、回车发送 | 是否需要模型选择、参数选择、模式选择 |
|
||||
| 运行状态 | 有 thinking 状态 | 是否要区分 simple run、Task run、验证中、重试中 |
|
||||
| 反馈按钮 | 最新 assistant Task 结果显示三按钮 | 需要改成带评论框的选择交互 |
|
||||
| 过程区 | 桌面端有 ProcessLane,移动端有 Process tab | 对话页不再常驻大过程区;复杂任务通过 Task 链接进入 Task 管理界面 |
|
||||
| 结果区 | 有 ArtifactSidebar | 需要和 Task 管理界面重新划边界 |
|
||||
| Office 入口 | 当前任务现场 banner | 不应该在顶部常驻;改成消息中的 Task 链接 |
|
||||
| 附件入口 | 前端已有上传入口 | 第一版必须保留,需要后端补足附件语义 |
|
||||
| Slash command | 前端已有 `/` 命令选择 | 不保留,应移除 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对话页必须有
|
||||
|
||||
### 3.1 会话区
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 新建会话。
|
||||
2. 切换会话。
|
||||
3. 归档会话。
|
||||
4. 显示会话最近更新时间或可读标题。
|
||||
5. 默认不显示已归档会话。
|
||||
|
||||
暂不确定:
|
||||
|
||||
1. 是否需要会话搜索。
|
||||
2. 是否需要手动重命名。
|
||||
3. 是否需要 pin。
|
||||
4. 是否需要“查看已归档会话”的二级入口。
|
||||
|
||||
已确认:
|
||||
|
||||
1. 不使用“删除”语义。
|
||||
2. 归档只影响前端列表可见性,不等于删除记忆或永久清除历史。
|
||||
|
||||
### 3.2 消息区
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 展示用户消息。
|
||||
2. 展示 assistant 最终回答。
|
||||
3. 刷新后保留 `run_id / task_id / task_status / validation_status / feedback_state` 对应的 UI 状态。
|
||||
4. 对 Markdown 内容做稳定渲染。
|
||||
|
||||
不应该做:
|
||||
|
||||
1. 不直接展示隐藏事件原始 JSON。
|
||||
2. 不把 sub-agent 的中间 summary 当成最终回答。
|
||||
3. 不让用户手动改内部 Task 状态。
|
||||
|
||||
### 3.3 输入区
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 输入文本。
|
||||
2. 发送。
|
||||
3. 发送失败时明确提示。
|
||||
4. 运行中避免重复提交,或明确支持排队。
|
||||
5. 上传附件。
|
||||
6. 在消息中展示用户已提交的附件。
|
||||
|
||||
暂不确定:
|
||||
|
||||
1. 附件进入模型上下文的具体语义:作为文件引用、文本提取、图片输入,还是先作为 workspace 文件供工具读取。
|
||||
2. 是否要提供模型/provider 快捷选择。
|
||||
|
||||
不保留:
|
||||
|
||||
1. Slash command。
|
||||
|
||||
### 3.4 运行状态
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 等待模型时显示“思考中”。
|
||||
2. Task 验证中有明确状态。
|
||||
3. 验证失败重试时有明确状态。
|
||||
4. 失败时告诉用户失败发生在发送、运行、验证还是反馈。
|
||||
5. 验证失败时,大字显示失败状态。
|
||||
6. 验证详细原因放在状态下方,以小字/展开区展示,默认折叠。
|
||||
|
||||
当前缺口:
|
||||
|
||||
1. 现在基本只有 thinking。
|
||||
2. 验证中、重试中、Task awaiting feedback 没有足够清晰的产品化表达。
|
||||
|
||||
### 3.5 反馈区
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 只对最新 assistant Task 结果显示反馈。
|
||||
2. 三种反馈:满意、需要修改、放弃。
|
||||
3. 已反馈后显示反馈状态。
|
||||
4. 反馈失败时显示错误。
|
||||
5. “满意 / 需要修改”使用带评论框的选择交互。
|
||||
6. 用户如果不点反馈、直接在主聊天框继续聊天,则默认上一轮结果满意。
|
||||
|
||||
待继续细化:
|
||||
|
||||
1. “满意”的评论框是可选还是默认隐藏。
|
||||
2. “需要修改”的评论是否必填。
|
||||
3. “放弃”是否需要二次确认。
|
||||
4. “满意”后是否显示“已用于学习候选”之类的反馈。
|
||||
|
||||
### 3.6 Task 入口
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 复杂任务创建后,对话流中出现一条 Task 创建回复。
|
||||
2. Task 创建回复包含可读标题或 description。
|
||||
3. 用户点击 Task 链接后跳转到 Task 管理界面。
|
||||
4. 对话页只保留轻量 Task 状态,不展开完整执行链条。
|
||||
|
||||
示例表达:
|
||||
|
||||
```text
|
||||
已创建 Task:整理最近三个月销售数据并生成分析结论
|
||||
查看 Task
|
||||
```
|
||||
|
||||
不应该做:
|
||||
|
||||
1. 不展示未实现的策略按钮,例如 `moa / hierarchy / group_chat`。
|
||||
2. 不让用户选择 specialist agent 来影响当前 Task。
|
||||
3. 不把完整过程链条塞在对话页主界面。
|
||||
4. 不再使用 `Office` 作为用户可见概念。
|
||||
|
||||
---
|
||||
|
||||
## 4. 对话页不承担的事情
|
||||
|
||||
| 不承担 | 原因 | 应该去哪里 |
|
||||
|---|---|---|
|
||||
| Skill 审核、发布、回滚 | 这是能力生命周期管理,不是对话主流程 | Skills 页 |
|
||||
| Provider 深度配置 | 配置属于系统设置,不应打断对话 | Status/Settings |
|
||||
| MCP server 管理 | 属于工具/集成管理 | MCP/Settings,是否保留待后续讨论 |
|
||||
| Outlook 浏览与连接 | 属于集成管理 | Outlook/Settings,是否保留待后续讨论 |
|
||||
| Plugin/Marketplace 管理 | 属于平台扩展 | Plugins/Marketplace,是否保留待后续讨论 |
|
||||
| 内部 Task 管理 | Task 是运行容器,不是当前产品级实体 | 仅通过过程投影展示 |
|
||||
| Team strategy 配置 | 当前 team 是 Task 内部执行策略 | 仅展示,不手动配置 |
|
||||
|
||||
对话页承担的轻量后台管理:
|
||||
|
||||
| 承担 | 说明 |
|
||||
|---|---|
|
||||
| session 归档 | 替代删除;归档后不在前端会话列表展示,但记忆仍然存在 |
|
||||
| Task 入口 | 复杂任务创建后,通过对话消息提供跳转到 Task 管理界面的入口 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 对话页和后端的最小契约
|
||||
|
||||
| 能力 | 后端接口/数据 | 对话页使用方式 |
|
||||
|---|---|---|
|
||||
| 发送消息 | `POST /api/chat` | 输入框发送;返回 final answer + run/task/validation 元数据 |
|
||||
| WebSocket 对话 | `WS /ws/{session_id}` | 实时发送和接收 assistant message / session_updated |
|
||||
| 提交反馈 | `POST /api/chat/feedback` | 最新 Task answer 下三按钮 |
|
||||
| 读取会话 | `GET /api/sessions/{session_id}` | 刷新消息流和反馈状态 |
|
||||
| 会话列表 | `GET /api/sessions` | 左侧会话列表 |
|
||||
| 过程投影 | `GET /api/sessions/{session_id}/process` | 右侧过程区,不直接展示隐藏事件 JSON |
|
||||
| 归档会话 | 待补:archive session API | 归档后会话不再出现在默认列表 |
|
||||
| 附件 | 待补:chat attachment / file reference 契约 | 对话页必须支持附件上传和附件展示 |
|
||||
| Task 链接 | 待补:Task 管理页路由与 Task identifier 映射 | 复杂任务创建回复跳转到 Task 管理界面 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前主要争议点
|
||||
|
||||
| 争议点 | 方案 A | 方案 B | 需要定什么 |
|
||||
|---|---|---|---|
|
||||
| 附件 | 当前版本保留,要求后端补附件语义 | 当前版本隐藏,等文件能力页讨论后再接回 | 已定:保留 |
|
||||
| Slash command | 保留为高级用户快捷入口 | 隐藏,避免旧系统命令残留干扰 | 已定:不保留 |
|
||||
| 过程区默认状态 | 桌面端默认显示 | 复杂 Task 出现后,以 Task 链接跳转到 Task 管理界面 | 已定:对话页不常驻完整过程区 |
|
||||
| Office banner | 对话页顶部显示当前任务现场 | 从对话页移除,复杂任务创建后在消息里显示 Task 链接 | 已定:移除顶部 Office banner,Office 改名 Task |
|
||||
| 模型/provider 选择 | 在输入区提供轻量选择 | 只在设置页改默认模型 | 用户是否经常需要按消息切模型 |
|
||||
| 反馈评论 | “满意 / 需要修改”弹出选择评论框 | 只点按钮,用户下一条消息补充 | 已定:需要评论框;用户继续主聊天则默认满意 |
|
||||
| 验证细节 | 大字状态 + 折叠详情 | 只显示通过/未通过 | 已定:状态大字,原因默认折叠 |
|
||||
| 调试元信息 | 可展开显示 run_id、task_id、token、工具次数 | 普通用户隐藏 | 当前产品面向开发者还是普通使用者 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前版本建议稿
|
||||
|
||||
这是讨论稿,不是最终结论。
|
||||
|
||||
对话页当前版本收敛成:
|
||||
|
||||
1. 左侧:会话列表。
|
||||
- 删除改为归档。
|
||||
- 归档后不在默认列表展示。
|
||||
2. 中间:消息流 + 输入框。
|
||||
3. 输入区保留附件能力。
|
||||
4. 移除 Slash command。
|
||||
5. 复杂任务创建后,在对话流里显示 Task 创建回复和跳转链接。
|
||||
6. 移除顶部 Office banner。
|
||||
7. `Office` 改名为 `Task`;完整任务过程进入 Task 管理界面展示。
|
||||
8. 最新 Task 回答下:满意 / 需要修改 / 放弃。
|
||||
9. “满意 / 需要修改”使用类似 Codex plan mode 的选择评论框。
|
||||
10. 用户不点反馈、直接继续聊天时,默认上一轮结果满意。
|
||||
11. 验证失败:大字状态,详细原因折叠展示。
|
||||
12. 对话页不提供 Team 策略选择、Sub-agent 选择、Skill 审核、MCP/插件/Outlook 管理。
|
||||
|
||||
---
|
||||
|
||||
## 8. 已确认问题
|
||||
|
||||
| 问题 | 结论 |
|
||||
|---|---|
|
||||
| 对话页是不是只作为“主工作台”,不承担后台管理? | 承担部分轻量后台管理,尤其是 session 归档和 Task 入口 |
|
||||
| 附件是不是对话页第一版必须能力? | 是,必须保留 |
|
||||
| Slash command 是否继续保留? | 不保留 |
|
||||
| 过程区是默认显示,还是复杂任务出现后再显示? | 不常驻完整过程区;复杂任务创建后显示 Task 链接,点击进入 Task 管理界面 |
|
||||
| Office 入口是否应该出现在对话页顶部? | 不应该;Office 改名 Task,入口放在对话消息中 |
|
||||
| “需要修改”是否需要评论框? | 需要;满意/需要修改使用选择评论框,用户继续主聊天则默认满意 |
|
||||
| 验证失败时,用户需要看到详细原因还是只看状态? | 大字状态 + 默认折叠的小字详细原因 |
|
||||
|
||||
## 9. 下一页遗留给 Task 管理界面的问题
|
||||
|
||||
这些问题不在对话页继续展开,留到下一页“Task 管理界面”讨论:
|
||||
|
||||
1. 原 Office 页改名 Task 后,路由叫 `/tasks` 还是继续兼容 `/office`。
|
||||
2. Task 管理界面如何展示任务交互过程和链条。
|
||||
3. 是否支持暂停、取消、重试某个 Task 或某个节点。
|
||||
4. 子任务、验证、技能选择、工具调用、产物如何分层展示。
|
||||
5. 删除图形化办公室后,新的 Task 页面信息架构怎么排。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 页:Task 管理界面
|
||||
|
||||
建议路径:
|
||||
|
||||
- 任务管理入口:`/tasks`
|
||||
- 普通任务详情:`/tasks/{task_id}`
|
||||
- 定时任务详情/编辑:`/tasks/scheduled/{job_id}` 或 `/tasks/scheduled`
|
||||
|
||||
历史对应:
|
||||
|
||||
- 当前前端里的 `/office` 和 `/office/[taskId]`
|
||||
- 当前前端里的 `/cron`
|
||||
- 后续用户可见名称统一改为 `Task`
|
||||
- 原图形化办公室展示删除,不再把任务现场做成地图/办公室/角色走动形式
|
||||
- 当前“任务管理”思路保留:任务分为普通任务和定时任务
|
||||
- 旧 `/office` 跳转到 `/tasks`
|
||||
- 当前 `/cron` 能力并入“定时任务”tab,不再作为顶层导航
|
||||
|
||||
一句话定位:
|
||||
|
||||
> Task 管理界面是任务中心。它统一管理普通任务和定时任务:普通任务用于查看复杂任务的执行链条和反馈状态;定时任务用于创建、启停和查看计划触发的任务。
|
||||
|
||||
它不是单个 Task 的详情页本身。单个普通 Task 的链条页是它下面的详情页。
|
||||
|
||||
它也不是普通聊天页,不是 agent/sub-agent 配置页,不是完整后台设置页。
|
||||
|
||||
已确认的方向:
|
||||
|
||||
1. 路由改为 `/tasks`,旧 `/office` 跳转到 `/tasks`。
|
||||
2. 任务管理入口分为“普通任务 / 定时任务”两个 tab。
|
||||
3. 当前 `/cron` 能力并入“定时任务”tab,不再作为顶层导航。
|
||||
4. 普通任务列表需要存在,用户可以从列表进入普通任务详情。
|
||||
5. 普通任务详情采用“阶段链 + 节点分组”展示执行链条。
|
||||
6. 普通任务详情允许直接输入修订意见。
|
||||
7. 用户可以暂停、取消、重试普通 Task 或节点。
|
||||
8. 第二次验证失败后,普通任务用户可见状态叫“任务失败”。
|
||||
9. sub-agent 输出只算节点结果,不算产物。
|
||||
10. 产物需要下载按钮,支持单个下载或全部下载。
|
||||
11. 已归档 session 里的普通任务仍然在普通任务列表显示。
|
||||
12. 已放弃普通任务不允许恢复。
|
||||
13. 所有任务没有归档概念;定时任务可以暂时关闭,也可以删除。
|
||||
14. 定时任务每次触发都创建普通 Task,并在运行历史里链接过去。
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户打开任务管理页是为了什么
|
||||
|
||||
| 用户目标 | 页面需要支持 |
|
||||
|---|---|
|
||||
| 查看普通任务 | 显示从对话中创建的复杂任务列表 |
|
||||
| 查看定时任务 | 显示计划触发的任务列表 |
|
||||
| 区分任务类型 | 普通任务和定时任务在同一个任务管理入口中分 tab 或分区展示 |
|
||||
| 进入普通任务详情 | 点击普通任务进入执行链条页 |
|
||||
| 管理定时任务 | 创建、启停、编辑、删除、手动运行定时任务 |
|
||||
| 看任务状态 | 对普通任务显示运行中、等待反馈、需要修改、已完成、已放弃、任务失败等状态 |
|
||||
| 看计划状态 | 对定时任务显示启用状态、计划规则、上次运行、下次运行、最近结果 |
|
||||
| 回到对话继续沟通 | 普通任务详情提供来源会话链接 |
|
||||
| 回看历史任务 | 普通任务列表也显示已归档 session 里的任务 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 任务类型
|
||||
|
||||
Task 管理界面至少分两类:
|
||||
|
||||
| 类型 | 来源 | 用户主要动作 | 详情页重点 |
|
||||
|---|---|---|---|
|
||||
| 普通任务 | 用户在对话中提交复杂任务后自动创建 | 查看、反馈、回到对话修订 | 执行链条、节点、验证、最终结果 |
|
||||
| 定时任务 | 用户手动创建计划任务 | 新建、启停、编辑、手动运行、查看历史触发 | 计划规则、触发记录、每次运行结果 |
|
||||
|
||||
普通任务详情页负责展示完整过程:
|
||||
|
||||
```text
|
||||
Task 创建
|
||||
│
|
||||
├─ 规划 plan
|
||||
│ ├─ single
|
||||
│ └─ team
|
||||
│ ├─ sequence
|
||||
│ ├─ parallel
|
||||
│ └─ dag
|
||||
│
|
||||
├─ 子任务执行 nodes
|
||||
│ ├─ selected skills
|
||||
│ ├─ generated ephemeral skill
|
||||
│ ├─ tool / file / memory activity
|
||||
│ └─ node result
|
||||
│
|
||||
├─ 主 Agent synthesis
|
||||
│
|
||||
├─ validation
|
||||
│ ├─ passed
|
||||
│ ├─ failed
|
||||
│ └─ retry
|
||||
│
|
||||
└─ feedback
|
||||
├─ satisfied
|
||||
├─ revise
|
||||
└─ abandon
|
||||
```
|
||||
|
||||
定时任务详情页负责展示计划和触发历史:
|
||||
|
||||
```text
|
||||
定时任务
|
||||
│
|
||||
├─ 计划规则
|
||||
│ ├─ at
|
||||
│ ├─ every
|
||||
│ └─ cron
|
||||
│
|
||||
├─ 目标会话 / message
|
||||
│
|
||||
├─ 触发历史
|
||||
│ ├─ run 1
|
||||
│ ├─ run 2
|
||||
│ └─ run N
|
||||
│
|
||||
└─ 最近结果 / 错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 页面结构建议
|
||||
|
||||
### 3.1 任务管理入口
|
||||
|
||||
路径建议:`/tasks`
|
||||
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| 顶部 tabs | 普通任务、定时任务 |
|
||||
| 普通任务 tab | 复杂任务列表、状态筛选、打开详情、回到对话 |
|
||||
| 定时任务 tab | 计划任务列表、新建任务、启停、编辑、手动运行 |
|
||||
| 全局空状态 | 引导用户回到对话页创建普通任务,或创建一个定时任务 |
|
||||
|
||||
待讨论:
|
||||
|
||||
1. 入口是否叫 `任务`,英文是否叫 `Tasks`。
|
||||
2. tabs 名称是“普通任务 / 定时任务”,还是“任务 / 计划任务”。
|
||||
3. 是否需要全局搜索。
|
||||
|
||||
### 3.2 普通任务列表
|
||||
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| 状态筛选 | 全部、运行中、等待反馈、需要修改、已完成、已放弃、失败 |
|
||||
| 任务卡片/表格 | 标题、description、来源会话、状态、当前阶段、更新时间、子任务数量、失败数 |
|
||||
| 快速动作 | 打开详情、回到对话、暂停、取消、重试 |
|
||||
| 空状态 | 引导用户回到对话页创建复杂任务 |
|
||||
|
||||
普通任务列表不负责展示完整链条,只负责让用户找到任务并进入详情。
|
||||
|
||||
已确认:
|
||||
|
||||
1. 普通任务列表需要存在。
|
||||
2. 已归档 session 里的普通任务仍然显示。
|
||||
3. 普通任务没有归档概念。
|
||||
4. 已放弃普通任务不允许恢复。
|
||||
|
||||
### 3.3 定时任务列表
|
||||
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| 顶部动作 | 新建定时任务 |
|
||||
| 列表字段 | 名称、启用状态、计划类型、计划表达式、目标会话、消息摘要、上次运行、下次运行、最近结果 |
|
||||
| 快速动作 | 启用/停用、编辑、手动运行、删除 |
|
||||
| 空状态 | 引导用户创建一个计划任务 |
|
||||
|
||||
定时任务管理保留现在 `/cron` 页的主要能力,但归入 Task 管理页,不再作为顶层独立概念。
|
||||
|
||||
已确认:
|
||||
|
||||
1. 定时任务可以暂时关闭,也可以删除。
|
||||
2. 定时任务没有归档概念。
|
||||
3. 每次定时触发都创建普通 Task。
|
||||
4. 定时任务运行历史必须链接到对应普通 Task。
|
||||
|
||||
### 3.4 普通任务详情页
|
||||
|
||||
路径建议:`/tasks/{task_id}`
|
||||
|
||||
建议分区:
|
||||
|
||||
| 区域 | 必须展示 | 说明 |
|
||||
|---|---|---|
|
||||
| Task header | 标题、description、状态、来源会话、创建时间、更新时间 | 用户先知道自己看的是哪个任务 |
|
||||
| 当前状态区 | 大字状态 + 当前阶段 + 下一步动作 | 例如“验证失败,等待修订” |
|
||||
| 执行链条 | plan、nodes、dependency、main synthesis、validation | 这是本页核心 |
|
||||
| 节点详情 | 每个节点的任务、状态、输入、输出、skills、错误 | 点击链条节点后在详情区展示 |
|
||||
| 最终结果 | 主 Agent synthesis 的用户可见结果 | 不把 sub-agent summary 当最终结果 |
|
||||
| 验证区 | 验证状态、分数、失败原因、重试记录 | 大状态明显,详情折叠 |
|
||||
| 反馈区 | 满意、需要修改、放弃;评论框 | 和对话页保持一致 |
|
||||
| 产物区 | 文件、链接、JSON、图片、报告 | 需要先定义产物来源 |
|
||||
| 事件时间线 | 重要事件,不展示原始隐藏 JSON | 用产品化语言展示 |
|
||||
|
||||
### 3.5 定时任务详情/编辑页
|
||||
|
||||
路径建议:`/tasks/scheduled/{job_id}`
|
||||
|
||||
| 区域 | 必须展示 | 说明 |
|
||||
|---|---|---|
|
||||
| Job header | 名称、启用状态、创建时间、更新时间 | 用户先知道这是哪个定时任务 |
|
||||
| 计划规则 | at / every / cron,下一次运行时间 | 核心配置 |
|
||||
| 消息内容 | 触发时发送给 Agent 的 message | 可编辑 |
|
||||
| 目标会话 | 触发运行归属哪个 session | 可选择或固定 |
|
||||
| 运行历史 | 每次触发的时间、状态、run/task 链接 | 和普通任务结果打通 |
|
||||
| 错误区 | 最近错误、失败原因 | 方便排查 |
|
||||
| 操作区 | 保存、启用/停用、手动运行、删除 | 管理动作 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 普通任务详情:执行链条怎么展示
|
||||
|
||||
这是普通任务详情页最核心的问题。删除图形化 Office 后,需要一个清晰的链条视图。
|
||||
|
||||
### 方案 A:纵向阶段链
|
||||
|
||||
```text
|
||||
Plan
|
||||
↓
|
||||
Team execution
|
||||
├─ Node A
|
||||
├─ Node B
|
||||
└─ Node C
|
||||
↓
|
||||
Main synthesis
|
||||
↓
|
||||
Validation
|
||||
↓
|
||||
Feedback
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
1. 最容易读。
|
||||
2. 移动端友好。
|
||||
3. 和事件时间顺序一致。
|
||||
|
||||
缺点:
|
||||
|
||||
1. 对 parallel / dag 的依赖关系表达较弱。
|
||||
|
||||
### 方案 B:DAG 链条图
|
||||
|
||||
```text
|
||||
Planner
|
||||
├── Node A ──┐
|
||||
├── Node B ──┼── Synthesis ── Validation
|
||||
└── Node C ──┘
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
1. 能清楚表达依赖关系。
|
||||
2. 对 team/dag 任务更准确。
|
||||
|
||||
缺点:
|
||||
|
||||
1. 实现和布局复杂。
|
||||
2. 小屏幕容易难读。
|
||||
|
||||
### 方案 C:阶段链 + 节点分组
|
||||
|
||||
纵向展示阶段,在 team execution 阶段内部用节点分组表达 sequence / parallel / dag:
|
||||
|
||||
```text
|
||||
Plan
|
||||
↓
|
||||
Team execution
|
||||
Group 1: Node A + Node B 并行
|
||||
Group 2: Node C 依赖 A/B
|
||||
↓
|
||||
Main synthesis
|
||||
↓
|
||||
Validation
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
1. 兼顾可读性和结构。
|
||||
2. 比纯 DAG 更适合产品界面。
|
||||
3. 能覆盖 sequence / parallel / dag。
|
||||
|
||||
缺点:
|
||||
|
||||
1. 需要后端或前端把 DAG 分层。
|
||||
|
||||
已确认:
|
||||
|
||||
> 普通任务详情执行链条采用方案 C:阶段链 + 节点分组。
|
||||
|
||||
---
|
||||
|
||||
## 5. 普通任务详情:节点卡片需要展示什么
|
||||
|
||||
| 信息 | 是否必须 | 说明 |
|
||||
|---|---|---|
|
||||
| 节点标题 / node_id | 必须 | 让用户知道哪个子任务 |
|
||||
| 状态 | 必须 | queued / running / done / error / blocked |
|
||||
| 分配目的 | 必须 | 这个节点被安排做什么 |
|
||||
| selected skills | 必须 | 体现为什么它按某种方式做 |
|
||||
| generated ephemeral skill | 必须 | 如果系统临时生成了 draft-only guidance,需要可见 |
|
||||
| 输出摘要 | 必须 | 用户不展开也能知道节点结果 |
|
||||
| 错误/阻断原因 | 必须 | 失败时必须清楚 |
|
||||
| 工具调用 | 待讨论 | 是否展示全部,还是只展示关键工具活动 |
|
||||
| token/model/provider | 可选 | 可能只放调试展开区 |
|
||||
| run_id/session_id | 可选 | 开发者调试用,不默认展示 |
|
||||
|
||||
已确认:
|
||||
|
||||
1. sub-agent 输出属于节点结果。
|
||||
2. sub-agent 输出不算产物。
|
||||
|
||||
---
|
||||
|
||||
## 6. 普通任务状态设计
|
||||
|
||||
用户可见状态建议不要完全照搬内部状态,而是做产品化映射。
|
||||
|
||||
| 用户可见状态 | 内部来源 | 说明 |
|
||||
|---|---|---|
|
||||
| 已创建 | task created / open | 已创建但还未开始执行 |
|
||||
| 规划中 | task_execution_planned 前后 | 正在决定 single/team 和执行结构 |
|
||||
| 执行中 | team run / main run running | 正在执行子任务或主综合 |
|
||||
| 验证中 | validating | 正在自动验证最终结果 |
|
||||
| 验证失败,正在重试 | validation failed + retry_scheduled | 第一次失败,系统自动重试 |
|
||||
| 等待反馈 | awaiting_feedback | 最终结果已给出,等用户满意/修改/放弃 |
|
||||
| 需要修改 | needs_revision | 用户要求修订,下一条消息复用该 Task |
|
||||
| 已完成 | closed | 用户满意并关闭 |
|
||||
| 已放弃 | abandoned | 用户放弃 |
|
||||
| 任务失败 | 第二次验证失败 / unrecoverable failure | 执行失败且没有恢复,或第二次验证仍失败 |
|
||||
|
||||
已确认:
|
||||
|
||||
1. 第二次验证失败后,用户可见状态叫“任务失败”。
|
||||
2. 用户可以暂停、取消、重试普通 Task 或节点。
|
||||
|
||||
待讨论:
|
||||
|
||||
1. 用户继续聊天默认满意后,状态是否直接变“已完成”。
|
||||
2. 暂停/取消/重试分别作用于整个 Task 还是当前节点时,状态如何显示。
|
||||
|
||||
---
|
||||
|
||||
## 7. 普通任务反馈与修订
|
||||
|
||||
普通任务详情页和对话页要保持一致。
|
||||
|
||||
必须支持:
|
||||
|
||||
1. 满意。
|
||||
2. 需要修改。
|
||||
3. 放弃。
|
||||
4. 满意/需要修改的评论框。
|
||||
5. 用户在对话页继续聊天时,默认上一轮 Task 结果满意。
|
||||
6. 普通任务详情页允许直接输入修订意见。
|
||||
|
||||
待讨论:
|
||||
|
||||
1. 详情页内输入修订意见时,是否同步写回来源会话消息流。
|
||||
2. 放弃是否需要原因。
|
||||
|
||||
已确认:
|
||||
|
||||
1. 已放弃普通任务不允许恢复。
|
||||
|
||||
---
|
||||
|
||||
## 8. 普通任务详情:产物区
|
||||
|
||||
产物需要先定义清楚,否则会变成空面板。
|
||||
|
||||
可能的产物类型:
|
||||
|
||||
| 类型 | 例子 | 来源 |
|
||||
|---|---|---|
|
||||
| 文件 | 生成的报告、代码、表格 | filesystem tool / workspace |
|
||||
| 链接 | 搜索结果、外部引用 | web tool / MCP |
|
||||
| JSON | 结构化计划、评估报告 | hidden events / validation |
|
||||
| 图片 | 生成图、上传图分析结果 | attachment / tool |
|
||||
| 文本报告 | 节点输出、最终总结 | main synthesis / sub-agent |
|
||||
|
||||
待讨论:
|
||||
|
||||
1. 无。
|
||||
|
||||
已确认:
|
||||
|
||||
1. sub-agent 输出只算节点结果,不算产物。
|
||||
2. 产物区需要下载按钮。
|
||||
3. 下载可以支持单个产物下载或全部下载。
|
||||
4. validation report 属于验证区,不算产物。
|
||||
5. 用户上传附件不出现在 Task 产物区。
|
||||
|
||||
---
|
||||
|
||||
## 9. 任务管理页不应该做什么
|
||||
|
||||
| 不应该 | 原因 |
|
||||
|---|---|
|
||||
| 不再展示图形化办公室/地图/人物移动 | 用户要看执行链条,不是看装饰性现场 |
|
||||
| 不允许手动选择 team strategy | 当前 strategy 是 Main Agent/Planner 内部决策 |
|
||||
| 不允许手动选择 specialist agent | 当前 Task sub-agent 是 generic worker + skill guidance |
|
||||
| 不直接暴露隐藏事件 JSON | 需要投影成产品可读事件 |
|
||||
| 不混淆最终回答和中间节点输出 | 最终回答仍来自主 Agent synthesis |
|
||||
| 不承载 Skills 审核发布 | 去 Skills 页 |
|
||||
| 不承载 MCP/插件/Outlook 管理 | 去对应设置/管理页,是否保留后续讨论 |
|
||||
| 不把定时任务和普通任务混成一张无区分列表 | 两者来源、动作和状态完全不同 |
|
||||
| 不给任务设计归档概念 | session 有归档;任务没有归档 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 与对话页的关系
|
||||
|
||||
| 对话页 | 任务管理页 |
|
||||
|---|---|
|
||||
| 提交复杂任务 | 普通任务列表出现该任务 |
|
||||
| 显示 Task 创建消息 | 打开普通任务详情 |
|
||||
| 展示最终回答 | 展示最终回答如何生成 |
|
||||
| 提供轻量反馈 | 提供完整反馈和修订上下文 |
|
||||
| 用户继续聊天默认满意 | Task 状态同步关闭 |
|
||||
| 不展示完整链条 | 展示链条、节点、验证、产物 |
|
||||
| 不创建定时任务 | 定时任务 tab 创建和管理计划任务 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 已确认问题
|
||||
|
||||
| 问题 | 结论 |
|
||||
|---|---|
|
||||
| 路由是否改为 `/tasks`,并让旧 `/office` 跳转到 `/tasks`? | 是 |
|
||||
| 任务管理入口是否分为“普通任务 / 定时任务”两个 tab? | 是 |
|
||||
| 当前 `/cron` 能力是否并入“定时任务”tab,不再作为顶层导航? | 是 |
|
||||
| 普通任务列表是否需要存在,还是主要从对话跳普通任务详情? | 需要存在,可以从列表进入详情 |
|
||||
| 普通任务详情执行链条是否采用“阶段链 + 节点分组”方案? | 是 |
|
||||
| 普通任务详情是否允许直接输入修订意见,还是必须回到对话页? | 允许直接输入 |
|
||||
| 普通任务详情内输入修订意见时,是否同步写回来源会话消息流? | 无需同步 |
|
||||
| 用户是否可以暂停、取消、重试普通 Task 或节点? | 前端只能取消或重试整个任务;没有暂停;不操作单个节点 |
|
||||
| 第二次验证失败后,普通任务用户可见状态叫什么? | 任务失败 |
|
||||
| sub-agent 输出算产物,还是只算节点结果? | 节点结果 |
|
||||
| 产物是否需要下载/导出全部? | 具备下载按钮,支持单个下载或全部下载 |
|
||||
| validation report 是否算产物,还是只属于验证区? | 属于验证区 |
|
||||
| 用户上传附件是否出现在 Task 产物区? | 无需 |
|
||||
| 已归档 session 里的普通任务是否还在普通任务列表显示? | 需要显示 |
|
||||
| 已放弃普通任务是否允许恢复? | 不允许 |
|
||||
| 定时任务删除语义是删除、禁用,还是归档? | 可以暂时关闭,也可以删除;所有任务没有归档概念 |
|
||||
| 定时任务每次触发是否创建普通 Task,并在运行历史里链接过去? | 是 |
|
||||
| tabs 名称最终用“普通任务 / 定时任务”,还是“任务 / 计划任务”? | 普通任务 / 定时任务 |
|
||||
|
||||
## 12. 本页剩余待讨论问题
|
||||
|
||||
1. 是否需要任务搜索。
|
||||
|
||||
说明:“全局搜索任务”指任务管理页里是否需要一个搜索框,可以跨普通任务和定时任务搜索任务标题、description、来源会话、状态、定时任务消息内容等。不是全站搜索,也不是搜索聊天内容。
|
||||
|
||||
---
|
||||
|
||||
## 第 3 页:技能页
|
||||
|
||||
建议路径:`/skills`
|
||||
|
||||
一句话定位:
|
||||
|
||||
> 技能页是 Agent 能力生命周期管理台。它负责查看已发布技能、处理学习候选、生成和审核草稿、查看安全/评估报告,并把通过审核的技能发布到 runtime catalog。
|
||||
|
||||
它不是聊天页,也不是直接在线编辑 published skill 的地方。
|
||||
|
||||
已确认的方向:
|
||||
|
||||
1. 主 tab 确定为“已发布 / 候选 / 草稿评审”。
|
||||
2. “运行学习”按钮不放在技能页。
|
||||
3. 保留技能上传;上传后必须进入 draft/review 流程。
|
||||
4. 保留技能下载。
|
||||
5. 允许删除技能。
|
||||
6. 已发布技能暂时不需要版本历史和 diff。
|
||||
7. 草稿评审需要 diff 视图。
|
||||
8. approve/reject 不强制填写 notes。
|
||||
9. publish 不需要二次确认。
|
||||
10. high risk draft 允许发布,但必须展示理由。
|
||||
11. candidate 允许忽略/关闭。
|
||||
12. candidate 和 draft 不需要链接回 source task/run。
|
||||
|
||||
---
|
||||
|
||||
## 1. 用户打开技能页是为了什么
|
||||
|
||||
| 用户目标 | 页面需要支持 |
|
||||
|---|---|
|
||||
| 看当前有哪些技能 | 展示已发布技能、状态、来源、描述、版本 |
|
||||
| 看系统从任务中学到了什么 | 展示 learning candidates、来源 run/task、原因和风险 |
|
||||
| 生成技能草稿 | 从 candidate 生成 draft,或重新生成 draft |
|
||||
| 审核技能草稿 | 查看 proposed content、frontmatter、review 状态 |
|
||||
| 判断草稿是否安全 | 查看 safety report、risk level、阻断原因 |
|
||||
| 判断草稿是否有效 | 查看 eval report、是否通过、provider unavailable 等状态 |
|
||||
| 发布技能 | approved + safety passed + eval not failed 后发布 |
|
||||
| 管理已发布技能 | 禁用、回滚、删除、下载 |
|
||||
| 上传技能 | 上传后进入 draft/review 流程 |
|
||||
| 处理无价值候选 | 忽略/关闭 candidate |
|
||||
|
||||
---
|
||||
|
||||
## 2. 技能页应该覆盖的生命周期
|
||||
|
||||
```text
|
||||
Task 成功 + 用户满意
|
||||
│
|
||||
└─ learning candidate
|
||||
├─ open
|
||||
├─ queued
|
||||
├─ synthesizing
|
||||
├─ draft_ready
|
||||
├─ safety_failed
|
||||
├─ eval_failed
|
||||
├─ review_pending
|
||||
├─ approved
|
||||
├─ rejected
|
||||
├─ published
|
||||
├─ failed
|
||||
└─ superseded
|
||||
│
|
||||
└─ draft
|
||||
├─ safety report
|
||||
├─ eval report
|
||||
├─ submit review
|
||||
├─ approve / reject
|
||||
└─ publish / disable / rollback
|
||||
```
|
||||
|
||||
核心约束:
|
||||
|
||||
1. draft 不进入 runtime catalog。
|
||||
2. rejected draft 不可 publish。
|
||||
3. publish 必须要求 approved review + safety passed + eval not failed。
|
||||
4. high risk publish 需要显式确认。
|
||||
5. worker 可以自动到 draft/safety/eval,但永不自动 approve/publish。
|
||||
6. 不允许绕过 lifecycle 直接在线改 published skill。
|
||||
|
||||
---
|
||||
|
||||
## 3. 页面结构建议
|
||||
|
||||
技能页建议保留三个主 tab:
|
||||
|
||||
| Tab | 目的 |
|
||||
|---|---|
|
||||
| 已发布 | 查看 runtime catalog 中当前可用/不可用的技能 |
|
||||
| 候选 | 查看 learning candidates,决定是否生成/重生成草稿 |
|
||||
| 草稿/评审 | 审核 draft,查看 safety/eval,批准/拒绝/发布 |
|
||||
|
||||
### 3.1 已发布 tab
|
||||
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| 技能列表 | 名称、描述、来源、状态、当前版本、更新时间 |
|
||||
| 状态标识 | available / unavailable / disabled / retired |
|
||||
| 操作 | 查看详情、禁用、回滚、删除、下载、上传 |
|
||||
| 可选操作 | 复制路径、查看支持文件 |
|
||||
|
||||
已确认:
|
||||
|
||||
1. 保留上传技能。
|
||||
2. 上传后必须进入 draft/review 流程。
|
||||
3. 保留下载技能。
|
||||
4. 允许删除技能。
|
||||
5. 已发布技能暂时不需要版本历史和 diff。
|
||||
|
||||
### 3.2 候选 tab
|
||||
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| 候选列表 | candidate id、kind、status、risk、reason、evidence summary |
|
||||
| 来源信息 | source run ids、task id、相关技能 |
|
||||
| 操作 | 生成草稿、重新生成、查看详情、忽略/关闭 |
|
||||
|
||||
候选类型:
|
||||
|
||||
| 类型 | 说明 |
|
||||
|---|---|
|
||||
| new_skill | 新建技能建议 |
|
||||
| revise_skill | 修订已有技能建议 |
|
||||
| merge_skills | 合并技能建议 |
|
||||
| retire_skill | 退役技能建议 |
|
||||
|
||||
待讨论:
|
||||
|
||||
1. 是否允许用户手动创建 candidate。
|
||||
|
||||
已确认:
|
||||
|
||||
1. 技能页不放“运行学习”按钮。
|
||||
2. candidate 允许忽略/关闭。
|
||||
3. candidate 不需要链接回 source task/run。
|
||||
|
||||
### 3.3 草稿/评审 tab
|
||||
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| 草稿列表 | skill name、draft id、proposal kind、status、base version |
|
||||
| 内容区 | proposed frontmatter、proposed content |
|
||||
| Safety report | passed、risk level、findings |
|
||||
| Eval report | passed、status、notes |
|
||||
| Review | submit、approve、reject、review notes |
|
||||
| Diff | base version vs proposed draft |
|
||||
| Publish | publish、high risk reason |
|
||||
|
||||
已确认:
|
||||
|
||||
1. 草稿评审需要 diff 视图。
|
||||
2. approve/reject 不强制填写 notes。
|
||||
3. publish 不需要二次确认。
|
||||
4. high risk draft 允许发布,但必须展示理由。
|
||||
5. draft 不需要链接回 source task/run。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已发布技能管理边界
|
||||
|
||||
| 动作 | 当前建议 | 说明 |
|
||||
|---|---|---|
|
||||
| 查看 | 必须 | 看名称、描述、版本、状态 |
|
||||
| 禁用 | 必须 | 保留 skill spec,不进入 runtime selection |
|
||||
| 回滚 | 必须 | 通过 publisher 回滚到旧版本 |
|
||||
| 删除 | 必须 | 允许删除技能,暂时不需要二次确认 |
|
||||
| 上传 | 必须 | 上传后进入 draft/review 流程,并自动跑 safety 和 eval |
|
||||
| 下载 | 必须 | 作为备份/迁移能力保留 |
|
||||
| 在线编辑 published | 不允许 | 必须通过 draft -> review -> publish |
|
||||
|
||||
---
|
||||
|
||||
## 5. 技能页和其它页面的关系
|
||||
|
||||
| 页面 | 关系 |
|
||||
|---|---|
|
||||
| 对话页 | 用户满意反馈触发学习候选;对话页不审核技能 |
|
||||
| 任务管理页 | 技能页不需要链接回 source task/run |
|
||||
| 状态页 | provider 不可用会影响 draft synthesis/eval,技能页只显示结果,不管理 provider |
|
||||
| MCP/工具页 | skill 可有 tool hints,但技能页不管理 MCP server |
|
||||
|
||||
---
|
||||
|
||||
## 6. 技能页不应该做什么
|
||||
|
||||
| 不应该 | 原因 |
|
||||
|---|---|
|
||||
| 不直接编辑 published skill | 破坏 review/publish lifecycle |
|
||||
| 不自动 approve/publish | 当前设计是 assisted learning |
|
||||
| 不让 rejected draft 发布 | 审核状态必须生效 |
|
||||
| 不让 safety_failed / eval_failed 绕过发布 | 安全和评估是发布门 |
|
||||
| 不把 draft 当 runtime skill | draft 不进入 runtime catalog |
|
||||
| 不把技能页做成普通文件管理器 | 技能是生命周期对象,不只是 Markdown 文件 |
|
||||
| 不在技能页放 run learning | 学习 worker 手动触发不属于这个页面 |
|
||||
| 不要求 candidate/draft 跳回 source task/run | 技能页只展示必要 evidence 摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 已确认问题
|
||||
|
||||
| 问题 | 结论 |
|
||||
|---|---|
|
||||
| 技能页主 tab 是否确定为“已发布 / 候选 / 草稿评审”? | 是 |
|
||||
| 是否保留“运行学习”按钮,允许用户手动触发 worker run-once? | 不在技能页做 |
|
||||
| 是否保留技能上传?如果保留,上传后是否必须进入 draft/review 流程? | 保留,且必须进入 draft/review 流程 |
|
||||
| 是否保留技能下载? | 是 |
|
||||
| 是否允许删除技能,还是只允许禁用/回滚/退役? | 允许删除 |
|
||||
| 技能删除是否需要二次确认? | 暂时不需要 |
|
||||
| 技能上传后是否自动跑 safety/eval? | 是,上传后自动跑 safety 和 eval |
|
||||
| 已发布技能是否需要版本历史和 diff? | 暂时无需 |
|
||||
| 草稿评审是否需要 diff 视图? | 需要 |
|
||||
| approve/reject 是否必须填写 notes? | 无需 |
|
||||
| publish 是否必须二次确认? | 无需 |
|
||||
| high risk draft 是否允许发布,还是只能重新生成/拒绝? | 允许,但要展示来自 safety report 的理由 |
|
||||
| high risk draft 的“理由”来自 safety report,还是需要发布者手动填写? | 来自 safety report |
|
||||
| candidate 是否允许忽略/关闭? | 是 |
|
||||
| candidate 和 draft 是否需要链接回 source task/run? | 无需 |
|
||||
|
||||
## 8. 本页剩余待讨论问题
|
||||
|
||||
暂无。
|
||||
Reference in New Issue
Block a user