188 lines
16 KiB
TypeScript
188 lines
16 KiB
TypeScript
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, CloudKey } from '../../../shared/cloud-agents';
|
||
import { CloudCosts } from './CloudCosts';
|
||
import { CloudLifecycle } from './CloudLifecycle';
|
||
import type { CloudAgentDraft } from '../../../shared/cloud-agents';
|
||
import type { CloudAgentOperations } from '../../../shared/cloud-agents';
|
||
import { cloudStatus } from './CloudChat';
|
||
|
||
const errorText = (e: unknown) => e instanceof Error ? e.message : '操作失败,请重试';
|
||
export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activeView = true }: { slug: string; revision: number; onPublished: () => void; onChanged?: (draft: CloudAgentDraft) => void; activeView?: boolean }) {
|
||
const [access, setAccess] = useState<Access | null>(null);
|
||
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 result = await cloudAgentsApi.call('access', { slug });
|
||
if (!alive.current) return;
|
||
setAccess(result);
|
||
}, [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>
|
||
{access?.published_version && <LinkedChannels slug={slug} activeView={activeView} />}
|
||
{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>
|
||
<CloudCosts slug={slug} applications={access?.applications ?? []} />
|
||
<CloudLifecycle slug={slug} revision={revision} versions={access?.versions.map(v => v.version) ?? []} onChanged={draft => { onChanged?.(draft); void refresh().catch(e => setError(errorText(e))); }} />
|
||
{access && <details className="text-sm"><summary className="cursor-pointer">发布历史({access.versions.length})</summary>
|
||
{access.versions.map(v => <p key={v.version} className="mt-3 tabular-nums">版本 {v.version} · 草稿修订 {v.draft_revision} · {new Date(v.created_at).toLocaleString()}</p>)}</details>}
|
||
</div>;
|
||
}
|
||
|
||
function LinkedChannels({ slug, activeView }: { slug: string; activeView: boolean }) {
|
||
const [items, setItems] = useState<CloudAgentOperations['channelBindings']['output']['items']>([]);
|
||
const [error, setError] = useState('');
|
||
useEffect(() => {
|
||
if (!activeView) return;
|
||
let live = true;
|
||
void cloudAgentsApi.call('channelBindings', { slug }).then(result => {
|
||
if (live) { setItems(result.items); setError(''); }
|
||
}).catch(error => { if (live) setError(errorText(error)); });
|
||
return () => { live = false; };
|
||
}, [activeView, slug]);
|
||
return <section aria-label="已关联渠道" className="space-y-3 rounded-xl border border-border p-5">
|
||
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium">已关联渠道</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">微信账号统一在“渠道”中连接、切换目标和查看对话活动。</p></div>
|
||
<Button variant="outline" onClick={() => { window.location.hash = `#/cloud-agents/channels?target=${encodeURIComponent(slug)}`; }}>打开渠道</Button></div>
|
||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||
{!error && items.length === 0 && <p className="text-sm text-muted-foreground">暂时没有关联的微信账号。打开渠道后会预选当前智能体。</p>}
|
||
{items.map(item => <button key={item.id} type="button" className="flex w-full items-center justify-between gap-3 rounded-lg bg-muted/30 p-3 text-left text-sm"
|
||
onClick={() => { window.location.hash = `#/cloud-agents/channels?account=${encodeURIComponent(item.id)}`; }}>
|
||
<span><strong>{item.display_name}</strong><span className="mt-1 block text-xs text-muted-foreground">{item.health === 'connected' ? '微信已连接' : '微信待连接'}</span></span>
|
||
<span className="text-xs text-muted-foreground">{item.enabled ? '已启用' : '已暂停'} · 管理</span>
|
||
</button>)}
|
||
</section>;
|
||
}
|
||
|
||
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>}
|
||
<ApplicationCalls applicationId={application.id} />
|
||
</div>;
|
||
}
|
||
|
||
function ApplicationCalls({ applicationId }: { applicationId: string }) {
|
||
const [page, setPage] = useState<CloudAgentOperations['applicationCalls']['output'] | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const read = async (offset = 0) => {
|
||
setBusy(true); setError('');
|
||
try { const result = await cloudAgentsApi.call('applicationCalls', { application_id: applicationId, offset });
|
||
setPage(previous => ({ ...result, items: offset && previous ? [...previous.items, ...result.items] : result.items })); }
|
||
catch (e) { setError(errorText(e)); } finally { setBusy(false); }
|
||
};
|
||
return <details onToggle={e => { if (e.currentTarget.open && !page) void read(); }}>
|
||
<summary className="min-h-8 cursor-pointer">应用调用状态</summary>
|
||
<p className="mt-2 text-xs text-muted-foreground">包括尚未产生模型费用的请求。只显示状态与版本,不展示应用的会话内容。</p>
|
||
{error && <p role="alert" className="mt-2 text-destructive">{error}</p>}
|
||
{page && !page.items.length && <p className="mt-3 text-muted-foreground">此应用还没有调用记录。</p>}
|
||
{page?.items.map(item => <div key={item.request_id} className="mt-3 border-t pt-2 text-xs"><p>{new Date(item.created_at).toLocaleString()} · v{item.version} · {cloudStatus(item.run_status ?? item.request_status)}</p>
|
||
<p className="mt-1 break-all text-muted-foreground">请求 {item.request_id}{item.run_id && ' · 运行 ' + item.run_id}</p></div>)}
|
||
<div className="mt-3 flex gap-2"><Button variant="ghost" size="sm" disabled={busy} onClick={() => void read()}>刷新调用状态</Button>
|
||
{page?.next_offset != null && <Button variant="outline" size="sm" disabled={busy} onClick={() => void read(page.next_offset!)}>加载更多调用</Button>}</div>
|
||
</details>;
|
||
}
|