feat(agents): 完成云智能体工作台与交互流程
This commit is contained in:
@@ -24,6 +24,7 @@ import { useUserSyncStore } from './stores/user-sync';
|
||||
import { flushPendingAgentSessionSync } from '@/lib/agent-session-sync';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
|
||||
import { resolveSupportedLanguage } from '../shared/language';
|
||||
import {
|
||||
@@ -399,11 +400,19 @@ function App() {
|
||||
};
|
||||
|
||||
const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate);
|
||||
const openAgentLink = () => {
|
||||
void invokeIpc<string | null>('app:take-cloud-agent-link').then(path => {
|
||||
if (path && /^\/cloud-agents\/shared\/ml-[a-f0-9]{32}$/.test(path)) navigate(path);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
const unsubscribeAgentLink = window.electron.ipcRenderer.on('cloud-agent-link', openAgentLink);
|
||||
openAgentLink();
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
if (typeof unsubscribeAgentLink === 'function') unsubscribeAgentLink();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { hostApiFetch } from './host-api';
|
||||
import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api';
|
||||
import type { CloudAgentOperations, CloudUpload, CloudKnowledgeFile } from '../../shared/cloud-agents';
|
||||
import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents';
|
||||
|
||||
export const cloudAgentsApi = {
|
||||
uploadKnowledge: (slug: string, kb_id: string, operation_id: string) =>
|
||||
hostApiFetch<CloudKnowledgeFile | null>(CLOUD_AGENTS_PATH + '/knowledge/pick', {
|
||||
method: 'POST', body: JSON.stringify({ slug, kb_id, operation_id }),
|
||||
}),
|
||||
upload: () => hostApiFetch<CloudUpload | null>(CLOUD_AGENTS_PATH + '/attachments/pick', { method: 'POST' }),
|
||||
download: (thread_id: string, path: string) => hostApiFetch<{ saved: boolean }>(CLOUD_AGENTS_PATH + '/files/save', {
|
||||
method: 'POST', body: JSON.stringify({ thread_id, path }),
|
||||
}),
|
||||
call: <K extends keyof CloudAgentOperations>(operation: K, input: CloudAgentOperations[K]['input']) =>
|
||||
hostApiFetch<CloudAgentOperations[K]['output']>(CLOUD_AGENTS_PATH + '/actions', {
|
||||
method: 'POST', body: JSON.stringify({ operation, input }),
|
||||
}),
|
||||
events: async (runId: string, after = '0-0') => {
|
||||
await ensureHostApiToken();
|
||||
return createHostEventSource(CLOUD_AGENTS_PATH + '/runs/' + encodeURIComponent(runId) + '/events?after_seq=' + encodeURIComponent(after));
|
||||
},
|
||||
list: (cursor: string | null = null) => hostApiFetch<CloudAgentPage>(
|
||||
CLOUD_AGENTS_PATH + '/agents' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''),
|
||||
),
|
||||
|
||||
144
src/pages/CloudAgents/CloudAccess.tsx
Normal file
144
src/pages/CloudAgents/CloudAccess.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Copy, Link as LinkIcon, Plus } from 'lucide-react';
|
||||
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';
|
||||
|
||||
const errorText = (e: unknown) => e instanceof Error ? e.message : '操作失败,请重试';
|
||||
export function CloudAccessPanel({ slug, revision, onPublished }: { slug: string; revision: number; onPublished: () => 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('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
usePendingCloudInput(busy || Boolean(name.trim()));
|
||||
const publishOperation = useRef<{ operation_id: string; expected_revision: number } | null>(null);
|
||||
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 })]);
|
||||
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;
|
||||
}, [slug]);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
refresh().catch(e => { if (alive.current) setError(errorText(e)); });
|
||||
return () => { alive.current = false; };
|
||||
}, [refresh]);
|
||||
const act = async (task: () => Promise<unknown>, success?: string) => {
|
||||
if (busy) return;
|
||||
setBusy(true); setError(''); setNotice('');
|
||||
try { await task(); await refresh(); if (alive.current && success) setNotice(success); }
|
||||
catch(e) { if (alive.current) setError(errorText(e)); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
};
|
||||
const publish = () => act(async () => {
|
||||
const input = publishOperation.current ?? { operation_id: crypto.randomUUID(), expected_revision: revision };
|
||||
publishOperation.current = input;
|
||||
await cloudAgentsApi.call('publish', { slug, ...input });
|
||||
publishOperation.current = null;
|
||||
onPublished();
|
||||
}, '已发布,新的调用将使用这个版本');
|
||||
const createApplication = () => act(async () => {
|
||||
const input = applicationOperation.current ?? { operation_id: crypto.randomUUID(), name: name.trim() };
|
||||
applicationOperation.current = input;
|
||||
await cloudAgentsApi.call('createApplication', { slug, ...input });
|
||||
applicationOperation.current = null;
|
||||
setName('');
|
||||
}, '应用已创建,可为它生成 API 凭据');
|
||||
const copy = async (text: string) => {
|
||||
try { await navigator.clipboard.writeText(text); setNotice('已复制'); }
|
||||
catch { setError('复制失败,请手动选择文本复制'); }
|
||||
};
|
||||
return <div className="space-y-8">
|
||||
<section className="rounded-xl bg-violet-50/60 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4"><div>
|
||||
<h2 className="font-medium">{access?.published_version ? '当前发布版本 ' + access.published_version : '尚未发布'}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">发布保存的修订 {revision}。发布后仅自己可用,分享与应用需要分别开启。</p>
|
||||
</div><Button disabled={busy} onClick={() => void publish()}>{publishOperation.current ? '重试本次发布' : '发布当前草稿'}</Button></div>
|
||||
<p className="mt-4 text-sm leading-6">自己使用、分享使用、API 调用和自动任务产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销访问。</p>
|
||||
{access?.published_version && <div className="mt-4 flex items-center gap-3 text-sm"><span>{access.enabled ? '智能体已启用' : '智能体已停用'}</span>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('setEnabled', { slug, enabled: !access.enabled }))}>
|
||||
{access.enabled ? '停用智能体' : '启用智能体'}</Button></div>}
|
||||
</section>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}>刷新状态</Button></p>}
|
||||
{notice && <p role="status" className="text-sm text-muted-foreground">{notice}</p>}
|
||||
<section className="space-y-4"><h2 className="font-medium">分享给指定用户</h2>
|
||||
<p className="text-sm text-muted-foreground">选择获准使用的账号,再发送分享链接。每个人的对话和文件分别保存。</p>
|
||||
<div className="flex gap-2"><Input aria-label="查找分享用户" value={query} onChange={e => setQuery(e.target.value)} placeholder="用户名、显示名或账号 ID" maxLength={100} />
|
||||
<Button variant="outline" disabled={busy || query.trim().length < 2 || !access?.published_version}
|
||||
onClick={() => void act(async () => { const result = await cloudAgentsApi.call('users', { query: query.trim() }); setUsers(result.users); })}>查找</Button></div>
|
||||
{users.map(user => <div key={user.account_id} className="flex items-center justify-between gap-3 rounded-lg bg-muted/30 p-3 text-sm">
|
||||
<div><p>{user.display_name} <span className="text-muted-foreground">@{user.username}</span></p><p className="mt-1 break-all text-xs text-muted-foreground">{user.account_id}</p></div>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('share', { slug, account_id: user.account_id, enabled: true }), '已授权此账号')}>授权使用</Button>
|
||||
</div>)}
|
||||
{access?.grants.filter(g => g.enabled).map(grant => <div key={grant.account_id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="break-all">{grant.account_id}</span><Button variant="ghost" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('share', { slug, account_id: grant.account_id, enabled: false }))}>撤销</Button>
|
||||
</div>)}
|
||||
{access?.published_version && <div className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<LinkIcon className="h-4 w-4 shrink-0" /><code className="min-w-0 flex-1 break-all text-xs">{access.share_url}</code>
|
||||
<Button aria-label="复制分享链接" variant="ghost" onClick={() => void copy(access.share_url)}><Copy className="h-4 w-4" /></Button></div>}
|
||||
</section>
|
||||
<section className="space-y-4"><h2 className="font-medium">应用与 API</h2>
|
||||
<p className="text-sm text-muted-foreground">为网站或脚本创建独立应用。应用通过凭据调用此智能体,并拥有自己的会话空间。</p>
|
||||
<div className="flex gap-2"><Input aria-label="应用名称" value={name} disabled={busy || Boolean(applicationOperation.current)} onChange={e => setName(e.target.value)} maxLength={100} placeholder="例如:我的个人网站" />
|
||||
<Button variant="outline" disabled={busy || !name.trim() || !access?.published_version} onClick={() => void createApplication()}><Plus className="mr-2 h-4 w-4" />{applicationOperation.current ? '重试创建' : '创建应用'}</Button></div>
|
||||
{access?.applications.map(app => <ApplicationKeys key={app.id} application={app} onUpdated={refresh} />)}
|
||||
{access?.api_url && <details className="rounded-lg bg-muted/30 p-4 text-sm"><summary className="cursor-pointer">API 调用示例</summary>
|
||||
<p className="mt-3 break-all font-mono text-xs">POST {access.api_url}</p>
|
||||
<pre className="mt-3 overflow-auto text-xs leading-6">{'Authorization: Bearer <应用凭据>\nContent-Type: application/json\n\n' + JSON.stringify({ request_id: '唯一请求标识', thread_id: '会话标识', query: '帮我整理今天的创作计划' }, null, 2)}</pre>
|
||||
<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>
|
||||
{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>;
|
||||
}
|
||||
|
||||
function ApplicationKeys({ application, onUpdated }: { application: CloudApplication; onUpdated: () => Promise<void> }) {
|
||||
const [keys, setKeys] = useState<CloudKey[]>([]);
|
||||
const [secret, setSecret] = useState('');
|
||||
usePendingCloudInput(Boolean(secret));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const operation = useRef<string | null>(null);
|
||||
const refresh = useCallback(async () => { const result = await cloudAgentsApi.call('keys', { application_id: application.id }); setKeys(result.keys); }, [application.id]);
|
||||
useEffect(() => { let live = true; cloudAgentsApi.call('keys', { application_id: application.id }).then(v => { if (live) setKeys(v.keys); }).catch(e => { if (live) setError(errorText(e)); }); return () => { live = false; }; }, [application.id]);
|
||||
const act = async (task: () => Promise<unknown>) => {
|
||||
setBusy(true); setError('');
|
||||
try { await task(); await refresh(); await onUpdated(); } catch(e) { setError(errorText(e)); } finally { setBusy(false); }
|
||||
};
|
||||
return <div className="space-y-3 rounded-xl border border-border p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium">{application.name} · {application.enabled ? '已启用' : '已停用'}</h3>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('setApplicationEnabled', { application_id: application.id, enabled: !application.enabled }))}>{application.enabled ? '停用' : '启用'}</Button></div>
|
||||
{keys.map(key => <div key={key.id} className="flex items-center justify-between gap-2"><code>{key.prefix}…</code>
|
||||
{key.revoked_at ? <span className="text-xs text-muted-foreground">已撤销</span> : <Button variant="ghost" disabled={busy} onClick={() => void act(async () => { await cloudAgentsApi.call('revokeKey', { application_id: application.id, key_id: key.id }); setSecret(''); })}>撤销凭据</Button>}</div>)}
|
||||
<Button variant="outline" disabled={busy || !application.enabled} onClick={() => void act(async () => {
|
||||
operation.current ??= crypto.randomUUID();
|
||||
const result = await cloudAgentsApi.call('createKey', { application_id: application.id, operation_id: operation.current });
|
||||
setSecret(result.secret); operation.current = null;
|
||||
})}>{operation.current ? '重试生成' : '生成 API 凭据'}</Button>
|
||||
{secret && <div className="space-y-2 rounded-lg bg-amber-50 p-3"><p>请保存这份凭据,关闭此页后不再展示。</p>
|
||||
<Input aria-label="新生成的 API 凭据" value={secret} readOnly className="font-mono text-xs" />
|
||||
<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>}
|
||||
</div>;
|
||||
}
|
||||
293
src/pages/CloudAgents/CloudChat.tsx
Normal file
293
src/pages/CloudAgents/CloudChat.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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 { CloudFiles } from './CloudFiles';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
||||
|
||||
export const cloudStatus = (status: string) => ({
|
||||
idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中',
|
||||
running: '运行中', cancel_requested: '正在停止', cancelled: '已停止', completed: '已完成',
|
||||
failed: '失败', interrupted: '等待你的确认', submitted: '已提交', rejected: '未执行',
|
||||
}[status] ?? status);
|
||||
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> : {};
|
||||
|
||||
export function CloudChat({ slug, previewRevision, initialThread }: { slug: string; previewRevision?: number; initialThread?: CloudThread }) {
|
||||
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 [run, setRun] = useState<CloudRun | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [intent, setIntent] = useState<CloudPrompt | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [liveText, setLiveText] = useState('');
|
||||
const [progress, setProgress] = useState('');
|
||||
const [interrupt, setInterrupt] = useState<CloudInterrupt | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const childrenPending = useCloudPendingState();
|
||||
usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending);
|
||||
const generation = useRef(0);
|
||||
const alive = useRef(true);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const mode = previewRevision === undefined ? 'published' : 'preview';
|
||||
const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => {
|
||||
let page = await cloudAgentsApi.call('history', { thread_id: id });
|
||||
while (page.next_offset !== null) {
|
||||
const more = await cloudAgentsApi.call('history', { thread_id: id, offset: page.next_offset });
|
||||
page = { ...more, messages: [...page.messages, ...more.messages] };
|
||||
}
|
||||
if (!alive.current || expectedGeneration !== generation.current) return;
|
||||
setHistory(page); setRun(page.run); setInterrupt(page.run?.interrupt ?? 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));
|
||||
if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
const g = ++generation.current;
|
||||
const open = async () => {
|
||||
try {
|
||||
if (previewRevision !== undefined) {
|
||||
if (initialThread) await readHistory(initialThread.thread_id, g);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
setThreads(items);
|
||||
const latest = initialThread ?? items[0];
|
||||
if (latest) {
|
||||
setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id);
|
||||
await readHistory(latest.thread_id, g);
|
||||
}
|
||||
} catch (e) { if (alive.current && g === generation.current) setError(message(e)); }
|
||||
finally { if (alive.current && g === generation.current) setLoading(false); }
|
||||
};
|
||||
void open();
|
||||
return () => { alive.current = false; generation.current++; };
|
||||
}, [slug, mode, previewRevision, initialThread, readHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request || request.run_id || !active(request.status)) return;
|
||||
let live = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await cloudAgentsApi.call('request', { request_id: request.request_id });
|
||||
if (live) {
|
||||
setRequest(next);
|
||||
if (next.run_id) {
|
||||
const acceptedRun = await cloudAgentsApi.call('run', { run_id: next.run_id });
|
||||
if (live) setRun(acceptedRun);
|
||||
}
|
||||
}
|
||||
} catch (e) { if (live) setError(message(e)); }
|
||||
};
|
||||
const timer = window.setInterval(() => void poll(), 1800);
|
||||
void poll();
|
||||
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 (!runId || !running) return;
|
||||
let live = true;
|
||||
let source: EventSource | undefined;
|
||||
const g = generation.current;
|
||||
const accept = (event: MessageEvent<string>) => {
|
||||
if (!live || g !== generation.current) return;
|
||||
try {
|
||||
const envelope = object(JSON.parse(event.data));
|
||||
const payload = object(envelope.payload);
|
||||
const chunk = object(payload.chunk);
|
||||
const targetThread = thread?.thread_id ?? request?.thread_id;
|
||||
if (envelope.thread_id && targetThread && envelope.thread_id !== targetThread) return;
|
||||
for (const item of Array.isArray(payload.items) ? payload.items : [chunk]) {
|
||||
const semantic = object(object(item).stream_event);
|
||||
if (semantic.type === 'message_delta' && typeof semantic.content === 'string') setLiveText(v => v + semantic.content);
|
||||
if (semantic.type === 'tool_call' && typeof semantic.name === 'string') setProgress('正在使用 ' + semantic.name);
|
||||
}
|
||||
if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt);
|
||||
if (event.type === 'end') {
|
||||
source?.close();
|
||||
setLiveText(''); setProgress('');
|
||||
const id = thread?.thread_id ?? request?.thread_id;
|
||||
if (id) void readHistory(id, g).catch(e => { if (live) setError(message(e)); });
|
||||
}
|
||||
} catch { setError('运行事件读取失败,正在重新读取状态'); }
|
||||
};
|
||||
cloudAgentsApi.events(runId).then(stream => {
|
||||
if (!live) { stream.close(); return; }
|
||||
source = stream;
|
||||
for (const name of ['metadata', 'messages', 'custom', 'interrupt', 'end']) stream.addEventListener(name, accept as EventListener);
|
||||
stream.onerror = () => { if (live) setProgress('连接中断,正在重连…'); };
|
||||
stream.onopen = () => { if (live) setProgress(''); };
|
||||
}).catch(e => { if (live) setError(message(e)); });
|
||||
// Durable state is also read after an event stream expires or its terminal event was missed.
|
||||
const timer = window.setInterval(() => {
|
||||
cloudAgentsApi.call('run', { run_id: runId }).then(next => {
|
||||
if (!live || g !== generation.current) return;
|
||||
setRun(next);
|
||||
if (!active(next.status)) {
|
||||
source?.close(); setLiveText(''); setProgress(''); setInterrupt(next.interrupt ?? null);
|
||||
void readHistory(next.thread_id, g).catch(e => { if (live) setError(message(e)); });
|
||||
}
|
||||
}).catch(e => { if (live) setError(message(e)); });
|
||||
}, 5000);
|
||||
return () => { live = false; source?.close(); window.clearInterval(timer); };
|
||||
}, [runId, running, thread?.thread_id, request?.thread_id, readHistory]);
|
||||
useEffect(() => { bottom.current?.scrollIntoView?.({ block: 'end' }); }, [history?.messages.length, liveText, interrupt]);
|
||||
|
||||
const rememberThread = (next: CloudThread) => {
|
||||
setThread(next);
|
||||
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
|
||||
};
|
||||
const send = async () => {
|
||||
if (busy || running || (!intent && !query.trim())) return;
|
||||
const input = intent ?? {
|
||||
request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(),
|
||||
attachment_file_ids: attachmentIds,
|
||||
...(previewRevision === undefined ? {} : { expected_revision: previewRevision }),
|
||||
};
|
||||
setBusy(true); setIntent(input); setError('');
|
||||
const g = generation.current;
|
||||
try {
|
||||
const accepted = await cloudAgentsApi.call(previewRevision === undefined ? 'submit' : 'preview', { slug, ...input });
|
||||
if (!alive.current || g !== generation.current) return;
|
||||
setRequest(accepted); setIntent(null); setQuery(''); setLiveText(''); setAttachmentIds([]);
|
||||
rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80),
|
||||
mode, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false });
|
||||
await readHistory(accepted.thread_id, g);
|
||||
} catch (e) { if (alive.current && g === generation.current) setError(message(e)); }
|
||||
finally { if (alive.current && g === generation.current) setBusy(false); }
|
||||
};
|
||||
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('');
|
||||
setAttachmentIds([]);
|
||||
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
|
||||
};
|
||||
const cancel = async () => {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
if (request) await cloudAgentsApi.call('cancelRequest', { request_id: request.request_id });
|
||||
else if (runId) await cloudAgentsApi.call('cancelRun', { run_id: runId });
|
||||
} catch (e) { setError(message(e)); } finally { setBusy(false); }
|
||||
};
|
||||
return <CloudPendingContext.Provider value={childrenPending.report}><section className="flex min-h-[480px] flex-col rounded-xl bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
||||
<div className="flex min-h-12 items-center justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0 text-sm">{previewRevision === undefined ? '正式对话' : '草稿预览 · 修订 ' + previewRevision}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{run?.version && `版本 ${run.version} · `}本次使用由智能体创建者支付词元点数</p></div>
|
||||
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={thread?.thread_id ?? ''} disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}>
|
||||
<option value="">新对话</option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
|
||||
</select>}
|
||||
<div className="my-4 max-h-[55vh] min-h-56 flex-1 space-y-5 overflow-y-auto pr-2">
|
||||
{loading ? <p role="status" className="text-sm text-muted-foreground">正在读取对话…</p>
|
||||
: !history?.messages.length && !liveText && <div className="py-12 text-center text-sm leading-7 text-muted-foreground">告诉它你想完成什么。<br />关闭客户端后,已提交的任务仍在云端继续。</div>}
|
||||
{history?.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{item.role === 'user' ? '你' : '智能体'}</p>
|
||||
<div className="prose prose-sm max-w-none break-words dark:prose-invert"><ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
||||
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) && thread
|
||||
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(thread.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => setError(message(e)))}>{children}</button>
|
||||
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
||||
}}>{item.content}</ReactMarkdown></div>
|
||||
</article>)}
|
||||
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>}
|
||||
{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 => {
|
||||
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
|
||||
}} />}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
<CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
|
||||
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading} 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,
|
||||
status: 'idle', updated_at: new Date().toISOString(), run_id: null, unread: false });
|
||||
return created.thread_id;
|
||||
}} />
|
||||
{leaving && <div className="mb-3 flex flex-wrap items-center gap-2 text-sm"><span>放弃未发送的内容并新建对话?</span>
|
||||
<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)} />
|
||||
<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>}
|
||||
</div>
|
||||
</form>
|
||||
</section></CloudPendingContext.Provider>;
|
||||
}
|
||||
|
||||
function CloudApproval({ interrupt, runId, onResumed }: { interrupt: CloudInterrupt; runId: string; onResumed: (run: CloudRun) => void }) {
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [intent, setIntent] = useState<{ operation_id: string; decision: Record<string, unknown> } | null>(null);
|
||||
const actions = Array.isArray(interrupt.approval?.action_requests) ? interrupt.approval.action_requests : [];
|
||||
usePendingCloudInput(busy || Boolean(intent) || Object.values(answers).some(value => value.length > 0) || Object.values(freeText).some(value => value.trim()));
|
||||
const questions = (interrupt.questions ?? []).map((item, index) => {
|
||||
const q = object(item);
|
||||
return {
|
||||
id: String(q.question_id ?? 'q-' + (index + 1)), text: String(q.question ?? ''),
|
||||
multiple: q.multi_select === true, allowOther: q.allow_other !== false,
|
||||
options: (Array.isArray(q.options) ? q.options : []).map(value => typeof value === 'string'
|
||||
? { value, label: value } : { value: String(object(value).value ?? object(value).label ?? ''), label: String(object(value).label ?? object(value).value ?? '') }),
|
||||
};
|
||||
});
|
||||
const complete = questions.length > 0 && questions.every(q => (freeText[q.id]?.trim() && q.allowOther) || answers[q.id]?.length);
|
||||
const submit = async (decision: Record<string, unknown>) => {
|
||||
const input = intent ?? { operation_id: crypto.randomUUID(), decision };
|
||||
setIntent(input); setBusy(true); setError('');
|
||||
try {
|
||||
const result = await cloudAgentsApi.call('resume', { run_id: runId, ...input });
|
||||
onResumed(await cloudAgentsApi.call('run', { run_id: result.run_id }));
|
||||
}
|
||||
catch(e) { setError(message(e)); } finally { setBusy(false); }
|
||||
};
|
||||
return <div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/50 p-4 text-sm">
|
||||
<h3 className="font-medium">需要你的确认</h3>
|
||||
{actions.length ? <>{actions.map((action, i) => <pre key={i} className="max-h-36 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(action, null, 2)}</pre>)}
|
||||
<div className="flex gap-2"><Button disabled={busy || Boolean(intent)} onClick={() => void submit({ decisions: actions.map(() => ({ type: 'approve' })) })}>允许执行</Button>
|
||||
<Button variant="outline" disabled={busy || Boolean(intent)} onClick={() => void submit({ decisions: actions.map(() => ({ type: 'reject', message: '用户拒绝执行' })) })}>拒绝</Button></div></>
|
||||
: <>{questions.map(q => <fieldset key={q.id} disabled={busy || Boolean(intent)} className="space-y-2">
|
||||
<legend className="mb-2 font-medium">{q.text}</legend>
|
||||
{q.options.map(option => <label key={option.value} className="flex min-h-10 items-center gap-2">
|
||||
<input type={q.multiple ? 'checkbox' : 'radio'} name={q.id} checked={answers[q.id]?.includes(option.value) ?? false}
|
||||
onChange={e => { setFreeText(v => ({ ...v, [q.id]: '' })); setAnswers(v => ({ ...v, [q.id]: q.multiple
|
||||
? e.target.checked ? [...(v[q.id] ?? []), option.value] : (v[q.id] ?? []).filter(item => item !== option.value) : [option.value] })); }} />
|
||||
{option.label}</label>)}
|
||||
{q.allowOther && <Textarea aria-label={q.text + ':补充回答'} placeholder="也可以填写自己的回答" value={freeText[q.id] ?? ''}
|
||||
onChange={e => setFreeText(v => ({ ...v, [q.id]: e.target.value }))} />}
|
||||
</fieldset>)}
|
||||
<Button disabled={busy || Boolean(intent) || !complete} onClick={() => void submit(Object.fromEntries(questions.map(q => [
|
||||
q.id, freeText[q.id]?.trim() ? { type: 'other', text: freeText[q.id].trim(), selected: answers[q.id] ?? [] }
|
||||
: q.multiple ? answers[q.id] : answers[q.id]?.[0],
|
||||
])))}>提交回答</Button></>}
|
||||
{intent && !busy && <Button variant="outline" onClick={() => void submit(intent.decision)}>重试本次确认</Button>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
75
src/pages/CloudAgents/CloudFiles.tsx
Normal file
75
src/pages/CloudAgents/CloudFiles.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Folder, Paperclip } from 'lucide-react';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudAttachment, CloudFile, CloudUpload } from '../../../shared/cloud-agents';
|
||||
|
||||
export function CloudFiles({ threadId, ensureThread, selected, onSelected, onBusy, disabled }: {
|
||||
threadId?: string; ensureThread: () => Promise<string>; selected: string[]; onSelected: (ids: string[]) => void;
|
||||
onBusy: (busy: boolean) => void; disabled: boolean;
|
||||
}) {
|
||||
const [attachments, setAttachments] = useState<CloudAttachment[]>([]);
|
||||
const [files, setFiles] = useState<CloudFile[]>([]);
|
||||
const [path, setPath] = useState('/');
|
||||
const [upload, setUpload] = useState<CloudUpload | null>(null);
|
||||
const [parseMethod, setParseMethod] = useState('disable');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
usePendingCloudInput(busy || Boolean(upload));
|
||||
useEffect(() => {
|
||||
if (!threadId) return;
|
||||
let live = true;
|
||||
Promise.all([cloudAgentsApi.call('attachments', { thread_id: threadId }), cloudAgentsApi.call('files', { thread_id: threadId, path })])
|
||||
.then(([a, f]) => { if (live) { setAttachments(a.attachments); setFiles(f.files); } })
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '读取文件失败'); });
|
||||
return () => { live = false; };
|
||||
}, [threadId, path, refresh]);
|
||||
const act = async (task: () => Promise<unknown>) => {
|
||||
setBusy(true); onBusy(true); setError('');
|
||||
try { await task(); setRefresh(v => v + 1); }
|
||||
catch(e) { setError(e instanceof Error ? e.message : '文件操作失败'); }
|
||||
finally { setBusy(false); onBusy(false); }
|
||||
};
|
||||
return <details className="my-3 rounded-lg border border-border px-3 py-2 text-sm">
|
||||
<summary className="min-h-8 cursor-pointer py-1">附件与产物 {selected.length ? '· 本条消息附带 ' + selected.length + ' 个文件' : ''}</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex items-center gap-2"><Button variant="outline" size="sm" disabled={disabled || busy || Boolean(upload)} onClick={() => void act(async () => {
|
||||
const picked = await cloudAgentsApi.upload();
|
||||
if (picked) { setUpload(picked); setParseMethod('disable'); }
|
||||
})}><Paperclip className="mr-2 h-4 w-4" />添加附件</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setRefresh(v => v + 1)}>刷新文件</Button></div>
|
||||
{upload && <div className="space-y-2 rounded-lg bg-muted/40 p-3"><p>{upload.file_name}</p>
|
||||
{upload.parse_supported && <label className="block text-xs">读取方式<select aria-label="附件读取方式" className="ml-2 h-10 rounded-md border border-input bg-background px-2" value={parseMethod} disabled={busy} onChange={e => setParseMethod(e.target.value)}>
|
||||
<option value="disable">保留原始文件</option>{upload.parse_methods.filter(m => m !== 'disable').map(method => <option key={method} value={method}>{method === 'rapid_ocr' ? '本地文字识别' : method}</option>)}
|
||||
</select></label>}
|
||||
<div className="flex gap-2"><Button size="sm" disabled={busy} onClick={() => void act(async () => {
|
||||
const id = await ensureThread();
|
||||
const parsed = parseMethod === 'disable' ? undefined : await cloudAgentsApi.call('parseAttachment', { object_name: upload.object_name, parse_method: parseMethod });
|
||||
const result = await cloudAgentsApi.call('confirmAttachment', { thread_id: id, attachments: [{
|
||||
object_name: upload.object_name, file_type: upload.file_type, ...(parsed ? { parsed_object_name: parsed.parsed_object_name } : {}),
|
||||
}] });
|
||||
onSelected([...selected, ...result.attachments.map(a => a.file_id)]); setUpload(null);
|
||||
})}>加入此对话</Button><Button variant="ghost" size="sm" disabled={busy} onClick={() => setUpload(null)}>取消</Button></div></div>}
|
||||
{attachments.map(file => <label key={file.file_id} className="flex min-h-10 items-center gap-2">
|
||||
<input type="checkbox" disabled={disabled || busy} checked={selected.includes(file.file_id)} onChange={e => onSelected(e.target.checked ? [...selected, file.file_id] : selected.filter(id => id !== file.file_id))} />
|
||||
<span className="min-w-0 flex-1 truncate">{file.file_name}</span><span className="text-xs text-muted-foreground tabular-nums">{Math.ceil(file.file_size / 1024)} KB</span>
|
||||
<Button variant="ghost" size="sm" disabled={disabled || busy} aria-label={'删除附件 ' + file.file_name} onClick={event => { event.preventDefault(); void act(async () => {
|
||||
await cloudAgentsApi.call('deleteAttachment', { thread_id: threadId!, file_id: file.file_id });
|
||||
onSelected(selected.filter(id => id !== file.file_id));
|
||||
}); }}>删除</Button>
|
||||
</label>)}
|
||||
{threadId && <div className="space-y-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between"><p className="break-all text-xs text-muted-foreground">会话文件 · {path}</p>
|
||||
{path !== '/' && <Button variant="ghost" size="sm" onClick={() => setPath(path.split('/').slice(0, -1).join('/') || '/')}>上一级</Button>}</div>
|
||||
{files.map(file => <div key={file.name} className="flex min-h-10 items-center justify-between gap-3">
|
||||
{file.is_dir ? <Button variant="ghost" className="min-w-0 justify-start" onClick={() => setPath(file.directory_path)}><Folder className="mr-2 h-4 w-4" /><span className="truncate">{file.name}</span></Button>
|
||||
: <><span className="min-w-0 truncate">{file.name}</span><Button variant="ghost" aria-label={'保存 ' + file.name} disabled={busy}
|
||||
onClick={() => void act(() => cloudAgentsApi.download(threadId, file.path))}><Download className="h-4 w-4" /></Button></>}
|
||||
</div>)}
|
||||
</div>}
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}。可刷新文件列表核对已上传的附件。</p>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
113
src/pages/CloudAgents/CloudKnowledge.tsx
Normal file
113
src/pages/CloudAgents/CloudKnowledge.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
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 }) {
|
||||
const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: { id: string; name: string }[] } | null>(null);
|
||||
const [kbId, setKbId] = useState('');
|
||||
const [files, setFiles] = useState<CloudKnowledgeFile[]>([]);
|
||||
const [next, setNext] = useState<number | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
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);
|
||||
const processIntents = useRef<Record<string, string>>({});
|
||||
const alive = useRef(true);
|
||||
const paged = useRef(false);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
cloudAgentsApi.call('knowledge', { slug }).then(v => { if (current) setCatalog(v); })
|
||||
.catch(e => { if (current) setError(String(e.message || e)); });
|
||||
return () => { current = false; };
|
||||
}, [slug, refresh]);
|
||||
useEffect(() => {
|
||||
if (!kbId) { setFiles([]); return; }
|
||||
paged.current = false;
|
||||
let current = true;
|
||||
const load = () => cloudAgentsApi.call('knowledgeFiles', { slug, kb_id: kbId })
|
||||
.then(v => { if (current) { setFiles(v.files); setNext(v.next_offset); } })
|
||||
.catch(e => { if (current) setError(String(e.message || e)); });
|
||||
void load();
|
||||
const timer = setInterval(() => { if (!paged.current) void load(); }, 5000);
|
||||
return () => { current = false; clearInterval(timer); };
|
||||
}, [slug, kbId, refresh]); // A refreshed first page replaces pagination only after an explicit operation.
|
||||
const act = async (operation: () => Promise<void>) => {
|
||||
setBusy(true); setError('');
|
||||
try { await operation(); }
|
||||
catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
};
|
||||
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>
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<select aria-label="管理的知识库" className="h-10 min-w-0 flex-1 rounded-md border bg-background px-3" value={kbId} disabled={busy}
|
||||
onChange={e => { setKbId(e.target.value); uploadIntent.current = null; setNext(null); }}>
|
||||
<option value="">选择我的知识库</option>
|
||||
{catalog?.databases.map(kb => <option key={kb.kb_id} value={kb.kb_id}>{kb.name}</option>)}
|
||||
</select>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setCreating(v => !v)}>新建知识库</Button>
|
||||
</div>
|
||||
{creating && <div className="space-y-3 rounded-md bg-muted/30 p-3">
|
||||
<Input aria-label="知识库名称" placeholder="知识库名称" value={name} disabled={busy || !!createIntent.current} onChange={e => setName(e.target.value)} />
|
||||
<select aria-label="向量模型" className="h-10 w-full rounded-md border bg-background px-3" value={model} disabled={busy || !!createIntent.current} onChange={e => setModel(e.target.value)}>
|
||||
<option value="">选择向量模型</option>{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
{catalog && !catalog.models.length && <p className="text-xs text-muted-foreground">服务端尚未接入可计费的向量模型。</p>}
|
||||
<Button type="button" disabled={busy || !name.trim() || !model} onClick={() => void act(async () => {
|
||||
createIntent.current ??= { operation_id: crypto.randomUUID(), name: name.trim(), embedding_model: model };
|
||||
const kb = await cloudAgentsApi.call('createKnowledge', { slug, ...createIntent.current });
|
||||
createIntent.current = null;
|
||||
if (alive.current) { setKbId(kb.kb_id); setCreating(false); setName(''); setRefresh(v => v + 1); onCreated(kb); }
|
||||
})}>{createIntent.current ? '重试创建' : '创建并选用'}</Button>
|
||||
</div>}
|
||||
{kbId && <>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" disabled={busy} onClick={() => void act(async () => {
|
||||
uploadIntent.current ??= crypto.randomUUID();
|
||||
const uploaded = await cloudAgentsApi.uploadKnowledge(slug, kbId, uploadIntent.current);
|
||||
if (!uploaded) return;
|
||||
uploadIntent.current = null;
|
||||
if (alive.current) setRefresh(v => v + 1);
|
||||
})}>{uploadIntent.current ? '重新选择同一文件并重试' : '上传文档'}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => setRefresh(v => v + 1)}>刷新状态</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。</p>
|
||||
<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}
|
||||
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>}
|
||||
</div>)}
|
||||
{!files.length && <p className="py-4 text-xs text-muted-foreground">尚未上传文档。</p>}
|
||||
</div>
|
||||
{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 });
|
||||
if (alive.current) { setFiles(v => [...v, ...page.files]); setNext(page.next_offset); }
|
||||
})}>加载更多文档</Button>}
|
||||
</>}
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
66
src/pages/CloudAgents/CloudOverview.tsx
Normal file
66
src/pages/CloudAgents/CloudOverview.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudAgentEntry, CloudThread } from '../../../shared/cloud-agents';
|
||||
import { cloudStatus } from './CloudChat';
|
||||
|
||||
export function CloudOverview({ section, onAgent, onThread }: {
|
||||
section: 'received' | 'activity'; onAgent: (entry: CloudAgentEntry) => void; onThread: (thread: CloudThread) => void;
|
||||
}) {
|
||||
const [agents, setAgents] = useState<CloudAgentEntry[]>([]);
|
||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [offset, setOffset] = useState<number | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const paged = useRef(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
paged.current = false;
|
||||
setLoading(true);
|
||||
const load = async () => {
|
||||
try {
|
||||
if (section === 'received') {
|
||||
const result = await cloudAgentsApi.call('received', {});
|
||||
if (live) setAgents(result.agents);
|
||||
} else {
|
||||
const result = await cloudAgentsApi.call('threads', {});
|
||||
if (live) { setThreads(result.threads); setOffset(result.next_offset); }
|
||||
}
|
||||
if (live) setError('');
|
||||
} catch(e) { if (live) setError(e instanceof Error ? e.message : '暂时无法读取'); }
|
||||
finally { if (live) setLoading(false); }
|
||||
};
|
||||
void load();
|
||||
const timer = window.setInterval(() => { if (!paged.current) void load(); }, 15000);
|
||||
return () => { live = false; window.clearInterval(timer); };
|
||||
}, [section, refresh]);
|
||||
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); }
|
||||
catch(e) { setError(e instanceof Error ? e.message : '加载失败'); } finally { setLoading(false); }
|
||||
};
|
||||
return <section className="space-y-4">
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}<Button variant="ghost" onClick={() => setRefresh(v => v + 1)}>重新连接</Button></p>}
|
||||
{section === 'received' ? <>
|
||||
<p className="text-sm text-muted-foreground">这里显示其他创作者授权你使用的智能体。由创建者承担费用,你的会话和文件仅属于你。</p>
|
||||
{!loading && !agents.length && <p className="py-12 text-center text-sm text-muted-foreground">还没有收到分享</p>}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{agents.map(agent => <button key={agent.slug} onClick={() => onAgent(agent)}
|
||||
className="rounded-xl border border-border p-5 text-left transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<h2 className="font-medium">{agent.name}</h2><p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
|
||||
<p className="mt-4 text-xs text-muted-foreground">版本 {agent.published_version} · 打开对话</p>
|
||||
</button>)}</div></> : <>
|
||||
<p className="text-sm text-muted-foreground">查看自己的对话、自动任务结果和待确认事项。</p>
|
||||
{!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)}
|
||||
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>)}
|
||||
{offset !== null && <Button variant="outline" disabled={loading} onClick={() => void more()}>加载更早记录</Button>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
15
src/pages/CloudAgents/CloudPending.ts
Normal file
15
src/pages/CloudAgents/CloudPending.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useState } from 'react';
|
||||
|
||||
/** Active editors report pending input to the workspace's existing leave dialog. */
|
||||
export const CloudPendingContext = createContext<(id: string, pending: boolean) => void>(() => undefined);
|
||||
export function useCloudPendingState() {
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
const report = useCallback((id: string, value: boolean) => setPending(previous =>
|
||||
previous[id] === value ? previous : { ...previous, [id]: value }), []);
|
||||
return { report, hasPending: Object.values(pending).some(Boolean) };
|
||||
}
|
||||
export function usePendingCloudInput(pending: boolean) {
|
||||
const report = useContext(CloudPendingContext);
|
||||
const id = useId();
|
||||
useEffect(() => { report(id, pending); return () => report(id, false); }, [id, pending, report]);
|
||||
}
|
||||
96
src/pages/CloudAgents/CloudSchedules.tsx
Normal file
96
src/pages/CloudAgents/CloudSchedules.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, 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 { CloudSchedule, CloudScheduleInput, CloudThread } from '../../../shared/cloud-agents';
|
||||
import { cloudStatus } from './CloudChat';
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
|
||||
export function CloudSchedules({ slug, onOpen }: { slug: string; onOpen: (thread: CloudThread) => void }) {
|
||||
const [jobs, setJobs] = useState<CloudSchedule[]>([]);
|
||||
const [editing, setEditing] = useState<CloudSchedule | 'new' | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const runs = useRef(new Map<string, string>());
|
||||
usePendingCloudInput(busy);
|
||||
const alive = useRef(true);
|
||||
const refresh = useCallback(async () => { const result = await cloudAgentsApi.call('schedules', { slug }); if (alive.current) setJobs(result.jobs); }, [slug]);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
const load = () => refresh().catch(e => { if (alive.current) setError(e instanceof Error ? e.message : '任务读取失败'); });
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(), 15000);
|
||||
return () => { alive.current = false; window.clearInterval(timer); };
|
||||
}, [refresh]);
|
||||
const act = async (task: () => Promise<unknown>) => {
|
||||
setBusy(true); setError('');
|
||||
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(); }} />;
|
||||
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>
|
||||
<Button onClick={() => setEditing('new')}>添加任务</Button></div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{!jobs.length && <p className="rounded-xl bg-muted/30 p-8 text-center text-sm text-muted-foreground">还没有自动任务。例如:每天早上为我整理创作选题。</p>}
|
||||
{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>
|
||||
<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={() => 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>
|
||||
<Button variant="ghost" onClick={() => setDeleting(null)}>取消</Button></div>}
|
||||
{job.runs?.map((run, i) => <div key={i} className="flex items-center justify-between gap-3 border-t border-border/50 pt-3 text-sm">
|
||||
<p>{cloudStatus(run.status)}{run.error_message && <span className="ml-2 text-muted-foreground">{run.error_message}</span>}</p>
|
||||
{run.conversation_available && <Button variant="ghost" onClick={() => onOpen({ thread_id: run.thread_id, client_thread_id: null, agent_slug: slug,
|
||||
title: job.name, status: run.status, mode: 'published', unread: false, updated_at: '', run_id: null })}>查看结果</Button>}
|
||||
</div>)}
|
||||
</article>)}
|
||||
</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 }));
|
||||
const [intent, setIntent] = useState<(CloudScheduleInput & { operation_id: string }) | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const save = async () => {
|
||||
if (busy) return;
|
||||
const input = intent ?? { ...value, operation_id: crypto.randomUUID() };
|
||||
setIntent(input); setBusy(true); setError('');
|
||||
try {
|
||||
if (initial) await cloudAgentsApi.call('updateSchedule', { slug, job_id: initial.id, ...input });
|
||||
else await cloudAgentsApi.call('createSchedule', { slug, ...input });
|
||||
onSaved();
|
||||
} catch(e) { setError(e instanceof Error ? e.message : '保存失败'); } finally { setBusy(false); }
|
||||
};
|
||||
return <form className="max-w-2xl space-y-5" onSubmit={e => { e.preventDefault(); void save(); }}>
|
||||
<h2 className="font-medium">{initial ? '编辑自动任务' : '添加自动任务'}</h2>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={onClose}>{intent ? '返回列表确认' : '取消'}</Button></div>
|
||||
</form>;
|
||||
}
|
||||
77
src/pages/CloudAgents/ConfigurationFields.tsx
Normal file
77
src/pages/CloudAgents/ConfigurationFields.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CloudKnowledgePanel } from './CloudKnowledge';
|
||||
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
|
||||
|
||||
export function ConfigurationFields({ slug, value, disabled, onChange }: {
|
||||
slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); });
|
||||
return () => { live = false; };
|
||||
}, [refresh]);
|
||||
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
|
||||
return <div className="space-y-6">
|
||||
<div className="flex items-center justify-between"><h2 className="font-medium">能力与运行限制</h2>
|
||||
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<label className="block space-y-2 text-sm"><span>模型</span>
|
||||
<select aria-label="模型" className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
|
||||
value={value.model} onChange={e => change('model', e.target.value)}>
|
||||
<option value="">选择可用模型</option>
|
||||
{value.model && !catalog?.models.some(m => m.id === value.model) && <option value={value.model}>{value.model}(目录中不可用)</option>}
|
||||
{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select></label>
|
||||
{catalog?.pricing && <p className="text-xs leading-5 text-muted-foreground tabular-nums">
|
||||
每 {catalog.pricing.unit_size.toLocaleString()} 个词元约 {catalog.pricing.points_per_unit} 词元点数,按实际模型调用结算。
|
||||
</p>}
|
||||
{(['tools', 'knowledges', 'mcps', 'skills', 'subagents'] as const).map(key => {
|
||||
const labels = { tools: '工具', knowledges: '知识库', mcps: 'MCP 服务', skills: 'Skills', subagents: '子智能体' };
|
||||
const options = catalog?.resources[key] ?? [];
|
||||
const missing = value[key].filter(id => !options.some(option => option.key === id));
|
||||
return <fieldset key={key} disabled={disabled} className="space-y-2">
|
||||
<legend className="mb-2 text-sm font-medium">{labels[key]} <span className="font-normal text-muted-foreground">· 已选 {value[key].length}</span></legend>
|
||||
{!options.length && !missing.length && <p className="text-xs text-muted-foreground">{catalog ? '当前没有可用资源' : '正在读取资源…'}</p>}
|
||||
<div className="max-h-48 overflow-auto rounded-lg bg-muted/30">
|
||||
{[...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))].map(option =>
|
||||
<label key={option.key} className="flex min-h-10 cursor-pointer items-start gap-3 px-3 py-2 text-sm hover:bg-muted/50">
|
||||
<input className="mt-1 h-4 w-4 accent-violet-600" type="checkbox" checked={value[key].includes(option.key)}
|
||||
onChange={e => {
|
||||
const selected = e.target.checked ? [...value[key], option.key] : value[key].filter(id => id !== option.key);
|
||||
onChange({ ...value, [key]: selected, ...(key === 'skills' ? { preload_skills: value.preload_skills.filter(id => selected.includes(id)) } : {}) });
|
||||
}} />
|
||||
<span><span>{option.name}</span>{option.description && <span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>}</span>
|
||||
</label>)}
|
||||
</div>
|
||||
{key === 'skills' && value.skills.length > 0 && <label className="flex min-h-10 items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选 Skills</label>}
|
||||
</fieldset>;
|
||||
})}
|
||||
<p className="text-xs leading-5 text-muted-foreground">未选择的资源不会开放给此智能体。分享使用时仍按本配置限制能力。</p>
|
||||
<CloudKnowledgePanel slug={slug} onCreated={kb => {
|
||||
change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]);
|
||||
setRefresh(v => v + 1);
|
||||
}} />
|
||||
<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" disabled={disabled}
|
||||
value={value.tool_approval_mode} onChange={e => change('tool_approval_mode', e.target.value === 'always_trust' ? 'always_trust' : 'default')}>
|
||||
<option value="default">按工具要求确认</option><option value="always_trust">信任已选工具,自动执行</option>
|
||||
</select></label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{([{ key: 'max_execution_steps', label: '最多执行步数', min: 1, max: 300 },
|
||||
{ key: 'max_output_tokens', label: '单次输出词元上限', min: 1, max: 32768 },
|
||||
{ key: 'max_run_seconds', label: '单次运行时限(秒)', min: 10, max: 3600 }] as const).map(item =>
|
||||
<label key={item.key} className="space-y-2 text-xs"><span>{item.label}</span>
|
||||
<Input type="number" min={item.min} max={item.max} value={value[item.key]} disabled={disabled}
|
||||
onChange={e => change(item.key, Number(e.target.value))} /></label>)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -6,19 +6,29 @@ 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 type { CloudAgentDraft, CreateCloudAgent } from '../../../shared/cloud-agents';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread } from '../../../shared/cloud-agents';
|
||||
import { ConfigurationFields } from './ConfigurationFields';
|
||||
import { CloudChat } from './CloudChat';
|
||||
import { CloudAccessPanel } from './CloudAccess';
|
||||
import { CloudSchedules } from './CloudSchedules';
|
||||
import { CloudOverview } from './CloudOverview';
|
||||
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
|
||||
}
|
||||
|
||||
function AgentWorkspace() {
|
||||
const location = useLocation();
|
||||
const [agents, setAgents] = useState<CloudAgentDraft[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selected, setSelected] = useState<CloudAgentDraft | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
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 mounted = useRef(true);
|
||||
const request = useRef(0);
|
||||
|
||||
@@ -43,6 +53,14 @@ function AgentWorkspace() {
|
||||
void load();
|
||||
return () => { mounted.current = false; request.current += 1; };
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
const match = /^\/cloud-agents\/shared\/(ml-[a-f0-9]{32})$/.exec(location.pathname);
|
||||
if (!match) return;
|
||||
let live = true;
|
||||
cloudAgentsApi.call('entry', { slug: match[1] }).then(entry => { if (live) setShared(entry); })
|
||||
.catch(failure => { if (live) setError(errorMessage(failure)); });
|
||||
return () => { live = false; };
|
||||
}, [location.pathname]);
|
||||
|
||||
const accepted = (agent: CloudAgentDraft) => {
|
||||
if (!mounted.current) return;
|
||||
@@ -55,17 +73,23 @@ function AgentWorkspace() {
|
||||
setSelected(null);
|
||||
void load();
|
||||
}} onSaved={accepted} />;
|
||||
if (shared || activity) return <SharedConversation shared={shared} activity={activity} onBack={() => { setShared(null); setActivity(null); }} />;
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl pb-10" data-testid="cloud-agents-page">
|
||||
<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>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">我的智能体</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">AI 智能体</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">从一个用途开始,定义它该怎样帮助你。</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreating(true)} disabled={creating}><Plus className="mr-2 h-4 w-4" />创建智能体</Button>
|
||||
</header>
|
||||
<nav aria-label="智能体导航" className="mb-6 flex gap-2">
|
||||
{([['mine', '我的智能体'], ['received', '收到的分享'], ['activity', '活动']] as const).map(([key, label]) =>
|
||||
<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} /> : <>
|
||||
{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>
|
||||
@@ -81,13 +105,14 @@ function AgentWorkspace() {
|
||||
{agents.map((agent) => <button key={agent.slug} disabled={creating} onClick={() => setSelected(agent)}
|
||||
className="rounded-xl border border-border bg-background p-5 text-left transition-colors enabled:hover:bg-violet-50/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span className="mb-4 flex items-center justify-between"><Sparkles className="h-5 w-5 text-violet-500" />
|
||||
<span className="text-xs text-muted-foreground">私人草稿</span></span>
|
||||
<span className="text-xs text-muted-foreground">{agent.published_version ? '已发布 · v' + agent.published_version : '私人草稿'}</span></span>
|
||||
<h2 className="truncate font-medium">{agent.name}</h2>
|
||||
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
|
||||
<p className="mt-5 text-xs text-muted-foreground">修订 {agent.draft_revision} · 配置智能体</p>
|
||||
<p className="mt-5 text-xs text-muted-foreground">修订 {agent.draft_revision} · {agent.published_version ? '打开智能体' : '配置智能体'}</p>
|
||||
</button>)}
|
||||
</div>
|
||||
{cursor && <Button className="mt-5" variant="outline" disabled={loading} onClick={() => void load(cursor)}>加载更多</Button>}
|
||||
</>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -149,6 +174,33 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
</form>;
|
||||
}
|
||||
|
||||
function SharedConversation({ shared, activity, onBack }: {
|
||||
shared: CloudAgentEntry | null; activity: CloudThread | null; onBack: () => void;
|
||||
}) {
|
||||
const pending = useCloudPendingState();
|
||||
const blocker = useBlocker(pending.hasPending);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (pending.hasPending) { event.preventDefault(); event.returnValue = ''; } };
|
||||
window.addEventListener('beforeunload', warn);
|
||||
return () => window.removeEventListener('beforeunload', warn);
|
||||
}, [pending.hasPending]);
|
||||
return <CloudPendingContext.Provider value={pending.report}><section className="mx-auto max-w-4xl pb-10">
|
||||
<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}
|
||||
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">
|
||||
<p>还有未发送或未确认的操作。离开后,已被云端接受的任务仍会继续,可在活动中查看。</p>
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => blocker.state === 'blocked' ? blocker.proceed() : onBack()}>放弃输入并离开</Button>
|
||||
<Button onClick={() => { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); }}>继续编辑</Button></div>
|
||||
</div>
|
||||
</div>}
|
||||
</section></CloudPendingContext.Provider>;
|
||||
}
|
||||
|
||||
|
||||
function DraftEditor({ initial, onBack, onSaved }: {
|
||||
initial: CloudAgentDraft; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
|
||||
}) {
|
||||
@@ -158,31 +210,42 @@ function DraftEditor({ initial, onBack, onSaved }: {
|
||||
const [busy, setBusy] = useState(false);
|
||||
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 [nextTab, setNextTab] = useState<Tab | null>(null);
|
||||
const [openedThread, setOpenedThread] = useState<CloudThread | undefined>(undefined);
|
||||
const pending = useCloudPendingState();
|
||||
const alive = useRef(true);
|
||||
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt;
|
||||
const blocker = useBlocker(dirty || busy);
|
||||
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|
||||
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
|
||||
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 onBack();
|
||||
};
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = ''; } };
|
||||
const warn = (event: BeforeUnloadEvent) => { if (dirty || pending.hasPending) { event.preventDefault(); event.returnValue = ''; } };
|
||||
window.addEventListener('beforeunload', warn);
|
||||
return () => window.removeEventListener('beforeunload', warn);
|
||||
}, [dirty]);
|
||||
}, [dirty, pending.hasPending]);
|
||||
|
||||
const save = async (exit = false) => {
|
||||
if (busy) return;
|
||||
if (busy || pending.hasPending) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await cloudAgentsApi.save(saved.slug, {
|
||||
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
|
||||
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
|
||||
});
|
||||
if (!alive.current) return;
|
||||
setSaved(result); setDraft(result); setRemote(null); onSaved(result);
|
||||
if (exit) finishLeaving();
|
||||
if (exit) {
|
||||
if (nextTab && blocker.state !== 'blocked') { setTab(nextTab); setNextTab(null); setLeaving(false); }
|
||||
else finishLeaving();
|
||||
}
|
||||
} catch (failure) {
|
||||
if (!alive.current) return;
|
||||
setError(errorMessage(failure));
|
||||
@@ -194,18 +257,23 @@ function DraftEditor({ initial, onBack, onSaved }: {
|
||||
} finally { if (alive.current) setBusy(false); }
|
||||
};
|
||||
|
||||
return <section className="mx-auto max-w-4xl pb-10" data-testid="cloud-agent-editor">
|
||||
<Button variant="ghost" className="mb-5 -ml-3" disabled={busy} onClick={() => dirty ? setLeaving(true) : onBack()}>
|
||||
return <CloudPendingContext.Provider value={pending.report}><section className="mx-auto max-w-6xl pb-10" data-testid="cloud-agent-editor">
|
||||
<Button variant="ghost" className="mb-5 -ml-3" disabled={busy} onClick={() => dirty || pending.hasPending ? setLeaving(true) : onBack()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />我的智能体
|
||||
</Button>
|
||||
<header className="mb-6 flex items-center justify-between gap-4 border-b border-border pb-5">
|
||||
<div><p className="mb-1 text-xs text-muted-foreground">私人草稿 · 修订 {saved.draft_revision}</p>
|
||||
<h1 className="text-xl font-semibold">{saved.name}</h1></div>
|
||||
<Button disabled={busy || !dirty || !draft.name.trim() || !draft.purpose.trim() || remote !== null} onClick={() => void save()}>
|
||||
{tab === 'configuration' && <Button disabled={busy || pending.hasPending || !dirty || !draft.name.trim() || !draft.purpose.trim() || remote !== null} onClick={() => void save()}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
|
||||
{busy ? '保存中…' : dirty ? '保存草稿' : '已保存'}
|
||||
</Button>
|
||||
</Button>}
|
||||
</header>
|
||||
<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>)}
|
||||
</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">
|
||||
<p className="font-medium">云端已有修订 {remote.draft_revision},本地输入仍保留</p>
|
||||
@@ -214,7 +282,18 @@ 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>}
|
||||
<div className="grid gap-8 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
{tab === 'chat' && (saved.published_version ? <CloudChat slug={saved.slug} initialThread={openedThread} />
|
||||
: <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={() => {
|
||||
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
|
||||
const publication = { published_version: result.published_version, enabled: result.enabled };
|
||||
setSaved(current => ({ ...current, ...publication }));
|
||||
setDraft(current => ({ ...current, ...publication }));
|
||||
onSaved({ ...saved, ...publication });
|
||||
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
|
||||
}} />}
|
||||
{tab === 'configuration' && <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.8fr)]">
|
||||
<div className="space-y-5">
|
||||
<label className="block space-y-2 text-sm"><span>名称</span><Input maxLength={100} value={draft.name} disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
|
||||
@@ -223,21 +302,27 @@ function DraftEditor({ initial, onBack, onSaved }: {
|
||||
<label className="block space-y-2 text-sm"><span>角色与指令</span><Textarea className="min-h-64 leading-7" maxLength={32000}
|
||||
value={draft.system_prompt} disabled={busy} onChange={(event) => setDraft({ ...draft, system_prompt: event.target.value })}
|
||||
placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
|
||||
<ConfigurationFields slug={saved.slug} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
|
||||
onChange={configuration => setDraft({ ...draft, configuration })} />
|
||||
</div>
|
||||
<aside className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<h2 className="font-medium text-foreground">先定义它,再逐步完善</h2>
|
||||
<p>说明它为谁工作、要完成什么,以及你希望得到怎样的结果。</p>
|
||||
<p>草稿仅你可见。保存不会发布,也不会触发模型调用或扣除词元点数。</p>
|
||||
{dirty && <p role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900">
|
||||
预览仍使用已保存的修订 {saved.draft_revision}。{pending.hasPending ? '还有未完成的输入或操作,完成或清空后再保存。' : '保存后将开始新一轮预览。'}
|
||||
</p>}
|
||||
{!saved.configuration?.model ? <div className="rounded-xl bg-muted/30 p-6"><h2 className="font-medium text-foreground">测试当前配置</h2>
|
||||
<p className="mt-3">选择模型并保存后,在这里测试智能体。</p>
|
||||
<p className="mt-3">草稿仅你可见。保存不会触发模型调用,发送预览消息会消耗你的词元点数。</p></div>
|
||||
: <CloudChat key={saved.draft_revision} slug={saved.slug} previewRevision={saved.draft_revision} />}
|
||||
</aside>
|
||||
</div>
|
||||
</div>}
|
||||
{(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 rounded-xl bg-background p-6 shadow-lg"><h2 className="font-medium">还有未保存的修改</h2>
|
||||
<p className="my-4 text-sm text-muted-foreground">保存后离开,或放弃这次修改。</p>
|
||||
<div className="flex flex-wrap gap-2"><Button disabled={busy || remote !== null || !draft.name.trim() || !draft.purpose.trim()} onClick={() => void save(true)}>保存并离开</Button>
|
||||
<p className="my-4 text-sm text-muted-foreground">{pending.hasPending ? '还有未发送的内容或未确认的操作。离开会丢弃当前输入;已被云端接受的任务仍会继续,可在活动中查看。' : '保存后离开,或放弃这次修改。'}</p>
|
||||
<div className="flex flex-wrap gap-2">{!pending.hasPending && <Button disabled={busy || remote !== null || !draft.name.trim() || !draft.purpose.trim()} onClick={() => void save(true)}>保存并离开</Button>}
|
||||
<Button variant="outline" disabled={busy} onClick={finishLeaving}>放弃修改</Button>
|
||||
<Button variant="ghost" onClick={() => { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); }}>继续编辑</Button></div></div>
|
||||
<Button variant="ghost" onClick={() => { setLeaving(false); setNextTab(null); if (blocker.state === 'blocked') blocker.reset(); }}>继续编辑</Button></div></div>
|
||||
</div>}
|
||||
</section>;
|
||||
</section></CloudPendingContext.Provider>;
|
||||
}
|
||||
|
||||
export default function CloudAgents() {
|
||||
|
||||
Reference in New Issue
Block a user