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:
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user