feat(agents): 完善恢复费用与云资源交互
This commit is contained in:
1 parent
f8f39f39de
commit
303f26294e
24 files changed
+1170
-127
No files matched your search
@@ -4,12 +4,16 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudAccess as Access, CloudApplication, CloudCost, CloudKey } from '../../../shared/cloud-agents';
|
||||
import type { CloudAccess as Access, CloudApplication, CloudKey } from '../../../shared/cloud-agents';
|
||||
import { CloudBudgetEditor, CloudCosts } from './CloudCosts';
|
||||
import { CloudLifecycle } from './CloudLifecycle';
|
||||
import type { CloudAgentDraft } from '../../../shared/cloud-agents';
|
||||
import type { CloudAgentOperations } from '../../../shared/cloud-agents';
|
||||
import { cloudStatus } from './CloudChat';
|
||||
|
||||
const errorText = (e: unknown) => e instanceof Error ? e.message : '操作失败,请重试';
|
||||
export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string; revision: number; onPublished: () => void }) {
|
||||
export function CloudAccessPanel({ slug, revision, onPublished, onChanged }: { slug: string; revision: number; onPublished: () => void; onChanged?: (draft: CloudAgentDraft) => void }) {
|
||||
const [access, setAccess] = useState<Access | null>(null);
|
||||
const [costs, setCosts] = useState<CloudCost[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<{ account_id: string; display_name: string; username: string }[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
@@ -21,12 +25,9 @@ export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string
|
||||
const applicationOperation = useRef<{ operation_id: string; name: string } | null>(null);
|
||||
const alive = useRef(true);
|
||||
const refresh = useCallback(async () => {
|
||||
const results = await Promise.allSettled([cloudAgentsApi.call('access', { slug }), cloudAgentsApi.call('costs', { slug })]);
|
||||
const result = await cloudAgentsApi.call('access', { slug });
|
||||
if (!alive.current) return;
|
||||
if (results[0].status === 'fulfilled') setAccess(results[0].value);
|
||||
if (results[1].status === 'fulfilled') setCosts(results[1].value.items);
|
||||
const failure = results.find(result => result.status === 'rejected');
|
||||
if (failure?.status === 'rejected') throw failure.reason;
|
||||
setAccess(result);
|
||||
}, [slug]);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
@@ -98,15 +99,9 @@ export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string
|
||||
<p className="mt-3 text-xs leading-6 text-muted-foreground">相同请求标识与输入可重试。通过返回的 request_id 查询排队状态,通过 run_id 读取运行结果或订阅事件;这些请求继续使用同一应用凭据。</p>
|
||||
</details>}
|
||||
</section>
|
||||
<section className="space-y-4"><h2 className="font-medium">最近费用</h2>
|
||||
<p className="text-sm text-muted-foreground">最近 100 次模型调用,仅显示费用归属。调用者的对话与文件不会在这里展示。</p>
|
||||
{!costs.length ? <p className="text-sm text-muted-foreground">还没有模型调用费用</p> : <div className="overflow-x-auto"><table className="w-full text-left text-sm tabular-nums">
|
||||
<thead><tr className="border-b border-border"><th className="p-3 font-medium">时间</th><th className="p-3 font-medium">调用来源</th><th className="p-3 font-medium">状态</th><th className="p-3 font-medium">实际点数</th></tr></thead>
|
||||
<tbody>{costs.map(cost => <tr key={cost.id} className="border-b border-border/50"><td className="p-3">{new Date(cost.created_at).toLocaleString()}</td>
|
||||
<td className="p-3"><p>{cost.context.caller_kind === 'application' ? '应用' : '个人账号'} · v{cost.context.version}</p><p className="text-xs text-muted-foreground">{cost.context.caller_id}</p></td>
|
||||
<td className="p-3">{({ reserved: '已预占', dispatched: '待结算', pending_review: '待核对用量', settled: '已结算', released: '已释放', failed: '失败' } as Record<string, string>)[cost.status] ?? cost.status}</td><td className="p-3">{cost.actual_points === null ? '待结算' : cost.actual_points}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
</section>
|
||||
<CloudBudgetEditor slug={slug} />
|
||||
<CloudCosts slug={slug} applications={access?.applications ?? []} />
|
||||
<CloudLifecycle slug={slug} revision={revision} versions={access?.versions.map(v => v.version) ?? []} onChanged={draft => { onChanged?.(draft); void refresh().catch(e => setError(errorText(e))); }} />
|
||||
{access && <details className="text-sm"><summary className="cursor-pointer">发布历史({access.versions.length})</summary>
|
||||
{access.versions.map(v => <p key={v.version} className="mt-3 tabular-nums">版本 {v.version} · 草稿修订 {v.draft_revision} · {new Date(v.created_at).toLocaleString()}</p>)}</details>}
|
||||
</div>;
|
||||
@@ -140,5 +135,28 @@ function ApplicationKeys({ application, onUpdated }: { application: CloudApplica
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => void navigator.clipboard.writeText(secret).catch(() => setError('复制失败,请手动选择复制'))}>复制凭据</Button>
|
||||
<Button variant="ghost" onClick={() => setSecret('')}>已保存,隐藏</Button></div></div>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
<ApplicationCalls applicationId={application.id} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ApplicationCalls({ applicationId }: { applicationId: string }) {
|
||||
const [page, setPage] = useState<CloudAgentOperations['applicationCalls']['output'] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const read = async (offset = 0) => {
|
||||
setBusy(true); setError('');
|
||||
try { const result = await cloudAgentsApi.call('applicationCalls', { application_id: applicationId, offset });
|
||||
setPage(previous => ({ ...result, items: offset && previous ? [...previous.items, ...result.items] : result.items })); }
|
||||
catch (e) { setError(errorText(e)); } finally { setBusy(false); }
|
||||
};
|
||||
return <details onToggle={e => { if (e.currentTarget.open && !page) void read(); }}>
|
||||
<summary className="min-h-8 cursor-pointer">应用调用状态</summary>
|
||||
<p className="mt-2 text-xs text-muted-foreground">包括尚未产生模型费用的请求。只显示状态与版本,不展示应用的会话内容。</p>
|
||||
{error && <p role="alert" className="mt-2 text-destructive">{error}</p>}
|
||||
{page && !page.items.length && <p className="mt-3 text-muted-foreground">此应用还没有调用记录。</p>}
|
||||
{page?.items.map(item => <div key={item.request_id} className="mt-3 border-t pt-2 text-xs"><p>{new Date(item.created_at).toLocaleString()} · v{item.version} · {cloudStatus(item.run_status ?? item.request_status)}</p>
|
||||
<p className="mt-1 break-all text-muted-foreground">请求 {item.request_id}{item.run_id && ' · 运行 ' + item.run_id}</p></div>)}
|
||||
<div className="mt-3 flex gap-2"><Button variant="ghost" size="sm" disabled={busy} onClick={() => void read()}>刷新调用状态</Button>
|
||||
{page?.next_offset != null && <Button variant="outline" size="sm" disabled={busy} onClick={() => void read(page.next_offset!)}>加载更多调用</Button>}</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { MessageSquarePlus, Send, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread } from '../../../shared/cloud-agents';
|
||||
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
|
||||
import { CloudFiles } from './CloudFiles';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -18,12 +18,13 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching',
|
||||
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> : {};
|
||||
|
||||
export function CloudChat({ slug, previewRevision, initialThread }: { slug: string; previewRevision?: number; initialThread?: CloudThread }) {
|
||||
export function CloudChat({ slug, previewRevision, initialThread, initialThreadId, onSchedule }: { slug: string; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) {
|
||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||
const [thread, setThread] = useState<CloudThread | null>(initialThread ?? null);
|
||||
const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID());
|
||||
const [history, setHistory] = useState<CloudHistory | null>(null);
|
||||
const [request, setRequest] = useState<CloudRequest | null>(null);
|
||||
const [queued, setQueued] = useState<CloudRequest[]>([]);
|
||||
const [run, setRun] = useState<CloudRun | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [intent, setIntent] = useState<CloudPrompt | null>(null);
|
||||
@@ -40,6 +41,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
const generation = useRef(0);
|
||||
const alive = useRef(true);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const entry = useRef({ initialThread, initialThreadId });
|
||||
const mode = previewRevision === undefined ? 'published' : 'preview';
|
||||
const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => {
|
||||
let page = await cloudAgentsApi.call('history', { thread_id: id });
|
||||
@@ -49,8 +51,10 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
}
|
||||
if (!alive.current || expectedGeneration !== generation.current) return;
|
||||
setHistory(page); setRun(page.run); setInterrupt(page.run?.interrupt ?? 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
|
||||
? { ...item, run_id: page.run!.agent_run_id, status: page.run!.status, unread: false } : item));
|
||||
? { ...item, run_id: page.run!.agent_run_id, status: !active(page.run!.status) && page.queued_requests?.length ? 'queued' : page.run!.status, unread: false } : item));
|
||||
if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
@@ -58,19 +62,31 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
const g = ++generation.current;
|
||||
const open = async () => {
|
||||
try {
|
||||
if (previewRevision !== undefined) {
|
||||
if (initialThread) await readHistory(initialThread.thread_id, g);
|
||||
return;
|
||||
}
|
||||
const { recent } = await cloudAgentsApi.recovery();
|
||||
let page = await cloudAgentsApi.call('threads', { slug });
|
||||
while (page.next_offset !== null) {
|
||||
const more = await cloudAgentsApi.call('threads', { slug, offset: page.next_offset });
|
||||
page = { ...more, threads: [...page.threads, ...more.threads] };
|
||||
}
|
||||
if (!alive.current || g !== generation.current) return;
|
||||
const items = page.threads.filter(t => t.mode === mode);
|
||||
const items = page.threads.filter(t => t.mode === mode && (mode !== 'preview' || t.draft_revision === previewRevision));
|
||||
setThreads(items);
|
||||
const latest = initialThread ?? items[0];
|
||||
const pointer = recent?.slug === slug && recent.mode === mode
|
||||
&& (mode !== 'preview' || recent.draft_revision === previewRevision) ? recent : null;
|
||||
const wanted = entry.current.initialThreadId ?? pointer?.thread_id;
|
||||
let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items[0]);
|
||||
if (wanted && !latest) {
|
||||
let archived = await cloudAgentsApi.call('threads', { slug, archived: true });
|
||||
for (;;) {
|
||||
latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode
|
||||
&& (mode !== 'preview' || t.draft_revision === previewRevision));
|
||||
if (latest || archived.next_offset === null) break;
|
||||
archived = await cloudAgentsApi.call('threads', { slug, archived: true, offset: archived.next_offset });
|
||||
}
|
||||
if (!alive.current || g !== generation.current) return;
|
||||
if (latest) setThreads([...items, latest]);
|
||||
}
|
||||
if (wanted && !latest) setError('上次对话已不可用,可选择其他对话或开始新对话');
|
||||
if (latest) {
|
||||
setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id);
|
||||
await readHistory(latest.thread_id, g);
|
||||
@@ -80,7 +96,15 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
};
|
||||
void open();
|
||||
return () => { alive.current = false; generation.current++; };
|
||||
}, [slug, mode, previewRevision, initialThread, readHistory]);
|
||||
}, [slug, mode, previewRevision, readHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
let live = true;
|
||||
void cloudAgentsApi.remember({ slug, mode, thread_id: thread?.thread_id, draft_revision: previewRevision })
|
||||
.catch(e => { if (live) setError(message(e)); });
|
||||
return () => { live = false; };
|
||||
}, [slug, mode, thread?.thread_id, previewRevision, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request || request.run_id || !active(request.status)) return;
|
||||
@@ -102,8 +126,19 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
return () => { live = false; window.clearInterval(timer); };
|
||||
}, [request]);
|
||||
|
||||
const runId = run?.agent_run_id ?? request?.run_id;
|
||||
const running = active(run?.status ?? request?.status);
|
||||
useEffect(() => {
|
||||
if (!queued.length || !thread) return;
|
||||
const g = generation.current;
|
||||
const timer = window.setInterval(() => {
|
||||
void readHistory(thread.thread_id, g).catch(e => {
|
||||
if (alive.current && generation.current === g) setError(message(e));
|
||||
});
|
||||
}, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [queued.length, thread, readHistory]);
|
||||
|
||||
const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id;
|
||||
const running = active(run?.status) || active(request?.status);
|
||||
useEffect(() => {
|
||||
if (!runId || !running) return;
|
||||
let live = true;
|
||||
@@ -158,7 +193,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
|
||||
};
|
||||
const send = async () => {
|
||||
if (busy || running || (!intent && !query.trim())) return;
|
||||
if (busy || running || thread?.archived || (!intent && !query.trim())) return;
|
||||
const input = intent ?? {
|
||||
request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(),
|
||||
attachment_file_ids: attachmentIds,
|
||||
@@ -179,7 +214,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
const choose = async (next: CloudThread | null) => {
|
||||
const g = ++generation.current;
|
||||
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
|
||||
setRequest(null); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery('');
|
||||
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery('');
|
||||
setAttachmentIds([]);
|
||||
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
|
||||
};
|
||||
@@ -212,15 +247,39 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
}}>{item.content}</ReactMarkdown></div>
|
||||
</article>)}
|
||||
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>}
|
||||
{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>
|
||||
<Button variant="outline" disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onClick={() => onSchedule(proposal)}>查看并配置自动任务</Button>
|
||||
</article>)}
|
||||
{run?.error && run.status === 'failed' && <p role="alert" className="text-sm text-destructive">{run.error.message}</p>}
|
||||
{interrupt && runId && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
|
||||
{interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
|
||||
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
|
||||
}} />}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
{queued.length > 0 && <div className="mb-3 space-y-2 rounded-lg bg-muted/40 p-3 text-sm">
|
||||
<p>还有 {queued.length} 条请求排队中,关闭客户端后仍会继续。</p>
|
||||
{queued.map((item, index) => <div key={item.request_id} className="flex items-center justify-between">
|
||||
<span>排队 {index + 1} · 版本 {item.version}</span>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={async () => {
|
||||
setBusy(true);
|
||||
try { await cloudAgentsApi.call('cancelRequest', { request_id: item.request_id }); await readHistory(item.thread_id); }
|
||||
catch (e) { setError(message(e)); } finally { setBusy(false); }
|
||||
}}>取消排队</Button>
|
||||
</div>)}
|
||||
</div>}
|
||||
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
{thread?.archived && <div className="mb-3 flex items-center justify-between gap-3 rounded-lg bg-muted/40 p-3 text-sm">
|
||||
<span>此对话已归档,恢复后可继续发送消息。</span>
|
||||
<Button variant="outline" disabled={busy} onClick={async () => {
|
||||
setBusy(true); setError('');
|
||||
try { await cloudAgentsApi.call('archiveThread', { thread_id: thread.thread_id, archived: false }); rememberThread({ ...thread, archived: false }); }
|
||||
catch (e) { setError(message(e)); } finally { setBusy(false); }
|
||||
}}>恢复对话</Button>
|
||||
</div>}
|
||||
<CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
|
||||
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading} ensureThread={async () => {
|
||||
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading || Boolean(thread?.archived)} ensureThread={async () => {
|
||||
if (thread) return thread.thread_id;
|
||||
const created = await cloudAgentsApi.call('createThread', { slug, thread_id: clientId, preview: previewRevision !== undefined, expected_revision: previewRevision });
|
||||
rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode,
|
||||
@@ -231,11 +290,11 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
|
||||
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
|
||||
<form className="rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<Textarea aria-label="消息" className="min-h-20 resize-none border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
|
||||
value={query} disabled={busy || Boolean(intent) || loading} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)} />
|
||||
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)} />
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p role="status" className="text-xs text-muted-foreground">{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p>
|
||||
{running ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
||||
: <Button size="sm" type="submit" disabled={busy || loading || (!intent && !query.trim())}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && !query.trim())}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
</div>
|
||||
</form>
|
||||
</section></CloudPendingContext.Provider>;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudApplication, CloudBudget, CloudCostPage, CloudCostSource } from '../../../shared/cloud-agents';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
|
||||
const message = (e: unknown) => e instanceof Error ? e.message : '费用服务暂不可用';
|
||||
const sources: Record<CloudCostSource, string> = { self: '自己使用', shared: '分享', api: 'API', scheduled: '自动任务', preview: '草稿预览', knowledge: '知识库处理' };
|
||||
|
||||
export function CloudBudgetEditor({ slug }: { slug: string }) {
|
||||
const [budget, setBudget] = useState<CloudBudget | null>(null);
|
||||
const [requestLimit, setRequestLimit] = useState('');
|
||||
const [dailyLimit, setDailyLimit] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const dirty = Boolean(budget) && (requestLimit !== (budget?.request_limit_points ?? '') || dailyLimit !== (budget?.daily_limit_points ?? ''));
|
||||
usePendingCloudInput(busy || dirty);
|
||||
const accept = useCallback((value: CloudBudget) => { setBudget(value); setRequestLimit(value.request_limit_points ?? ''); setDailyLimit(value.daily_limit_points ?? ''); }, []);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
cloudAgentsApi.call('budget', { slug }).then(v => { if (live) accept(v); }).catch(e => { if (live) setError(message(e)); });
|
||||
return () => { live = false; };
|
||||
}, [slug, accept]);
|
||||
return <section className="space-y-4 rounded-xl border border-border p-5">
|
||||
<h2 className="font-medium">费用上限</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground">这个智能体的自用、分享、API 和自动任务共同计入。单次任务包含多轮模型调用及子智能体;知识库处理也计入费用。留空表示不设额外上限,填写 0 可暂停新增模型调用。</p>
|
||||
<form className="flex flex-wrap items-end gap-4" onSubmit={async e => {
|
||||
e.preventDefault(); setBusy(true); setError('');
|
||||
try { accept(await cloudAgentsApi.call('saveBudget', { slug, request_limit_points: requestLimit.trim() || null, daily_limit_points: dailyLimit.trim() || null })); }
|
||||
catch (failure) { setError(message(failure)); } finally { setBusy(false); }
|
||||
}}>
|
||||
<label className="space-y-2 text-sm"><span>每次任务上限(词元点数)</span><Input type="number" min="0" step="0.01" value={requestLimit} disabled={!budget || busy} onChange={e => setRequestLimit(e.target.value)} placeholder="不设限" /></label>
|
||||
<label className="space-y-2 text-sm"><span>每日上限(词元点数)</span><Input type="number" min="0" step="0.01" value={dailyLimit} disabled={!budget || busy} onChange={e => setDailyLimit(e.target.value)} placeholder="不设限" /></label>
|
||||
<Button type="submit" disabled={!budget || busy || !dirty}>{busy ? '正在保存…' : '保存费用上限'}</Button>
|
||||
</form>
|
||||
{budget && <p className="text-xs leading-6 text-muted-foreground">今日已消费及预占 {budget.daily_committed_points} 点,每日北京时间 00:00 重置。新上限对后续模型调用生效;已获准调用继续结算实际费用。</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function CloudCosts({ slug, applications }: { slug: string; applications: CloudApplication[] }) {
|
||||
const [page, setPage] = useState<CloudCostPage | null>(null);
|
||||
const [source, setSource] = useState<CloudCostSource | ''>('');
|
||||
const [application, setApplication] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const generation = useRef(0);
|
||||
const read = useCallback(async (offset = 0) => {
|
||||
const current = ++generation.current; setBusy(true); setError('');
|
||||
try {
|
||||
const result = await cloudAgentsApi.call('costs', { slug, offset, source: source || undefined, application_id: application || undefined,
|
||||
started_at: from ? new Date(from).toISOString() : undefined, ended_at: to ? new Date(to).toISOString() : undefined });
|
||||
if (current === generation.current) setPage(previous => ({ ...result, items: offset && previous ? [...previous.items, ...result.items] : result.items }));
|
||||
} catch (e) { if (current === generation.current) setError(message(e)); }
|
||||
finally { if (current === generation.current) setBusy(false); }
|
||||
}, [slug, source, application, from, to]);
|
||||
useEffect(() => { setPage(null); void read(); return () => { generation.current++; }; }, [read]);
|
||||
return <section className="space-y-4"><h2 className="font-medium">费用与调用记录</h2>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<label>来源<select aria-label="费用来源" className="ml-2 h-10 rounded-md border bg-background px-2" value={source} onChange={e => setSource(e.target.value as CloudCostSource | '')}>
|
||||
<option value="">全部来源</option>{Object.entries(sources).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||
<label>应用<select aria-label="费用应用" className="ml-2 h-10 rounded-md border bg-background px-2" value={application} onChange={e => setApplication(e.target.value)}>
|
||||
<option value="">全部应用</option>{applications.map(app => <option key={app.id} value={app.id}>{app.name}</option>)}</select></label>
|
||||
<label>开始时间<Input aria-label="费用开始时间" type="datetime-local" value={from} onChange={e => setFrom(e.target.value)} /></label>
|
||||
<label>结束时间<Input aria-label="费用结束时间" type="datetime-local" value={to} onChange={e => setTo(e.target.value)} /></label>
|
||||
</div>
|
||||
{page?.summary && <p className="text-sm tabular-nums">共 {page.summary.count} 次模型调用 · 已结算 {page.summary.settled_points} 点 · 待结算及待核对 {page.summary.pending_points} 点</p>}
|
||||
<p className="text-xs text-muted-foreground">统计覆盖全部筛选结果。仅展示调用与计费状态,不展示调用者的消息和文件;时间按本机时区显示。</p>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error} <Button variant="ghost" onClick={() => void read()}>重试读取费用</Button></p>}
|
||||
{page && !page.items.length && <p className="text-sm text-muted-foreground">这个范围还没有模型调用费用</p>}
|
||||
{!!page?.items.length && <div className="overflow-x-auto"><table className="w-full text-left text-sm tabular-nums">
|
||||
<thead><tr className="border-b"><th className="p-3">时间</th><th className="p-3">来源与调用</th><th className="p-3">结算状态</th><th className="p-3">实际 / 预占点数</th></tr></thead>
|
||||
<tbody>{page.items.map(cost => <tr key={cost.id} className="border-b border-border/50"><td className="p-3">{new Date(cost.created_at).toLocaleString()}</td>
|
||||
<td className="p-3"><p>{sources[cost.context.source ?? (cost.context.caller_kind === 'application' ? 'api' : cost.context.caller_id === cost.context.creator_id ? 'self' : 'shared')]} · v{cost.context.version}</p>
|
||||
<details className="mt-1 text-xs text-muted-foreground"><summary className="cursor-pointer">调用标识</summary><p>调用者:{cost.context.caller_id}</p><p>任务:{cost.context.request_id}</p><p>运行:{cost.context.run_id}</p></details></td>
|
||||
<td className="p-3">{({ reserved: '已预占', dispatched: '待结算', pending_review: '待核对用量', settled: '已结算', released: '已释放', refunded: '已退回', expired: '已过期' } as Record<string, string>)[cost.status] ?? cost.status}</td>
|
||||
<td className="p-3">{cost.actual_points ?? '待结算'} / {cost.reserved_points}</td></tr>)}</tbody>
|
||||
</table></div>}
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">正在读取费用…</p>}
|
||||
{page?.next_offset != null && <Button variant="outline" disabled={busy} onClick={() => void read(page.next_offset!)}>加载更多费用</Button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
import type { CloudKnowledge, CloudKnowledgeFile } from '../../../shared/cloud-agents';
|
||||
import type { CloudKnowledge, CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents';
|
||||
|
||||
const statusName: Record<string, string> = {
|
||||
uploaded: '待解析', parsing: '解析中', parsed: '待索引', indexing: '索引中', indexed: '可检索',
|
||||
error_parsing: '解析失败', error_indexing: '索引失败',
|
||||
};
|
||||
export function CloudKnowledgePanel({ slug, onCreated }: { slug: string; onCreated: (kb: CloudKnowledge) => void }) {
|
||||
export function CloudKnowledgePanel({ slug, onCreated, onDeleted }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void }) {
|
||||
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: { id: string; name: string }[] } | null>(null);
|
||||
const [kbId, setKbId] = useState('');
|
||||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||||
@@ -20,6 +20,9 @@ export function CloudKnowledgePanel({ slug, onCreated }: { slug: string; onCreat
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleting, setDeleting] = useState<CloudKnowledgeFile | 'database' | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const replacements = useRef<Record<string, string>>({});
|
||||
usePendingCloudInput(busy || (creating && Boolean(name.trim())));
|
||||
const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null);
|
||||
const uploadIntent = useRef<string | null>(null);
|
||||
@@ -86,22 +89,41 @@ export function CloudKnowledgePanel({ slug, onCreated }: { slug: string; onCreat
|
||||
if (alive.current) setRefresh(v => v + 1);
|
||||
})}>{uploadIntent.current ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setRefresh(v => v + 1)}>刷新状态</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setImporting(v => !v)}>从对话附件导入</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting('database')}>删除知识库</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
|
||||
{importing && <AttachmentImport key={kbId} slug={slug} kbId={kbId} onImported={() => { setImporting(false); setRefresh(v => v + 1); }} />}
|
||||
{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} onClick={() => void act(async () => {
|
||||
if (deleting === 'database') { await cloudAgentsApi.call('deleteKnowledge', { slug, kb_id: kbId }); onDeleted?.(kbId); setKbId(''); }
|
||||
else await cloudAgentsApi.call('deleteKnowledgeFile', { slug, kb_id: kbId, file_id: deleting.file_id });
|
||||
setDeleting(null); setRefresh(v => v + 1);
|
||||
})}>确认删除知识内容</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div>
|
||||
</div>}
|
||||
<div className="divide-y">
|
||||
{files.map(file => <div key={file.file_id} className="flex items-start justify-between gap-3 py-3">
|
||||
<div className="min-w-0"><p className="break-all">{file.name}</p><p className="mt-1 text-xs text-muted-foreground">{statusName[file.status] || file.status} · {file.chunk_count || 0} 个分块</p>
|
||||
{file.error && <p className="mt-1 text-xs text-destructive">{file.error}</p>}</div>
|
||||
{!['parsing', 'indexing', 'indexed'].includes(file.status) && <Button type="button" size="sm" variant="outline" disabled={busy}
|
||||
<div className="flex shrink-0 flex-wrap gap-1">{!['parsing', 'indexing', 'indexed'].includes(file.status) && <Button type="button" size="sm" variant="outline" disabled={busy}
|
||||
onClick={() => void act(async () => {
|
||||
processIntents.current[file.file_id] ??= crypto.randomUUID();
|
||||
await cloudAgentsApi.call('processKnowledge', { slug, kb_id: kbId, file_id: file.file_id, operation_id: processIntents.current[file.file_id] });
|
||||
delete processIntents.current[file.file_id];
|
||||
if (alive.current) setRefresh(v => v + 1);
|
||||
})}>{file.status.startsWith('error') ? '重试处理' : '解析并索引'}</Button>}
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || ['parsing', 'indexing'].includes(file.status)} onClick={() => void act(async () => {
|
||||
replacements.current[file.file_id] ??= crypto.randomUUID();
|
||||
const result = await cloudAgentsApi.uploadKnowledge(slug, kbId, replacements.current[file.file_id], file.file_id);
|
||||
if (result) { delete replacements.current[file.file_id]; setRefresh(v => v + 1); }
|
||||
})}>{replacements.current[file.file_id] ? '重试替换' : '上传替换文档'}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || ['parsing', 'indexing'].includes(file.status)} onClick={() => setDeleting(file)}>删除文档</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
{!files.length && <p className="py-4 text-xs text-muted-foreground">尚未上传文档。</p>}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">替换文档上传后,需对新文档点击“解析并索引”。新索引成功后才移除旧文档;失败时旧文档仍可检索。</p>
|
||||
{next !== null && <Button type="button" variant="ghost" disabled={busy} onClick={() => void act(async () => {
|
||||
paged.current = true;
|
||||
const page = await cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId, offset: next });
|
||||
@@ -111,3 +133,40 @@ export function CloudKnowledgePanel({ slug, onCreated }: { slug: string; onCreat
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
|
||||
function AttachmentImport({ slug, kbId, onImported }: { slug: string; kbId: string; onImported: () => void }) {
|
||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||
const [thread, setThread] = useState('');
|
||||
const [files, setFiles] = useState<CloudAttachment[]>([]);
|
||||
const [file, setFile] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const operation = useRef<string | null>(null);
|
||||
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(() => {
|
||||
if (!thread) return;
|
||||
let live = true; setFiles([]); setFile('');
|
||||
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={async () => {
|
||||
operation.current ??= crypto.randomUUID(); setBusy(true); setError('');
|
||||
try { await cloudAgentsApi.call('importKnowledgeAttachment', { slug, kb_id: kbId, thread_id: thread, attachment_id: file, operation_id: operation.current }); onImported(); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '导入失败'); } finally { setBusy(false); }
|
||||
}}>{operation.current ? '重试本次导入' : '确认导入知识库'}</Button>
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
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',
|
||||
subagents: '子智能体', tool_approval_mode: '工具确认方式', max_execution_steps: '最多执行步数',
|
||||
max_output_tokens: '每次输出词元上限', max_run_seconds: '最长执行秒数',
|
||||
};
|
||||
|
||||
export function CloudLifecycle({ slug, revision, versions, onChanged }: {
|
||||
slug: string; revision: number; versions: number[]; onChanged?: (draft: CloudAgentDraft) => void;
|
||||
}) {
|
||||
const [version, setVersion] = useState<CloudVersion | null>(null);
|
||||
const [current, setCurrent] = useState<CloudAgentDraft | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
usePendingCloudInput(busy);
|
||||
const act = async (operation: () => Promise<void>) => {
|
||||
setBusy(true); setError(''); setNotice('');
|
||||
try { await operation(); } catch (e) { setError(e instanceof Error ? e.message : '管理操作失败'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const rows = version && current ? [
|
||||
['名称', current.name, version.name], ['用途', current.purpose, version.purpose], ['角色与指令', current.system_prompt, version.system_prompt],
|
||||
...Object.keys(version.configuration).map(key => [configurationLabels[key] ?? key, JSON.stringify(current.configuration[key as keyof typeof current.configuration]), JSON.stringify(version.configuration[key as keyof typeof version.configuration])]),
|
||||
].filter(([, before, after]) => before !== after) : [];
|
||||
return <section className="space-y-4 border-t border-border pt-5">
|
||||
<h2 className="font-medium">版本与归档</h2>
|
||||
<p className="text-sm text-muted-foreground">将旧版本恢复成新草稿,核对后再发布。已有运行继续使用提交时的版本。</p>
|
||||
<div className="flex flex-wrap gap-2">{versions.map(v => <Button key={v} variant="outline" size="sm" disabled={busy} onClick={() => void act(async () => {
|
||||
const [snapshot, latest] = await Promise.all([cloudAgentsApi.call('version', { slug, version: String(v) }), cloudAgentsApi.get(slug)]);
|
||||
setVersion(snapshot); setCurrent(latest);
|
||||
})}>比较版本 {v}</Button>)}</div>
|
||||
{version && current && <div className="space-y-3 rounded-xl border border-border p-4 text-sm">
|
||||
<h3 className="font-medium">当前草稿修订 {current.draft_revision} → 版本 {version.version}</h3>
|
||||
{!rows.length && <p>配置相同。</p>}
|
||||
<div className="max-h-96 overflow-auto"><table className="w-full table-fixed text-left"><thead><tr><th className="w-1/5 p-2">字段</th><th className="p-2">当前草稿</th><th className="p-2">旧版本</th></tr></thead>
|
||||
<tbody>{rows.map(([key, before, after]) => <tr key={key} className="border-t align-top"><td className="break-words p-2">{key}</td><td className="whitespace-pre-wrap break-words p-2">{before}</td><td className="whitespace-pre-wrap break-words p-2">{after}</td></tr>)}</tbody></table></div>
|
||||
<Button variant="outline" disabled={busy || current.draft_revision !== revision} onClick={() => void act(async () => {
|
||||
const result = await cloudAgentsApi.call('restoreVersion', { slug, version: String(version.version), expected_revision: current.draft_revision });
|
||||
setCurrent(result); setVersion(null); onChanged?.(result); setNotice('已恢复为新草稿,请到配置页核对后发布');
|
||||
})}>恢复为新草稿</Button>
|
||||
{current.draft_revision !== revision && <p>草稿已经变化,请回到配置页读取最新修订再恢复。</p>}
|
||||
</div>}
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void act(async () => { setCurrent(await cloudAgentsApi.get(slug)); setConfirmArchive(true); })}>管理归档</Button>
|
||||
{confirmArchive && current && <div className="space-y-3 rounded-xl bg-muted/30 p-4 text-sm"><p>{current.archived ? '恢复到我的智能体列表,之后可另行启用。' : '归档会停用后续调用,并移入归档列表。已有对话、任务定义和费用记录保留。'}</p>
|
||||
<div className="flex gap-2"><Button variant="outline" disabled={busy} onClick={() => void act(async () => {
|
||||
const result = await cloudAgentsApi.call('archiveAgent', { slug, archived: !current.archived }); setCurrent(result); setConfirmArchive(false); onChanged?.(result); setNotice(result.archived ? '智能体已归档' : '智能体已恢复,当前仍为停用状态');
|
||||
})}>{current.archived ? '恢复智能体' : '确认归档'}</Button><Button variant="ghost" onClick={() => setConfirmArchive(false)}>取消</Button></div>
|
||||
</div>}
|
||||
{notice && <p role="status" className="text-sm">{notice}</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}。如响应丢失,请先重新读取版本与草稿确认结果。</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export function CloudOverview({ section, onAgent, onThread }: {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [offset, setOffset] = useState<number | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [archived, setArchived] = useState(false);
|
||||
const [confirm, setConfirm] = useState<CloudThread | null>(null);
|
||||
const paged = useRef(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -24,7 +26,7 @@ export function CloudOverview({ section, onAgent, onThread }: {
|
||||
const result = await cloudAgentsApi.call('received', {});
|
||||
if (live) setAgents(result.agents);
|
||||
} else {
|
||||
const result = await cloudAgentsApi.call('threads', {});
|
||||
const result = await cloudAgentsApi.call('threads', { archived });
|
||||
if (live) { setThreads(result.threads); setOffset(result.next_offset); }
|
||||
}
|
||||
if (live) setError('');
|
||||
@@ -34,12 +36,12 @@ export function CloudOverview({ section, onAgent, onThread }: {
|
||||
void load();
|
||||
const timer = window.setInterval(() => { if (!paged.current) void load(); }, 15000);
|
||||
return () => { live = false; window.clearInterval(timer); };
|
||||
}, [section, refresh]);
|
||||
}, [section, refresh, archived]);
|
||||
const more = async () => {
|
||||
if (offset === null) return;
|
||||
paged.current = true;
|
||||
setLoading(true);
|
||||
try { const page = await cloudAgentsApi.call('threads', { offset }); setThreads(v => [...v, ...page.threads]); setOffset(page.next_offset); }
|
||||
try { const page = await cloudAgentsApi.call('threads', { offset, archived }); setThreads(v => [...v, ...page.threads]); setOffset(page.next_offset); }
|
||||
catch(e) { setError(e instanceof Error ? e.message : '加载失败'); } finally { setLoading(false); }
|
||||
};
|
||||
return <section className="space-y-4">
|
||||
@@ -53,13 +55,19 @@ export function CloudOverview({ section, onAgent, onThread }: {
|
||||
<p className="mt-4 text-xs text-muted-foreground">版本 {agent.published_version} · 打开对话</p>
|
||||
</button>)}</div></> : <>
|
||||
<p className="text-sm text-muted-foreground">查看自己的对话、自动任务结果和待确认事项。</p>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={archived} onChange={e => setArchived(e.target.checked)} />查看已归档对话</label>
|
||||
{confirm && <div className="space-y-3 rounded-xl bg-muted/30 p-4 text-sm"><p>{archived ? '恢复此对话到活动列表?' : '归档此对话?运行或排队中的任务需先停止,历史与费用仍会保留。'}</p>
|
||||
<div className="flex gap-2"><Button variant="outline" disabled={loading} onClick={async () => {
|
||||
setLoading(true); try { await cloudAgentsApi.call('archiveThread', { thread_id: confirm.thread_id, archived: !archived }); setConfirm(null); setRefresh(v => v + 1); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '归档失败'); } finally { setLoading(false); }
|
||||
}}>确认{archived ? '恢复' : '归档'}</Button><Button variant="ghost" onClick={() => setConfirm(null)}>取消</Button></div></div>}
|
||||
{!loading && !threads.length && <p className="py-12 text-center text-sm text-muted-foreground">还没有运行记录</p>}
|
||||
{threads.map(thread => <button key={thread.thread_id} onClick={() => onThread(thread)}
|
||||
{threads.map(thread => <div key={thread.thread_id} className="flex items-center gap-2"><button onClick={() => onThread(thread)}
|
||||
className="flex min-h-20 w-full items-center justify-between gap-4 rounded-xl border border-border p-4 text-left transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{thread.unread && <span className="mr-2 inline-block h-2 w-2 rounded-full bg-violet-500" aria-label="未读" />}{thread.title || '未命名对话'}</h2>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{thread.mode === 'preview' ? '草稿预览' : '正式运行'} · {new Date(thread.updated_at).toLocaleString()}</p></div>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">{cloudStatus(thread.status)}</span>
|
||||
</button>)}
|
||||
</button><Button variant="ghost" disabled={loading} onClick={() => setConfirm(thread)}>{archived ? '恢复' : '归档'}</Button></div>)}
|
||||
{offset !== null && <Button variant="outline" disabled={loading} onClick={() => void more()}>加载更早记录</Button>}
|
||||
</>}
|
||||
</section>;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudPendingOperation } from '../../../shared/cloud-agents';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
createAgent: '创建智能体', submit: '发送消息', preview: '预览草稿', resume: '继续任务', publish: '发布版本',
|
||||
createApplication: '创建应用', createKey: '创建密钥', createKnowledge: '创建知识库', processKnowledge: '处理文档',
|
||||
createSchedule: '创建自动任务', runSchedule: '立即运行任务',
|
||||
importKnowledgeAttachment: '导入附件到知识库', createChild: '创建子智能体',
|
||||
};
|
||||
|
||||
export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) {
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null);
|
||||
const resolve = async (item: CloudPendingOperation, discard = false) => {
|
||||
setBusy(item.id); setError('');
|
||||
try {
|
||||
const response = await cloudAgentsApi.resolvePending(item.id, discard);
|
||||
if (!discard) setResult({ operation: item.operation, value: response.result });
|
||||
onResolved();
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); }
|
||||
finally { setBusy(''); }
|
||||
};
|
||||
if (!pending.length && !result && !error) return null;
|
||||
return <aside aria-label="未确认的云端操作" className="mx-auto mb-6 max-w-6xl space-y-3 rounded-xl border border-amber-200 bg-amber-50/30 p-4 text-sm">
|
||||
{pending.length > 0 && <><h2 className="font-medium">有 {pending.length} 个操作的结果尚未确认</h2>
|
||||
<p className="text-muted-foreground">重试会确认原操作,不会新建一份任务。移除提醒只清除本机记录,云端已接受的任务仍会继续。</p></>}
|
||||
{pending.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><p>{labels[item.operation] ?? '云端操作'} · {new Date(item.created_at).toLocaleString()}</p>
|
||||
{typeof item.input.query === 'string' && <p className="mt-1 line-clamp-2 text-muted-foreground">{item.input.query}</p>}</div>
|
||||
<div className="flex gap-2"><Button size="sm" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在确认…' : '重试并确认结果'}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item, true)}>移除提醒</Button></div>
|
||||
</div>)}
|
||||
{result && <div className="space-y-2"><p>{labels[result.operation] ?? '操作'}的结果已确认。{result.operation === 'createKey' && '请保存下面的密钥,关闭后不再显示。'}</p>
|
||||
<details open={result.operation === 'createKey'}><summary className="cursor-pointer">查看结果</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all p-2 text-xs">{JSON.stringify(result.value, null, 2)}</pre></details>
|
||||
<Button variant="ghost" size="sm" onClick={() => setResult(null)}>关闭结果</Button></div>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudChild, CloudMcp, CloudMcpInput, CloudPersonalResources, CloudSkillDraft } from '../../../shared/cloud-agents';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
|
||||
export function CloudResources({ onChanged }: { onChanged: () => void }) {
|
||||
const [data, setData] = useState<CloudPersonalResources | null>(null);
|
||||
const [mcp, setMcp] = useState<CloudMcp | 'new' | null>(null);
|
||||
const [child, setChild] = useState<CloudChild | 'new' | null>(null);
|
||||
const [skill, setSkill] = useState<CloudSkillDraft | null>(null);
|
||||
const [deleting, setDeleting] = useState<{ kind: 'mcp' | 'skill'; slug: string; name: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
usePendingCloudInput(busy || Boolean(skill));
|
||||
const refresh = useCallback(async () => setData(await cloudAgentsApi.call('resources', {})), []);
|
||||
const act = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true); setError('');
|
||||
try { await action(); await refresh(); onChanged(); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '能力管理失败'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return <details className="rounded-lg border p-4" onToggle={e => { if (e.currentTarget.open && !data) void act(refresh); }}>
|
||||
<summary className="min-h-8 cursor-pointer text-sm font-medium">管理我的云端能力</summary>
|
||||
<div className="mt-4 space-y-6 text-sm">
|
||||
<p className="text-xs leading-5 text-muted-foreground">连接、Skills 和子智能体属于你的个人账号。创建后在上面的能力目录选用,保存并发布后生效。</p>
|
||||
{error && <p role="alert" className="text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}>刷新能力</Button></p>}
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">MCP 连接</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(mcp)} onClick={() => setMcp('new')}>添加 MCP</Button></div>
|
||||
<p className="text-xs text-muted-foreground">连接由云端访问。凭据只用于你的连接,分享调用可在你已发布的授权范围内使用这些工具。</p>
|
||||
{mcp && <McpEditor key={mcp === 'new' ? 'new' : mcp.slug} initial={mcp === 'new' ? undefined : mcp} onClose={() => setMcp(null)} onSaved={() => { setMcp(null); void act(refresh); }} />}
|
||||
{data?.mcps.map(item => <div key={item.slug} className="space-y-2 rounded-lg bg-muted/30 p-3"><p>{item.name} · {item.enabled ? '已启用' : '已停用'} · {item.has_credentials ? '已配置凭据' : '无凭据'}</p>
|
||||
<p className="break-all text-xs text-muted-foreground">{item.url}</p><div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || Boolean(mcp)} onClick={() => setMcp(item)}>编辑连接</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('enableMcp', { key: item.slug, enabled: !item.enabled }))}>{item.enabled ? '停用连接' : '启用连接'}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'mcp', slug: item.slug, name: item.name })}>删除连接</Button>
|
||||
</div></div>)}
|
||||
</section>
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">个人 Skills</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(skill)} onClick={() => void act(async () => setSkill(await cloudAgentsApi.uploadSkill()))}>上传 Skill</Button></div>
|
||||
<p className="text-xs text-muted-foreground">支持 SKILL.md 或单个 Skill 的 ZIP,最大 5 MB。同名 Skill 需先删除个人旧版再安装,并重新核对相关智能体配置。</p>
|
||||
{skill && <div className="space-y-3 rounded-lg border p-3">{skill.items.map(item => <p key={item.slug}>{item.name || item.slug} · {item.description}{item.error && <span className="text-destructive">{item.error}</span>}</p>)}
|
||||
<div className="flex gap-2"><Button type="button" size="sm" disabled={busy} onClick={() => void act(async () => {
|
||||
const result = await cloudAgentsApi.call('confirmSkill', { draft_id: skill.draft_id });
|
||||
const failures = result.items.filter(item => !item.success); if (failures.length) throw new Error(failures.map(item => item.error).join(';'));
|
||||
setSkill(null);
|
||||
})}>确认安装到个人能力</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setSkill(null)}>取消安装</Button></div></div>}
|
||||
{data?.skills.map(item => <div key={item.slug} className="flex items-center justify-between gap-2 rounded-lg bg-muted/30 p-3"><div><p>{item.name}</p><p className="mt-1 text-xs text-muted-foreground">{item.description}</p></div>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'skill', slug: item.slug, name: item.name })}>删除 Skill</Button></div>)}
|
||||
</section>
|
||||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">个人子智能体</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(child)} onClick={() => setChild('new')}>创建子智能体</Button></div>
|
||||
<p className="text-xs text-muted-foreground">子智能体处理专门任务,继承父智能体发布的模型、能力和费用限制。</p>
|
||||
{child && <ChildEditor key={child === 'new' ? 'new' : child.slug} initial={child === 'new' ? undefined : child} onClose={() => setChild(null)} onSaved={() => { setChild(null); void act(refresh); }} />}
|
||||
{data?.subagents.map(item => <div key={item.slug} className="flex items-center justify-between gap-2 rounded-lg bg-muted/30 p-3"><div><p>{item.name}</p><p className="mt-1 text-xs text-muted-foreground">{item.purpose}</p></div>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy || Boolean(child)} onClick={() => setChild(item)}>编辑子智能体</Button></div>)}
|
||||
</section>
|
||||
{deleting && <div role="dialog" aria-label="删除云端能力" className="space-y-3 rounded-lg border border-amber-200 p-4"><p>删除“{deleting.name}”?其他智能体中选用的此项能力也会不可用,请同步调整配置。</p>
|
||||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => void act(async () => {
|
||||
await cloudAgentsApi.call(deleting.kind === 'mcp' ? 'deleteMcp' : 'deleteSkill', { key: deleting.slug }); setDeleting(null);
|
||||
})}>确认删除能力</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div></div>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
|
||||
function McpEditor({ initial, onClose, onSaved }: { initial?: CloudMcp; onClose: () => void; onSaved: () => void }) {
|
||||
const [value, setValue] = useState<CloudMcpInput>(initial ?? { name: '', description: '', url: '', transport: 'streamable_http' });
|
||||
const [token, setToken] = useState('');
|
||||
const [clear, setClear] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [intent, setIntent] = useState<(CloudMcpInput & { operation_id: string }) | null>(null);
|
||||
usePendingCloudInput(busy || Boolean(intent) || Boolean(token) || clear || value.name !== (initial?.name ?? '') || value.url !== (initial?.url ?? '') || value.description !== (initial?.description ?? '') || value.transport !== (initial?.transport ?? 'streamable_http'));
|
||||
return <div className="space-y-3 rounded-lg border p-3"><fieldset disabled={busy || Boolean(intent)} className="space-y-3">
|
||||
<label className="block space-y-1"><span>连接名称</span><Input value={value.name} maxLength={100} onChange={e => setValue({ ...value, name: e.target.value })} /></label>
|
||||
<label className="block space-y-1"><span>连接地址</span><Input type="url" value={value.url} onChange={e => setValue({ ...value, url: e.target.value })} placeholder="https://example.com/mcp" /></label>
|
||||
<label className="block space-y-1"><span>传输方式</span><select className="h-10 w-full rounded-md border bg-background px-3" value={value.transport} onChange={e => setValue({ ...value, transport: e.target.value as CloudMcpInput['transport'] })}><option value="streamable_http">Streamable HTTP</option><option value="sse">SSE</option></select></label>
|
||||
<label className="block space-y-1"><span>连接说明</span><Input maxLength={500} value={value.description} onChange={e => setValue({ ...value, description: e.target.value })} /></label>
|
||||
<label className="block space-y-1"><span>Bearer 凭据</span><Input type="password" autoComplete="new-password" value={token} disabled={clear} onChange={e => setToken(e.target.value)} placeholder={initial?.has_credentials ? '留空保留已有凭据' : '服务无需凭据时留空'} /></label>
|
||||
{initial?.has_credentials && <label className="flex items-center gap-2"><input type="checkbox" checked={clear} onChange={e => setClear(e.target.checked)} />清除已有凭据</label>}
|
||||
</fieldset>
|
||||
{error && <p role="alert" className="text-destructive">{error}。可重试原操作,或返回能力列表核对。</p>}
|
||||
<div className="flex gap-2"><Button type="button" disabled={busy || !value.name.trim() || !value.url.trim()} onClick={async () => {
|
||||
const input = intent ?? { name: value.name, description: value.description, transport: value.transport, url: value.url,
|
||||
headers: clear ? {} : token.trim() ? { Authorization: 'Bearer ' + token.trim() } : null, operation_id: crypto.randomUUID() };
|
||||
setIntent(input); setBusy(true); setError('');
|
||||
try { if (initial) await cloudAgentsApi.call('updateMcp', { key: initial.slug, ...input }); else await cloudAgentsApi.call('createMcp', input); setToken(''); onSaved(); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '连接保存失败'); } finally { setBusy(false); }
|
||||
}}>{intent ? '重试保存连接' : '保存连接'}</Button><Button type="button" variant="ghost" disabled={busy} onClick={onClose}>返回能力列表</Button></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ChildEditor({ initial, onClose, onSaved }: { initial?: CloudChild; onClose: () => void; onSaved: () => void }) {
|
||||
const [value, setValue] = useState<Omit<CloudChild, 'slug'>>(initial ?? { name: '', purpose: '', system_prompt: '' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const operation = useRef<string | null>(null);
|
||||
usePendingCloudInput(busy || Boolean(operation.current) || value.name !== (initial?.name ?? '') || value.purpose !== (initial?.purpose ?? '') || value.system_prompt !== (initial?.system_prompt ?? ''));
|
||||
return <div className="space-y-3 rounded-lg border p-3"><fieldset disabled={busy || Boolean(operation.current)} className="space-y-3">
|
||||
<label className="block space-y-1"><span>子智能体名称</span><Input maxLength={100} value={value.name} onChange={e => setValue({ ...value, name: e.target.value })} /></label>
|
||||
<label className="block space-y-1"><span>子智能体用途</span><Input maxLength={2000} value={value.purpose} onChange={e => setValue({ ...value, purpose: e.target.value })} /></label>
|
||||
<label className="block space-y-1"><span>子智能体指令</span><Textarea maxLength={32000} value={value.system_prompt} onChange={e => setValue({ ...value, system_prompt: e.target.value })} /></label>
|
||||
</fieldset>{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
<div className="flex gap-2"><Button type="button" disabled={busy || !value.name.trim() || !value.purpose.trim() || !value.system_prompt.trim()} onClick={async () => {
|
||||
operation.current ??= crypto.randomUUID(); setBusy(true); setError('');
|
||||
try { if (initial) await cloudAgentsApi.call('updateChild', { key: initial.slug, name: value.name, purpose: value.purpose, system_prompt: value.system_prompt });
|
||||
else await cloudAgentsApi.call('createChild', { name: value.name, purpose: value.purpose, system_prompt: value.system_prompt, operation_id: operation.current }); onSaved(); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '子智能体保存失败'); } finally { setBusy(false); }
|
||||
}}>{operation.current ? '重试保存子智能体' : '保存子智能体'}</Button><Button type="button" variant="ghost" disabled={busy} onClick={onClose}>返回能力列表</Button></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudSchedule, CloudScheduleInput, CloudThread } from '../../../shared/cloud-agents';
|
||||
import type { CloudSchedule, CloudScheduleInput, CloudThread, CloudScheduleProposal, CloudScheduleContext, CloudBudget } from '../../../shared/cloud-agents';
|
||||
import { cloudStatus } from './CloudChat';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
|
||||
export function CloudSchedules({ slug, onOpen }: { slug: string; onOpen: (thread: CloudThread) => void }) {
|
||||
export function CloudSchedules({ slug, onOpen, proposal, onProposalClosed }: { slug: string; onOpen: (thread: CloudThread) => void; proposal?: CloudScheduleProposal; onProposalClosed?: () => void }) {
|
||||
const [jobs, setJobs] = useState<CloudSchedule[]>([]);
|
||||
const [editing, setEditing] = useState<CloudSchedule | 'new' | null>(null);
|
||||
const [editing, setEditing] = useState<CloudSchedule | 'new' | null>(proposal ? 'new' : null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
@@ -29,7 +29,8 @@ export function CloudSchedules({ slug, onOpen }: { slug: string; onOpen: (thread
|
||||
try { await task(); await refresh(); } catch(e) { setError(e instanceof Error ? e.message : '任务操作失败'); } finally { setBusy(false); }
|
||||
};
|
||||
if (editing) return <ScheduleEditor key={editing === 'new' ? 'new' : editing.id} slug={slug} initial={editing === 'new' ? undefined : editing}
|
||||
onClose={() => setEditing(null)} onSaved={() => { setEditing(null); void refresh(); }} />;
|
||||
proposal={editing === 'new' ? proposal : undefined}
|
||||
onClose={() => { setEditing(null); onProposalClosed?.(); }} onSaved={() => { setEditing(null); onProposalClosed?.(); void refresh(); }} />;
|
||||
return <div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4"><div><h2 className="font-medium">自动任务</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">设置目标与时间,云端会在触发时使用当时的发布版本。需确认的操作会等待你处理。</p></div>
|
||||
@@ -39,14 +40,14 @@ export function CloudSchedules({ slug, onOpen }: { slug: string; onOpen: (thread
|
||||
{jobs.map(job => <article key={job.id} className="space-y-4 rounded-xl border border-border p-5">
|
||||
<div className="flex items-start justify-between gap-4"><div><h3 className="font-medium">{job.name}</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{job.prompt}</p></div><span className="shrink-0 text-xs">{job.enabled ? '已启用' : '已暂停'}</span></div>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{job.cron_expression} · {job.timezone} · 下次 {job.enabled && job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '—'}</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{scheduleLabel(job.cron_expression)} · {job.timezone} · 下次 {job.enabled && job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '—'}</p>
|
||||
<div className="flex flex-wrap gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={() => void act(async () => {
|
||||
const operation = runs.current.get(job.id) ?? crypto.randomUUID(); runs.current.set(job.id, operation);
|
||||
await cloudAgentsApi.call('runSchedule', { slug, job_id: job.id, operation_id: operation }); runs.current.delete(job.id);
|
||||
})}>{runs.current.has(job.id) ? '重试本次触发' : '立即执行'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setEditing(job)}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('updateSchedule', { slug, job_id: job.id,
|
||||
name: job.name, prompt: job.prompt, cron_expression: job.cron_expression, timezone: job.timezone, enabled: !job.enabled }))}>{job.enabled ? '暂停' : '启用'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => job.enabled ? void act(() => cloudAgentsApi.call('updateSchedule', { slug, job_id: job.id,
|
||||
name: job.name, prompt: job.prompt, cron_expression: job.cron_expression, timezone: job.timezone, enabled: false })) : setEditing({ ...job, enabled: true })}>{job.enabled ? '暂停' : '启用'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDeleting(job.id)}>删除</Button></div>
|
||||
{deleting === job.id && <div role="alert" className="flex items-center gap-2 text-sm"><span>删除此自动任务?历史结果仍保留。</span>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void act(async () => { await cloudAgentsApi.call('deleteSchedule', { slug, job_id: job.id }); setDeleting(null); })}>确认删除</Button>
|
||||
@@ -60,12 +61,22 @@ export function CloudSchedules({ slug, onOpen }: { slug: string; onOpen: (thread
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ScheduleEditor({ slug, initial, onClose, onSaved }: { slug: string; initial?: CloudSchedule; onClose: () => void; onSaved: () => void }) {
|
||||
const [value, setValue] = useState<CloudScheduleInput>(initial ?? { name: '', prompt: '', cron_expression: '0 9 * * *', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, enabled: true });
|
||||
usePendingCloudInput(JSON.stringify(value) !== JSON.stringify(initial ?? { name: '', prompt: '', cron_expression: '0 9 * * *', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, enabled: true }));
|
||||
function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: string; initial?: CloudSchedule; proposal?: CloudScheduleProposal; onClose: () => void; onSaved: () => void }) {
|
||||
const [baseline] = useState<CloudScheduleInput>(() => initial ?? (proposal ? { ...proposal, enabled: false } : { name: '', prompt: '', cron_expression: '0 9 * * *', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, enabled: false }));
|
||||
const [value, setValue] = useState<CloudScheduleInput>(baseline);
|
||||
const [intent, setIntent] = useState<(CloudScheduleInput & { operation_id: string }) | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [context, setContext] = useState<CloudScheduleContext | null>(null);
|
||||
const [budget, setBudget] = useState<CloudBudget | null>(null);
|
||||
usePendingCloudInput(busy || Boolean(intent) || Boolean(proposal) || JSON.stringify(value) !== JSON.stringify(baseline));
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void Promise.all([cloudAgentsApi.call('scheduleContext', { slug }), cloudAgentsApi.call('budget', { slug })])
|
||||
.then(([next, limits]) => { if (live) { setContext(next); setBudget(limits); } })
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '运行配置读取失败'); });
|
||||
return () => { live = false; };
|
||||
}, [slug]);
|
||||
const save = async () => {
|
||||
if (busy) return;
|
||||
const input = intent ?? { ...value, operation_id: crypto.randomUUID() };
|
||||
@@ -81,16 +92,49 @@ function ScheduleEditor({ slug, initial, onClose, onSaved }: { slug: string; ini
|
||||
<fieldset disabled={busy || Boolean(intent)} className="space-y-5">
|
||||
<label className="block space-y-2 text-sm"><span>任务名称</span><Input required maxLength={255} value={value.name} onChange={e => setValue({ ...value, name: e.target.value })} /></label>
|
||||
<label className="block space-y-2 text-sm"><span>目标与要求</span><Textarea required maxLength={32000} className="min-h-36" value={value.prompt} onChange={e => setValue({ ...value, prompt: e.target.value })} placeholder="说明每次运行要完成什么,以及你希望看到怎样的结果。" /></label>
|
||||
<label className="block space-y-2 text-sm"><span>执行频率</span><select className="h-10 w-full rounded-md border border-input bg-background px-3" value={['0 9 * * *', '0 9 * * 1-5', '0 9 * * 1'].includes(value.cron_expression) ? value.cron_expression : 'custom'}
|
||||
onChange={e => setValue({ ...value, cron_expression: e.target.value === 'custom' ? '' : e.target.value })}>
|
||||
<option value="0 9 * * *">每天 09:00</option><option value="0 9 * * 1-5">工作日 09:00</option><option value="0 9 * * 1">每周一 09:00</option><option value="custom">自定义</option>
|
||||
</select></label>
|
||||
<label className="block space-y-2 text-sm"><span>Cron(分 时 日 月 周)</span><Input required value={value.cron_expression} onChange={e => setValue({ ...value, cron_expression: e.target.value })} /></label>
|
||||
<ScheduleTime expression={value.cron_expression} onChange={cron_expression => setValue({ ...value, cron_expression })} />
|
||||
<label className="block space-y-2 text-sm"><span>时区</span><Input required value={value.timezone} onChange={e => setValue({ ...value, timezone: e.target.value })} placeholder="Asia/Shanghai" /></label>
|
||||
<label className="flex min-h-10 items-center gap-2 text-sm"><input type="checkbox" checked={value.enabled} onChange={e => setValue({ ...value, enabled: e.target.checked })} />保存后启用</label>
|
||||
</fieldset>
|
||||
{context?.configuration && <div className="space-y-2 rounded-xl bg-muted/40 p-4 text-sm leading-6">
|
||||
<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 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>
|
||||
<p>费用由你支付。每次任务上限 {budget?.request_limit_points ?? '不设限'},每日上限 {budget?.daily_limit_points ?? '不设限'}(词元点数)。</p>
|
||||
<p>结果保存到{context.result_destination};需要确认的操作会暂停等待你处理。</p>
|
||||
</div>}
|
||||
{context && (!context.version || !context.enabled) && <p className="text-sm text-muted-foreground">请先发布并启用智能体,再启用自动任务。</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}。重试会确认同一次保存。</p>}
|
||||
<div className="flex gap-3"><Button type="submit" disabled={busy || !value.name.trim() || !value.prompt.trim() || !value.cron_expression.trim()}>{busy ? '保存中…' : intent ? '重试保存' : value.enabled ? '保存并启用' : '保存为停用任务'}</Button>
|
||||
<div className="flex gap-3"><Button type="submit" disabled={busy || !value.name.trim() || !value.prompt.trim() || !value.cron_expression.trim() || (value.enabled && (!context?.version || !context.enabled || !budget))}>{busy ? '保存中…' : intent ? '重试保存' : value.enabled ? '保存并启用' : '保存为停用任务'}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={onClose}>{intent ? '返回列表确认' : '取消'}</Button></div>
|
||||
</form>;
|
||||
}
|
||||
|
||||
function scheduleLabel(expression: string) {
|
||||
if (!/^\d+ \d+ \* \* (\*|1-5|[0-6])$/.test(expression)) return `自定义时间规则:${expression}`;
|
||||
const [minute, hour, , , day] = expression.split(' ');
|
||||
const frequency = day === '*' ? '每天' : day === '1-5' ? '工作日' : '每周' + '日一二三四五六'[Number(day)];
|
||||
return `${frequency} ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function ScheduleTime({ expression, onChange }: { expression: string; onChange: (expression: string) => void }) {
|
||||
const parts = expression.split(' ');
|
||||
const simple = /^\d+ \d+ \* \* (\*|1-5|[0-6])$/.test(expression);
|
||||
const frequency = !simple ? 'custom' : parts[4] === '*' ? 'daily' : parts[4] === '1-5' ? 'weekday' : 'weekly';
|
||||
const [advanced, setAdvanced] = useState(!simple);
|
||||
const set = (minute: string, hour: string, day: string) => onChange(`${Number(minute)} ${Number(hour)} * * ${day}`);
|
||||
return <div className="space-y-4">
|
||||
<label className="block space-y-2 text-sm"><span>执行频率</span><select className="h-10 w-full rounded-md border border-input bg-background px-3" value={advanced ? 'custom' : frequency} onChange={e => {
|
||||
const key = e.target.value; setAdvanced(key === 'custom');
|
||||
if (key !== 'custom') set(simple ? parts[0] : '0', simple ? parts[1] : '9', key === 'daily' ? '*' : key === 'weekday' ? '1-5' : '1');
|
||||
}}><option value="daily">每天</option><option value="weekday">工作日</option><option value="weekly">每周</option><option value="custom">高级 Cron</option></select></label>
|
||||
{!advanced && <div className="flex gap-4"><label className="space-y-2 text-sm"><span>执行时间</span><Input type="time" required value={`${parts[1].padStart(2, '0')}:${parts[0].padStart(2, '0')}`} onChange={e => { const [hour, minute] = e.target.value.split(':'); if (hour && minute) set(minute, hour, parts[4]); }} /></label>
|
||||
{frequency === 'weekly' && <label className="space-y-2 text-sm"><span>星期</span><select className="block h-10 rounded-md border bg-background px-3" value={parts[4]} onChange={e => set(parts[0], parts[1], e.target.value)}>
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map((day, i) => <option key={i} value={i}>星期{day}</option>)}</select></label>}</div>}
|
||||
{advanced && <label className="block space-y-2 text-sm"><span>Cron(分 时 日 月 周)</span><Input required value={expression} onChange={e => onChange(e.target.value)} /></label>}
|
||||
</div>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CloudKnowledgePanel } from './CloudKnowledge';
|
||||
import { CloudResources } from './CloudResources';
|
||||
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
|
||||
|
||||
export function ConfigurationFields({ slug, value, disabled, onChange }: {
|
||||
@@ -56,7 +57,8 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
|
||||
</fieldset>;
|
||||
})}
|
||||
<p className="text-xs leading-5 text-muted-foreground">未选择的资源不会开放给此智能体。分享使用时仍按本配置限制能力。</p>
|
||||
<CloudKnowledgePanel slug={slug} onCreated={kb => {
|
||||
<CloudResources onChanged={() => setRefresh(v => v + 1)} />
|
||||
<CloudKnowledgePanel slug={slug} onDeleted={id => { change('knowledges', value.knowledges.filter(key => key !== id)); setRefresh(v => v + 1); }} onCreated={kb => {
|
||||
change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]);
|
||||
setRefresh(v => v + 1);
|
||||
}} />
|
||||
|
||||
@@ -6,13 +6,15 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread } from '../../../shared/cloud-agents';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
|
||||
import { ConfigurationFields } from './ConfigurationFields';
|
||||
import { CloudChat } from './CloudChat';
|
||||
import { CloudAccessPanel } from './CloudAccess';
|
||||
import { CloudSchedules } from './CloudSchedules';
|
||||
import type { CloudScheduleProposal } from '../../../shared/cloud-agents';
|
||||
import { CloudOverview } from './CloudOverview';
|
||||
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||||
import { CloudRecovery } from './CloudRecovery';
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
|
||||
@@ -29,6 +31,9 @@ function AgentWorkspace() {
|
||||
const [section, setSection] = useState<'mine' | 'received' | 'activity'>(() => new URLSearchParams(location.search).get('view') === 'activity' ? 'activity' : 'mine');
|
||||
const [shared, setShared] = useState<CloudAgentEntry | null>(null);
|
||||
const [activity, setActivity] = useState<CloudThread | null>(null);
|
||||
const [recent, setRecent] = useState<CloudRecent | null>(null);
|
||||
const [pending, setPending] = useState<CloudPendingOperation[]>([]);
|
||||
const [archived, setArchived] = useState(false);
|
||||
const mounted = useRef(true);
|
||||
const request = useRef(0);
|
||||
|
||||
@@ -37,7 +42,7 @@ function AgentWorkspace() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const page = await cloudAgentsApi.list(next);
|
||||
const page = await cloudAgentsApi.list(next, archived);
|
||||
if (!mounted.current || generation !== request.current) return;
|
||||
setAgents((previous) => next ? [...previous, ...page.agents] : page.agents);
|
||||
setCursor(page.next_cursor);
|
||||
@@ -46,7 +51,7 @@ function AgentWorkspace() {
|
||||
} finally {
|
||||
if (mounted.current && generation === request.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [archived]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
@@ -62,6 +67,37 @@ function AgentWorkspace() {
|
||||
return () => { live = false; };
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
cloudAgentsApi.recovery().then(async state => {
|
||||
if (!live) return;
|
||||
setPending(state.pending);
|
||||
if (!state.recent || location.pathname.includes('/shared/') || new URLSearchParams(location.search).has('view')) return;
|
||||
const pointer = state.recent;
|
||||
// Creator drafts may be unpublished; shared entry revalidates current access separately.
|
||||
let draft: CloudAgentDraft | null = null;
|
||||
let entry: CloudAgentEntry | null = null;
|
||||
try { draft = await cloudAgentsApi.get(pointer.slug); }
|
||||
catch { entry = await cloudAgentsApi.call('entry', { slug: pointer.slug }); }
|
||||
if (!live) return;
|
||||
setRecent(pointer);
|
||||
if (draft) setSelected(draft); else setShared(entry);
|
||||
}).catch(failure => { if (live) setError('上次工作区未能恢复:' + errorMessage(failure)); });
|
||||
return () => { live = false; };
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
const refreshRecovery = () => {
|
||||
void cloudAgentsApi.recovery().then(state => { if (mounted.current) setPending(state.pending); })
|
||||
.catch(failure => { if (mounted.current) setError(errorMessage(failure)); });
|
||||
void load();
|
||||
};
|
||||
const clearRecent = () => {
|
||||
setRecent(null);
|
||||
void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); });
|
||||
refreshRecovery();
|
||||
};
|
||||
const recovery = <CloudRecovery pending={pending} onResolved={refreshRecovery} />;
|
||||
|
||||
const accepted = (agent: CloudAgentDraft) => {
|
||||
if (!mounted.current) return;
|
||||
setAgents((previous) => [agent, ...previous.filter((item) => item.slug !== agent.slug)]);
|
||||
@@ -69,14 +105,15 @@ function AgentWorkspace() {
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
if (selected) return <DraftEditor key={selected.slug} initial={selected} onBack={() => {
|
||||
if (selected) return <>{recovery}<DraftEditor key={selected.slug} initial={selected} recent={recent} onBack={() => {
|
||||
setSelected(null);
|
||||
void load();
|
||||
}} onSaved={accepted} />;
|
||||
if (shared || activity) return <SharedConversation shared={shared} activity={activity} onBack={() => { setShared(null); setActivity(null); }} />;
|
||||
clearRecent();
|
||||
}} onSaved={accepted} /></>;
|
||||
if (shared || activity) return <>{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></>;
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl pb-10" data-testid="cloud-agents-page">
|
||||
{recovery}
|
||||
<header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-6">
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted-foreground">Makelore Agents</p>
|
||||
@@ -90,6 +127,7 @@ function AgentWorkspace() {
|
||||
<Button key={key} variant={section === key ? 'secondary' : 'ghost'} aria-current={section === key ? 'page' : undefined} disabled={creating} onClick={() => setSection(key)}>{label}</Button>)}
|
||||
</nav>
|
||||
{section !== 'mine' ? <CloudOverview key={section} section={section} onAgent={setShared} onThread={setActivity} /> : <>
|
||||
<label className="mb-5 flex items-center gap-2 text-sm"><input type="checkbox" checked={archived} disabled={creating} onChange={e => setArchived(e.target.checked)} />查看已归档智能体</label>
|
||||
{creating && <CreateAgentForm onCreated={accepted} onCancel={() => { setCreating(false); void load(); }} />}
|
||||
{error && <div role="alert" className="mb-5 rounded-lg border border-border p-4 text-sm">
|
||||
<p>{error}</p><Button variant="outline" className="mt-3" onClick={() => void load()}>重新连接</Button>
|
||||
@@ -174,8 +212,8 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
</form>;
|
||||
}
|
||||
|
||||
function SharedConversation({ shared, activity, onBack }: {
|
||||
shared: CloudAgentEntry | null; activity: CloudThread | null; onBack: () => void;
|
||||
function SharedConversation({ shared, activity, recent, onBack }: {
|
||||
shared: CloudAgentEntry | null; activity: CloudThread | null; recent: CloudRecent | null; onBack: () => void;
|
||||
}) {
|
||||
const pending = useCloudPendingState();
|
||||
const blocker = useBlocker(pending.hasPending);
|
||||
@@ -189,6 +227,7 @@ function SharedConversation({ shared, activity, onBack }: {
|
||||
<Button variant="ghost" className="mb-5" onClick={() => pending.hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="mr-2 h-4 w-4" />返回智能体</Button>
|
||||
<h1 className="mb-5 text-xl font-semibold">{shared?.name ?? activity?.title}</h1>
|
||||
<CloudChat key={shared?.slug ?? activity?.thread_id} slug={shared?.slug ?? activity!.agent_slug} initialThread={activity ?? undefined}
|
||||
initialThreadId={recent?.slug === shared?.slug ? recent?.thread_id : undefined}
|
||||
previewRevision={activity?.mode === 'preview' ? activity.draft_revision : undefined} />
|
||||
{(leaving || blocker.state === 'blocked') && <div role="dialog" aria-modal="true" aria-label="未完成的输入" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
|
||||
<div className="max-w-md space-y-4 rounded-xl bg-background p-6 shadow-lg">
|
||||
@@ -201,8 +240,8 @@ function SharedConversation({ shared, activity, onBack }: {
|
||||
}
|
||||
|
||||
|
||||
function DraftEditor({ initial, onBack, onSaved }: {
|
||||
initial: CloudAgentDraft; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
|
||||
function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
initial: CloudAgentDraft; recent: CloudRecent | null; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
|
||||
}) {
|
||||
const [saved, setSaved] = useState(initial);
|
||||
const [draft, setDraft] = useState(initial);
|
||||
@@ -211,17 +250,24 @@ function DraftEditor({ initial, onBack, onSaved }: {
|
||||
const [error, setError] = useState('');
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
type Tab = 'chat' | 'tasks' | 'configuration' | 'access';
|
||||
const [tab, setTab] = useState<Tab>(initial.published_version ? 'chat' : 'configuration');
|
||||
const [tab, setTab] = useState<Tab>(recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration');
|
||||
const [nextTab, setNextTab] = useState<Tab | null>(null);
|
||||
const [openedThread, setOpenedThread] = useState<CloudThread | undefined>(undefined);
|
||||
const [scheduleProposal, setScheduleProposal] = useState<CloudScheduleProposal | undefined>();
|
||||
const pending = useCloudPendingState();
|
||||
const alive = useRef(true);
|
||||
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|
||||
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
|
||||
useEffect(() => {
|
||||
if (!saved.configuration?.model && !saved.published_version) {
|
||||
void cloudAgentsApi.remember({ slug: saved.slug, mode: 'preview', draft_revision: saved.draft_revision })
|
||||
.catch(e => { if (alive.current) setError(errorMessage(e)); });
|
||||
}
|
||||
}, [saved.slug, saved.draft_revision, saved.configuration?.model, saved.published_version]);
|
||||
const blocker = useBlocker(dirty || busy || pending.hasPending);
|
||||
const finishLeaving = () => {
|
||||
if (blocker.state === 'blocked') blocker.proceed();
|
||||
else if (nextTab) { setTab(nextTab); setNextTab(null); setLeaving(false); setDraft(saved); }
|
||||
else if (nextTab) { setOpenedThread(undefined); setTab(nextTab); setNextTab(null); setLeaving(false); setDraft(saved); }
|
||||
else onBack();
|
||||
};
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
@@ -272,7 +318,7 @@ function DraftEditor({ initial, onBack, onSaved }: {
|
||||
<nav aria-label="智能体工作区" className="mb-6 flex flex-wrap gap-2">
|
||||
{([['chat', '对话'], ['tasks', '自动任务'], ['configuration', '配置'], ['access', '发布与访问']] as const).map(([key, label]) =>
|
||||
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} disabled={busy}
|
||||
onClick={() => { if (key === tab) return; if (dirty || pending.hasPending) { setNextTab(key); setLeaving(true); } else setTab(key); }}>{label}</Button>)}
|
||||
onClick={() => { if (key === tab) return; if (dirty || pending.hasPending) { setNextTab(key); setLeaving(true); } else { setOpenedThread(undefined); setTab(key); } }}>{label}</Button>)}
|
||||
</nav>
|
||||
{error && <p role="alert" className="mb-4 text-sm text-destructive">{error}</p>}
|
||||
{remote && <div className="mb-5 space-y-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm">
|
||||
@@ -282,10 +328,11 @@ function DraftEditor({ initial, onBack, onSaved }: {
|
||||
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => { setSaved(remote); setDraft(remote); setRemote(null); setError(''); }}>使用最新草稿</Button>
|
||||
<Button variant="outline" onClick={() => { setSaved(remote); setRemote(null); setError(''); }}>保留我的内容,继续编辑</Button></div>
|
||||
</div>}
|
||||
{tab === 'chat' && (saved.published_version ? <CloudChat slug={saved.slug} initialThread={openedThread} />
|
||||
{tab === 'chat' && (saved.published_version ? <CloudChat slug={saved.slug} initialThread={openedThread}
|
||||
onSchedule={proposal => { setScheduleProposal(proposal); setTab('tasks'); }} />
|
||||
: <p className="rounded-xl bg-muted/30 p-8 text-sm text-muted-foreground">先在配置中测试,再发布智能体,即可开始正式对话。</p>)}
|
||||
{tab === 'tasks' && <CloudSchedules slug={saved.slug} onOpen={thread => { setOpenedThread(thread); setTab('chat'); }} />}
|
||||
{tab === 'access' && <CloudAccessPanel slug={saved.slug} revision={saved.draft_revision} onPublished={() => {
|
||||
{tab === 'tasks' && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => { setOpenedThread(thread); setTab('chat'); }} />}
|
||||
{tab === 'access' && <CloudAccessPanel slug={saved.slug} revision={saved.draft_revision} onChanged={result => { setSaved(result); setDraft(result); onSaved(result); }} onPublished={() => {
|
||||
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
|
||||
const publication = { published_version: result.published_version, enabled: result.enabled };
|
||||
setSaved(current => ({ ...current, ...publication }));
|
||||
|
||||
Reference in new issue
Block a user