feat(agents): 完成云智能体工作台与交互流程

This commit is contained in:
brother7 committed 2026-09-10 19:25:12 +08:00
1 parent 46fbae9dec
commit 64a3cf4934
26 files changed
+1955 -49

No files matched your search

+144
View 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>;
}