Files
makelore/src/pages/CloudAgents/CloudAccess.tsx

188 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
}