feat(beaver): 完成Task Team功能v1实现,重构后端架构支持统一内核

新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证
(通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。

实现Agent Team v1协调器,支持sequence/parallel/dag执行策略,
sub-agent复用主AgentLoop,每个run使用独立memory snapshot。

建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期,
通过Task验证通过且用户满意才生成学习候选。

重构目录结构,移除third_party依赖,建立统一engine内核,
所有agent共享运行时基础组件。

更新ContextBuilder清理provider消息字段,增强SkillContext版本管理,
集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
This commit is contained in:
2026-05-08 17:14:14 +08:00
parent 5ba5c7e4c1
commit 8a12c30141
93 changed files with 16724 additions and 1247 deletions

View File

@ -14,9 +14,11 @@ import {
createSession,
deleteSession,
getSession,
getSessionProcess,
listCommands,
listSessions,
sendMessage,
submitChatFeedback,
uploadFile,
wsManager,
} from '@/lib/api';
@ -79,6 +81,8 @@ export default function ChatPage() {
clearMessages,
setIsThinking,
setSelectedRunId,
setSessionProcess,
updateMessageFeedback,
} = useChatStore();
const [input, setInput] = useState('');
@ -155,9 +159,15 @@ export default function ChatPage() {
const localSnapshot = useChatStore.getState().messages;
const waitingForReply = useChatStore.getState().isLoading || useChatStore.getState().isThinking;
try {
const detail = await getSession(key);
const [detail, process] = await Promise.all([
getSession(key),
getSessionProcess(key).catch(() => null),
]);
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
if (process) {
setSessionProcess(key, process);
}
const nextMessages = waitingForReply
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
@ -172,7 +182,7 @@ export default function ChatPage() {
if (reqSeq !== loadSessionReqSeq.current) return;
if (useChatStore.getState().sessionId !== key) return;
}
}, [setIsLoading, setIsThinking, setMessages]);
}, [setIsLoading, setIsThinking, setMessages, setSessionProcess]);
const loadCommands = useCallback(async () => {
if (commandsLoadedRef.current) return;
@ -231,6 +241,12 @@ export default function ChatPage() {
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
const validationResult = data.validation_result ?? data.metadata?.validation_result;
const validationStatus = data.validation_status
? data.validation_status
: validationResult
? ((validationResult as Record<string, unknown>).accepted === true ? 'passed' : 'failed')
: 'unknown';
setIsThinking(false);
setIsLoading(false);
addMessage({
@ -238,7 +254,12 @@ export default function ChatPage() {
content: typeof data.content === 'string' ? data.content : '',
timestamp: new Date().toISOString(),
attachments: Array.isArray(data.attachments) ? data.attachments : undefined,
run_id: typeof data.run_id === 'string' ? data.run_id : undefined,
task_id: data.task_id ?? data.metadata?.task_id ?? null,
task_status: data.task_status ?? data.metadata?.task_status ?? null,
validation_status: validationStatus,
});
void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
loadSessions();
}
});
@ -348,7 +369,14 @@ export default function ChatPage() {
role: 'assistant',
content: result.response,
timestamp: new Date().toISOString(),
run_id: result.run_id,
task_id: result.task_id,
task_status: result.task_status,
validation_status: result.validation_result
? (result.validation_result.accepted === true ? 'passed' : 'failed')
: 'unknown',
});
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
loadSessions();
} else {
await loadSessionMessages(sessionId);
@ -367,7 +395,23 @@ export default function ChatPage() {
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking]);
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking, setSessionProcess]);
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => {
updateMessageFeedback(runId, feedbackType);
try {
await submitChatFeedback({
sessionId,
runId,
feedbackType,
});
void loadSessionMessages(sessionId);
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
void loadSessions();
} catch (err: any) {
updateMessageFeedback(runId, undefined, err?.message || pickAppText(locale, '反馈提交失败', 'Feedback failed'));
}
}, [loadSessionMessages, loadSessions, locale, sessionId, setSessionProcess, updateMessageFeedback]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
@ -575,6 +619,7 @@ export default function ChatPage() {
selectedRunId={selectedSessionRunId}
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
onCancelRun={handleCancelRun}
onFeedback={handleFeedback}
/>
</div>

View File

@ -1,20 +1,45 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Puzzle,
Upload,
Download,
Trash2,
RefreshCw,
Loader2,
AlertCircle,
Check,
Download,
FileText,
Loader2,
Puzzle,
RefreshCw,
Rocket,
Send,
ShieldCheck,
Trash2,
Upload,
Wand2,
X,
XCircle,
} from 'lucide-react';
import { listSkills, deleteSkill, uploadSkill, downloadSkill } from '@/lib/api';
import {
approveSkillDraft,
deleteSkill,
disablePublishedSkill,
downloadSkill,
listSkillCandidates,
listSkillDrafts,
listSkills,
publishSkillDraft,
regenerateSkillDraft,
rejectSkillDraft,
rollbackPublishedSkill,
runSkillLearningOnce,
submitSkillDraft,
synthesizeSkillDraft,
uploadSkill,
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
@ -23,53 +48,63 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { Skill } from '@/types';
import type { Skill, SkillDraft, SkillLearningCandidate } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function SkillsPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [skills, setSkills] = useState<Skill[]>([]);
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
const [loading, setLoading] = useState(true);
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 loadSkills = async () => {
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listSkills();
setSkills(Array.isArray(data) ? data : []);
const [skillData, candidateData, draftData] = await Promise.all([
listSkills(),
listSkillCandidates().catch(() => []),
listSkillDrafts().catch(() => []),
]);
setSkills(Array.isArray(skillData) ? skillData : []);
setCandidates(Array.isArray(candidateData) ? candidateData : []);
setDrafts(Array.isArray(draftData) ? draftData : []);
} catch (err: any) {
setError(err.message || pickAppText(locale, '加载技能失败', 'Failed to load skills'));
} finally {
setLoading(false);
}
};
}, [locale]);
useEffect(() => {
loadSkills();
}, []);
void load();
}, [load]);
const handleDelete = async (name: string) => {
setDeleting(name);
};
const confirmDelete = async (name: string) => {
const runAction = async (id: string, action: () => Promise<unknown>) => {
setActionId(id);
setError(null);
try {
await deleteSkill(name);
setDeleting(null);
loadSkills();
await action();
await load();
} catch (err: any) {
setError(err.message || pickAppText(locale, '删除技能失败', 'Failed to delete the skill'));
setDeleting(null);
setError(err.message || t('操作失败', 'Action failed'));
} finally {
setActionId(null);
}
};
const handleUploadDone = () => {
setShowUpload(false);
loadSkills();
const confirmDelete = async (name: string) => {
await runAction(`delete:${name}`, async () => {
await deleteSkill(name);
setDeleting(null);
});
};
if (loading) {
@ -81,20 +116,33 @@ export default function SkillsPage() {
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<div className="mx-auto max-w-6xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Puzzle className="w-6 h-6" />
{pickAppText(locale, '技能', 'Skills')}
{t('技能', 'Skills')}
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadSkills} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
<Button onClick={() => void load()} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
{t('刷新', 'Refresh')}
</Button>
<Button
onClick={() => void runAction('learning:run-once', () => runSkillLearningOnce())}
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="w-4 h-4 mr-2" />
{pickAppText(locale, '上传技能', 'Upload skill')}
<Upload className="mr-2 h-4 w-4" />
{t('上传技能', 'Upload skill')}
</Button>
</div>
</div>
@ -102,134 +150,396 @@ export default function SkillsPage() {
{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" />
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Upload Dialog */}
{showUpload && (
<UploadSkillForm
onDone={handleUploadDone}
onDone={() => {
setShowUpload(false);
void load();
}}
onCancel={() => setShowUpload(false)}
onError={(msg) => setError(msg)}
/>
)}
{/* Delete Confirmation */}
{deleting && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<p className="text-sm">
{pickAppText(locale, '确定删除技能', 'Delete skill')} <strong>{deleting}</strong> {pickAppText(locale, '吗?此操作不可撤销。', '? This action cannot be undone.')}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleting(null)}
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(deleting)}
>
{pickAppText(locale, '删除', 'Delete')}
</Button>
</div>
<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>
)}
{/* Skills Table */}
<Card>
<CardContent className="p-0">
{skills.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">{pickAppText(locale, '暂无技能', 'No skills yet')}</p>
<p className="text-sm mt-1">{pickAppText(locale, '上传一个技能 zip 包即可开始使用。', 'Upload a skill zip package to get started.')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead>{pickAppText(locale, '描述', 'Description')}</TableHead>
<TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
<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>
</TabsList>
<TabsContent value="published">
<PublishedSkillsTable
skills={skills}
onDownload={(name) => downloadSkill(name).catch((err) => setError(err.message))}
onDelete={(name) => setDeleting(name)}
onDisable={(name) =>
runAction(`disable:${name}`, () => disablePublishedSkill(name, t('人工禁用', 'Manual disable')))
}
onRollback={(name) => {
const target = window.prompt(t('回滚到版本,例如 v0001', 'Rollback target version, for example v0001'));
if (target) {
void runAction(`rollback:${name}`, () =>
rollbackPublishedSkill(name, target, t('人工回滚', 'Manual rollback'))
);
}
}}
/>
</TabsContent>
<TabsContent value="candidates">
<Card>
<CardHeader>
<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')} />
) : (
<div className="space-y-3">
{candidates.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">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{candidate.kind}</Badge>
<Badge variant="secondary">{candidate.status}</Badge>
<Badge variant={candidate.risk_level === 'critical' || candidate.risk_level === 'high' ? 'destructive' : 'outline'}>
{candidate.risk_level || 'medium'}
</Badge>
<span className="font-mono text-xs text-muted-foreground">{candidate.candidate_id}</span>
</div>
<p className="mt-2 text-sm text-foreground">{candidate.reason}</p>
{candidate.evidence_summary && (
<p className="mt-1 text-xs text-muted-foreground">{candidate.evidence_summary}</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{t('来源 runs', 'Source runs')}: {candidate.source_run_ids.join(', ') || '-'}
</p>
{candidate.related_skill_names.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{t('关联技能', 'Related skills')}: {candidate.related_skill_names.join(', ')}
</p>
)}
{candidate.last_error && (
<p className="mt-1 text-xs text-destructive">{candidate.last_error}</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={Boolean(actionId)}
onClick={() =>
void runAction(`draft:${candidate.candidate_id}`, () =>
synthesizeSkillDraft(candidate.candidate_id)
)
}
>
{actionId === `draft:${candidate.candidate_id}` ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
{t('生成草稿', 'Synthesize draft')}
</Button>
{candidate.draft_id && (
<Button
size="sm"
variant="outline"
disabled={Boolean(actionId)}
onClick={() =>
void runAction(`regen:${candidate.candidate_id}`, () =>
regenerateSkillDraft(candidate.candidate_id)
)
}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('重新生成', 'Regenerate')}
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
</CardHeader>
<CardContent>
{drafts.length === 0 ? (
<EmptyState icon={<FileText className="h-8 w-8" />} text={t('暂无草稿', 'No drafts yet')} />
) : (
<div className="space-y-4">
{drafts.map((draft) => (
<DraftCard
key={`${draft.skill_name}:${draft.draft_id}`}
draft={draft}
actionId={actionId}
onSubmit={() =>
runAction(`submit:${draft.draft_id}`, () =>
submitSkillDraft(draft.skill_name, draft.draft_id)
)
}
onApprove={() =>
runAction(`approve:${draft.draft_id}`, () =>
approveSkillDraft(draft.skill_name, draft.draft_id)
)
}
onReject={() =>
runAction(`reject:${draft.draft_id}`, () =>
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);
})
}
/>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
function PublishedSkillsTable({
skills,
onDownload,
onDelete,
onDisable,
onRollback,
}: {
skills: Skill[];
onDownload: (name: string) => void;
onDelete: (name: string) => void;
onDisable: (name: string) => void;
onRollback: (name: string) => void;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
return (
<Card>
<CardContent className="p-0">
{skills.length === 0 ? (
<EmptyState icon={<Puzzle className="h-8 w-8" />} text={t('暂无技能', 'No skills yet')} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('名称', 'Name')}</TableHead>
<TableHead>{t('描述', 'Description')}</TableHead>
<TableHead>{t('来源', 'Source')}</TableHead>
<TableHead>{t('状态', 'Status')}</TableHead>
<TableHead className="w-24">{t('操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{skills.map((skill) => (
<TableRow key={`${skill.source}:${skill.name}`}>
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell>
<span className="block max-w-[360px] truncate text-sm text-muted-foreground">
{skill.description}
</span>
</TableCell>
<TableCell>
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
</Badge>
</TableCell>
<TableCell>
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDownload(skill.name)}>
<Download className="h-3.5 w-3.5" />
</Button>
{skill.source === 'workspace' && (
<>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onRollback(skill.name)}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDisable(skill.name)}>
<ShieldCheck className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => onDelete(skill.name)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{skills.map((skill) => (
<TableRow key={`${skill.source}:${skill.name}`}>
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
{skill.description}
</span>
</TableCell>
<TableCell>
{skill.source === 'builtin' ? (
<Badge variant="secondary" className="text-xs">
{pickAppText(locale, '内置', 'Built in')}
</Badge>
) : (
<Badge variant="default" className="text-xs">
{pickAppText(locale, '工作区', 'Workspace')}
</Badge>
)}
</TableCell>
<TableCell>
{skill.available ? (
<Badge variant="default" className="text-xs bg-green-600">
{pickAppText(locale, '可用', 'Available')}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{pickAppText(locale, '不可用', 'Unavailable')}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={pickAppText(locale, '下载', 'Download')}
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
>
<Download className="w-3.5 h-3.5" />
</Button>
{skill.source === 'workspace' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(skill.name)}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
function DraftCard({
draft,
actionId,
onSubmit,
onApprove,
onReject,
onPublish,
}: {
draft: SkillDraft;
actionId: string | null;
onSubmit: () => Promise<unknown>;
onApprove: () => Promise<unknown>;
onReject: () => Promise<unknown>;
onPublish: () => Promise<unknown>;
}) {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const busy = Boolean(actionId);
const safety = draft.safety_report;
const evalReport = draft.eval_report;
const publishBlocked =
draft.status !== 'approved'
|| !safety
|| !safety.passed
|| safety.risk_level === 'critical'
|| (evalReport?.status !== 'skipped_provider_unavailable' && evalReport?.passed === false);
return (
<div className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{draft.proposal_kind}</Badge>
<Badge variant="secondary">{draft.status}</Badge>
{safety && (
<Badge variant={safety.risk_level === 'critical' || safety.risk_level === 'high' ? 'destructive' : 'outline'}>
{safety.risk_level}
</Badge>
)}
{evalReport && (
<Badge variant={evalReport.passed ? 'outline' : 'destructive'}>
{evalReport.status === 'skipped_provider_unavailable' ? t('未评估', 'Eval skipped') : evalReport.passed ? t('评估通过', 'Eval passed') : t('评估失败', 'Eval failed')}
</Badge>
)}
<span className="font-mono text-xs text-muted-foreground">{draft.skill_name}/{draft.draft_id}</span>
</div>
<p className="mt-2 text-sm">{draft.reason || t('无说明', 'No notes')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('base', 'base')}: {draft.base_version || '-'}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy || draft.status !== 'draft'} 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()}>
<Check className="mr-2 h-4 w-4" />
{t('批准', 'Approve')}
</Button>
<Button variant="outline" size="sm" disabled={busy || draft.status === 'published'} 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>
</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>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<ReportBlock
title={t('安全报告', 'Safety report')}
empty={t('暂无安全报告', 'No safety report')}
payload={safety}
/>
<ReportBlock
title={t('评估报告', 'Eval report')}
empty={t('暂无评估报告', 'No eval report')}
payload={evalReport}
/>
</div>
</div>
);
}
function ReportBlock({ title, empty, payload }: { title: string; empty: string; payload: unknown }) {
return (
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>
{payload ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(payload, null, 2)}</pre>
) : (
<p className="text-xs text-muted-foreground">{empty}</p>
)}
</div>
);
}
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="py-12 text-center text-muted-foreground">
<div className="mx-auto mb-3 flex justify-center opacity-40">{icon}</div>
<p className="text-sm font-medium">{text}</p>
</div>
);
}
@ -247,11 +557,10 @@ function UploadSkillForm({
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setUploading(true);
try {
await uploadSkill(file);
@ -269,7 +578,7 @@ function UploadSkillForm({
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '上传技能', 'Upload skill')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
@ -284,28 +593,16 @@ function UploadSkillForm({
ref={fileRef}
type="file"
accept=".zip"
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
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, '压缩包中必须包含 `SKILL.md` 文件', 'The archive must contain a `SKILL.md` file')}
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button type="submit" disabled={uploading}>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{pickAppText(locale, '上传中...', 'Uploading...')}
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
{pickAppText(locale, '上传', 'Upload')}
</>
)}
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
{pickAppText(locale, '上传', 'Upload')}
</Button>
</div>
</form>

View File

@ -11,8 +11,9 @@ import {
Radio,
Key,
Loader2,
Settings2,
} from 'lucide-react';
import { getStatus, restartSystem } from '@/lib/api';
import { getStatus, restartSystem, updateProviderConfig } from '@/lib/api';
import {
AlertDialog,
AlertDialogAction,
@ -26,10 +27,29 @@ import {
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { SystemStatus } from '@/types';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { ProviderStatus, SystemStatus } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type ProviderFormState = {
enabled: boolean;
model: string;
apiKey: string;
apiBase: string;
requestTimeoutSeconds: string;
};
export default function StatusPage() {
const { locale } = useAppI18n();
const [status, setStatus] = useState<SystemStatus | null>(null);
@ -38,6 +58,16 @@ export default function StatusPage() {
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = useState<ProviderStatus | null>(null);
const [providerForm, setProviderForm] = useState<ProviderFormState>(() => ({
enabled: false,
model: '',
apiKey: '',
apiBase: '',
requestTimeoutSeconds: '',
}));
const [savingProvider, setSavingProvider] = useState(false);
const [providerError, setProviderError] = useState<string | null>(null);
const loadStatus = async () => {
setLoading(true);
@ -86,6 +116,46 @@ export default function StatusPage() {
}
};
const openProviderDialog = (provider: ProviderStatus) => {
setSelectedProvider(provider);
setProviderError(null);
setProviderForm({
enabled: Boolean(provider.enabled || provider.has_key),
model: status?.model || '',
apiKey: '',
apiBase: provider.api_base || provider.default_api_base || provider.detail || '',
requestTimeoutSeconds: '',
});
};
const handleSaveProvider = async () => {
if (!selectedProvider) return;
const providerId = selectedProvider.id || selectedProvider.name;
setSavingProvider(true);
setProviderError(null);
try {
const timeout = providerForm.requestTimeoutSeconds.trim()
? Number(providerForm.requestTimeoutSeconds.trim())
: undefined;
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new Error(pickAppText(locale, '请求超时必须是正数', 'Request timeout must be a positive number'));
}
await updateProviderConfig(providerId, {
enabled: providerForm.enabled,
model: providerForm.model.trim() || undefined,
api_key: providerForm.apiKey.trim() || undefined,
api_base: providerForm.apiBase.trim() || undefined,
request_timeout_seconds: timeout,
});
await loadStatus();
setSelectedProvider(null);
} catch (err: any) {
setProviderError(err.message || pickAppText(locale, '保存提供商配置失败', 'Failed to save provider settings'));
} finally {
setSavingProvider(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@ -210,31 +280,137 @@ export default function StatusPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{status.providers.map((p) => (
<div
key={p.name}
className="flex items-center gap-2 text-sm"
<button
key={p.id || p.name}
type="button"
onClick={() => openProviderDialog(p)}
className={[
'group flex min-h-[76px] w-full items-start justify-between rounded-lg border p-3 text-left transition',
p.active
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-background hover:border-primary/50 hover:bg-muted/40',
].join(' ')}
>
{p.has_key ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-muted-foreground/40" />
)}
<span className={p.has_key ? '' : 'text-muted-foreground'}>
{p.name}
</span>
{p.detail && (
<span className="text-xs text-muted-foreground truncate">
{p.detail}
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 text-sm font-medium">
{p.has_key ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : (
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground/40" />
)}
<span className={p.has_key ? 'truncate' : 'truncate text-muted-foreground'}>
{providerLabel(p)}
</span>
</span>
)}
</div>
<span className="block truncate text-xs text-muted-foreground">
{p.active
? pickAppText(locale, '当前默认', 'Current default')
: p.enabled
? pickAppText(locale, '已启用', 'Enabled')
: pickAppText(locale, '点击配置', 'Click to configure')}
</span>
{(p.detail || p.api_key_masked) && (
<span className="block truncate text-xs text-muted-foreground">
{p.api_key_masked || p.detail}
</span>
)}
</span>
<Settings2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground opacity-60 group-hover:text-primary" />
</button>
))}
</div>
</CardContent>
</Card>
<Dialog open={Boolean(selectedProvider)} onOpenChange={(open) => !open && setSelectedProvider(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>
{pickAppText(locale, '配置提供商', 'Configure provider')}
{selectedProvider ? ` · ${providerLabel(selectedProvider)}` : ''}
</DialogTitle>
<DialogDescription>
{pickAppText(locale, '启用后会把它设为当前实例默认提供商。API Key 留空会保留已保存的值。', 'When enabled, this becomes the default provider for this instance. Leave API key empty to keep the saved value.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<Label className="text-sm">{pickAppText(locale, '启用提供商', 'Enable provider')}</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '关闭会从配置中移除这个提供商', 'Turning this off removes this provider from config')}
</p>
</div>
<Switch
checked={providerForm.enabled}
onCheckedChange={(checked) => setProviderForm((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-model">{pickAppText(locale, '默认模型', 'Default model')}</Label>
<Input
id="provider-model"
value={providerForm.model}
onChange={(event) => setProviderForm((prev) => ({ ...prev, model: event.target.value }))}
placeholder="qwen-plus"
disabled={!providerForm.enabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-api-key">API Key</Label>
<Input
id="provider-api-key"
type="password"
value={providerForm.apiKey}
onChange={(event) => setProviderForm((prev) => ({ ...prev, apiKey: event.target.value }))}
placeholder={selectedProvider?.api_key_masked || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')}
disabled={!providerForm.enabled || Boolean(selectedProvider?.is_oauth)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-api-base">API Base</Label>
<Input
id="provider-api-base"
value={providerForm.apiBase}
onChange={(event) => setProviderForm((prev) => ({ ...prev, apiBase: event.target.value }))}
placeholder={selectedProvider?.default_api_base || 'https://api.example.com/v1'}
disabled={!providerForm.enabled || Boolean(selectedProvider?.is_oauth)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-timeout">{pickAppText(locale, '请求超时(秒)', 'Request timeout (seconds)')}</Label>
<Input
id="provider-timeout"
inputMode="decimal"
value={providerForm.requestTimeoutSeconds}
onChange={(event) => setProviderForm((prev) => ({ ...prev, requestTimeoutSeconds: event.target.value }))}
placeholder={pickAppText(locale, '默认', 'Default')}
disabled={!providerForm.enabled}
/>
</div>
{providerError ? (
<p className="text-sm text-destructive">{providerError}</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedProvider(null)} disabled={savingProvider}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button onClick={handleSaveProvider} disabled={savingProvider}>
{savingProvider ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存', 'Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Channels */}
<Card>
<CardHeader>
@ -307,3 +483,7 @@ function InfoRow({
</div>
);
}
function providerLabel(provider: ProviderStatus): string {
return provider.label || provider.name;
}

View File

@ -161,6 +161,36 @@ function runSummary(run: ProcessRun, feed: AgentFeedItem[], locale: 'zh-CN' | 'e
return latestAssistant?.text || pickAppText(locale, '已完成子任务处理', 'Subtask processing completed');
}
function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) {
const rawSelected = metadata?.selected_skill_names;
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) {
return null;
}
return (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{selected.map((name) => (
<Badge key={`skill:${name}`} variant="secondary" className="max-w-[128px] truncate text-[10px]">
skill:{name}
</Badge>
))}
{ephemeral.map((name) => (
<Badge key={`ephemeral:${name}`} variant="outline" className="max-w-[128px] truncate text-[10px]">
ephemeral:{name}
</Badge>
))}
{draftId && (
<Badge variant="outline" className="text-[10px]">
draft:{draftId.slice(0, 8)}
</Badge>
)}
</div>
);
}
function useRunCardPhases(runs: ProcessRun[]) {
const [phases, setPhases] = React.useState<Record<string, RunCardPhase>>(() =>
Object.fromEntries(
@ -288,10 +318,11 @@ function LiveAgentCard({
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
<span>{pickAppText(locale, '子 Agent', 'Sub-agent')}</span>
<span>{pickAppText(locale, '子任务', 'Subtask')}</span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{run.title}</div>
<SkillChips metadata={run.metadata} />
</div>
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
{appStatusLabel(run.status, locale)}
@ -302,7 +333,7 @@ function LiveAgentCard({
<div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1">
{feed.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-background/60 px-4 py-5 text-center text-sm text-muted-foreground">
{pickAppText(locale, '等待子 agent 输出...', 'Waiting for sub-agent output...')}
{pickAppText(locale, '等待子任务输出...', 'Waiting for subtask output...')}
</div>
)}
{feed.map((item) => (
@ -445,13 +476,13 @@ export function AgentTeamBlock({
<div>
<div className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
{pickAppText(locale, '智能体团队', 'Agent team')}
{pickAppText(locale, '任务子流程', 'Task subprocess')}
</div>
<div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div>
<p className="mt-1 text-sm text-muted-foreground">
{liveCount > 0
? pickAppText(locale, `agent 正在协调 ${liveCount} 个运行中的 sub-agent`, `Lead agent is coordinating ${liveCount} running sub-agents`)
: pickAppText(locale, '子 agent 已完成,结果已折叠为摘要卡片', 'Sub-agents are done. Results are folded into summary cards')}
? pickAppText(locale, `Agent 正在协调 ${liveCount} 个运行中的子任务`, `Main Agent is coordinating ${liveCount} running subtasks`)
: pickAppText(locale, '子任务已完成,结果已折叠为摘要卡片', 'Subtasks are done. Results are folded into summary cards')}
</p>
</div>
<div className="flex items-center gap-2">
@ -462,7 +493,7 @@ export function AgentTeamBlock({
</Button>
)}
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
{pickAppText(locale, `${memberRuns.length} sub-agent`, `${memberRuns.length} sub-agents`)}
{pickAppText(locale, `${memberRuns.length}子任务`, `${memberRuns.length} subtasks`)}
</Badge>
<Badge variant="outline" className={cn('border', statusTone(rootRun.status))}>
{appStatusLabel(rootRun.status, locale)}

View File

@ -6,6 +6,7 @@ import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/t
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';
@ -20,6 +21,7 @@ export function ChatWorkbench({
selectedRunId,
onSelectRun,
onCancelRun,
onFeedback,
}: {
messages: ChatMessage[];
isThinking: boolean;
@ -31,6 +33,7 @@ export function ChatWorkbench({
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
}) {
const { locale } = useAppI18n();
const [isDesktop, setIsDesktop] = React.useState(() =>
@ -72,9 +75,14 @@ export function ChatWorkbench({
selectedRunArtifacts.length > 0
)
);
const desktopColumns = hasResultsPanel
? 'grid-cols-[minmax(0,1fr)_360px]'
: 'grid-cols-[minmax(0,1fr)]';
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 = (
<MessageList
@ -88,6 +96,7 @@ export function ChatWorkbench({
selectedRunId={selectedRun?.run_id || null}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
onFeedback={onFeedback}
/>
);
@ -97,6 +106,17 @@ export function ChatWorkbench({
<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
@ -112,26 +132,40 @@ export function ChatWorkbench({
return (
<div className="h-full">
{!hasResultsPanel ? (
{!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 grid-cols-2">
<TabsList className={`grid w-full ${hasResultsPanel ? 'grid-cols-3' : 'grid-cols-2'}`}>
<TabsTrigger value="chat">{pickAppText(locale, '聊天', 'Chat')}</TabsTrigger>
<TabsTrigger value="results">{pickAppText(locale, '结果', 'Results')}</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="results" className="flex-1 min-h-0 mt-0">
<ArtifactSidebar
selectedRun={selectedRun}
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
<ProcessLane
runs={processRuns}
events={processEvents}
artifacts={processArtifacts}
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

@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
import { Bot, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
@ -37,7 +37,16 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
return <img src={blobUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
}
function MessageBubble({ message }: { message: ChatMessage }) {
function MessageBubble({
message,
canSendFeedback,
onFeedback,
}: {
message: ChatMessage;
canSendFeedback: boolean;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
}) {
const { locale } = useAppI18n();
const isUser = message.role === 'user';
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
@ -101,6 +110,56 @@ function MessageBubble({ message }: { message: ChatMessage }) {
) : (
<MarkdownContent content={textContent} />
)}
{!isUser && canSendFeedback && message.run_id && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/70 pt-2">
{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>
) : (
<>
<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>
</>
)}
{message.validation_status && message.validation_status !== 'unknown' && (
<span className="text-xs text-muted-foreground">
{message.validation_status === 'passed'
? pickAppText(locale, '验证通过', 'Validated')
: pickAppText(locale, '验证未通过', 'Validation failed')}
</span>
)}
{message.feedback_error && (
<span className="text-xs text-destructive">{message.feedback_error}</span>
)}
</div>
)}
</div>
{isUser && (
<div className="w-7 h-7 rounded-full bg-secondary flex items-center justify-center flex-shrink-0 mt-0.5">
@ -198,6 +257,7 @@ export function MessageList({
selectedRunId,
onSelectRun,
onCancelRun,
onFeedback,
}: {
messages: ChatMessage[];
isThinking: boolean;
@ -209,6 +269,7 @@ export function MessageList({
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon') => void;
}) {
const { locale } = useAppI18n();
const visibleMessages = React.useMemo(
@ -245,6 +306,9 @@ export function MessageList({
return a.order - b.order;
});
}, [teamGroups, visibleMessages]);
const latestAssistantRunId = [...visibleMessages]
.reverse()
.find((message) => message.role === 'assistant' && message.run_id && message.task_id)?.run_id;
return (
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
@ -259,7 +323,12 @@ export function MessageList({
{timelineItems.map((item) =>
item.kind === 'message' ? (
<MessageBubble key={item.key} message={item.message} />
<MessageBubble
key={item.key}
message={item.message}
canSendFeedback={Boolean(latestAssistantRunId && item.message.run_id === latestAssistantRunId)}
onFeedback={onFeedback}
/>
) : (
<AgentTeamBlock
key={item.key}

View File

@ -127,6 +127,7 @@ export function ProcessLane({
{run.summary}
</div>
)}
<SkillMetadata metadata={run.metadata} />
<div className="space-y-1.5">
{runEvents.length === 0 && run.status === 'running' && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@ -161,3 +162,33 @@ export function ProcessLane({
</div>
);
}
function SkillMetadata({ metadata }: { metadata?: Record<string, unknown> }) {
const rawSelected = metadata?.selected_skill_names;
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) {
return null;
}
return (
<div className="flex flex-wrap gap-1.5 text-[11px]">
{selected.map((name) => (
<Badge key={`skill:${name}`} variant="secondary" className="text-[10px]">
skill:{name}
</Badge>
))}
{ephemeral.map((name) => (
<Badge key={`ephemeral:${name}`} variant="outline" className="text-[10px]">
ephemeral:{name}
</Badge>
))}
{draftId && (
<Badge variant="outline" className="text-[10px]">
draft:{draftId.slice(0, 8)}
</Badge>
)}
</div>
);
}

View File

@ -12,10 +12,17 @@ import type {
Marketplace,
MarketplacePlugin,
PluginInfo,
ProviderConfigPayload,
Session,
SessionDetail,
Skill,
SkillDraft,
SkillDraftEvalReport,
SkillDraftSafetyReport,
SkillLearningCandidate,
SkillReviewRecord,
SlashCommand,
SessionProcessProjection,
SystemStatus,
TokenResponse,
OutlookConnectionPayload,
@ -246,7 +253,15 @@ export async function sendMessage(
message: string,
sessionId: string = 'web:default',
attachments?: FileAttachment[]
): Promise<{ response?: string; status?: string; session_id: string }> {
): Promise<{
response?: string;
status?: string;
session_id: string;
run_id?: string;
task_id?: string | null;
task_status?: string | null;
validation_result?: Record<string, unknown> | null;
}> {
const body: Record<string, unknown> = { message, session_id: sessionId };
if (attachments && attachments.length > 0) {
body.attachments = attachments;
@ -255,8 +270,12 @@ export async function sendMessage(
response?: string;
status?: string;
session_id: string;
run_id?: string;
output_text?: string;
finish_reason?: string;
task_id?: string | null;
task_status?: string | null;
validation_result?: Record<string, unknown> | null;
}>('/api/chat', {
method: 'POST',
body: JSON.stringify(body),
@ -265,9 +284,36 @@ export async function sendMessage(
response: result.response ?? result.output_text,
status: result.status ?? result.finish_reason,
session_id: result.session_id,
run_id: result.run_id,
task_id: result.task_id,
task_status: result.task_status,
validation_result: result.validation_result,
};
}
export async function submitChatFeedback(params: {
sessionId: string;
runId: string;
feedbackType: 'satisfied' | 'revise' | 'abandon';
comment?: string;
}): Promise<{
session_id: string;
run_id: string;
task_id: string;
task_status: string;
feedback_type: string;
}> {
return fetchJSON('/api/chat/feedback', {
method: 'POST',
body: JSON.stringify({
session_id: params.sessionId,
run_id: params.runId,
feedback_type: params.feedbackType,
comment: params.comment,
}),
});
}
export function streamMessage(
message: string,
sessionId: string,
@ -533,6 +579,10 @@ export async function getSession(key: string): Promise<SessionDetail> {
return fetchJSON(`/api/sessions/${encodeURIComponent(key)}`);
}
export async function getSessionProcess(key: string): Promise<SessionProcessProjection> {
return fetchJSON(`/api/sessions/${encodeURIComponent(key)}/process`);
}
export async function deleteSession(key: string): Promise<void> {
await fetchJSON(`/api/sessions/${encodeURIComponent(key)}`, { method: 'DELETE' });
}
@ -545,6 +595,16 @@ export async function getStatus(): Promise<SystemStatus> {
return fetchJSON('/api/status');
}
export async function updateProviderConfig(
providerId: string,
payload: ProviderConfigPayload
): Promise<{ ok: boolean; provider: string; enabled: boolean }> {
return fetchJSON(`/api/providers/${encodeURIComponent(providerId)}/config`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function restartSystem(): Promise<{
ok: boolean;
restarting: boolean;
@ -604,6 +664,117 @@ export async function listSkills(): Promise<Skill[]> {
return fetchJSON('/api/skills');
}
export async function listSkillCandidates(status?: string): Promise<SkillLearningCandidate[]> {
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return fetchJSON(`/api/skills/candidates${query}`);
}
export async function synthesizeSkillDraft(candidateId: string): Promise<SkillDraft> {
return fetchJSON(`/api/skills/candidates/${encodeURIComponent(candidateId)}/draft`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function regenerateSkillDraft(candidateId: string): Promise<SkillDraft> {
return fetchJSON(`/api/skills/candidates/${encodeURIComponent(candidateId)}/regenerate`, {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function runSkillLearningOnce(): Promise<{
processed: number;
succeeded: number;
failed: number;
skipped: number;
failures: Array<Record<string, string>>;
}> {
return fetchJSON('/api/skills/learning/run-once', {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function listSkillDrafts(): Promise<SkillDraft[]> {
return fetchJSON('/api/skills/drafts');
}
export async function getSkillDraft(skillName: string, draftId: string): Promise<SkillDraft> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}`);
}
export async function getSkillDraftSafety(skillName: string, draftId: string): Promise<SkillDraftSafetyReport> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/safety`);
}
export async function getSkillDraftEval(skillName: string, draftId: string): Promise<SkillDraftEvalReport> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/eval`);
}
export async function submitSkillDraft(
skillName: string,
draftId: string,
notes: string = ''
): Promise<SkillReviewRecord> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/submit`, {
method: 'POST',
body: JSON.stringify({ notes }),
});
}
export async function approveSkillDraft(
skillName: string,
draftId: string,
notes: string = ''
): Promise<SkillReviewRecord> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/approve`, {
method: 'POST',
body: JSON.stringify({ notes }),
});
}
export async function rejectSkillDraft(
skillName: string,
draftId: string,
notes: string = ''
): Promise<SkillReviewRecord> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/reject`, {
method: 'POST',
body: JSON.stringify({ notes }),
});
}
export async function publishSkillDraft(
skillName: string,
draftId: string,
notes: string = '',
confirmHighRisk: boolean = false
): Promise<Record<string, unknown>> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/drafts/${encodeURIComponent(draftId)}/publish`, {
method: 'POST',
body: JSON.stringify({ notes, confirm_high_risk: confirmHighRisk }),
});
}
export async function disablePublishedSkill(skillName: string, reason: string = ''): Promise<Record<string, unknown>> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/disable`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
export async function rollbackPublishedSkill(
skillName: string,
targetVersion: string,
reason: string = ''
): Promise<Record<string, unknown>> {
return fetchJSON(`/api/skills/${encodeURIComponent(skillName)}/rollback`, {
method: 'POST',
body: JSON.stringify({ target_version: targetVersion, reason }),
});
}
export async function listCommands(): Promise<SlashCommand[]> {
return fetchJSON('/api/commands');
}

View File

@ -8,6 +8,7 @@ import type {
ProcessRun,
ProcessWsEvent,
Session,
SessionProcessProjection,
UiAgentDescriptor,
UiMcpServerDescriptor,
} from '@/types';
@ -55,6 +56,11 @@ interface ChatStore {
setSessionId: (id: string) => void;
setMessages: (msgs: ChatMessage[]) => void;
addMessage: (msg: ChatMessage) => void;
updateMessageFeedback: (
runId: string,
feedbackState: ChatMessage['feedback_state'],
error?: string
) => void;
setIsLoading: (loading: boolean) => void;
setStreamingContent: (content: string) => void;
appendStreamingContent: (chunk: string) => void;
@ -65,6 +71,7 @@ interface ChatStore {
setNanobotReady: (ready: boolean | null) => void;
resetProcessState: () => void;
ingestProcessEvent: (event: ProcessWsEvent) => void;
setSessionProcess: (sessionId: string, projection: SessionProcessProjection) => void;
setSelectedRunId: (runId: string | null) => void;
setSelectedArtifactId: (artifactId: string | null) => void;
setAgentRegistry: (agents: UiAgentDescriptor[]) => void;
@ -148,6 +155,18 @@ export const useChatStore = create<ChatStore>((set) => ({
},
setMessages: (msgs) => set({ messages: msgs }),
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
updateMessageFeedback: (runId, feedbackState, error) =>
set((s) => ({
messages: s.messages.map((message) =>
message.run_id === runId
? {
...message,
feedback_state: feedbackState,
feedback_error: error,
}
: message
),
})),
setIsLoading: (loading) => set({ isLoading: loading }),
setStreamingContent: (content) => set({ streamingContent: content }),
appendStreamingContent: (chunk) =>
@ -345,6 +364,37 @@ export const useChatStore = create<ChatStore>((set) => ({
selectedRunId: nextSelectedRunId,
};
}),
setSessionProcess: (sessionId, projection) =>
set((state) => {
const incomingRuns = projection.runs || [];
const incomingEvents = projection.events || [];
const incomingArtifacts = projection.artifacts || [];
const incomingRunIds = new Set(incomingRuns.map((run) => run.run_id));
const nextRuns = [
...state.processRuns.filter((run) => run.session_id !== sessionId && !incomingRunIds.has(run.run_id)),
...incomingRuns,
];
const liveRunIds = new Set(nextRuns.map((run) => run.run_id));
const incomingEventIds = new Set(incomingEvents.map((event) => event.event_id));
const nextEvents = [
...state.processEvents.filter(
(event) => liveRunIds.has(event.run_id) && !incomingEventIds.has(event.event_id)
),
...incomingEvents,
];
const incomingArtifactIds = new Set(incomingArtifacts.map((artifact) => artifact.artifact_id));
const nextArtifacts = [
...state.processArtifacts.filter(
(artifact) => liveRunIds.has(artifact.run_id) && !incomingArtifactIds.has(artifact.artifact_id)
),
...incomingArtifacts,
];
return {
processRuns: nextRuns,
processEvents: nextEvents,
processArtifacts: nextArtifacts,
};
}),
setSelectedRunId: (runId) => set({ selectedRunId: runId }),
setSelectedArtifactId: (artifactId) => set({ selectedArtifactId: artifactId }),
setAgentRegistry: (agents) => set({ agentRegistry: agents }),

View File

@ -45,6 +45,12 @@ export interface ChatMessage {
content: string;
timestamp?: string;
attachments?: FileAttachment[];
run_id?: string;
task_id?: string | null;
task_status?: string | null;
validation_status?: 'passed' | 'failed' | 'unknown';
feedback_state?: 'satisfied' | 'revise' | 'abandon';
feedback_error?: string;
}
export interface Session {
@ -62,11 +68,29 @@ export interface SessionDetail {
}
export interface ProviderStatus {
id?: string;
name: string;
label?: string;
enabled?: boolean;
active?: boolean;
has_key: boolean;
api_key_masked?: string;
api_base?: string;
default_api_base?: string;
requires_api_key?: boolean;
is_oauth?: boolean;
is_local?: boolean;
detail?: string;
}
export interface ProviderConfigPayload {
enabled: boolean;
model?: string;
api_key?: string;
api_base?: string;
request_timeout_seconds?: number;
}
export interface ChannelStatus {
name: string;
enabled: boolean;
@ -533,6 +557,98 @@ export interface ProcessArtifact {
created_at: string;
}
export interface SessionProcessProjection {
runs: ProcessRun[];
events: ProcessEvent[];
artifacts: ProcessArtifact[];
agents?: Array<Record<string, unknown>>;
}
export interface SkillLearningCandidate {
candidate_id: string;
kind: string;
source_run_ids: string[];
source_session_ids: string[];
related_skill_names: string[];
reason: string;
evidence: Record<string, unknown>;
status: string;
priority?: number;
confidence?: number;
risk_level?: 'low' | 'medium' | 'high' | 'critical' | string;
owner?: string | null;
retry_count?: number;
last_error?: string | null;
trigger_reason?: string;
evidence_summary?: string;
draft_skill_name?: string | null;
draft_id?: string | null;
safety_report_id?: string | null;
eval_report_id?: string | null;
created_at?: string;
updated_at?: string;
}
export interface SkillDraftSafetyReport {
report_id: string;
skill_name: string;
draft_id: string;
passed: boolean;
risk_level: 'low' | 'medium' | 'high' | 'critical' | string;
issues: string[];
blocked_reasons: string[];
suggested_fix: string;
created_at: string;
}
export interface SkillDraftEvalReport {
report_id: string;
skill_name: string;
draft_id: string;
candidate_id: string;
passed: boolean;
baseline_score_avg: number;
candidate_score_avg: number;
score_delta: number;
regression_count: number;
improved_count: number;
unchanged_count: number;
cases: Array<Record<string, unknown>>;
status: string;
created_at: string;
}
export interface SkillDraft {
draft_id: string;
skill_name: string;
base_version?: string | null;
proposed_content: string;
proposed_frontmatter: Record<string, unknown>;
created_at: string;
created_by: string;
trigger_run_id?: string | null;
trigger_session_id?: string | null;
reason: string;
status: string;
evidence_refs: Array<Record<string, unknown>>;
proposal_kind: string;
reviews?: SkillReviewRecord[];
safety_report?: SkillDraftSafetyReport | null;
eval_report?: SkillDraftEvalReport | null;
}
export interface SkillReviewRecord {
review_id: string;
draft_id: string;
skill_name: string;
requested_at: string;
requested_by: string;
status: string;
reviewer?: string | null;
reviewed_at?: string | null;
notes: string;
}
export interface ProcessRunStartedEvent {
type: 'process_run_started';
session_id?: string;
@ -641,6 +757,18 @@ export interface ChatAssistantEvent {
role: 'assistant';
content: string;
attachments?: FileAttachment[];
session_id?: string;
run_id?: string;
task_id?: string | null;
task_status?: string | null;
validation_status?: 'passed' | 'failed' | 'unknown';
validation_result?: Record<string, unknown> | null;
metadata?: {
task_id?: string | null;
task_status?: string | null;
validation_result?: Record<string, unknown> | null;
[key: string]: unknown;
};
}
export interface ChatThinkingEvent {