Files
makelore/src/pages/CloudAgents/CloudResources.tsx
T

111 lines
13 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, 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>;
}