feat(trial): assemble completed Agent workspace updates

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 12:11:56 +08:00
1 parent 8289f27f3e
commit 7c51bb4a00
38 files changed
+2081 -276

No files matched your search

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Download, Folder, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CloudMessageBody } from './CloudMessageBody';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents';
import { CloudApproval, cloudStatus } from './CloudChat';
@@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="space-y-4 rounded-xl border border-border p-4" aria-label="微信消息">
{data?.messages.filter(message => message.role !== 'system').map(message => <article key={message.id} className={message.role === 'user' ? 'ml-6 rounded-xl bg-muted/40 p-3' : 'p-1'}>
<p className="mb-2 text-xs text-muted-foreground">{message.role === 'user' ? '你 · 微信' : '智能体'}</p><div className="prose prose-sm max-w-none break-words"><ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ href, children }) => href && /^(https?:|mailto:)/i.test(href) ? <a href={href} target="_blank" rel="noreferrer">{children}</a> : <span>{children}</span> }}>{message.content}</ReactMarkdown></div></article>)}
<p className="mb-2 text-xs text-muted-foreground">{message.role === 'user' ? '你 · 微信' : '智能体'}</p><CloudMessageBody {...message} /></article>)}
{data && !data.messages.length && <p className="text-sm text-muted-foreground">还没有消息。</p>}
</div>
{!recoveryReady && <Button variant="outline" size="sm" onClick={() => void loadRecovery().catch(error => setError(failureText(error)))}>重新读取操作记录</Button>}
+27 -29
View File
@@ -7,12 +7,11 @@ import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun,
import { CloudComposerAttachments } from './CloudComposerAttachments';
import { CloudAvatar } from './CloudAvatar';
import './desktop-workspace.css';
import { CloudMessageBody } from './CloudMessageBody';
import './agent-chat.css';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
import { agentExamples } from './agent-guidance';
import { consumeCloudChatEvent, type CloudChatStream } from './cloud-chat-stream';
import { consumeCloudChatEvent, type CloudChatStream, type CloudLiveMessage } from './cloud-chat-stream';
export const cloudStatus = (status: string) => ({
idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中',
@@ -22,6 +21,8 @@ export const cloudStatus = (status: string) => ({
const active = (status?: string) => Boolean(status && ['queued', 'dispatching', 'dispatched', 'pending', 'running', 'cancel_requested', 'submitted'].includes(status));
const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作';
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
const savedMessage = (history: CloudHistory, message: CloudLiveMessage) => history.messages.some(saved =>
saved.role === 'assistant' && saved.run_id === message.run_id && (!saved.message_id || saved.message_id === message.message_id));
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true,
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
@@ -75,8 +76,10 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
// Replace the preview only after authoritative assistant history arrives.
// Failed/cancelled Runs without a saved answer retain received fragments.
const preview = streamReply.current;
if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)
&& page.messages.some(item => item.role === 'assistant' && item.run_id === preview.runId)) updateLiveReply(null);
if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)) {
const remaining = preview.messages.filter(item => !savedMessage(page, item));
if (remaining.length !== preview.messages.length) updateLiveReply(remaining.length ? { ...preview, messages: remaining } : null);
}
setQueued(page.queued_requests ?? []);
setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null);
if (page.run) setThreads(current => current.map(item => item.thread_id === id
@@ -167,6 +170,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id;
const running = active(run?.status) || active(request?.status);
const liveHistoryReady = history?.run && history.run.agent_run_id === liveReply?.runId
? !active(history.run.status)
: Boolean(history?.messages.some(item => item.run_id === liveReply?.runId));
useEffect(() => {
if (!runId || !running) return;
let live = true;
@@ -184,7 +190,8 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
if (envelope.run_id !== runId || envelope.thread_id !== targetThread
|| targetRequest && envelope.request_id && envelope.request_id !== targetRequest) return;
const chunk = object(object(envelope.payload).chunk);
if (next?.toolName) setProgress('正在使用 ' + next.toolName);
const latestTool = next?.messages.at(-1)?.tool_calls.at(-1);
if (latestTool && ['pending', 'running'].includes(latestTool.status)) setProgress('正在使用 ' + (latestTool.display_name || latestTool.name));
if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt);
if (event.type === 'end') {
source?.close();
@@ -326,7 +333,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll"
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="agent-chat-transcript">
{loading ? <div role="status" className="agent-chat-loading"><MessageCircle aria-hidden="true" /><p>正在读取对话…</p></div>
: !history?.messages.length && !liveReply?.content && !liveReply?.reasoning && <div className="agent-chat-welcome">
: !history?.messages.length && !liveReply?.messages.length && <div className="agent-chat-welcome">
<div className="agent-chat-welcome-portrait"><CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-welcome-avatar" /><Sparkles aria-hidden="true" className="agent-chat-welcome-spark" /></div>
<p className="agent-chat-welcome-name">{assistantName}</p>
<h2>{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}</h2>
@@ -341,13 +348,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
<p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p>
<TrialMessages history={previousTrial.history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />
</details>}
{history && <TrialMessages history={history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
{liveReply && (liveReply.content || liveReply.reasoning) && <article className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
{history && <TrialMessages history={{ ...history, messages: history.messages.filter(saved =>
!(!liveHistoryReady && liveReply?.messages.some(item => saved.run_id === item.run_id && saved.message_id === item.message_id)),
) }} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
{liveReply?.messages.filter(item => !liveHistoryReady || !history || !savedMessage(history, item)).map(item => <article key={item.run_id + ':' + item.message_id} className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
<CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{assistantName}</p>
{liveReply.reasoning && <CloudReasoning content={liveReply.reasoning} running={running && runId === liveReply.runId && !liveReply.ended} />}
{liveReply.content && <div className="agent-chat-message-body whitespace-pre-wrap">{liveReply.content}</div>}</div>
</article>}
<div className="agent-chat-message-body"><CloudMessageBody {...item} active={running && runId === item.run_id && !liveReply.ended} onDownload={path => {
const id = thread?.thread_id ?? request?.thread_id;
if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e)));
}} /></div></div>
</article>)}
{onSchedule && history?.schedule_proposals?.map(proposal => <article key={proposal.id} className="space-y-3 rounded-xl border border-violet-200 bg-violet-50/30 p-4 text-sm">
<h3 className="font-medium">自动任务建议 · {proposal.name}</h3><p className="whitespace-pre-wrap">{proposal.prompt}</p>
<p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p>
@@ -419,26 +430,13 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv
return history.messages.map(item => <article key={item.id} className="agent-chat-message" data-role={item.role === 'user' ? 'user' : 'assistant'}>
{item.role !== 'user' && <CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />}
<div className="agent-chat-message-content">{item.role !== 'user' && <p className="agent-chat-speaker">{assistantName}</p>}
{item.role === 'assistant' && typeof item.reasoning_content === 'string' && item.reasoning_content
&& <CloudReasoning content={item.reasoning_content} running={false} />}
<div className="agent-chat-message-body prose prose-sm max-w-none break-words">{item.role === 'user' ? <p className="whitespace-pre-wrap">{item.content}</p> : <ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(history.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => onError(message(e)))}>{children}</button>
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
}}>{item.content}</ReactMarkdown>}</div></div>
<div className="agent-chat-message-body">{item.role === 'user' ? <p className="whitespace-pre-wrap">{item.content}</p> :
<CloudMessageBody content={item.content} reasoning_content={item.role === 'assistant' ? item.reasoning_content : undefined}
tool_calls={item.role === 'assistant' ? item.tool_calls : undefined}
onDownload={path => void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}</div></div>
</article>);
}
function CloudReasoning({ content, running }: { content: string; running: boolean }) {
const [expanded, setExpanded] = useState(running);
useEffect(() => { setExpanded(running); }, [running]);
return <details open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}
className="agent-chat-reasoning" data-testid="cloud-chat-reasoning">
<summary>思考过程</summary>
<div aria-label="智能体思考过程" className="agent-chat-reasoning-content">{content}</div>
</details>;
}
export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: {
interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void;
submitDecision?: (input: { operation_id: string; decision: Record<string, unknown> }) => Promise<void>;
+16 -10
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { Upload } from 'lucide-react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -27,6 +28,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
const [refresh, setRefresh] = useState(0);
const [creating, setCreating] = useState(false);
const [documentPending, setDocumentPending] = useState(false);
const targetId = useId();
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim())));
const operationInFlight = useRef(false);
@@ -62,15 +64,15 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId);
const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError);
const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id));
return <details className="rounded-lg border p-4">
<summary className="min-h-8 cursor-pointer text-sm font-medium">上传自己的文件</summary>
<div className="mt-4 space-y-4 text-sm">
<p className="text-xs leading-5 text-muted-foreground">选择或新建自己的知识库,再上传文件。选用只会加入当前配置,保存并应用后生效。</p>
<p className="text-xs leading-5 text-muted-foreground">上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。</p>
return <section aria-label="上传自己的文件" className="agent-knowledge-upload space-y-4">
<div className="agent-knowledge-panel-heading"><Upload aria-hidden="true" className="size-5" /><h3>上传自己的文件</h3></div>
<div className="space-y-4 text-sm">
<p className="text-xs leading-5 text-muted-foreground">选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。</p>
<label htmlFor={targetId} className="agent-knowledge-field-label">添加到我的知识库</label>
{error && <p role="alert" className="text-destructive">{error}</p>}
{catalogError && <p role="alert" className="text-destructive">知识库与模型目录加载失败:{catalogError}。请刷新重试。</p>}
<div className="flex flex-wrap gap-2">
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={managementDisabled || documentPending}
<select id={targetId} aria-label="管理的知识库" className="h-10 min-w-0 flex-1 basis-48 rounded-md border bg-background px-3" value={kbId} disabled={managementDisabled || documentPending}
onChange={e => {
const selected = manageable.find(kb => kb.kb_id === e.target.value);
if (managementDisabled || documentPending || (selected && bindable(selected.kb_id) && selectionFull && !selectedIds.includes(selected.kb_id))) return;
@@ -86,6 +88,10 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
<Button type="button" variant="outline" disabled={disabled || busy || documentPending || selectionFull || Boolean(createIntent.current)} onClick={() => setCreating(v => !v)}>新建知识库</Button>
<Button type="button" variant="ghost" disabled={disabled || busy || catalogLoading} onClick={() => setRefresh(v => v + 1)}>刷新知识库与模型</Button>
</div>
<details className="agent-knowledge-help">
<summary>使用说明与费用</summary>
<p className="mt-2 text-xs leading-5 text-muted-foreground">上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。</p>
</details>
{!catalogLoading && !catalogError && !manageable.length && <p className="text-xs leading-5 text-muted-foreground">还没有可管理的知识库,可以新建一个保存自己的文件。MakeLore知识仅供检索。</p>}
{selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) && <p role="status" className="text-xs leading-5 text-amber-800">
{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。
@@ -126,9 +132,9 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on
</Button>
{documentPending && <p className="text-xs leading-5 text-muted-foreground">放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。</p>}
</div>}
{kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} busy={managementDisabled || !selectedKnowledge}
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending}
{kbId && <div hidden={!selectedKnowledge}><CloudKnowledgeDocuments key={slug + ':' + kbId} slug={slug} kbId={kbId} kbName={selectedKnowledge?.name} busy={managementDisabled || !selectedKnowledge}
canRead={Boolean(selectedKnowledge)} act={act} onPendingChange={setDocumentPending} onRefreshKnowledge={() => setRefresh(v => v + 1)}
onDeleteKnowledge={() => { callbacks.current.onDeleted?.(kbId); setKbId(''); setDocumentPending(false); setRefresh(v => v + 1); }} /></div>}
</div>
</details>;
</section>;
}
@@ -0,0 +1,280 @@
import { useEffect, useRef, useState } from 'react';
import { CheckCircle2, FileText, Loader2, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { usePendingCloudInput } from './CloudPending';
import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../../shared/cloud-agents';
type Selection = { key: string; threadId: string; threadTitle: string; file: CloudAttachment };
type ImportJob = Selection & {
operationId: string; status: 'queued' | 'running' | 'saved' | 'failed' | 'done';
importedFile?: CloudKnowledgeFile; error?: string;
};
function fileDetails(file: CloudAttachment) {
const extension = file.file_name.split('.').pop()?.toLowerCase() ?? '';
const type = file.file_name.includes('.') ? extension.toUpperCase() : '文件';
const size = Number.isFinite(file.file_size)
? file.file_size < 1024 ? `${file.file_size} B` : file.file_size < 1024 * 1024
? `${Math.ceil(file.file_size / 1024)} KB` : `${(file.file_size / (1024 * 1024)).toFixed(1)} MB`
: '大小未知';
const reason = file.file_size > 5 * 1024 * 1024 ? '超过 5 MB,暂不能导入'
: !/^(txt|md|csv|json|docx|xlsx|pdf)$/.test(extension) ? '此格式暂不支持知识库导入' : '';
return { type, size, reason };
}
function updatedDate(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', year: 'numeric' }) : '更新时间未知';
}
function errorMessage(error: unknown) { return error instanceof Error ? error.message : '请稍后重试。'; }
export function CloudKnowledgeAttachmentPicker({ slug, kbId, kbName, busy, canRead = true, act, onImported, onPendingChange, onClose, onRefreshKnowledge }: {
slug: string; kbId: string; kbName: string; busy: boolean; canRead?: boolean;
act: (operation: () => Promise<void>) => Promise<void>;
onImported: (file: CloudKnowledgeFile) => Promise<void>;
onPendingChange: (pending: boolean) => void;
onClose?: () => void; onRefreshKnowledge?: () => void;
}) {
const scope = slug + ':' + kbId;
const [threads, setThreads] = useState<CloudThread[]>([]);
const [agentNames, setAgentNames] = useState<Record<string, string>>({});
const [threadId, setThreadId] = useState('');
const [threadQuery, setThreadQuery] = useState('');
const [fileQuery, setFileQuery] = useState('');
const [files, setFiles] = useState<{ threadId: string; items: CloudAttachment[] }>({ threadId: '', items: [] });
const [threadsLoading, setThreadsLoading] = useState(true);
const [filesLoading, setFilesLoading] = useState(false);
const [threadError, setThreadError] = useState('');
const [fileError, setFileError] = useState('');
const [threadsRevision, setThreadsRevision] = useState(0);
const [filesRevision, setFilesRevision] = useState(0);
const [selected, setSelected] = useState<Selection[]>([]);
const [jobs, setJobs] = useState<ImportJob[]>([]);
const [running, setRunning] = useState(false);
const [completed, setCompleted] = useState(0);
const [abandoning, setAbandoning] = useState(false);
const jobsRef = useRef<ImportJob[]>([]);
const runningRef = useRef(false);
const alive = useRef(true);
const activeScope = useRef(scope);
const permitted = useRef(canRead);
const callbacks = useRef({ onImported, onClose });
useEffect(() => { callbacks.current = { onImported, onClose }; }, [onImported, onClose]);
useEffect(() => { permitted.current = canRead; }, [canRead]);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
activeScope.current = scope;
jobsRef.current = []; runningRef.current = false;
setJobs([]); setSelected([]); setThreadId(''); setThreadQuery(''); setFileQuery(''); setCompleted(0); setRunning(false); setAbandoning(false);
}, [scope]);
const unresolved = jobs.some(job => job.status !== 'done');
const pending = running || unresolved || selected.length > 0;
const locked = busy || !canRead || running || unresolved;
usePendingCloudInput(pending);
useEffect(() => { onPendingChange(pending); return () => onPendingChange(false); }, [pending, onPendingChange]);
useEffect(() => {
let live = true;
setThreads([]); setThreadError(''); setThreadsLoading(canRead);
if (!canRead) return;
const load = async () => {
try {
let page = await cloudAgentsApi.call('threads', {});
const all = [...page.threads];
const seen = new Set<number>();
while (live && page.next_offset !== null) {
if (seen.has(page.next_offset)) throw new Error('对话列表读取未完成,请重试。');
seen.add(page.next_offset);
page = await cloudAgentsApi.call('threads', { offset: page.next_offset });
all.push(...page.threads);
}
if (live) setThreads([...new Map(all.map(thread => [thread.thread_id, thread])).values()]);
} catch (error) { if (live) setThreadError(errorMessage(error)); }
finally { if (live) setThreadsLoading(false); }
};
void load();
return () => { live = false; };
}, [scope, canRead, threadsRevision]);
useEffect(() => {
let live = true;
setAgentNames({});
if (!canRead) return;
const names: Record<string, string> = {};
const publish = () => { if (live) setAgentNames({ ...names }); };
// Names are supplementary: an unavailable agent directory must not hide chat files.
const ownNames = async () => {
let page = await cloudAgentsApi.list();
const seen = new Set<string>();
for (;;) {
for (const agent of page.agents) names[agent.slug] = agent.name;
publish();
if (!live || !page.next_cursor || seen.has(page.next_cursor)) return;
seen.add(page.next_cursor);
page = await cloudAgentsApi.list(page.next_cursor);
}
};
void ownNames().catch(() => undefined);
void cloudAgentsApi.call('received', {}).then(page => {
if (!live) return;
for (const agent of page.agents) names[agent.slug] = agent.name;
publish();
}).catch(() => undefined);
return () => { live = false; };
}, [scope, canRead, threadsRevision]);
useEffect(() => {
let live = true;
setFiles({ threadId, items: [] }); setFileError(''); setFilesLoading(Boolean(threadId) && canRead);
if (!threadId || !canRead) return;
cloudAgentsApi.call('attachments', { thread_id: threadId }).then(page => {
if (live) setFiles({ threadId, items: page.attachments });
}).catch(error => { if (live) setFileError(errorMessage(error)); })
.finally(() => { if (live) setFilesLoading(false); });
return () => { live = false; };
}, [scope, canRead, threadId, filesRevision]);
const currentThread = threads.find(thread => thread.thread_id === threadId);
const visibleThreads = threads.filter(thread => `${thread.title} ${agentNames[thread.agent_slug] ?? ''}`.toLocaleLowerCase().includes(threadQuery.trim().toLocaleLowerCase()));
const visibleFiles = files.threadId === threadId ? files.items.filter(file => file.file_name.toLocaleLowerCase().includes(fileQuery.trim().toLocaleLowerCase())) : [];
const retryJobs = jobs.filter(job => job.status !== 'done');
const failureOnly = retryJobs.length > 0 && retryJobs.every(job => job.status === 'failed');
const selectedKeys = new Set(selected.map(item => item.key));
function choose(file: CloudAttachment, checked: boolean) {
if (locked || !currentThread || filesLoading || fileError || fileDetails(file).reason) return;
const key = JSON.stringify([threadId, file.file_id]);
if (jobsRef.current.length && jobsRef.current.every(job => job.status === 'done')) { jobsRef.current = []; setJobs([]); }
setCompleted(0);
setSelected(previous => checked ? [...previous.filter(item => item.key !== key), {
key, threadId, threadTitle: currentThread.title || '未命名对话', file,
}] : previous.filter(item => item.key !== key));
}
async function importSelected() {
if (busy || !canRead || runningRef.current) return;
const attemptScope = scope;
const current = () => alive.current && activeScope.current === attemptScope;
const initialJobs: ImportJob[] = jobsRef.current.length ? jobsRef.current : selected.map(item => ({
...item, operationId: crypto.randomUUID(), status: 'queued' as const,
}));
const attempts = initialJobs.filter(job => job.status !== 'done');
if (!attempts.length) return;
const update = (key: string, value: Partial<ImportJob>) => {
jobsRef.current = jobsRef.current.map(job => job.key === key ? { ...job, ...value } : job);
setJobs([...jobsRef.current]);
};
jobsRef.current = initialJobs; setJobs([...initialJobs]);
runningRef.current = true; setRunning(true); setAbandoning(false);
try {
await act(async () => {
for (const job of attempts) {
if (!current() || !permitted.current) return;
update(job.key, { status: 'running', error: '' });
try {
const file = job.importedFile ?? await cloudAgentsApi.call('importKnowledgeAttachment', {
slug, kb_id: kbId, thread_id: job.threadId, attachment_id: job.file.file_id, operation_id: job.operationId,
});
if (!current()) return;
// A confirmed import is never repeated, even if permission changes or processing fails.
update(job.key, { status: 'saved', importedFile: file });
if (!permitted.current) return;
try {
await callbacks.current.onImported(file);
if (!current()) return;
update(job.key, { status: 'done', error: '' });
} catch (error) {
if (!current()) return;
update(job.key, { status: 'done', error: '文件已导入,处理未全部启动。请在知识库重试处理。' + errorMessage(error) });
}
} catch (error) {
if (!current()) return;
update(job.key, { status: 'failed', error: '导入结果尚未确认:' + errorMessage(error) });
}
}
if (current() && jobsRef.current.every(job => job.status === 'done')) {
setSelected([]); setCompleted(jobsRef.current.length);
if (jobsRef.current.every(job => !job.error)) {
jobsRef.current = []; setJobs([]);
callbacks.current.onClose?.();
}
}
});
} finally {
if (current()) { runningRef.current = false; setRunning(false); }
}
}
const close = () => { if (!runningRef.current && !unresolved) onClose?.(); };
return <Dialog open={canRead} onOpenChange={open => { if (!open) close(); }}>
<DialogContent className="agent-knowledge-import agent-knowledge-import-modal"
onEscapeKeyDown={event => { if (runningRef.current || unresolved) event.preventDefault(); }}
onInteractOutside={event => { if (runningRef.current || unresolved) event.preventDefault(); }}>
<div className="agent-knowledge-import-header">
<div><DialogTitle>从聊天选择文件</DialogTitle><DialogDescription>导入到「{kbName}」</DialogDescription></div>
{onClose && <Button type="button" variant="ghost" size="icon" aria-label="关闭文件选择" disabled={running || unresolved}
onClick={close}><X size={18} /></Button>}
</div>
<div className="agent-knowledge-import-content">
<p className="agent-knowledge-import-help">选择你在 MakeLore Agents 聊天中上传的文件。聊天文字和助手生成的文件不会导入。</p>
{!canRead && <p role="status">知识库管理权限待确认,文件操作已暂停;未完成请求仍保留。</p>}
{canRead && <div className="agent-knowledge-import-body">
<section className="agent-knowledge-import-chats" aria-label="来源对话">
<h3>选择对话</h3>
<div className="agent-knowledge-import-search"><Search size={16} aria-hidden="true" /><Input aria-label="搜索对话" placeholder="搜索对话或智能体" value={threadQuery} disabled={locked} onChange={event => setThreadQuery(event.target.value)} /></div>
<div className="agent-knowledge-import-chat-list">
{threadsLoading ? <p role="status">正在读取对话…</p> : threadError ? <div><p role="alert">对话读取失败:{threadError}</p><Button type="button" variant="ghost" disabled={locked} onClick={() => setThreadsRevision(value => value + 1)}>重试读取对话</Button></div>
: !threads.length ? <p>还没有个人智能体对话。</p> : !visibleThreads.length ? <p>没有匹配的对话。</p> : visibleThreads.map(thread => <button type="button" key={thread.thread_id}
className="agent-knowledge-import-chat" aria-label={'选择对话 ' + (thread.title || '未命名对话')} aria-pressed={threadId === thread.thread_id} disabled={locked}
onClick={() => { if (!locked) { setThreadId(thread.thread_id); setFileQuery(''); } }}>
<strong>{thread.title || '未命名对话'}</strong><small>{agentNames[thread.agent_slug] || '智能体名称暂不可用'} · {updatedDate(thread.updated_at)}</small>
</button>)}
</div>
</section>
<section className="agent-knowledge-import-files" aria-label="聊天中上传的文件">
<h3>{currentThread?.title || '选择文件'}</h3>
<div className="agent-knowledge-import-search"><Search size={16} aria-hidden="true" /><Input aria-label="搜索文件" placeholder="搜索文件名" value={fileQuery} disabled={locked || !threadId} onChange={event => setFileQuery(event.target.value)} /></div>
<div className="agent-knowledge-import-file-list">
{!threadId ? <p>先选择一段对话,再挑选其中的文件。</p> : filesLoading ? <p role="status">正在读取文件…</p>
: fileError ? <div><p role="alert">文件读取失败:{fileError}</p><Button type="button" variant="ghost" disabled={locked} onClick={() => setFilesRevision(value => value + 1)}>重试读取文件</Button></div>
: !files.items.length ? <p>这段对话还没有上传文件。</p> : !visibleFiles.length ? <p>没有匹配的文件。</p> : visibleFiles.map(file => {
const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]);
return <label key={file.file_id} className="agent-knowledge-import-file" data-selected={selectedKeys.has(key)} data-disabled={Boolean(details.reason)}>
<input type="checkbox" aria-label={'选择文件 ' + file.file_name} checked={selectedKeys.has(key)} disabled={locked || Boolean(details.reason)} onChange={event => choose(file, event.target.checked)} />
<FileText size={20} aria-hidden="true" /><span><strong>{file.file_name}</strong><small>{details.type} · {details.size}</small>{details.reason && <small>{details.reason}</small>}</span>
</label>;
})}
</div>
</section>
</div>}
{selected.length > 0 && <div className="agent-knowledge-import-selection"><p>已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}</p>
<ul aria-label="已选聊天文件">{selected.map(item => <li key={item.key}><span>{item.file.file_name}<small>{item.threadTitle}</small></span>
<Button type="button" variant="ghost" size="icon" aria-label={'移除所选文件 ' + item.file.file_name} disabled={locked} onClick={() => { if (!locked) setSelected(previous => previous.filter(file => file.key !== item.key)); }}><X size={15} /></Button></li>)}</ul>
</div>}
{jobs.length > 0 && <ul className="agent-knowledge-import-results" aria-label="文件导入结果">{jobs.map(job => <li key={job.key}>
<span>{job.file.file_name}</span><span>{job.status === 'done' ? <><CheckCircle2 size={15} />已导入,处理状态见知识库</> : job.status === 'running' ? '正在导入…' : job.status === 'saved' ? '已保存,等待继续处理' : job.status === 'failed' ? '等待重试' : '等待导入'}</span>
{job.error && <p role="alert">{job.error}</p>}
</li>)}</ul>}
{abandoning && <div className="agent-knowledge-import-abandon" role="group" aria-label="放弃当前页面的重试">
<p>这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。</p>
<div className="flex flex-wrap gap-2"><Button type="button" variant="outline" disabled={running} onClick={() => {
if (runningRef.current) return;
jobsRef.current = []; setJobs([]); setSelected([]); setCompleted(0); setAbandoning(false);
callbacks.current.onClose?.();
}}>确认放弃重试</Button><Button type="button" variant="ghost" disabled={running} onClick={() => setAbandoning(false)}>继续保留</Button></div>
</div>}
</div>
<div className="agent-knowledge-import-footer">
<p>最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。</p>
{onRefreshKnowledge && <Button type="button" variant="outline" disabled={running} onClick={() => { if (!runningRef.current) onRefreshKnowledge(); }}>刷新知识库</Button>}
{unresolved && !running && !abandoning && <Button type="button" variant="ghost" onClick={() => setAbandoning(true)}>放弃重试</Button>}
<Button type="button" disabled={busy || !canRead || running || (!selected.length && !retryJobs.length)} onClick={() => void importSelected()}>
{running ? <><Loader2 size={16} className="animate-spin" />正在导入…</> : retryJobs.length ? `${failureOnly ? '重试失败的' : '继续导入'} ${retryJobs.length} 个文件` : `导入 ${selected.length} 个文件`}
</Button>
</div>
{completed > 0 && <p role="status">已导入 {completed} 个文件,处理状态见知识库。</p>}
</DialogContent></Dialog>;
}
@@ -3,7 +3,8 @@ import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { Button } from '@/components/ui/button';
import { usePendingCloudInput } from './CloudPending';
import type { CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
import { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker';
import type { CloudKnowledgeFile } from '../../../shared/cloud-agents';
function documentState(file: CloudKnowledgeFile) {
const task = file.processing_task;
@@ -47,11 +48,11 @@ function documentState(file: CloudKnowledgeFile) {
return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 };
}
export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: {
slug: string; kbId: string; busy: boolean;
export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: {
slug: string; kbId: string; kbName?: string; busy: boolean;
act: (operation: () => Promise<void>) => Promise<void>;
onDeleteKnowledge: () => void;
canRead?: boolean; onPendingChange?: (pending: boolean) => void;
canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void;
}) {
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
const [pageCount, setPageCount] = useState(1);
@@ -154,14 +155,14 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
const active = states.some(state => state.active) || submitting !== null;
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '上传文档'}</Button>
<Button type="button" variant="outline" disabled={busy || !canRead || unresolved} onClick={() => setImporting(value => !value)}>从对话附件导入</Button>
<Button type="button" disabled={busy || !canRead || (unresolved && !uploadIntents.current.new)} onClick={() => void upload()}>{uploadIntents.current.new ? '重新选择同一文件并重试' : '从电脑上传'}</Button>
<Button type="button" variant="outline" disabled={busy || !canRead || unresolved} onClick={() => setImporting(value => !value)}>从聊天选择文件</Button>
<Button type="button" variant="ghost" disabled={loading || !canRead} onClick={refresh}>刷新状态</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
{uploading && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin" />{uploading}</p>}
{importing && <AttachmentImport slug={slug} kbId={kbId} busy={busy || !canRead} act={act} onPendingChange={setImportPending}
onImported={async file => { setImporting(false); await acceptFile(file); }} />}
{importing && <CloudKnowledgeAttachmentPicker slug={slug} kbId={kbId} kbName={kbName} busy={busy} canRead={canRead}
act={act} onPendingChange={setImportPending} onImported={acceptFile} onClose={() => setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
<div className="flex flex-wrap items-center justify-between gap-2 border-b pb-2 text-xs text-muted-foreground" aria-live="polite">
<span>{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'}</span>
<span>{active ? '处理中 · 状态自动更新' : '状态自动更新'}</span>
@@ -215,52 +216,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled
{deleting && <div role="dialog" aria-label="删除知识内容" className="space-y-3 rounded-lg border border-amber-200 p-3">
<p>{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}</p>
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy || unresolved} onClick={() => void act(async () => {
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); onDeleteKnowledge(); }
if (!alive.current || !permitted.current) return;
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); if (alive.current) onDeleteKnowledge(); }
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
if (alive.current) { setDeleting(null); refresh(); }
})}>确认删除知识内容</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div>
</div>}
</div>;
}
function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: {
slug: string; kbId: string; busy: boolean; act: (operation: () => Promise<void>) => Promise<void>;
onImported: (file: CloudKnowledgeFile) => Promise<void>;
onPendingChange: (pending: boolean) => void;
}) {
const [threads, setThreads] = useState<CloudThread[]>([]);
const [thread, setThread] = useState('');
const [files, setFiles] = useState<CloudAttachment[]>([]);
const [file, setFile] = useState('');
const [error, setError] = useState('');
const operation = useRef<string | null>(null);
const unresolved = Boolean(operation.current);
useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]);
usePendingCloudInput(busy || Boolean(operation.current));
useEffect(() => {
let live = true;
const load = async () => {
let page = await cloudAgentsApi.call('threads', {});
while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; }
if (live) setThreads(page.threads);
};
void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); });
return () => { live = false; };
}, []);
useEffect(() => {
let live = true; setFiles([]); setFile('');
if (thread) cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); });
return () => { live = false; };
}, [thread]);
return <div className="space-y-3 rounded-lg border p-3">
<p className="text-xs text-muted-foreground">只显示你自己的对话附件。导入后自动处理,变为“可用于回答”后才能检索。</p>
<select aria-label="附件来源对话" className="h-10 w-full rounded-md border bg-background px-3" value={thread} disabled={busy || Boolean(operation.current)} onChange={e => setThread(e.target.value)}><option value="">选择对话</option>{threads.map(item => <option key={item.thread_id} value={item.thread_id}>{item.title || '未命名对话'}</option>)}</select>
<select aria-label="入库附件" className="h-10 w-full rounded-md border bg-background px-3" value={file} disabled={busy || Boolean(operation.current)} onChange={e => setFile(e.target.value)}><option value="">选择附件</option>{files.map(item => <option key={item.file_id} value={item.file_id}>{item.file_name}</option>)}</select>
<Button type="button" disabled={busy || !thread || !file} onClick={() => void act(async () => {
operation.current ??= crypto.randomUUID();
const result = await cloudAgentsApi.call('importKnowledgeAttachment', { slug, kb_id: kbId, thread_id: thread, attachment_id: file, operation_id: operation.current });
operation.current = null; await onImported(result);
})}>{busy ? '正在导入…' : operation.current ? '重试本次导入' : '确认导入知识库'}</Button>
{error && <p role="alert" className="text-destructive">{error}</p>}
</div>;
}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { Library, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge';
import type { CloudResource } from '../../../shared/cloud-agents';
@@ -16,7 +16,15 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load
const unknown = ownedIds === null ? available : [];
const selectionFull = selected.length >= 100;
const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); };
return <div className="space-y-3">
return <div className="agent-knowledge-workspace">
<CloudKnowledgePanel key={slug} slug={slug} disabled={disabled} onCatalog={setOwned} refreshToken={refreshToken}
selectionFull={selectionFull} selectedIds={selected} selectableIds={loading || error ? null : options.map(option => option.key)}
onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }}
onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }}
onCreated={kb => { add(kb.kb_id); onRefresh(); }} />
<section aria-label="选择知识库" className="agent-knowledge-selection space-y-4">
<div className="agent-knowledge-panel-heading"><Library aria-hidden="true" className="size-5" /><h3>选择知识库</h3></div>
<p className="text-xs leading-5 text-muted-foreground">从 MakeLore知识和我的知识库中选择,可同时使用多个。</p>
<div className="flex flex-wrap items-center gap-2">
<select aria-label="选择知识库" className="min-w-0 flex-1 basis-48" disabled={disabled || loading || Boolean(error) || selectionFull || available.length === 0} value=""
onChange={event => { if (!disabled && !loading && !error && event.target.value && available.some(option => option.key === event.target.value)) add(event.target.value); }}>
@@ -50,10 +58,6 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load
})}
</ul> : <p className="agent-resource-empty">还没有选择知识库</p>}
<p className="text-xs leading-5 text-muted-foreground">选择后保存并应用配置,智能体即可使用这些知识。MakeLore知识只可检索,无法在这里修改。</p>
<div className="agent-config-resource-manager"><CloudKnowledgePanel key={slug} slug={slug} disabled={disabled} onCatalog={setOwned} refreshToken={refreshToken}
selectionFull={selectionFull} selectedIds={selected} selectableIds={loading || error ? null : options.map(option => option.key)}
onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }}
onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }}
onCreated={kb => { add(kb.kb_id); onRefresh(); }} /></div>
</section>
</div>;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import type { CloudAgentDraft, CloudVersion } from '../../../shared/cloud-agents
import { usePendingCloudInput } from './CloudPending';
const configurationLabels: Record<string, string> = {
model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: 'Skills', preload_skills: '预加载 Skills',
model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: '技能', preload_skills: '提前读好的技能',
subagents: '子智能体', tool_approval_mode: '工具确认方式', max_execution_steps: '最多执行步数',
max_output_tokens: '每次输出词元上限', max_run_seconds: '最长执行秒数',
};
@@ -0,0 +1,36 @@
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { CloudToolCall } from '../../../shared/cloud-agents';
const display = (value: unknown) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value, null, 2);
export function CloudMessageBody({ content, reasoning_content, tool_calls = [], active = false, onDownload }: {
content: string; reasoning_content?: string; tool_calls?: CloudToolCall[]; active?: boolean;
onDownload?: (path: string) => void;
}) {
return <div className="min-w-0 space-y-3">
{(reasoning_content || tool_calls.length > 0) && <details open={active && !content} className="rounded-lg border border-border bg-muted/20 p-3 text-xs text-muted-foreground" data-testid="cloud-message-process">
<summary className="cursor-pointer font-medium">{reasoning_content ? '思考过程' : '工具调用'}{tool_calls.length > 0 && ' · ' + tool_calls.length + ' 个工具'}{active && !content ? ' · 进行中' : ''}</summary>
<div className="mt-3 space-y-3">
{reasoning_content && <div aria-label="智能体思考过程" className="max-h-72 overflow-auto whitespace-pre-wrap break-words border-l-2 pl-3 leading-6">{reasoning_content}</div>}
{tool_calls.map(tool => <details key={tool.id} className="rounded-md border border-border p-2">
<summary className="cursor-pointer break-words">{tool.display_name || tool.name} · {tool.status === 'success' ? '已完成' : tool.status === 'error' ? '失败' : active ? tool.status === 'running' ? '执行中' : '准备调用' : '未完成'}</summary>
<div className="mt-2 space-y-2">
{tool.display_description && <p className="leading-5">{tool.display_description}</p>}
{tool.display_name && tool.display_name !== tool.name && <p className="break-all">工具标识:{tool.name}</p>}
<p>输入</p><pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words">{display(tool.args)}</pre>
{tool.tool_call_result != null && <><p>结果</p><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words">{display(tool.tool_call_result.content)}</pre></>}
{tool.error_message && <p className="whitespace-pre-wrap break-words text-destructive">{tool.error_message}</p>}
</div>
</details>)}
</div>
</details>}
{content && <div className="prose prose-sm max-w-none break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto">
<ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
? onDownload ? <button className="text-primary underline" onClick={() => onDownload(href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/'))}>{children}</button> : <span>{children}</span>
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
}}>{content}</ReactMarkdown>
</div>}
</div>;
}
+9 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { AppError } from '@/lib/error-model';
import type { CloudPendingOperation } from '../../../shared/cloud-agents';
const labels: Record<string, string> = {
@@ -21,7 +22,7 @@ export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOp
const [notice, setNotice] = useState('');
const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null);
const resolve = async (item: CloudPendingOperation, discard = false) => {
setBusy(item.id); setError(''); setNotice('');
setBusy(item.id); setError(''); setNotice(''); setResult(null);
try {
const response = await cloudAgentsApi.resolvePending(item.id, discard);
if (response.requires_input) {
@@ -32,7 +33,13 @@ export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOp
setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。');
} else if (!discard) setResult({ operation: item.operation, value: response.result });
onResolved();
} catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); }
} catch (e) {
const message = e instanceof Error ? e.message : '暂时无法确认结果';
if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') {
setError(`${labels[item.operation] ?? '操作'}失败:${message}`);
onResolved();
} else setError(message);
}
finally { setBusy(''); }
};
if (!pending.length && !result && !error && !notice) return null;
+1 -1
View File
@@ -113,7 +113,7 @@ function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: s
<h3 className="font-medium">启用前确认</h3>
<p>当前发布版本 {context.version} · {context.configuration.model}。触发时使用当时的发布版本。</p>
<p>能力:{['tools', 'knowledges', 'mcps', 'skills', 'subagents'].map(key => {
const labels: Record<string, string> = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: 'Skills', subagents: '子智能体' };
const labels: Record<string, string> = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: '技能', subagents: '子智能体' };
const selected = context.configuration![key as 'tools']; return selected.length ? labels[key] + ':' + selected.join('、') : '';
}).filter(Boolean).join(';') || '基础对话'}</p>
<p>每次最多 {context.configuration.max_execution_steps} 步、{context.configuration.max_run_seconds} 秒,每次模型输出最多 {context.configuration.max_output_tokens} 词元。</p>
@@ -76,7 +76,7 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
</>}
{key === 'skills' && value.skills.length > 0 && <label className="agent-resource-preload">
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册</label>}
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法</label>}
</fieldset>;
})}
</div>;
+68 -1
View File
@@ -41,6 +41,66 @@
.agent-resource-preload { display: flex; min-height: 44px; align-items: center; gap: 9px; margin-top: 12px; cursor: pointer; font-size: 13px; }
.agent-config-resource-manager { margin-top: 18px; }
.agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; }
.agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; }
.agent-knowledge-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: start; gap: 18px; min-width: 0; }
.agent-knowledge-upload, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; }
.agent-knowledge-upload { border-color: #dce5f3; }
.agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; }
.agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; }
.agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; }
.agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; }
.agent-knowledge-field-label { display: block; font-size: 13px; font-weight: 600; color: #4c586b; }
.agent-knowledge-help { border-top: 1px solid #e8e6e1; padding-top: 12px; }
.agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; }
.agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; }
.agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; }
.agent-knowledge-selection button { flex-shrink: 0; }
.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; }
.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; }
.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; }
.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; }
.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; }
.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; }
.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; }
.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; }
.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; }
.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; }
.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; }
.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; }
.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; }
.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; }
.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; }
.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; }
.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; }
.agent-knowledge-import-chat:hover { background: #f6f5fa; }
.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; }
.agent-knowledge-import-chat:disabled { cursor: not-allowed; }
.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; }
.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; }
.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; }
.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; }
.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; }
.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; }
.agent-knowledge-import-file > span { min-width: 0; }
.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; }
.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; }
.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; }
.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; }
.agent-knowledge-import-selection li > span { min-width: 0; }
.agent-knowledge-import-selection li button { flex-shrink: 0; }
.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; }
.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; }
.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; }
.agent-knowledge-import-results li > span + span { color: #526c5f; }
.agent-knowledge-import-results [role="alert"] { color: #b54532; }
.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; }
.agent-knowledge-import-abandon p { margin-bottom: 10px; }
.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; }
.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; }
.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; }
@container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) {
.agent-editor-tabs { padding: 10px 12px; }
.agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; }
@@ -52,5 +112,12 @@
.agent-config-heading-title { gap: 6px; }
.agent-config-icon { width: 38px; height: 38px; border-radius: 13px; }
.agent-config-section-top > button { margin-left: 48px; }
.agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; }
.agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; }
.agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; }
.agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); }
.agent-knowledge-import-chat-list { max-height: 160px; }
.agent-knowledge-import-file-list { max-height: 240px; }
.agent-knowledge-import-footer button { width: 100%; }
}
@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } }
@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } }
+89 -44
View File
@@ -1,58 +1,103 @@
export interface CloudChatStream {
import type { CloudToolCall } from '../../../shared/cloud-agents';
export interface CloudLiveMessage {
message_id: string;
run_id: string;
content: string;
reasoning_content: string;
tool_calls: (CloudToolCall & { index?: number })[];
}
export interface CloudStream {
runId: string;
cursor: string;
messages: CloudLiveMessage[];
finished?: boolean;
}
export const object = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */
export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream {
if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) {
const [time, sequence] = cursor.split('-').map(BigInt);
const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt);
if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state;
}
const envelope = object(data);
if (envelope.run_id && envelope.run_id !== state.runId) return state;
const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor };
if (envelope.thread_id && envelope.thread_id !== threadId) return next;
const payload = object(envelope.payload);
const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk];
let messages = state.messages;
for (const value of chunks) {
const chunk = object(value);
const event = object(chunk.stream_event);
if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue;
if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type))) {
const id = typeof event.message_id === 'string' ? event.message_id
: messages.filter(message => message.run_id === state.runId).at(-1)?.message_id ?? state.runId + ':message';
const current = messages.find(message => message.message_id === id && message.run_id === state.runId)
?? { message_id: id, run_id: state.runId, content: '', reasoning_content: '', tool_calls: [] };
const updated = { ...current };
if (event.type === 'message_delta') {
updated.content += typeof event.content === 'string' ? event.content : '';
updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
} else {
const index = typeof event.index === 'number' ? event.index : 0;
const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index);
const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index,
name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具',
display_name: typeof event.display_name === 'string' ? event.display_name : existing?.display_name,
display_description: typeof event.display_description === 'string' ? event.display_description : existing?.display_description,
args: existing && existing.status !== 'pending' ? existing.args
: event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''),
status: existing?.status ?? 'pending', index };
updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool];
}
messages = messages.includes(current)
? messages.map(message => message === current ? updated : message) : [...messages, updated];
}
const toolEvent = object(chunk.event);
const result = object(toolEvent.data);
if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') {
messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => {
if (message.run_id !== state.runId || tool.id !== result.tool_call_id) return tool;
tool = { ...tool,
display_name: typeof result.display_name === 'string' ? result.display_name : tool.display_name,
display_description: typeof result.display_description === 'string' ? result.display_description : tool.display_description };
if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' };
if (result.event !== 'tool-finished') return tool;
const output = object(result.output);
const error = result.error || (output.status === 'error' ? output.content : null);
return { ...tool, status: error ? 'error' : 'success',
error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null,
tool_call_result: { content: output.content ?? result.output ?? '' } };
}) }));
}
}
return { ...next, messages };
}
export interface CloudChatStream extends CloudStream {
threadId: string;
requestId?: string;
cursor: string;
content: string;
reasoning: string;
messageId: string;
reasoningMessageId: string;
ended: boolean;
toolName: string;
}
const object = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> : {};
const validCursor = (value: string) => /^\d+-\d+$/.test(value);
function newerCursor(next: string, previous: string) {
const [nextTime, nextIndex] = next.split('-').map(BigInt);
const [previousTime, previousIndex] = previous.split('-').map(BigInt);
return nextTime > previousTime || nextTime === previousTime && nextIndex > previousIndex;
}
/** Project only the subscribed Run's main-thread messages; Redis IDs survive reconnects. */
/** Keep transport identity and continuation state around the message-level projection. */
export function consumeCloudChatEvent(previous: CloudChatStream | null, value: unknown, type: string, eventId: string,
runId: string, threadId: string, requestId?: string): CloudChatStream | null {
const envelope = object(value);
if (envelope.run_id !== runId || requestId && envelope.request_id && envelope.request_id !== requestId) return previous;
const continuation = previous?.threadId === threadId && requestId && previous.requestId === requestId;
const current: CloudChatStream = previous?.runId === runId && previous.threadId === threadId ? previous : {
runId, threadId, requestId, cursor: '0-0', content: '', reasoning: continuation ? previous.reasoning : '',
messageId: '', reasoningMessageId: continuation ? previous.reasoningMessageId : '', ended: false, toolName: '',
runId, threadId, requestId, cursor: '0-0', messages: continuation ? previous.messages : [], ended: false,
};
const fresh = validCursor(eventId) && newerCursor(eventId, current.cursor);
// A synthesized terminal event can reuse the last message's Redis ID.
if (type === 'end' && envelope.thread_id === threadId) return { ...current, ended: true, cursor: fresh ? eventId : current.cursor };
if (validCursor(eventId) && !fresh) return previous;
const next = { ...current, cursor: fresh ? eventId : current.cursor };
// Child events still advance the replay cursor, but never become visible text.
if (envelope.thread_id !== threadId) return next;
const payload = object(envelope.payload);
for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) {
const event = object(object(item).stream_event);
if (event.type === 'tool_call' && typeof event.name === 'string') next.toolName = event.name;
if (event.type !== 'message_delta') continue;
const id = typeof event.message_id === 'string' ? event.message_id : next.messageId;
const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
if (reasoning) {
next.reasoning += (next.reasoning && next.reasoningMessageId && id !== next.reasoningMessageId ? '\n\n' : '') + reasoning;
next.reasoningMessageId = id;
}
if (typeof event.content === 'string' && event.content) {
next.content += (next.content && next.messageId && id !== next.messageId ? '\n\n' : '') + event.content;
next.messageId = id;
}
}
return next;
// Child events advance the cursor without becoming visible messages.
const projected = reduceCloudStream(current, envelope.thread_id === threadId ? envelope : { ...envelope, payload: {} }, threadId, eventId);
// Terminal events may reuse the last message cursor.
if (type === 'end' && envelope.thread_id === threadId) return { ...current, ...projected, ended: true };
return projected === current ? previous : { ...current, ...projected };
}
+19 -5
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { AppError } from '@/lib/error-model';
import { useAuthStore } from '@/stores/auth';
import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
import { DraftEditor } from './DraftEditor';
@@ -164,7 +165,7 @@ function AgentWorkspace() {
if (shared || activity) return <div className="agent-desktop">{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
if (creating) return <section className="h-full overflow-auto p-6" data-testid="cloud-agents-page">
{recovery}
<div className="mx-auto max-w-3xl"><CreateAgentForm onCreated={agent => { setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }}
<div className="mx-auto max-w-3xl"><CreateAgentForm onCreated={agent => { setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery}
onCancel={() => { setCreating(false); void load(); }} /></div>
</section>;
@@ -242,7 +243,9 @@ function AgentWorkspace() {
);
}
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) {
function CreateAgentForm({ onCreated, onCancel, onSettled }: {
onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void; onSettled: () => void;
}) {
const [name, setName] = useState('');
const [avatar, setAvatar] = useState<CloudAgentAvatar>({ icon: null, icon_url: null });
const [uploading, setUploading] = useState(false);
@@ -250,6 +253,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const alive = useRef(true);
const uncertainOperation = useRef<string | null>(null);
const hasInput = Boolean(name || avatar.icon);
const blocker = useBlocker(busy || uploading || intent !== null || hasInput);
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
@@ -271,9 +275,19 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
const agent = await cloudAgentsApi.create(input);
if (alive.current) onCreated(agent);
} catch (failure) {
if (alive.current) setError(errorMessage(failure));
if (alive.current) {
if (failure instanceof AppError && failure.details?.commandOutcome === 'definitive_failure'
&& uncertainOperation.current !== input.operation_id) {
setIntent(null);
setError('创建失败:' + errorMessage(failure));
} else {
// Discarding the local reminder cannot rule out acceptance of an earlier attempt.
uncertainOperation.current = input.operation_id;
setError('创建结果暂未确认,可重试确认结果。' + errorMessage(failure));
}
}
} finally {
if (alive.current) setBusy(false);
if (alive.current) { setBusy(false); onSettled(); }
}
};
@@ -285,7 +299,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
<CloudAvatarEditor value={avatar} name={name} disabled={busy || intent !== null} uploading={uploading}
onUploading={setUploading} onChange={setAvatar} />
</fieldset>
{error && <p role="alert" className="text-sm text-destructive">{error}。可重试确认本次创建结果。</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={busy || uploading || !name.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '下一步'}</Button>
<Button type="button" variant="ghost" disabled={busy || uploading} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>