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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user