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:
2026-05-14 09:43:48 +08:00
parent 8a12c30141
commit 30ab74ffb2
149 changed files with 12293 additions and 2812 deletions

View File

@ -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');
}

View File

@ -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>;
}

View 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>
);
}

View File

@ -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>
);

View File

@ -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">

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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}`);
}

View File

@ -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');
}

View File

@ -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>

View File

@ -0,0 +1 @@
export { default } from '../status/page';

View File

@ -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}>

View File

@ -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>
);
}

View 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) : [];
}

View 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>
);
}

View File

@ -18,31 +18,31 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--background: 0 0% 99%;
--foreground: 0 0% 4%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--card-foreground: 0 0% 4%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--popover-foreground: 0 0% 4%;
--primary: 15 16% 10%;
--primary-foreground: 0 0% 99%;
--secondary: 30 10% 94%;
--secondary-foreground: 15 16% 10%;
--muted: 24 9% 91%;
--muted-foreground: 20 8% 46%;
--accent: 30 8% 95%;
--accent-foreground: 15 16% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--border: 24 8% 88%;
--input: 0 0% 100%;
--ring: 18 9% 52%;
--chart-1: 17 9% 51%;
--chart-2: 107 9% 55%;
--chart-3: 216 12% 59%;
--chart-4: 18 8% 68%;
--chart-5: 102 12% 74%;
--radius: 1rem;
}
.dark {
--background: 0 0% 3.9%;
@ -78,8 +78,13 @@
}
body {
@apply bg-background text-foreground;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC",
"Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: "Public Sans", Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", "Source Han Sans SC", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
h1, h2 {
font-family: "Lora", Georgia, "Times New Roman", serif;
}
}

View File

@ -1,5 +1,6 @@
import './globals.css';
import type { Metadata } from 'next';
import type { CSSProperties } from 'react';
import { AppI18nProvider } from '@/lib/i18n/provider';
import { getServerAppLocale } from '@/lib/i18n/server';
@ -17,9 +18,29 @@ export default function RootLayout({
children: React.ReactNode;
}) {
const locale = getServerAppLocale();
const taupeTheme = {
'--background': '0 0% 99%',
'--foreground': '0 0% 4%',
'--card': '0 0% 100%',
'--card-foreground': '0 0% 4%',
'--popover': '0 0% 100%',
'--popover-foreground': '0 0% 4%',
'--primary': '15 16% 10%',
'--primary-foreground': '0 0% 99%',
'--secondary': '30 10% 94%',
'--secondary-foreground': '15 16% 10%',
'--muted': '24 9% 91%',
'--muted-foreground': '20 8% 46%',
'--accent': '30 8% 95%',
'--accent-foreground': '15 16% 10%',
'--border': '24 8% 88%',
'--input': '0 0% 100%',
'--ring': '18 9% 52%',
'--radius': '1rem',
} as CSSProperties;
return (
<html lang={locale} className="dark">
<html lang={locale} style={taupeTheme}>
<body className="bg-background text-foreground">
<AppI18nProvider initialLocale={locale}>{children}</AppI18nProvider>
</body>

View File

@ -78,8 +78,7 @@ export function AppRuntimeBridge() {
React.useEffect(() => {
resetProcessState();
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
wsManager.connect(wsSessionId);
wsManager.connect(sessionId);
}, [resetProcessState, sessionId]);
React.useEffect(() => {

View File

@ -0,0 +1,21 @@
'use client';
import type { ReactNode } from 'react';
import Header from '@/components/Header';
import AuthGuard from '@/components/AuthGuard';
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground">
<Header />
<main className="pt-16">
<AuthGuard>
<AppRuntimeBridge />
{children}
</AuthGuard>
</main>
</div>
);
}

View File

@ -2,9 +2,8 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation';
import { MessageSquare, Activity, Clock, Puzzle, Blocks, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, ChevronDown } from 'lucide-react';
import { Bell, Bot, ChevronDown, ListTodo, LogOut, Mail, MessageSquare, PackageOpen, Puzzle, Settings, Store, Wrench } from 'lucide-react';
import { logout } from '@/lib/api';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
@ -16,17 +15,7 @@ import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
type NavItem = {
key:
| 'chat'
| 'status'
| 'office'
| 'skills'
| 'plugins'
| 'agents'
| 'mcp'
| 'outlook'
| 'marketplace'
| 'files';
key: 'chat' | 'tasks' | 'notifications' | 'skills' | 'tools' | 'agents' | 'outlook' | 'marketplace' | 'plugins' | 'settings';
href: string;
icon: React.ComponentType<{ className?: string }>;
matchPrefixes?: string[];
@ -34,22 +23,22 @@ type NavItem = {
const NAV_ITEMS: NavItem[] = [
{ key: 'chat', href: '/', icon: MessageSquare },
{ key: 'status', href: '/status', icon: Activity },
{ key: 'office', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
{ key: 'tasks', href: '/tasks', icon: ListTodo, matchPrefixes: ['/tasks', '/office', '/cron'] },
{ key: 'notifications', href: '/notifications', icon: Bell, matchPrefixes: ['/notifications'] },
{ key: 'skills', href: '/skills', icon: Puzzle },
{ key: 'plugins', href: '/plugins', icon: Blocks },
{ key: 'agents', href: '/agents', icon: Bot },
{ key: 'mcp', href: '/mcp', icon: ServerCog },
{ key: 'outlook', href: '/outlook', icon: Mail },
{ key: 'marketplace', href: '/marketplace', icon: Store },
{ key: 'files', href: '/files', icon: FolderOpen },
{ key: 'tools', href: '/mcp', icon: Wrench, matchPrefixes: ['/mcp'] },
{ key: 'agents', href: '/agents', icon: Bot, matchPrefixes: ['/agents'] },
{ key: 'outlook', href: '/outlook', icon: Mail, matchPrefixes: ['/outlook'] },
{ key: 'marketplace', href: '/marketplace', icon: Store, matchPrefixes: ['/marketplace'] },
{ key: 'plugins', href: '/plugins', icon: PackageOpen, matchPrefixes: ['/plugins'] },
{
key: 'settings',
href: '/settings',
icon: Settings,
matchPrefixes: ['/settings', '/status', '/logs'],
},
];
const AUTH_ITEMS = [
{ key: 'login', href: '/login', icon: LogIn },
{ key: 'register', href: '/register', icon: UserPlus },
] as const;
function ConnectionDot() {
const { locale } = useAppI18n();
const wsStatus = useChatStore((s) => s.wsStatus);
@ -61,10 +50,10 @@ function ConnectionDot() {
const isOffline = wsStatus === 'disconnected' || (wsStatus === 'connected' && nanobotReady === false);
const color = isOnline
? 'bg-green-500'
? 'bg-[#869683]'
: isConnecting
? 'bg-yellow-500'
: 'bg-red-500';
? 'bg-[#8B7E77]'
: 'bg-[#5F5550]';
const label = appConnectionStatusLabel(wsStatus, nanobotReady, locale);
@ -86,23 +75,17 @@ const Header = () => {
const navLabel = React.useCallback((key: NavItem['key']) => {
if (key === 'chat') return pickAppText(locale, '对话', 'Chat');
if (key === 'status') return pickAppText(locale, '状态', 'Status');
if (key === 'office') return pickAppText(locale, '任务管理', 'Tasks');
if (key === 'tasks') return 'Task';
if (key === 'notifications') return pickAppText(locale, '通知', 'Notifications');
if (key === 'skills') return pickAppText(locale, '技能', 'Skills');
if (key === 'plugins') return pickAppText(locale, '插件', 'Plugins');
if (key === 'tools') return pickAppText(locale, '工具', 'Tools');
if (key === 'agents') return pickAppText(locale, '智能体', 'Agents');
if (key === 'mcp') return 'MCP';
if (key === 'outlook') return 'Outlook';
if (key === 'marketplace') return pickAppText(locale, '市场', 'Marketplace');
return pickAppText(locale, '件', 'Files');
if (key === 'plugins') return pickAppText(locale, '件', 'Plugins');
return pickAppText(locale, '配置', 'Settings');
}, [locale]);
const authLabel = React.useCallback((key: 'login' | 'register') => (
key === 'login'
? pickAppText(locale, '登录', 'Sign In')
: pickAppText(locale, '注册', 'Sign Up')
), [locale]);
const handleLogout = async () => {
await logout();
setUser(null);
@ -113,24 +96,16 @@ const Header = () => {
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
return (
<header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50">
<div className="max-w-[1720px] mx-auto px-5 sm:px-6 lg:px-8 xl:px-10">
<div className="flex items-center h-16 gap-6">
<Link href="/" className="flex shrink-0 items-center gap-3 pr-2">
<Image
src="/boardware-logo.jpg"
alt="Boardware logo"
width={40}
height={32}
className="h-8 w-10 shrink-0 rounded-sm bg-white object-contain p-0.5"
/>
<span className="whitespace-nowrap text-[1.05rem] font-semibold leading-none tracking-tight sm:text-[1.15rem]">
Boardware Agent Sandbox
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
<div className="grid h-16 grid-cols-[minmax(120px,1fr)_auto_minmax(120px,1fr)] items-center gap-4">
<Link href="/" className="flex shrink-0 items-center">
<span className="font-serif text-[28px] font-semibold leading-none text-[#0B0B0B]">
Beaver
</span>
</Link>
<div className="flex min-w-0 flex-1 items-center justify-end gap-3">
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<nav className="flex items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
{NAV_ITEMS.map((item) => {
const isActive =
item.href === '/'
@ -141,10 +116,10 @@ const Header = () => {
<Link
key={item.href}
href={item.href}
className={`flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
}`}
>
<Icon className="w-4 h-4" />
@ -154,26 +129,30 @@ const Header = () => {
})}
</nav>
<div className="flex shrink-0 items-center gap-2 border-l border-border pl-4">
<div className="flex min-w-0 items-center justify-end gap-3">
<div className="hidden shrink-0 sm:block">
<ConnectionDot />
</div>
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
{user ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-full border border-border/70 bg-background px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
className="flex items-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-2 py-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4]"
>
<Avatar className="h-8 w-8 border border-border/60">
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
{userInitial}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Avatar>
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
<div className="overflow-hidden rounded-3xl bg-gradient-to-b from-slate-50 via-slate-50 to-white">
<div className="overflow-hidden rounded-3xl bg-[linear-gradient(180deg,#F7F5F4,#FFFFFF)]">
<div className="border-b border-border/60 px-6 py-5">
<p className="truncate text-center text-sm font-medium text-muted-foreground">
{user.email}
@ -210,30 +189,7 @@ const Header = () => {
</div>
</PopoverContent>
</Popover>
) : !isAuthLoading ? (
AUTH_ITEMS.map((item) => {
const isActive = pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Icon className="w-4 h-4" />
{authLabel(item.key)}
</Link>
);
})
) : null}
</div>
<div className="shrink-0 border-l border-border pl-4">
<ConnectionDot />
) : !isAuthLoading ? null : null}
</div>
</div>
</div>

View File

@ -25,28 +25,28 @@ const TERMINAL_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cance
const AGENT_ACCENTS = [
{
frame: 'border-sky-500/25 bg-sky-500/[0.05]',
title: 'text-sky-300',
dot: 'bg-sky-400',
result: 'border-sky-500/25 bg-sky-500/[0.08]',
frame: 'border-[#BCC4CE] bg-[#E4E7EB]/45',
title: 'text-[#697281]',
dot: 'bg-[#8C96A3]',
result: 'border-[#BCC4CE] bg-[#E4E7EB]/55',
},
{
frame: 'border-emerald-500/25 bg-emerald-500/[0.05]',
title: 'text-emerald-300',
dot: 'bg-emerald-400',
result: 'border-emerald-500/25 bg-emerald-500/[0.08]',
frame: 'border-[#B7C2B5] bg-[#E3E8E2]/45',
title: 'text-[#657162]',
dot: 'bg-[#869683]',
result: 'border-[#B7C2B5] bg-[#E3E8E2]/55',
},
{
frame: 'border-amber-500/25 bg-amber-500/[0.05]',
title: 'text-amber-300',
dot: 'bg-amber-400',
result: 'border-amber-500/25 bg-amber-500/[0.08]',
frame: 'border-[#B8AEA8] bg-[#E7E2DE]/55',
title: 'text-[#5F5550]',
dot: 'bg-[#8B7E77]',
result: 'border-[#B8AEA8] bg-[#E7E2DE]/65',
},
{
frame: 'border-fuchsia-500/25 bg-fuchsia-500/[0.05]',
title: 'text-fuchsia-300',
dot: 'bg-fuchsia-400',
result: 'border-fuchsia-500/25 bg-fuchsia-500/[0.08]',
frame: 'border-[#D8D2CE] bg-[#ECE8E5]/70',
title: 'text-[#4F4642]',
dot: 'bg-[#6A5E58]',
result: 'border-[#D8D2CE] bg-[#ECE8E5]/80',
},
] as const;
@ -55,12 +55,12 @@ function accentFor(index: number) {
}
function statusTone(status: ProcessRun['status']) {
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300';
if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
if (status === 'cancelled') return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300';
if (status === 'waiting') return 'border-amber-500/20 bg-amber-500/10 text-amber-300';
if (status === 'queued') return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]';
if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]';
if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]';
if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]';
if (status === 'queued') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]';
return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]';
}
function feedTone(role: AgentFeedItem['role']) {
@ -166,8 +166,8 @@ function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) {
const rawEphemeral = metadata?.ephemeral_skill_names;
const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : [];
const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : [];
const draftId = typeof metadata?.generated_skill_draft_id === 'string' ? metadata.generated_skill_draft_id : '';
if (selected.length === 0 && ephemeral.length === 0 && !draftId) {
const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : '';
if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) {
return null;
}
return (
@ -182,9 +182,9 @@ function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) {
ephemeral:{name}
</Badge>
))}
{draftId && (
{guidanceId && (
<Badge variant="outline" className="text-[10px]">
draft:{draftId.slice(0, 8)}
guidance:{guidanceId.slice(0, 8)}
</Badge>
)}
</div>
@ -390,7 +390,7 @@ function ResultCard({
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">{pickAppText(locale, '结果', 'Result')}</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
</div>
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
</div>
<div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</div>
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">

View File

@ -28,7 +28,7 @@ function renderArtifactBody(artifact: ProcessArtifact, locale: 'zh-CN' | 'en-US'
}
if (artifact.artifact_type === 'link' && artifact.url) {
return (
<a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-sky-300 underline break-all">
<a href={artifact.url} target="_blank" rel="noreferrer" className="text-sm text-[#5F5550] underline break-all">
{artifact.url}
</a>
);

View File

@ -3,12 +3,7 @@
import React from 'react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MessageList } from '@/components/chat-workbench/MessageList';
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export function ChatWorkbench({
messages,
@ -33,58 +28,10 @@ export function ChatWorkbench({
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
}) {
const { locale } = useAppI18n();
const [isDesktop, setIsDesktop] = React.useState(() =>
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 1024px)').matches
);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(min-width: 1024px)');
const updateLayout = () => setIsDesktop(mediaQuery.matches);
updateLayout();
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
const selectedRun = selectedRunId
? processRuns.find((item) => item.run_id === selectedRunId) || null
: null;
const selectedRunEvents = selectedRun
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
: [];
const selectedRunArtifacts = selectedRun
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
: [];
const hasResultsPanel = Boolean(
selectedRun &&
(
selectedRun.summary ||
selectedRunEvents.length > 0 ||
selectedRunArtifacts.length > 0
)
);
const hasProcessPanel = processRuns.length > 0;
const desktopColumns = hasProcessPanel && hasResultsPanel
? 'grid-cols-[minmax(0,1fr)_340px_360px]'
: hasProcessPanel
? 'grid-cols-[minmax(0,1fr)_340px]'
: hasResultsPanel
? 'grid-cols-[minmax(0,1fr)_360px]'
: 'grid-cols-[minmax(0,1fr)]';
const messageList = (
return (
<div className="h-full">
<MessageList
messages={messages}
isThinking={isThinking}
@ -93,81 +40,11 @@ export function ChatWorkbench({
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRun?.run_id || null}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
onFeedback={onFeedback}
/>
);
if (isDesktop) {
return (
<div className={`grid h-full ${desktopColumns}`}>
<div className="min-h-0">
{messageList}
</div>
{hasProcessPanel && (
<div className="min-h-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</div>
)}
{hasResultsPanel && (
<div className="min-h-0">
<ArtifactSidebar
selectedRun={selectedRun}
events={processEvents}
artifacts={processArtifacts}
/>
</div>
)}
</div>
);
}
return (
<div className="h-full">
{!hasResultsPanel && !hasProcessPanel ? (
messageList
) : (
<Tabs defaultValue="chat" className="h-full flex flex-col">
<div className="px-4 pt-3 border-b border-border">
<TabsList className={`grid w-full ${hasResultsPanel ? 'grid-cols-3' : 'grid-cols-2'}`}>
<TabsTrigger value="chat">{pickAppText(locale, '聊天', 'Chat')}</TabsTrigger>
<TabsTrigger value="process">{pickAppText(locale, '过程', 'Process')}</TabsTrigger>
{hasResultsPanel && (
<TabsTrigger value="results">{pickAppText(locale, '结果', 'Results')}</TabsTrigger>
)}
</TabsList>
</div>
<TabsContent value="chat" className="flex-1 min-h-0 mt-0">
{messageList}
</TabsContent>
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
<ProcessLane
runs={processRuns}
events={processEvents}
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
/>
</TabsContent>
{hasResultsPanel && (
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
<ArtifactSidebar
selectedRun={selectedRun}
events={processEvents}
artifacts={processArtifacts}
/>
</TabsContent>
)}
</Tabs>
)}
</div>
);
}

View File

@ -5,34 +5,34 @@ import remarkGfm from 'remark-gfm';
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ children, ...props }) => (
<div className="my-3 overflow-x-auto rounded-lg border border-border">
<div className="my-3 overflow-x-auto rounded-lg border border-[#D8D2CE]">
<table className="w-full border-collapse text-sm" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-muted/60" {...props}>
<thead className="bg-[#ECE8E5]" {...props}>
{children}
</thead>
),
th: ({ children, ...props }) => (
<th className="px-3 py-2 text-left font-semibold text-foreground border-b border-border" {...props}>
<th className="border-b border-[#D8D2CE] px-3 py-2 text-left font-semibold text-[#0B0B0B]" {...props}>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="px-3 py-2 border-b border-border/50" {...props}>
<td className="border-b border-[#E7E2DE] px-3 py-2 text-[#1D1715]" {...props}>
{children}
</td>
),
tr: ({ children, ...props }) => (
<tr className="hover:bg-muted/30 transition-colors" {...props}>
<tr className="transition-colors hover:bg-[#F7F5F4]" {...props}>
{children}
</tr>
),

View File

@ -1,7 +1,8 @@
'use client';
import React from 'react';
import { Bot, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
import Link from 'next/link';
import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
@ -44,24 +45,31 @@ function MessageBubble({
}: {
message: ChatMessage;
canSendFeedback: boolean;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
}) {
const { locale } = useAppI18n();
const isUser = message.role === 'user';
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | 'revise' | null>(null);
const [feedbackComment, setFeedbackComment] = React.useState('');
const validationFailed = message.validation_status === 'failed';
const validationDetails =
validationFailed
? pickAppText(locale, '详细原因会在任务验证区展示;展开任务可查看验证报告。', 'Detailed reasons are shown in the task validation area. Open the task to inspect the validation report.')
: '';
return (
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && (
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-[#F1EFEE]">
<Bot className="w-4 h-4 text-primary" />
</div>
)}
<div
className={`rounded-xl px-4 py-3 max-w-[88%] shadow-sm ${
className={`max-w-[88%] px-4 py-3 ${
isUser
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border/80'
? 'rounded-[28px] bg-primary text-primary-foreground'
: 'rounded-none bg-transparent text-[#1D1715]'
}`}
>
{message.attachments && message.attachments.length > 0 && (
@ -110,42 +118,107 @@ function MessageBubble({
) : (
<MarkdownContent content={textContent} />
)}
{!isUser && message.task_id && (
<div className="mt-3 rounded-md border border-border bg-muted/35 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium uppercase text-muted-foreground">Task</div>
<div className="mt-1 truncate text-sm font-medium">
{pickAppText(locale, '已创建任务', 'Task created')}: {message.task_id}
</div>
</div>
<Link
href={`/tasks/${encodeURIComponent(message.task_id)}`}
className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{pickAppText(locale, '查看任务', 'Open task')}
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
)}
{!isUser && validationFailed && (
<details className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
<summary className="cursor-pointer text-base font-semibold text-destructive">
{pickAppText(locale, '验证失败', 'Validation failed')}
</summary>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
</details>
)}
{!isUser && canSendFeedback && message.run_id && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/70 pt-2">
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
{message.feedback_state ? (
<span className="text-xs text-muted-foreground">
{message.feedback_state === 'satisfied'
? pickAppText(locale, '已标记满意', 'Marked satisfied')
: message.feedback_state === 'revise'
? pickAppText(locale, '已请求修改', 'Revision requested')
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>
{message.feedback_state === 'satisfied'
? pickAppText(locale, '已标记满意', 'Marked satisfied')
: message.feedback_state === 'revise'
? pickAppText(locale, '已请求修改', 'Revision requested')
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
</span>
</div>
) : (
<>
<button
type="button"
onClick={() => onFeedback(message.run_id!, 'satisfied')}
className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ThumbsUp className="h-3.5 w-3.5" />
{pickAppText(locale, '满意', 'Satisfied')}
</button>
<button
type="button"
onClick={() => onFeedback(message.run_id!, 'revise')}
className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RefreshCcw className="h-3.5 w-3.5" />
{pickAppText(locale, '需要修改', 'Revise')}
</button>
<button
type="button"
onClick={() => onFeedback(message.run_id!, 'abandon')}
className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<XCircle className="h-3.5 w-3.5" />
{pickAppText(locale, '放弃', 'Abandon')}
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setFeedbackMode('satisfied')}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ThumbsUp className="h-3.5 w-3.5" />
{pickAppText(locale, '满意', 'Satisfied')}
</button>
<button
type="button"
onClick={() => setFeedbackMode('revise')}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RefreshCcw className="h-3.5 w-3.5" />
{pickAppText(locale, '需要修改', 'Revise')}
</button>
<button
type="button"
onClick={() => onFeedback(message.run_id!, 'abandon')}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<XCircle className="h-3.5 w-3.5" />
{pickAppText(locale, '放弃', 'Abandon')}
</button>
</div>
{feedbackMode && (
<div className="space-y-2 rounded-md border border-border bg-background p-2">
<textarea
value={feedbackComment}
onChange={(event) => setFeedbackComment(event.target.value)}
placeholder={
feedbackMode === 'revise'
? pickAppText(locale, '写下需要修改的地方...', 'Describe what needs to change...')
: pickAppText(locale, '可选:补充说明...', 'Optional note...')
}
className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setFeedbackMode(null);
setFeedbackComment('');
}}
className="h-8 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)}
className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{pickAppText(locale, '提交', 'Submit')}
</button>
</div>
</div>
)}
</>
)}
{message.validation_status && message.validation_status !== 'unknown' && (
@ -162,7 +235,7 @@ function MessageBubble({
)}
</div>
{isUser && (
<div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="mt-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-secondary">
<User className="w-4 h-4" />
</div>
)}
@ -269,7 +342,7 @@ export function MessageList({
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
}) {
const { locale } = useAppI18n();
const visibleMessages = React.useMemo(
@ -311,12 +384,12 @@ export function MessageList({
.find((message) => message.role === 'assistant' && message.run_id && message.task_id)?.run_id;
return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
<div className="max-w-6xl mx-auto py-4 space-y-4">
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
<div className="mx-auto max-w-5xl space-y-8 py-10">
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
<p className="text-lg font-medium text-foreground">Beaver</p>
<p className="text-sm">{pickAppText(locale, '发送消息开始对话', 'Send a message to start the conversation')}</p>
</div>
)}

View File

@ -13,11 +13,11 @@ import { useAppI18n } from '@/lib/i18n/provider';
import { cn } from '@/lib/utils';
function statusTone(status: string) {
if (status === 'done') return 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20';
if (status === 'error') return 'bg-rose-500/10 text-rose-300 border-rose-500/20';
if (status === 'cancelled') return 'bg-zinc-500/10 text-zinc-300 border-zinc-500/20';
if (status === 'waiting') return 'bg-amber-500/10 text-amber-300 border-amber-500/20';
return 'bg-sky-500/10 text-sky-300 border-sky-500/20';
if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]';
if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]';
if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]';
if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]';
return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]';
}
function actorIcon(run: ProcessRun) {
@ -147,7 +147,7 @@ export function ProcessLane({
</div>
))}
{run.status === 'error' && (
<div className="flex items-center gap-2 text-xs text-rose-300">
<div className="flex items-center gap-2 text-xs text-[#5F5550]">
<AlertCircle className="w-3.5 h-3.5" />
{pickAppText(locale, '此任务执行失败。', 'This task failed.')}
</div>
@ -168,8 +168,8 @@ function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) {
const rawEphemeral = metadata?.ephemeral_skill_names;
const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : [];
const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : [];
const draftId = typeof metadata?.generated_skill_draft_id === 'string' ? metadata.generated_skill_draft_id : '';
if (selected.length === 0 && ephemeral.length === 0 && !draftId) {
const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : '';
if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) {
return null;
}
return (
@ -184,9 +184,9 @@ function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) {
ephemeral:{name}
</Badge>
))}
{draftId && (
{guidanceId && (
<Badge variant="outline" className="text-[10px]">
draft:{draftId.slice(0, 8)}
guidance:{guidanceId.slice(0, 8)}
</Badge>
)}
</div>

View File

@ -20,13 +20,13 @@ export function OfficeStatusBadge({
variant="outline"
className={cn(
'border text-[11px]',
status === 'done' && 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
status === 'running' && 'border-sky-500/30 bg-sky-500/10 text-sky-700',
status === 'waiting' && 'border-amber-500/30 bg-amber-500/10 text-amber-700',
status === 'blocked' && 'border-orange-500/30 bg-orange-500/10 text-orange-700',
status === 'queued' && 'border-slate-500/30 bg-slate-500/10 text-slate-700',
status === 'error' && 'border-rose-500/30 bg-rose-500/10 text-rose-700',
status === 'cancelled' && 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700',
status === 'done' && 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]',
status === 'running' && 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]',
status === 'waiting' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]',
status === 'blocked' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]',
status === 'queued' && 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]',
status === 'error' && 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]',
status === 'cancelled' && 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]',
className
)}
>
@ -70,10 +70,10 @@ export function zonePanelClassName(zone: OfficeZoneView): string {
return cn(
'relative min-h-[220px] overflow-hidden rounded-2xl border p-4 shadow-sm',
'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_40%)]',
zone.tone === 'info' && 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.95),rgba(224,242,254,0.7))]',
zone.tone === 'warn' && 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.95),rgba(254,243,199,0.72))]',
zone.tone === 'danger' && 'border-rose-200 bg-[linear-gradient(180deg,rgba(255,241,242,0.96),rgba(255,228,230,0.76))]',
zone.tone === 'success' && 'border-emerald-200 bg-[linear-gradient(180deg,rgba(236,253,245,0.96),rgba(209,250,229,0.74))]',
zone.tone === 'info' && 'border-[#BCC4CE] bg-[#E4E7EB]/70',
zone.tone === 'warn' && 'border-[#B8AEA8] bg-[#E7E2DE]/70',
zone.tone === 'danger' && 'border-[#B8AEA8] bg-[#E7E2DE]/80',
zone.tone === 'success' && 'border-[#B7C2B5] bg-[#E3E8E2]/75',
zone.tone === 'neutral' && 'border-border bg-card'
);
}

View File

@ -1,8 +1,8 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Building2, Clock3 } from 'lucide-react';
import { usePathname, useSearchParams } from 'next/navigation';
import { Clock3, ListTodo } from 'lucide-react';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
@ -10,28 +10,30 @@ import { cn } from '@/lib/utils';
const TASK_MANAGEMENT_TABS = [
{
label: 'Office',
href: '/office',
icon: Building2,
match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'),
label: 'ordinary',
href: '/tasks',
icon: ListTodo,
match: (pathname: string, tab: string | null) => pathname.startsWith('/tasks') && tab !== 'scheduled',
},
{
label: 'Scheduled tasks',
href: '/cron',
label: 'scheduled',
href: '/tasks?tab=scheduled',
icon: Clock3,
match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'),
match: (pathname: string, tab: string | null) => pathname.startsWith('/tasks') && tab === 'scheduled',
},
] as const;
export function TaskManagementTabs() {
const { locale } = useAppI18n();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeTab = searchParams.get('tab');
return (
<div className="rounded-2xl border border-border/70 bg-muted/20 p-1">
<div className="flex flex-wrap gap-1">
{TASK_MANAGEMENT_TABS.map((tab) => {
const isActive = tab.match(pathname);
const isActive = tab.match(pathname, activeTab);
const Icon = tab.icon;
return (
@ -46,9 +48,9 @@ export function TaskManagementTabs() {
)}
>
<Icon className="h-4 w-4" />
{tab.href === '/cron'
{tab.label === 'scheduled'
? pickAppText(locale, '定时任务', 'Scheduled tasks')
: pickAppText(locale, '办公室', 'Office')}
: pickAppText(locale, '普通任务', 'Ordinary tasks')}
</Link>
);
})}

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.03)] ring-offset-background transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
'border border-transparent bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',

View File

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
'rounded-2xl border border-black/[0.04] bg-card/70 text-card-foreground shadow-[0_1px_2px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.03)]',
className
)}
{...props}

View File

@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-[80px] w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@ -6,11 +6,16 @@ import type {
AuthzRegisterBackendResponse,
AuthzStatus,
AuthUser,
ActiveTask,
ChatLogsResponse,
BackendTask,
ChatMessage,
CronJob,
FileAttachment,
Marketplace,
MarketplacePlugin,
NotificationDetail,
NotificationRun,
PluginInfo,
ProviderConfigPayload,
Session,
@ -19,6 +24,10 @@ import type {
SkillDraft,
SkillDraftEvalReport,
SkillDraftSafetyReport,
SkillHubInstallResponse,
SkillHubSearchItem,
SkillHubSearchResponse,
SkillHubVersionResponse,
SkillLearningCandidate,
SkillReviewRecord,
SlashCommand,
@ -252,7 +261,12 @@ export async function getMe(): Promise<AuthUser> {
export async function sendMessage(
message: string,
sessionId: string = 'web:default',
attachments?: FileAttachment[]
attachments?: FileAttachment[],
options?: {
replyToScheduledRunId?: string;
scheduledReplyIntent?: 'revise_once' | 'update_future' | 'continue_task';
thinkingEnabled?: boolean;
}
): Promise<{
response?: string;
status?: string;
@ -266,6 +280,13 @@ export async function sendMessage(
if (attachments && attachments.length > 0) {
body.attachments = attachments;
}
if (options?.replyToScheduledRunId) {
body.reply_to_scheduled_run_id = options.replyToScheduledRunId;
body.scheduled_reply_intent = options.scheduledReplyIntent || 'revise_once';
}
if (typeof options?.thinkingEnabled === 'boolean') {
body.thinking_enabled = options.thinkingEnabled;
}
const result = await fetchJSON<{
response?: string;
status?: string;
@ -583,8 +604,14 @@ export async function getSessionProcess(key: string): Promise<SessionProcessProj
return fetchJSON(`/api/sessions/${encodeURIComponent(key)}/process`);
}
export async function deleteSession(key: string): Promise<void> {
await fetchJSON(`/api/sessions/${encodeURIComponent(key)}`, { method: 'DELETE' });
export async function getChatLogs(limit = 50): Promise<ChatLogsResponse> {
return fetchJSON(`/api/debug/chat-logs?limit=${encodeURIComponent(String(limit))}`, {
timeoutMs: 30000,
});
}
export async function archiveSession(key: string): Promise<void> {
await fetchJSON(`/api/sessions/${encodeURIComponent(key)}/archive`, { method: 'POST' });
}
// ---------------------------------------------------------------------------
@ -629,7 +656,10 @@ export async function addCronJob(params: {
every_seconds?: number;
cron_expr?: string;
at_iso?: string;
tz?: string;
session_key?: string;
mode?: 'notification' | 'task';
requires_followup?: boolean;
}): Promise<CronJob> {
return fetchJSON('/api/cron/jobs', {
method: 'POST',
@ -652,6 +682,40 @@ export async function runCronJob(jobId: string): Promise<void> {
await fetchJSON(`/api/cron/jobs/${jobId}/run`, { method: 'POST' });
}
export async function listNotifications(): Promise<NotificationRun[]> {
return fetchJSON('/api/notifications');
}
export async function getNotification(scheduledRunId: string): Promise<NotificationDetail> {
return fetchJSON(`/api/notifications/${encodeURIComponent(scheduledRunId)}`);
}
export async function engageNotification(
scheduledRunId: string,
intent: 'revise_once' | 'update_future' | 'continue_task'
): Promise<{ ok: boolean; task_id: string; intent: string }> {
return fetchJSON(`/api/notifications/${encodeURIComponent(scheduledRunId)}/engage`, {
method: 'POST',
body: JSON.stringify({ intent }),
});
}
export async function listBackendTasks(): Promise<BackendTask[]> {
return fetchJSON('/api/tasks');
}
export async function getBackendTask(taskId: string): Promise<BackendTask> {
return fetchJSON(`/api/tasks/${encodeURIComponent(taskId)}`);
}
export async function deleteBackendTask(taskId: string): Promise<void> {
await fetchJSON(`/api/tasks/${encodeURIComponent(taskId)}`, { method: 'DELETE' });
}
export async function getActiveTask(sessionId: string): Promise<ActiveTask | null> {
return fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}/active-task`);
}
export async function ping(): Promise<{ message: string }> {
return fetchJSON('/api/ping');
}
@ -877,6 +941,12 @@ export async function cancelDelegation(runId: string): Promise<{ ok: boolean; ru
});
}
export async function retryDelegation(runId: string): Promise<{ ok: boolean; run_id: string }> {
return fetchJSON(`/api/delegations/${encodeURIComponent(runId)}/retry`, {
method: 'POST',
});
}
export async function listMcpServers(): Promise<UiMcpServerDescriptor[]> {
return fetchJSON('/api/mcp/servers');
}
@ -1190,6 +1260,62 @@ export async function uploadSkill(file: File): Promise<Skill> {
return res.json();
}
export async function migrateSkills(): Promise<{ included: Array<Record<string, unknown>>; skipped: Array<Record<string, unknown>> }> {
return fetchJSON('/api/skills/migrate', { method: 'POST', timeoutMs: 45000 });
}
// ---------------------------------------------------------------------------
// SkillHub marketplace
// ---------------------------------------------------------------------------
export async function searchSkillHubSkills(params: {
q?: string;
sort?: 'relevance' | 'downloads' | 'newest';
page?: number;
size?: number;
namespace?: string;
} = {}): Promise<SkillHubSearchResponse> {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
if (params.sort) search.set('sort', params.sort);
if (typeof params.page === 'number') search.set('page', String(params.page));
if (typeof params.size === 'number') search.set('size', String(params.size));
if (params.namespace) search.set('namespace', params.namespace);
const suffix = search.toString();
return fetchJSON(`/api/marketplaces/skills/search${suffix ? `?${suffix}` : ''}`);
}
export async function getSkillHubDetail(namespace: string, slug: string): Promise<SkillHubSearchItem> {
return fetchJSON(
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}`
);
}
export async function getSkillHubVersion(
namespace: string,
slug: string,
version: string
): Promise<SkillHubVersionResponse> {
return fetchJSON(
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`
);
}
export async function installSkillHubSkill(
namespace: string,
slug: string,
version?: string
): Promise<SkillHubInstallResponse> {
return fetchJSON(
`/api/marketplaces/skills/${encodeURIComponent(namespace.replace(/^@/, ''))}/${encodeURIComponent(slug)}/install`,
{
method: 'POST',
body: JSON.stringify({ version }),
timeoutMs: 45000,
}
);
}
// ---------------------------------------------------------------------------
// Marketplace (proxied)
// ---------------------------------------------------------------------------

View File

@ -51,6 +51,10 @@ export interface ChatMessage {
validation_status?: 'passed' | 'failed' | 'unknown';
feedback_state?: 'satisfied' | 'revise' | 'abandon';
feedback_error?: string;
message_type?: string | null;
scheduled_job_id?: string | null;
scheduled_run_id?: string | null;
cron_job_name?: string | null;
}
export interface Session {
@ -67,6 +71,52 @@ export interface SessionDetail {
updated_at: string;
}
export interface ChatLogEvent {
message_id?: number | null;
run_id?: string | null;
role: string;
event_type?: string | null;
content?: string | null;
timestamp?: string;
context_visible?: boolean;
tool_name?: string | null;
tool_call_id?: string | null;
tool_calls?: Array<Record<string, unknown>> | null;
finish_reason?: string | null;
reasoning?: string | null;
reasoning_details?: unknown;
codex_reasoning_items?: unknown;
event_payload?: Record<string, unknown> | null;
}
export interface ChatLogRun {
run_id: string;
session_id: string;
title?: string | null;
source?: string | null;
task_id?: string | null;
attempt_index?: number | null;
task_mode?: boolean | null;
user_input?: string;
started_at?: string;
ended_at?: string | null;
finish_reason?: string | null;
events: ChatLogEvent[];
}
export interface ChatLogSession {
session_id: string;
source?: string | null;
title?: string | null;
created_at?: string;
updated_at?: string;
runs: ChatLogRun[];
}
export interface ChatLogsResponse {
sessions: ChatLogSession[];
}
export interface ProviderStatus {
id?: string;
name: string;
@ -168,14 +218,125 @@ export interface CronJob {
schedule_expr: string | null;
schedule_every_ms: number | null;
message: string;
mode?: 'notification' | 'task';
requires_followup?: boolean;
deliver: boolean;
channel: string | null;
to: string | null;
session_key?: string | null;
next_run_at_ms: number | null;
last_run_at_ms: number | null;
last_status: string | null;
last_error: string | null;
last_scheduled_run_id?: string | null;
last_task_id?: string | null;
last_run_id?: string | null;
history?: Array<{
started_at_ms: number;
finished_at_ms?: number | null;
status: string;
mode?: 'notification' | 'task';
notification_session_id?: string | null;
output?: string | null;
task_id?: string | null;
run_id?: string | null;
error?: string | null;
scheduled_run_id?: string;
engaged?: boolean;
engage_intent?: string | null;
}>;
created_at_ms: number;
updated_at_ms?: number;
}
export interface NotificationRun {
scheduled_run_id: string;
job_id: string;
job_name: string;
title: string;
message: string;
status: string;
mode: 'notification' | 'task';
started_at_ms: number;
finished_at_ms?: number | null;
started_at?: string | null;
finished_at?: string | null;
output?: string | null;
error?: string | null;
notification_session_id: string;
task_id?: string | null;
run_id?: string | null;
engaged?: boolean;
engage_intent?: string | null;
}
export interface NotificationDetail extends NotificationRun {
detail: SessionDetail;
}
export interface BackendTaskEvent {
event_id: string;
task_id: string;
session_id: string;
run_id?: string | null;
event_type: string;
created_at: string;
payload: Record<string, unknown>;
}
export interface BackendTaskRunMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
created_at?: string;
tool_name?: string | null;
}
export interface BackendTaskRun {
run_id: string;
title: string;
session_id: string;
started_at?: string | null;
ended_at?: string | null;
success?: boolean | null;
finish_reason?: string | null;
attempt_index?: number | null;
task_text?: string;
messages: BackendTaskRunMessage[];
validation_result?: Record<string, unknown> | null;
}
export interface BackendTask {
task_id: string;
session_id: string;
parent_task_id?: string | null;
description: string;
short_title?: string | null;
is_open?: boolean;
goal: string;
constraints: string[];
priority: number;
status: string;
creator: string;
created_at: string;
updated_at: string;
closed_at?: string | null;
close_reason?: string | null;
satisfaction?: number | null;
run_ids: string[];
skill_names: string[];
feedback: Array<Record<string, unknown>>;
validation_result?: Record<string, unknown> | null;
metadata: Record<string, unknown>;
events?: BackendTaskEvent[];
runs?: BackendTaskRun[];
}
export interface ActiveTask {
task_id: string;
status: string;
short_title: string;
description: string;
updated_at: string;
}
export interface Marketplace {
@ -191,6 +352,75 @@ export interface MarketplacePlugin {
installed: boolean;
}
export interface SkillHubVersionRef {
id?: number;
version: string;
status?: string;
createdAt?: string;
publishedAt?: string | null;
}
export interface SkillHubSearchItem {
id?: number;
slug: string;
displayName: string;
summary: string;
namespace: string;
downloadCount: number;
starCount: number;
ratingAvg?: number;
ratingCount?: number;
headlineVersion?: SkillHubVersionRef | null;
publishedVersion?: SkillHubVersionRef | null;
installed?: boolean;
installed_version?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface SkillHubSearchResponse {
items: SkillHubSearchItem[];
total: number;
page: number;
size: number;
}
export interface SkillHubFileInfo {
id?: number;
filePath: string;
fileSize: number;
contentType?: string | null;
sha256?: string | null;
}
export interface SkillHubVersionDetail {
id?: number;
version?: string;
status?: string;
fileCount?: number;
totalSize?: number;
parsedMetadataJson?: string | null;
manifestJson?: string | null;
createdAt?: string;
publishedAt?: string | null;
}
export interface SkillHubVersionResponse {
detail: SkillHubVersionDetail;
files: SkillHubFileInfo[];
}
export interface SkillHubInstallResponse {
ok: boolean;
skill_name: string;
version: string;
source: 'skillhub';
namespace: string;
slug: string;
installed_path: string;
already_installed?: boolean;
}
export type ProcessActorType = 'agent' | 'mcp' | 'system';
export type ProcessRunStatus =
| 'queued'
@ -251,6 +481,10 @@ export interface UiMcpServerDescriptor {
id: string;
name: string;
transport: 'stdio' | 'http';
kind?: 'local' | 'online';
category?: string;
managed?: boolean;
source?: string;
url?: string | null;
command?: string | null;
args?: string[];

View File

@ -0,0 +1,968 @@
# 前端需求讨论
当前讨论到第二页:**Task 管理界面**。
这份文档先不展开其它页面。每次只把一页讲清楚:页面定位、用户目标、必须展示什么、不要塞什么、争议点是什么。等这一页定下来,再开下一页。
相关参考:
- 后端运行结构:`../backend/flow.md`
- 后端施工状态:`../backend/施工指南.md`
- 长期蓝图:`../backend/change.md`
- 旧系统蓝图:`../backend-old/change.md`
---
## 第 1 页:对话页
路径:`/`
一句话定位:
> 对话页是用户和主 Agent 交互的主工作台。它负责提交问题/任务、展示最终回答、展示必要的运行状态,并收集用户对 Task 结果的反馈。
它承担一部分轻量后台管理工作,主要是会话管理和任务入口。但它不应该变成完整后台管理台,也不应该让用户直接配置 team strategy、sub-agent、MCP、插件市场等系统能力。
已确认的方向:
1. session 不做“删除”,改成“归档”。
- 归档后的 session 不再出现在前端会话列表。
- 归档不等于抹除记忆;相关记忆和历史数据仍然存在。
2. 附件是对话页第一版必须能力。
3. Slash command 不保留。
4. 复杂任务创建后,对话中显示 Task 创建回复,点击跳转到 Task 管理界面。
5.`Office` 概念改名为 `Task`,并删除图形化办公室展示,改为更合理的任务交互过程与链条展示。
6. “需要修改”走类似 Codex plan mode 的选择/评论框;用户也可以继续在主聊天框说话,此时默认上一轮结果满意。
7. 验证失败时,大字显示状态,详细原因小字展示并默认折叠,可展开查看。
---
## 1. 用户打开对话页是为了什么
| 用户目标 | 页面需要支持 |
|---|---|
| 问一个简单问题 | 快速输入、快速看到 assistant 回答,不制造 Task 负担 |
| 提交一个复杂任务 | 输入任务后进入运行中状态,最终看到主 Agent synthesis 的结果 |
| 知道系统是不是还在工作 | 明确显示 thinking/running/失败状态 |
| 看到必要过程 | 能看到规划、子任务、验证、重试等摘要,但不被原始事件淹没 |
| 判断结果是否可接受 | 最新 Task 结果下提供“满意 / 需要修改 / 放弃” |
| 继续上下文 | 会话列表、切换历史会话、加载历史消息 |
| 修改结果 | 反馈“需要修改”后,下一条消息应自然复用未关闭 Task |
| 管理会话可见性 | 支持归档 session使它不再出现在前端列表 |
| 进入复杂任务工作台 | Task 创建后,从对话消息中的 Task 链接跳转到 Task 管理界面 |
---
## 2. 当前对话页已有元素
| 元素 | 当前情况 | 本页讨论点 |
|---|---|---|
| 会话列表 | 左侧已有新建、切换、删除会话 | 删除应改为归档;归档后前端不再显示 |
| 消息流 | 中间展示 user / assistant 消息 | 是否需要显示模型、run_id、token、工具次数等元信息 |
| 输入框 | 支持文本输入、回车发送 | 是否需要模型选择、参数选择、模式选择 |
| 运行状态 | 有 thinking 状态 | 是否要区分 simple run、Task run、验证中、重试中 |
| 反馈按钮 | 最新 assistant Task 结果显示三按钮 | 需要改成带评论框的选择交互 |
| 过程区 | 桌面端有 ProcessLane移动端有 Process tab | 对话页不再常驻大过程区;复杂任务通过 Task 链接进入 Task 管理界面 |
| 结果区 | 有 ArtifactSidebar | 需要和 Task 管理界面重新划边界 |
| Office 入口 | 当前任务现场 banner | 不应该在顶部常驻;改成消息中的 Task 链接 |
| 附件入口 | 前端已有上传入口 | 第一版必须保留,需要后端补足附件语义 |
| Slash command | 前端已有 `/` 命令选择 | 不保留,应移除 |
---
## 3. 对话页必须有
### 3.1 会话区
必须支持:
1. 新建会话。
2. 切换会话。
3. 归档会话。
4. 显示会话最近更新时间或可读标题。
5. 默认不显示已归档会话。
暂不确定:
1. 是否需要会话搜索。
2. 是否需要手动重命名。
3. 是否需要 pin。
4. 是否需要“查看已归档会话”的二级入口。
已确认:
1. 不使用“删除”语义。
2. 归档只影响前端列表可见性,不等于删除记忆或永久清除历史。
### 3.2 消息区
必须支持:
1. 展示用户消息。
2. 展示 assistant 最终回答。
3. 刷新后保留 `run_id / task_id / task_status / validation_status / feedback_state` 对应的 UI 状态。
4. 对 Markdown 内容做稳定渲染。
不应该做:
1. 不直接展示隐藏事件原始 JSON。
2. 不把 sub-agent 的中间 summary 当成最终回答。
3. 不让用户手动改内部 Task 状态。
### 3.3 输入区
必须支持:
1. 输入文本。
2. 发送。
3. 发送失败时明确提示。
4. 运行中避免重复提交,或明确支持排队。
5. 上传附件。
6. 在消息中展示用户已提交的附件。
暂不确定:
1. 附件进入模型上下文的具体语义:作为文件引用、文本提取、图片输入,还是先作为 workspace 文件供工具读取。
2. 是否要提供模型/provider 快捷选择。
不保留:
1. Slash command。
### 3.4 运行状态
必须支持:
1. 等待模型时显示“思考中”。
2. Task 验证中有明确状态。
3. 验证失败重试时有明确状态。
4. 失败时告诉用户失败发生在发送、运行、验证还是反馈。
5. 验证失败时,大字显示失败状态。
6. 验证详细原因放在状态下方,以小字/展开区展示,默认折叠。
当前缺口:
1. 现在基本只有 thinking。
2. 验证中、重试中、Task awaiting feedback 没有足够清晰的产品化表达。
### 3.5 反馈区
必须支持:
1. 只对最新 assistant Task 结果显示反馈。
2. 三种反馈:满意、需要修改、放弃。
3. 已反馈后显示反馈状态。
4. 反馈失败时显示错误。
5. “满意 / 需要修改”使用带评论框的选择交互。
6. 用户如果不点反馈、直接在主聊天框继续聊天,则默认上一轮结果满意。
待继续细化:
1. “满意”的评论框是可选还是默认隐藏。
2. “需要修改”的评论是否必填。
3. “放弃”是否需要二次确认。
4. “满意”后是否显示“已用于学习候选”之类的反馈。
### 3.6 Task 入口
必须支持:
1. 复杂任务创建后,对话流中出现一条 Task 创建回复。
2. Task 创建回复包含可读标题或 description。
3. 用户点击 Task 链接后跳转到 Task 管理界面。
4. 对话页只保留轻量 Task 状态,不展开完整执行链条。
示例表达:
```text
已创建 Task整理最近三个月销售数据并生成分析结论
查看 Task
```
不应该做:
1. 不展示未实现的策略按钮,例如 `moa / hierarchy / group_chat`
2. 不让用户选择 specialist agent 来影响当前 Task。
3. 不把完整过程链条塞在对话页主界面。
4. 不再使用 `Office` 作为用户可见概念。
---
## 4. 对话页不承担的事情
| 不承担 | 原因 | 应该去哪里 |
|---|---|---|
| Skill 审核、发布、回滚 | 这是能力生命周期管理,不是对话主流程 | Skills 页 |
| Provider 深度配置 | 配置属于系统设置,不应打断对话 | Status/Settings |
| MCP server 管理 | 属于工具/集成管理 | MCP/Settings是否保留待后续讨论 |
| Outlook 浏览与连接 | 属于集成管理 | Outlook/Settings是否保留待后续讨论 |
| Plugin/Marketplace 管理 | 属于平台扩展 | Plugins/Marketplace是否保留待后续讨论 |
| 内部 Task 管理 | Task 是运行容器,不是当前产品级实体 | 仅通过过程投影展示 |
| Team strategy 配置 | 当前 team 是 Task 内部执行策略 | 仅展示,不手动配置 |
对话页承担的轻量后台管理:
| 承担 | 说明 |
|---|---|
| session 归档 | 替代删除;归档后不在前端会话列表展示,但记忆仍然存在 |
| Task 入口 | 复杂任务创建后,通过对话消息提供跳转到 Task 管理界面的入口 |
---
## 5. 对话页和后端的最小契约
| 能力 | 后端接口/数据 | 对话页使用方式 |
|---|---|---|
| 发送消息 | `POST /api/chat` | 输入框发送;返回 final answer + run/task/validation 元数据 |
| WebSocket 对话 | `WS /ws/{session_id}` | 实时发送和接收 assistant message / session_updated |
| 提交反馈 | `POST /api/chat/feedback` | 最新 Task answer 下三按钮 |
| 读取会话 | `GET /api/sessions/{session_id}` | 刷新消息流和反馈状态 |
| 会话列表 | `GET /api/sessions` | 左侧会话列表 |
| 过程投影 | `GET /api/sessions/{session_id}/process` | 右侧过程区,不直接展示隐藏事件 JSON |
| 归档会话 | 待补archive session API | 归档后会话不再出现在默认列表 |
| 附件 | 待补chat attachment / file reference 契约 | 对话页必须支持附件上传和附件展示 |
| Task 链接 | 待补Task 管理页路由与 Task identifier 映射 | 复杂任务创建回复跳转到 Task 管理界面 |
---
## 6. 当前主要争议点
| 争议点 | 方案 A | 方案 B | 需要定什么 |
|---|---|---|---|
| 附件 | 当前版本保留,要求后端补附件语义 | 当前版本隐藏,等文件能力页讨论后再接回 | 已定:保留 |
| Slash command | 保留为高级用户快捷入口 | 隐藏,避免旧系统命令残留干扰 | 已定:不保留 |
| 过程区默认状态 | 桌面端默认显示 | 复杂 Task 出现后,以 Task 链接跳转到 Task 管理界面 | 已定:对话页不常驻完整过程区 |
| Office banner | 对话页顶部显示当前任务现场 | 从对话页移除,复杂任务创建后在消息里显示 Task 链接 | 已定:移除顶部 Office bannerOffice 改名 Task |
| 模型/provider 选择 | 在输入区提供轻量选择 | 只在设置页改默认模型 | 用户是否经常需要按消息切模型 |
| 反馈评论 | “满意 / 需要修改”弹出选择评论框 | 只点按钮,用户下一条消息补充 | 已定:需要评论框;用户继续主聊天则默认满意 |
| 验证细节 | 大字状态 + 折叠详情 | 只显示通过/未通过 | 已定:状态大字,原因默认折叠 |
| 调试元信息 | 可展开显示 run_id、task_id、token、工具次数 | 普通用户隐藏 | 当前产品面向开发者还是普通使用者 |
---
## 7. 当前版本建议稿
这是讨论稿,不是最终结论。
对话页当前版本收敛成:
1. 左侧:会话列表。
- 删除改为归档。
- 归档后不在默认列表展示。
2. 中间:消息流 + 输入框。
3. 输入区保留附件能力。
4. 移除 Slash command。
5. 复杂任务创建后,在对话流里显示 Task 创建回复和跳转链接。
6. 移除顶部 Office banner。
7. `Office` 改名为 `Task`;完整任务过程进入 Task 管理界面展示。
8. 最新 Task 回答下:满意 / 需要修改 / 放弃。
9. “满意 / 需要修改”使用类似 Codex plan mode 的选择评论框。
10. 用户不点反馈、直接继续聊天时,默认上一轮结果满意。
11. 验证失败:大字状态,详细原因折叠展示。
12. 对话页不提供 Team 策略选择、Sub-agent 选择、Skill 审核、MCP/插件/Outlook 管理。
---
## 8. 已确认问题
| 问题 | 结论 |
|---|---|
| 对话页是不是只作为“主工作台”,不承担后台管理? | 承担部分轻量后台管理,尤其是 session 归档和 Task 入口 |
| 附件是不是对话页第一版必须能力? | 是,必须保留 |
| Slash command 是否继续保留? | 不保留 |
| 过程区是默认显示,还是复杂任务出现后再显示? | 不常驻完整过程区;复杂任务创建后显示 Task 链接,点击进入 Task 管理界面 |
| Office 入口是否应该出现在对话页顶部? | 不应该Office 改名 Task入口放在对话消息中 |
| “需要修改”是否需要评论框? | 需要;满意/需要修改使用选择评论框,用户继续主聊天则默认满意 |
| 验证失败时,用户需要看到详细原因还是只看状态? | 大字状态 + 默认折叠的小字详细原因 |
## 9. 下一页遗留给 Task 管理界面的问题
这些问题不在对话页继续展开留到下一页“Task 管理界面”讨论:
1. 原 Office 页改名 Task 后,路由叫 `/tasks` 还是继续兼容 `/office`
2. Task 管理界面如何展示任务交互过程和链条。
3. 是否支持暂停、取消、重试某个 Task 或某个节点。
4. 子任务、验证、技能选择、工具调用、产物如何分层展示。
5. 删除图形化办公室后,新的 Task 页面信息架构怎么排。
---
## 第 2 页Task 管理界面
建议路径:
- 任务管理入口:`/tasks`
- 普通任务详情:`/tasks/{task_id}`
- 定时任务详情/编辑:`/tasks/scheduled/{job_id}``/tasks/scheduled`
历史对应:
- 当前前端里的 `/office``/office/[taskId]`
- 当前前端里的 `/cron`
- 后续用户可见名称统一改为 `Task`
- 原图形化办公室展示删除,不再把任务现场做成地图/办公室/角色走动形式
- 当前“任务管理”思路保留:任务分为普通任务和定时任务
-`/office` 跳转到 `/tasks`
- 当前 `/cron` 能力并入“定时任务”tab不再作为顶层导航
一句话定位:
> Task 管理界面是任务中心。它统一管理普通任务和定时任务:普通任务用于查看复杂任务的执行链条和反馈状态;定时任务用于创建、启停和查看计划触发的任务。
它不是单个 Task 的详情页本身。单个普通 Task 的链条页是它下面的详情页。
它也不是普通聊天页,不是 agent/sub-agent 配置页,不是完整后台设置页。
已确认的方向:
1. 路由改为 `/tasks`,旧 `/office` 跳转到 `/tasks`
2. 任务管理入口分为“普通任务 / 定时任务”两个 tab。
3. 当前 `/cron` 能力并入“定时任务”tab不再作为顶层导航。
4. 普通任务列表需要存在,用户可以从列表进入普通任务详情。
5. 普通任务详情采用“阶段链 + 节点分组”展示执行链条。
6. 普通任务详情允许直接输入修订意见。
7. 用户可以暂停、取消、重试普通 Task 或节点。
8. 第二次验证失败后,普通任务用户可见状态叫“任务失败”。
9. sub-agent 输出只算节点结果,不算产物。
10. 产物需要下载按钮,支持单个下载或全部下载。
11. 已归档 session 里的普通任务仍然在普通任务列表显示。
12. 已放弃普通任务不允许恢复。
13. 所有任务没有归档概念;定时任务可以暂时关闭,也可以删除。
14. 定时任务每次触发都创建普通 Task并在运行历史里链接过去。
---
## 1. 用户打开任务管理页是为了什么
| 用户目标 | 页面需要支持 |
|---|---|
| 查看普通任务 | 显示从对话中创建的复杂任务列表 |
| 查看定时任务 | 显示计划触发的任务列表 |
| 区分任务类型 | 普通任务和定时任务在同一个任务管理入口中分 tab 或分区展示 |
| 进入普通任务详情 | 点击普通任务进入执行链条页 |
| 管理定时任务 | 创建、启停、编辑、删除、手动运行定时任务 |
| 看任务状态 | 对普通任务显示运行中、等待反馈、需要修改、已完成、已放弃、任务失败等状态 |
| 看计划状态 | 对定时任务显示启用状态、计划规则、上次运行、下次运行、最近结果 |
| 回到对话继续沟通 | 普通任务详情提供来源会话链接 |
| 回看历史任务 | 普通任务列表也显示已归档 session 里的任务 |
---
## 2. 任务类型
Task 管理界面至少分两类:
| 类型 | 来源 | 用户主要动作 | 详情页重点 |
|---|---|---|---|
| 普通任务 | 用户在对话中提交复杂任务后自动创建 | 查看、反馈、回到对话修订 | 执行链条、节点、验证、最终结果 |
| 定时任务 | 用户手动创建计划任务 | 新建、启停、编辑、手动运行、查看历史触发 | 计划规则、触发记录、每次运行结果 |
普通任务详情页负责展示完整过程:
```text
Task 创建
├─ 规划 plan
│ ├─ single
│ └─ team
│ ├─ sequence
│ ├─ parallel
│ └─ dag
├─ 子任务执行 nodes
│ ├─ selected skills
│ ├─ generated ephemeral skill
│ ├─ tool / file / memory activity
│ └─ node result
├─ 主 Agent synthesis
├─ validation
│ ├─ passed
│ ├─ failed
│ └─ retry
└─ feedback
├─ satisfied
├─ revise
└─ abandon
```
定时任务详情页负责展示计划和触发历史:
```text
定时任务
├─ 计划规则
│ ├─ at
│ ├─ every
│ └─ cron
├─ 目标会话 / message
├─ 触发历史
│ ├─ run 1
│ ├─ run 2
│ └─ run N
└─ 最近结果 / 错误
```
---
## 3. 页面结构建议
### 3.1 任务管理入口
路径建议:`/tasks`
| 区域 | 内容 |
|---|---|
| 顶部 tabs | 普通任务、定时任务 |
| 普通任务 tab | 复杂任务列表、状态筛选、打开详情、回到对话 |
| 定时任务 tab | 计划任务列表、新建任务、启停、编辑、手动运行 |
| 全局空状态 | 引导用户回到对话页创建普通任务,或创建一个定时任务 |
待讨论:
1. 入口是否叫 `任务`,英文是否叫 `Tasks`
2. tabs 名称是“普通任务 / 定时任务”,还是“任务 / 计划任务”。
3. 是否需要全局搜索。
### 3.2 普通任务列表
| 区域 | 内容 |
|---|---|
| 状态筛选 | 全部、运行中、等待反馈、需要修改、已完成、已放弃、失败 |
| 任务卡片/表格 | 标题、description、来源会话、状态、当前阶段、更新时间、子任务数量、失败数 |
| 快速动作 | 打开详情、回到对话、暂停、取消、重试 |
| 空状态 | 引导用户回到对话页创建复杂任务 |
普通任务列表不负责展示完整链条,只负责让用户找到任务并进入详情。
已确认:
1. 普通任务列表需要存在。
2. 已归档 session 里的普通任务仍然显示。
3. 普通任务没有归档概念。
4. 已放弃普通任务不允许恢复。
### 3.3 定时任务列表
| 区域 | 内容 |
|---|---|
| 顶部动作 | 新建定时任务 |
| 列表字段 | 名称、启用状态、计划类型、计划表达式、目标会话、消息摘要、上次运行、下次运行、最近结果 |
| 快速动作 | 启用/停用、编辑、手动运行、删除 |
| 空状态 | 引导用户创建一个计划任务 |
定时任务管理保留现在 `/cron` 页的主要能力,但归入 Task 管理页,不再作为顶层独立概念。
已确认:
1. 定时任务可以暂时关闭,也可以删除。
2. 定时任务没有归档概念。
3. 每次定时触发都创建普通 Task。
4. 定时任务运行历史必须链接到对应普通 Task。
### 3.4 普通任务详情页
路径建议:`/tasks/{task_id}`
建议分区:
| 区域 | 必须展示 | 说明 |
|---|---|---|
| Task header | 标题、description、状态、来源会话、创建时间、更新时间 | 用户先知道自己看的是哪个任务 |
| 当前状态区 | 大字状态 + 当前阶段 + 下一步动作 | 例如“验证失败,等待修订” |
| 执行链条 | plan、nodes、dependency、main synthesis、validation | 这是本页核心 |
| 节点详情 | 每个节点的任务、状态、输入、输出、skills、错误 | 点击链条节点后在详情区展示 |
| 最终结果 | 主 Agent synthesis 的用户可见结果 | 不把 sub-agent summary 当最终结果 |
| 验证区 | 验证状态、分数、失败原因、重试记录 | 大状态明显,详情折叠 |
| 反馈区 | 满意、需要修改、放弃;评论框 | 和对话页保持一致 |
| 产物区 | 文件、链接、JSON、图片、报告 | 需要先定义产物来源 |
| 事件时间线 | 重要事件,不展示原始隐藏 JSON | 用产品化语言展示 |
### 3.5 定时任务详情/编辑页
路径建议:`/tasks/scheduled/{job_id}`
| 区域 | 必须展示 | 说明 |
|---|---|---|
| Job header | 名称、启用状态、创建时间、更新时间 | 用户先知道这是哪个定时任务 |
| 计划规则 | at / every / cron下一次运行时间 | 核心配置 |
| 消息内容 | 触发时发送给 Agent 的 message | 可编辑 |
| 目标会话 | 触发运行归属哪个 session | 可选择或固定 |
| 运行历史 | 每次触发的时间、状态、run/task 链接 | 和普通任务结果打通 |
| 错误区 | 最近错误、失败原因 | 方便排查 |
| 操作区 | 保存、启用/停用、手动运行、删除 | 管理动作 |
---
## 4. 普通任务详情:执行链条怎么展示
这是普通任务详情页最核心的问题。删除图形化 Office 后,需要一个清晰的链条视图。
### 方案 A纵向阶段链
```text
Plan
Team execution
├─ Node A
├─ Node B
└─ Node C
Main synthesis
Validation
Feedback
```
优点:
1. 最容易读。
2. 移动端友好。
3. 和事件时间顺序一致。
缺点:
1. 对 parallel / dag 的依赖关系表达较弱。
### 方案 BDAG 链条图
```text
Planner
├── Node A ──┐
├── Node B ──┼── Synthesis ── Validation
└── Node C ──┘
```
优点:
1. 能清楚表达依赖关系。
2. 对 team/dag 任务更准确。
缺点:
1. 实现和布局复杂。
2. 小屏幕容易难读。
### 方案 C阶段链 + 节点分组
纵向展示阶段,在 team execution 阶段内部用节点分组表达 sequence / parallel / dag
```text
Plan
Team execution
Group 1: Node A + Node B 并行
Group 2: Node C 依赖 A/B
Main synthesis
Validation
```
优点:
1. 兼顾可读性和结构。
2. 比纯 DAG 更适合产品界面。
3. 能覆盖 sequence / parallel / dag。
缺点:
1. 需要后端或前端把 DAG 分层。
已确认:
> 普通任务详情执行链条采用方案 C阶段链 + 节点分组。
---
## 5. 普通任务详情:节点卡片需要展示什么
| 信息 | 是否必须 | 说明 |
|---|---|---|
| 节点标题 / node_id | 必须 | 让用户知道哪个子任务 |
| 状态 | 必须 | queued / running / done / error / blocked |
| 分配目的 | 必须 | 这个节点被安排做什么 |
| selected skills | 必须 | 体现为什么它按某种方式做 |
| generated ephemeral skill | 必须 | 如果系统临时生成了 draft-only guidance需要可见 |
| 输出摘要 | 必须 | 用户不展开也能知道节点结果 |
| 错误/阻断原因 | 必须 | 失败时必须清楚 |
| 工具调用 | 待讨论 | 是否展示全部,还是只展示关键工具活动 |
| token/model/provider | 可选 | 可能只放调试展开区 |
| run_id/session_id | 可选 | 开发者调试用,不默认展示 |
已确认:
1. sub-agent 输出属于节点结果。
2. sub-agent 输出不算产物。
---
## 6. 普通任务状态设计
用户可见状态建议不要完全照搬内部状态,而是做产品化映射。
| 用户可见状态 | 内部来源 | 说明 |
|---|---|---|
| 已创建 | task created / open | 已创建但还未开始执行 |
| 规划中 | task_execution_planned 前后 | 正在决定 single/team 和执行结构 |
| 执行中 | team run / main run running | 正在执行子任务或主综合 |
| 验证中 | validating | 正在自动验证最终结果 |
| 验证失败,正在重试 | validation failed + retry_scheduled | 第一次失败,系统自动重试 |
| 等待反馈 | awaiting_feedback | 最终结果已给出,等用户满意/修改/放弃 |
| 需要修改 | needs_revision | 用户要求修订,下一条消息复用该 Task |
| 已完成 | closed | 用户满意并关闭 |
| 已放弃 | abandoned | 用户放弃 |
| 任务失败 | 第二次验证失败 / unrecoverable failure | 执行失败且没有恢复,或第二次验证仍失败 |
已确认:
1. 第二次验证失败后,用户可见状态叫“任务失败”。
2. 用户可以暂停、取消、重试普通 Task 或节点。
待讨论:
1. 用户继续聊天默认满意后,状态是否直接变“已完成”。
2. 暂停/取消/重试分别作用于整个 Task 还是当前节点时,状态如何显示。
---
## 7. 普通任务反馈与修订
普通任务详情页和对话页要保持一致。
必须支持:
1. 满意。
2. 需要修改。
3. 放弃。
4. 满意/需要修改的评论框。
5. 用户在对话页继续聊天时,默认上一轮 Task 结果满意。
6. 普通任务详情页允许直接输入修订意见。
待讨论:
1. 详情页内输入修订意见时,是否同步写回来源会话消息流。
2. 放弃是否需要原因。
已确认:
1. 已放弃普通任务不允许恢复。
---
## 8. 普通任务详情:产物区
产物需要先定义清楚,否则会变成空面板。
可能的产物类型:
| 类型 | 例子 | 来源 |
|---|---|---|
| 文件 | 生成的报告、代码、表格 | filesystem tool / workspace |
| 链接 | 搜索结果、外部引用 | web tool / MCP |
| JSON | 结构化计划、评估报告 | hidden events / validation |
| 图片 | 生成图、上传图分析结果 | attachment / tool |
| 文本报告 | 节点输出、最终总结 | main synthesis / sub-agent |
待讨论:
1. 无。
已确认:
1. sub-agent 输出只算节点结果,不算产物。
2. 产物区需要下载按钮。
3. 下载可以支持单个产物下载或全部下载。
4. validation report 属于验证区,不算产物。
5. 用户上传附件不出现在 Task 产物区。
---
## 9. 任务管理页不应该做什么
| 不应该 | 原因 |
|---|---|
| 不再展示图形化办公室/地图/人物移动 | 用户要看执行链条,不是看装饰性现场 |
| 不允许手动选择 team strategy | 当前 strategy 是 Main Agent/Planner 内部决策 |
| 不允许手动选择 specialist agent | 当前 Task sub-agent 是 generic worker + skill guidance |
| 不直接暴露隐藏事件 JSON | 需要投影成产品可读事件 |
| 不混淆最终回答和中间节点输出 | 最终回答仍来自主 Agent synthesis |
| 不承载 Skills 审核发布 | 去 Skills 页 |
| 不承载 MCP/插件/Outlook 管理 | 去对应设置/管理页,是否保留后续讨论 |
| 不把定时任务和普通任务混成一张无区分列表 | 两者来源、动作和状态完全不同 |
| 不给任务设计归档概念 | session 有归档;任务没有归档 |
---
## 10. 与对话页的关系
| 对话页 | 任务管理页 |
|---|---|
| 提交复杂任务 | 普通任务列表出现该任务 |
| 显示 Task 创建消息 | 打开普通任务详情 |
| 展示最终回答 | 展示最终回答如何生成 |
| 提供轻量反馈 | 提供完整反馈和修订上下文 |
| 用户继续聊天默认满意 | Task 状态同步关闭 |
| 不展示完整链条 | 展示链条、节点、验证、产物 |
| 不创建定时任务 | 定时任务 tab 创建和管理计划任务 |
---
## 11. 已确认问题
| 问题 | 结论 |
|---|---|
| 路由是否改为 `/tasks`,并让旧 `/office` 跳转到 `/tasks` | 是 |
| 任务管理入口是否分为“普通任务 / 定时任务”两个 tab | 是 |
| 当前 `/cron` 能力是否并入“定时任务”tab不再作为顶层导航 | 是 |
| 普通任务列表是否需要存在,还是主要从对话跳普通任务详情? | 需要存在,可以从列表进入详情 |
| 普通任务详情执行链条是否采用“阶段链 + 节点分组”方案? | 是 |
| 普通任务详情是否允许直接输入修订意见,还是必须回到对话页? | 允许直接输入 |
| 普通任务详情内输入修订意见时,是否同步写回来源会话消息流? | 无需同步 |
| 用户是否可以暂停、取消、重试普通 Task 或节点? | 前端只能取消或重试整个任务;没有暂停;不操作单个节点 |
| 第二次验证失败后,普通任务用户可见状态叫什么? | 任务失败 |
| sub-agent 输出算产物,还是只算节点结果? | 节点结果 |
| 产物是否需要下载/导出全部? | 具备下载按钮,支持单个下载或全部下载 |
| validation report 是否算产物,还是只属于验证区? | 属于验证区 |
| 用户上传附件是否出现在 Task 产物区? | 无需 |
| 已归档 session 里的普通任务是否还在普通任务列表显示? | 需要显示 |
| 已放弃普通任务是否允许恢复? | 不允许 |
| 定时任务删除语义是删除、禁用,还是归档? | 可以暂时关闭,也可以删除;所有任务没有归档概念 |
| 定时任务每次触发是否创建普通 Task并在运行历史里链接过去 | 是 |
| tabs 名称最终用“普通任务 / 定时任务”,还是“任务 / 计划任务”? | 普通任务 / 定时任务 |
## 12. 本页剩余待讨论问题
1. 是否需要任务搜索。
说明“全局搜索任务”指任务管理页里是否需要一个搜索框可以跨普通任务和定时任务搜索任务标题、description、来源会话、状态、定时任务消息内容等。不是全站搜索也不是搜索聊天内容。
---
## 第 3 页:技能页
建议路径:`/skills`
一句话定位:
> 技能页是 Agent 能力生命周期管理台。它负责查看已发布技能、处理学习候选、生成和审核草稿、查看安全/评估报告,并把通过审核的技能发布到 runtime catalog。
它不是聊天页,也不是直接在线编辑 published skill 的地方。
已确认的方向:
1. 主 tab 确定为“已发布 / 候选 / 草稿评审”。
2. “运行学习”按钮不放在技能页。
3. 保留技能上传;上传后必须进入 draft/review 流程。
4. 保留技能下载。
5. 允许删除技能。
6. 已发布技能暂时不需要版本历史和 diff。
7. 草稿评审需要 diff 视图。
8. approve/reject 不强制填写 notes。
9. publish 不需要二次确认。
10. high risk draft 允许发布,但必须展示理由。
11. candidate 允许忽略/关闭。
12. candidate 和 draft 不需要链接回 source task/run。
---
## 1. 用户打开技能页是为了什么
| 用户目标 | 页面需要支持 |
|---|---|
| 看当前有哪些技能 | 展示已发布技能、状态、来源、描述、版本 |
| 看系统从任务中学到了什么 | 展示 learning candidates、来源 run/task、原因和风险 |
| 生成技能草稿 | 从 candidate 生成 draft或重新生成 draft |
| 审核技能草稿 | 查看 proposed content、frontmatter、review 状态 |
| 判断草稿是否安全 | 查看 safety report、risk level、阻断原因 |
| 判断草稿是否有效 | 查看 eval report、是否通过、provider unavailable 等状态 |
| 发布技能 | approved + safety passed + eval not failed 后发布 |
| 管理已发布技能 | 禁用、回滚、删除、下载 |
| 上传技能 | 上传后进入 draft/review 流程 |
| 处理无价值候选 | 忽略/关闭 candidate |
---
## 2. 技能页应该覆盖的生命周期
```text
Task 成功 + 用户满意
└─ learning candidate
├─ open
├─ queued
├─ synthesizing
├─ draft_ready
├─ safety_failed
├─ eval_failed
├─ review_pending
├─ approved
├─ rejected
├─ published
├─ failed
└─ superseded
└─ draft
├─ safety report
├─ eval report
├─ submit review
├─ approve / reject
└─ publish / disable / rollback
```
核心约束:
1. draft 不进入 runtime catalog。
2. rejected draft 不可 publish。
3. publish 必须要求 approved review + safety passed + eval not failed。
4. high risk publish 需要显式确认。
5. worker 可以自动到 draft/safety/eval但永不自动 approve/publish。
6. 不允许绕过 lifecycle 直接在线改 published skill。
---
## 3. 页面结构建议
技能页建议保留三个主 tab
| Tab | 目的 |
|---|---|
| 已发布 | 查看 runtime catalog 中当前可用/不可用的技能 |
| 候选 | 查看 learning candidates决定是否生成/重生成草稿 |
| 草稿/评审 | 审核 draft查看 safety/eval批准/拒绝/发布 |
### 3.1 已发布 tab
| 区域 | 内容 |
|---|---|
| 技能列表 | 名称、描述、来源、状态、当前版本、更新时间 |
| 状态标识 | available / unavailable / disabled / retired |
| 操作 | 查看详情、禁用、回滚、删除、下载、上传 |
| 可选操作 | 复制路径、查看支持文件 |
已确认:
1. 保留上传技能。
2. 上传后必须进入 draft/review 流程。
3. 保留下载技能。
4. 允许删除技能。
5. 已发布技能暂时不需要版本历史和 diff。
### 3.2 候选 tab
| 区域 | 内容 |
|---|---|
| 候选列表 | candidate id、kind、status、risk、reason、evidence summary |
| 来源信息 | source run ids、task id、相关技能 |
| 操作 | 生成草稿、重新生成、查看详情、忽略/关闭 |
候选类型:
| 类型 | 说明 |
|---|---|
| new_skill | 新建技能建议 |
| revise_skill | 修订已有技能建议 |
| merge_skills | 合并技能建议 |
| retire_skill | 退役技能建议 |
待讨论:
1. 是否允许用户手动创建 candidate。
已确认:
1. 技能页不放“运行学习”按钮。
2. candidate 允许忽略/关闭。
3. candidate 不需要链接回 source task/run。
### 3.3 草稿/评审 tab
| 区域 | 内容 |
|---|---|
| 草稿列表 | skill name、draft id、proposal kind、status、base version |
| 内容区 | proposed frontmatter、proposed content |
| Safety report | passed、risk level、findings |
| Eval report | passed、status、notes |
| Review | submit、approve、reject、review notes |
| Diff | base version vs proposed draft |
| Publish | publish、high risk reason |
已确认:
1. 草稿评审需要 diff 视图。
2. approve/reject 不强制填写 notes。
3. publish 不需要二次确认。
4. high risk draft 允许发布,但必须展示理由。
5. draft 不需要链接回 source task/run。
---
## 4. 已发布技能管理边界
| 动作 | 当前建议 | 说明 |
|---|---|---|
| 查看 | 必须 | 看名称、描述、版本、状态 |
| 禁用 | 必须 | 保留 skill spec不进入 runtime selection |
| 回滚 | 必须 | 通过 publisher 回滚到旧版本 |
| 删除 | 必须 | 允许删除技能,暂时不需要二次确认 |
| 上传 | 必须 | 上传后进入 draft/review 流程,并自动跑 safety 和 eval |
| 下载 | 必须 | 作为备份/迁移能力保留 |
| 在线编辑 published | 不允许 | 必须通过 draft -> review -> publish |
---
## 5. 技能页和其它页面的关系
| 页面 | 关系 |
|---|---|
| 对话页 | 用户满意反馈触发学习候选;对话页不审核技能 |
| 任务管理页 | 技能页不需要链接回 source task/run |
| 状态页 | provider 不可用会影响 draft synthesis/eval技能页只显示结果不管理 provider |
| MCP/工具页 | skill 可有 tool hints但技能页不管理 MCP server |
---
## 6. 技能页不应该做什么
| 不应该 | 原因 |
|---|---|
| 不直接编辑 published skill | 破坏 review/publish lifecycle |
| 不自动 approve/publish | 当前设计是 assisted learning |
| 不让 rejected draft 发布 | 审核状态必须生效 |
| 不让 safety_failed / eval_failed 绕过发布 | 安全和评估是发布门 |
| 不把 draft 当 runtime skill | draft 不进入 runtime catalog |
| 不把技能页做成普通文件管理器 | 技能是生命周期对象,不只是 Markdown 文件 |
| 不在技能页放 run learning | 学习 worker 手动触发不属于这个页面 |
| 不要求 candidate/draft 跳回 source task/run | 技能页只展示必要 evidence 摘要 |
---
## 7. 已确认问题
| 问题 | 结论 |
|---|---|
| 技能页主 tab 是否确定为“已发布 / 候选 / 草稿评审”? | 是 |
| 是否保留“运行学习”按钮,允许用户手动触发 worker run-once | 不在技能页做 |
| 是否保留技能上传?如果保留,上传后是否必须进入 draft/review 流程? | 保留,且必须进入 draft/review 流程 |
| 是否保留技能下载? | 是 |
| 是否允许删除技能,还是只允许禁用/回滚/退役? | 允许删除 |
| 技能删除是否需要二次确认? | 暂时不需要 |
| 技能上传后是否自动跑 safety/eval | 是,上传后自动跑 safety 和 eval |
| 已发布技能是否需要版本历史和 diff | 暂时无需 |
| 草稿评审是否需要 diff 视图? | 需要 |
| approve/reject 是否必须填写 notes | 无需 |
| publish 是否必须二次确认? | 无需 |
| high risk draft 是否允许发布,还是只能重新生成/拒绝? | 允许,但要展示来自 safety report 的理由 |
| high risk draft 的“理由”来自 safety report还是需要发布者手动填写 | 来自 safety report |
| candidate 是否允许忽略/关闭? | 是 |
| candidate 和 draft 是否需要链接回 source task/run | 无需 |
## 8. 本页剩余待讨论问题
暂无。