111 lines
13 KiB
TypeScript
111 lines
13 KiB
TypeScript
import { useCallback, useRef, useState } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||
import type { CloudChild, CloudMcp, CloudMcpInput, CloudPersonalResources, CloudSkillDraft } from '../../../shared/cloud-agents';
|
||
import { usePendingCloudInput } from './CloudPending';
|
||
|
||
export function CloudResources({ onChanged }: { onChanged: () => void }) {
|
||
const [data, setData] = useState<CloudPersonalResources | null>(null);
|
||
const [mcp, setMcp] = useState<CloudMcp | 'new' | null>(null);
|
||
const [child, setChild] = useState<CloudChild | 'new' | null>(null);
|
||
const [skill, setSkill] = useState<CloudSkillDraft | null>(null);
|
||
const [deleting, setDeleting] = useState<{ kind: 'mcp' | 'skill'; slug: string; name: string } | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
usePendingCloudInput(busy || Boolean(skill));
|
||
const refresh = useCallback(async () => setData(await cloudAgentsApi.call('resources', {})), []);
|
||
const act = async (action: () => Promise<unknown>) => {
|
||
setBusy(true); setError('');
|
||
try { await action(); await refresh(); onChanged(); }
|
||
catch (e) { setError(e instanceof Error ? e.message : '能力管理失败'); }
|
||
finally { setBusy(false); }
|
||
};
|
||
return <details className="rounded-lg border p-4" onToggle={e => { if (e.currentTarget.open && !data) void act(refresh); }}>
|
||
<summary className="min-h-8 cursor-pointer text-sm font-medium">管理我的云端能力</summary>
|
||
<div className="mt-4 space-y-6 text-sm">
|
||
<p className="text-xs leading-5 text-muted-foreground">连接、Skills 和子智能体属于你的个人账号。创建后在上面的能力目录选用,保存并发布后生效。</p>
|
||
{error && <p role="alert" className="text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}>刷新能力</Button></p>}
|
||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">MCP 连接</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(mcp)} onClick={() => setMcp('new')}>添加 MCP</Button></div>
|
||
<p className="text-xs text-muted-foreground">连接由云端访问。凭据只用于你的连接,分享调用可在你已发布的授权范围内使用这些工具。</p>
|
||
{mcp && <McpEditor key={mcp === 'new' ? 'new' : mcp.slug} initial={mcp === 'new' ? undefined : mcp} onClose={() => setMcp(null)} onSaved={() => { setMcp(null); void act(refresh); }} />}
|
||
{data?.mcps.map(item => <div key={item.slug} className="space-y-2 rounded-lg bg-muted/30 p-3"><p>{item.name} · {item.enabled ? '已启用' : '已停用'} · {item.has_credentials ? '已配置凭据' : '无凭据'}</p>
|
||
<p className="break-all text-xs text-muted-foreground">{item.url}</p><div className="flex gap-2">
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy || Boolean(mcp)} onClick={() => setMcp(item)}>编辑连接</Button>
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('enableMcp', { key: item.slug, enabled: !item.enabled }))}>{item.enabled ? '停用连接' : '启用连接'}</Button>
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'mcp', slug: item.slug, name: item.name })}>删除连接</Button>
|
||
</div></div>)}
|
||
</section>
|
||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">个人 Skills</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(skill)} onClick={() => void act(async () => setSkill(await cloudAgentsApi.uploadSkill()))}>上传 Skill</Button></div>
|
||
<p className="text-xs text-muted-foreground">支持 SKILL.md 或单个 Skill 的 ZIP,最大 5 MB。同名 Skill 需先删除个人旧版再安装,并重新核对相关智能体配置。</p>
|
||
{skill && <div className="space-y-3 rounded-lg border p-3">{skill.items.map(item => <p key={item.slug}>{item.name || item.slug} · {item.description}{item.error && <span className="text-destructive">{item.error}</span>}</p>)}
|
||
<div className="flex gap-2"><Button type="button" size="sm" disabled={busy} onClick={() => void act(async () => {
|
||
const result = await cloudAgentsApi.call('confirmSkill', { draft_id: skill.draft_id });
|
||
const failures = result.items.filter(item => !item.success); if (failures.length) throw new Error(failures.map(item => item.error).join(';'));
|
||
setSkill(null);
|
||
})}>确认安装到个人能力</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setSkill(null)}>取消安装</Button></div></div>}
|
||
{data?.skills.map(item => <div key={item.slug} className="flex items-center justify-between gap-2 rounded-lg bg-muted/30 p-3"><div><p>{item.name}</p><p className="mt-1 text-xs text-muted-foreground">{item.description}</p></div>
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => setDeleting({ kind: 'skill', slug: item.slug, name: item.name })}>删除 Skill</Button></div>)}
|
||
</section>
|
||
<section className="space-y-3"><div className="flex items-center justify-between"><h3 className="font-medium">个人子智能体</h3><Button type="button" size="sm" variant="outline" disabled={busy || Boolean(child)} onClick={() => setChild('new')}>创建子智能体</Button></div>
|
||
<p className="text-xs text-muted-foreground">子智能体处理专门任务,继承父智能体发布的模型、能力和费用限制。</p>
|
||
{child && <ChildEditor key={child === 'new' ? 'new' : child.slug} initial={child === 'new' ? undefined : child} onClose={() => setChild(null)} onSaved={() => { setChild(null); void act(refresh); }} />}
|
||
{data?.subagents.map(item => <div key={item.slug} className="flex items-center justify-between gap-2 rounded-lg bg-muted/30 p-3"><div><p>{item.name}</p><p className="mt-1 text-xs text-muted-foreground">{item.purpose}</p></div>
|
||
<Button type="button" size="sm" variant="ghost" disabled={busy || Boolean(child)} onClick={() => setChild(item)}>编辑子智能体</Button></div>)}
|
||
</section>
|
||
{deleting && <div role="dialog" aria-label="删除云端能力" className="space-y-3 rounded-lg border border-amber-200 p-4"><p>删除“{deleting.name}”?其他智能体中选用的此项能力也会不可用,请同步调整配置。</p>
|
||
<div className="flex gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => void act(async () => {
|
||
await cloudAgentsApi.call(deleting.kind === 'mcp' ? 'deleteMcp' : 'deleteSkill', { key: deleting.slug }); setDeleting(null);
|
||
})}>确认删除能力</Button><Button type="button" variant="ghost" disabled={busy} onClick={() => setDeleting(null)}>取消</Button></div></div>}
|
||
</div>
|
||
</details>;
|
||
}
|
||
|
||
function McpEditor({ initial, onClose, onSaved }: { initial?: CloudMcp; onClose: () => void; onSaved: () => void }) {
|
||
const [value, setValue] = useState<CloudMcpInput>(initial ?? { name: '', description: '', url: '', transport: 'streamable_http' });
|
||
const [token, setToken] = useState('');
|
||
const [clear, setClear] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [intent, setIntent] = useState<(CloudMcpInput & { operation_id: string }) | null>(null);
|
||
usePendingCloudInput(busy || Boolean(intent) || Boolean(token) || clear || value.name !== (initial?.name ?? '') || value.url !== (initial?.url ?? '') || value.description !== (initial?.description ?? '') || value.transport !== (initial?.transport ?? 'streamable_http'));
|
||
return <div className="space-y-3 rounded-lg border p-3"><fieldset disabled={busy || Boolean(intent)} className="space-y-3">
|
||
<label className="block space-y-1"><span>连接名称</span><Input value={value.name} maxLength={100} onChange={e => setValue({ ...value, name: e.target.value })} /></label>
|
||
<label className="block space-y-1"><span>连接地址</span><Input type="url" value={value.url} onChange={e => setValue({ ...value, url: e.target.value })} placeholder="https://example.com/mcp" /></label>
|
||
<label className="block space-y-1"><span>传输方式</span><select className="h-10 w-full rounded-md border bg-background px-3" value={value.transport} onChange={e => setValue({ ...value, transport: e.target.value as CloudMcpInput['transport'] })}><option value="streamable_http">Streamable HTTP</option><option value="sse">SSE</option></select></label>
|
||
<label className="block space-y-1"><span>连接说明</span><Input maxLength={500} value={value.description} onChange={e => setValue({ ...value, description: e.target.value })} /></label>
|
||
<label className="block space-y-1"><span>Bearer 凭据</span><Input type="password" autoComplete="new-password" value={token} disabled={clear} onChange={e => setToken(e.target.value)} placeholder={initial?.has_credentials ? '留空保留已有凭据' : '服务无需凭据时留空'} /></label>
|
||
{initial?.has_credentials && <label className="flex items-center gap-2"><input type="checkbox" checked={clear} onChange={e => setClear(e.target.checked)} />清除已有凭据</label>}
|
||
</fieldset>
|
||
{error && <p role="alert" className="text-destructive">{error}。可重试原操作,或返回能力列表核对。</p>}
|
||
<div className="flex gap-2"><Button type="button" disabled={busy || !value.name.trim() || !value.url.trim()} onClick={async () => {
|
||
const input = intent ?? { name: value.name, description: value.description, transport: value.transport, url: value.url,
|
||
headers: clear ? {} : token.trim() ? { Authorization: 'Bearer ' + token.trim() } : null, operation_id: crypto.randomUUID() };
|
||
setIntent(input); setBusy(true); setError('');
|
||
try { if (initial) await cloudAgentsApi.call('updateMcp', { key: initial.slug, ...input }); else await cloudAgentsApi.call('createMcp', input); setToken(''); onSaved(); }
|
||
catch (e) { setError(e instanceof Error ? e.message : '连接保存失败'); } finally { setBusy(false); }
|
||
}}>{intent ? '重试保存连接' : '保存连接'}</Button><Button type="button" variant="ghost" disabled={busy} onClick={onClose}>返回能力列表</Button></div>
|
||
</div>;
|
||
}
|
||
|
||
function ChildEditor({ initial, onClose, onSaved }: { initial?: CloudChild; onClose: () => void; onSaved: () => void }) {
|
||
const [value, setValue] = useState<Omit<CloudChild, 'slug'>>(initial ?? { name: '', purpose: '', system_prompt: '' });
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const operation = useRef<string | null>(null);
|
||
usePendingCloudInput(busy || Boolean(operation.current) || value.name !== (initial?.name ?? '') || value.purpose !== (initial?.purpose ?? '') || value.system_prompt !== (initial?.system_prompt ?? ''));
|
||
return <div className="space-y-3 rounded-lg border p-3"><fieldset disabled={busy || Boolean(operation.current)} className="space-y-3">
|
||
<label className="block space-y-1"><span>子智能体名称</span><Input maxLength={100} value={value.name} onChange={e => setValue({ ...value, name: e.target.value })} /></label>
|
||
<label className="block space-y-1"><span>子智能体用途</span><Input maxLength={2000} value={value.purpose} onChange={e => setValue({ ...value, purpose: e.target.value })} /></label>
|
||
<label className="block space-y-1"><span>子智能体指令</span><Textarea maxLength={32000} value={value.system_prompt} onChange={e => setValue({ ...value, system_prompt: e.target.value })} /></label>
|
||
</fieldset>{error && <p role="alert" className="text-destructive">{error}</p>}
|
||
<div className="flex gap-2"><Button type="button" disabled={busy || !value.name.trim() || !value.purpose.trim() || !value.system_prompt.trim()} onClick={async () => {
|
||
operation.current ??= crypto.randomUUID(); setBusy(true); setError('');
|
||
try { if (initial) await cloudAgentsApi.call('updateChild', { key: initial.slug, name: value.name, purpose: value.purpose, system_prompt: value.system_prompt });
|
||
else await cloudAgentsApi.call('createChild', { name: value.name, purpose: value.purpose, system_prompt: value.system_prompt, operation_id: operation.current }); onSaved(); }
|
||
catch (e) { setError(e instanceof Error ? e.message : '子智能体保存失败'); } finally { setBusy(false); }
|
||
}}>{operation.current ? '重试保存子智能体' : '保存子智能体'}</Button><Button type="button" variant="ghost" disabled={busy} onClick={onClose}>返回能力列表</Button></div>
|
||
</div>;
|
||
}
|