feat(agents): 新增云端智能体入口与草稿编辑

This commit is contained in:
2026-09-10 16:00:37 +08:00
parent 927a85fa22
commit 46fbae9dec
21 changed files with 921 additions and 17 deletions

View File

@@ -0,0 +1,247 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker, useLocation } from 'react-router-dom';
import { ArrowLeft, Check, Loader2, Plus, Save, Sparkles } from 'lucide-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 { useAuthStore } from '@/stores/auth';
import type { CloudAgentDraft, CreateCloudAgent } from '../../../shared/cloud-agents';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
}
function AgentWorkspace() {
const [agents, setAgents] = useState<CloudAgentDraft[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selected, setSelected] = useState<CloudAgentDraft | null>(null);
const [creating, setCreating] = useState(false);
const mounted = useRef(true);
const request = useRef(0);
const load = useCallback(async (next: string | null = null) => {
const generation = ++request.current;
setLoading(true);
setError('');
try {
const page = await cloudAgentsApi.list(next);
if (!mounted.current || generation !== request.current) return;
setAgents((previous) => next ? [...previous, ...page.agents] : page.agents);
setCursor(page.next_cursor);
} catch (failure) {
if (mounted.current && generation === request.current) setError(errorMessage(failure));
} finally {
if (mounted.current && generation === request.current) setLoading(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void load();
return () => { mounted.current = false; request.current += 1; };
}, [load]);
const accepted = (agent: CloudAgentDraft) => {
if (!mounted.current) return;
setAgents((previous) => [agent, ...previous.filter((item) => item.slug !== agent.slug)]);
setSelected(agent);
setCreating(false);
};
if (selected) return <DraftEditor key={selected.slug} initial={selected} onBack={() => {
setSelected(null);
void load();
}} onSaved={accepted} />;
return (
<section className="mx-auto max-w-5xl pb-10" data-testid="cloud-agents-page">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-6">
<div>
<p className="mb-2 text-sm text-muted-foreground">Makelore Agents</p>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<Button onClick={() => setCreating(true)} disabled={creating}><Plus className="mr-2 h-4 w-4" /></Button>
</header>
{creating && <CreateAgentForm onCreated={accepted} onCancel={() => { setCreating(false); void load(); }} />}
{error && <div role="alert" className="mb-5 rounded-lg border border-border p-4 text-sm">
<p>{error}</p><Button variant="outline" className="mt-3" onClick={() => void load()}></Button>
</div>}
{loading && agents.length === 0 ? <p role="status" className="py-12 text-muted-foreground"></p>
: !error && agents.length === 0 && !creating ? <div className="rounded-xl border border-dashed border-border px-6 py-16 text-center">
<Sparkles className="mx-auto mb-5 h-9 w-9 text-violet-500" strokeWidth={1.4} />
<h2 className="text-lg font-medium"></h2>
<p className="mb-6 mt-2 text-sm text-muted-foreground"></p>
<Button variant="outline" onClick={() => setCreating(true)}></Button>
</div> : null}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => <button key={agent.slug} disabled={creating} onClick={() => setSelected(agent)}
className="rounded-xl border border-border bg-background p-5 text-left transition-colors enabled:hover:bg-violet-50/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
<span className="mb-4 flex items-center justify-between"><Sparkles className="h-5 w-5 text-violet-500" />
<span className="text-xs text-muted-foreground">稿</span></span>
<h2 className="truncate font-medium">{agent.name}</h2>
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
<p className="mt-5 text-xs text-muted-foreground"> {agent.draft_revision} · </p>
</button>)}
</div>
{cursor && <Button className="mt-5" variant="outline" disabled={loading} onClick={() => void load(cursor)}></Button>}
</section>
);
}
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) {
const [name, setName] = useState('');
const [purpose, setPurpose] = useState('');
const [intent, setIntent] = useState<CreateCloudAgent | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const alive = useRef(true);
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose));
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (busy || intent || name || purpose) { event.preventDefault(); event.returnValue = ''; }
};
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [busy, intent, name, purpose]);
const submit = async () => {
if (busy || !name.trim() || !purpose.trim()) return;
const input = intent ?? { operation_id: crypto.randomUUID(), name: name.trim(), purpose: purpose.trim() };
setIntent(input);
setBusy(true);
setError('');
try {
const agent = await cloudAgentsApi.create(input);
if (alive.current) onCreated(agent);
} catch (failure) {
if (alive.current) setError(errorMessage(failure));
} finally {
if (alive.current) setBusy(false);
}
};
return <form className="mb-8 space-y-4 rounded-xl border border-border p-5" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<h2 className="font-medium"></h2>
<label className="block space-y-2 text-sm"><span></span><Input autoFocus maxLength={100} value={name}
disabled={busy || intent !== null} onChange={(event) => setName(event.target.value)} placeholder="例如:我的写作搭档" /></label>
<label className="block space-y-2 text-sm"><span></span><Textarea maxLength={2000} value={purpose}
disabled={busy || intent !== null} onChange={(event) => setPurpose(event.target.value)} placeholder="希望它帮你完成什么?" /></label>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex gap-3">
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '创建草稿'}</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>
</div>
{blocker.state === 'blocked' && <div role="dialog" aria-modal="true" aria-label="离开创建" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
<div className="max-w-md space-y-4 rounded-xl bg-background p-6 shadow-lg">
<h2 className="font-medium">{intent ? '本次创建结果尚未确认' : '还有未提交的内容'}</h2>
<p className="text-sm text-muted-foreground">{intent ? '可继续重试确认结果。如果离开,请先查看智能体列表再创建。' : '离开将放弃本次输入。'}</p>
<div className="flex gap-2">
<Button type="button" onClick={() => blocker.reset()}></Button>
<Button type="button" variant="outline" disabled={busy} onClick={() => blocker.proceed()}></Button>
</div>
</div>
</div>}
</form>;
}
function DraftEditor({ initial, onBack, onSaved }: {
initial: CloudAgentDraft; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
}) {
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(initial);
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
const alive = useRef(true);
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt;
const blocker = useBlocker(dirty || busy);
const finishLeaving = () => {
if (blocker.state === 'blocked') blocker.proceed();
else onBack();
};
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
const save = async (exit = false) => {
if (busy) return;
setBusy(true);
setError('');
try {
const result = await cloudAgentsApi.save(saved.slug, {
expected_revision: saved.draft_revision, name: draft.name, purpose: draft.purpose, system_prompt: draft.system_prompt,
});
if (!alive.current) return;
setSaved(result); setDraft(result); setRemote(null); onSaved(result);
if (exit) finishLeaving();
} catch (failure) {
if (!alive.current) return;
setError(errorMessage(failure));
// A readback also resolves an uncertain save without silently replacing local edits.
try {
const latest = await cloudAgentsApi.get(saved.slug);
if (alive.current && latest.draft_revision !== saved.draft_revision) setRemote(latest);
} catch { /* Keep the original save failure and the user's input. */ }
} finally { if (alive.current) setBusy(false); }
};
return <section className="mx-auto max-w-4xl pb-10" data-testid="cloud-agent-editor">
<Button variant="ghost" className="mb-5 -ml-3" disabled={busy} onClick={() => dirty ? setLeaving(true) : onBack()}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<header className="mb-6 flex items-center justify-between gap-4 border-b border-border pb-5">
<div><p className="mb-1 text-xs text-muted-foreground">稿 · {saved.draft_revision}</p>
<h1 className="text-xl font-semibold">{saved.name}</h1></div>
<Button disabled={busy || !dirty || !draft.name.trim() || !draft.purpose.trim() || remote !== null} onClick={() => void save()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
{busy ? '保存中…' : dirty ? '保存草稿' : '已保存'}
</Button>
</header>
{error && <p role="alert" className="mb-4 text-sm text-destructive">{error}</p>}
{remote && <div className="mb-5 space-y-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm">
<p className="font-medium"> {remote.draft_revision}</p>
<details><summary className="cursor-pointer">稿</summary><p className="mt-2">{remote.name} · {remote.purpose}</p>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-sans">{remote.system_prompt}</pre></details>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => { setSaved(remote); setDraft(remote); setRemote(null); setError(''); }}>使稿</Button>
<Button variant="outline" onClick={() => { setSaved(remote); setRemote(null); setError(''); }}></Button></div>
</div>}
<div className="grid gap-8 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-5">
<label className="block space-y-2 text-sm"><span></span><Input maxLength={100} value={draft.name} disabled={busy}
onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></label>
<label className="block space-y-2 text-sm"><span></span><Textarea maxLength={2000} value={draft.purpose} disabled={busy}
onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} /></label>
<label className="block space-y-2 text-sm"><span></span><Textarea className="min-h-64 leading-7" maxLength={32000}
value={draft.system_prompt} disabled={busy} onChange={(event) => setDraft({ ...draft, system_prompt: event.target.value })}
placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
</div>
<aside className="space-y-3 text-sm leading-6 text-muted-foreground">
<h2 className="font-medium text-foreground"></h2>
<p></p>
<p>稿</p>
</aside>
</div>
{(leaving || blocker.state === 'blocked') && <div role="dialog" aria-modal="true" aria-label="未保存的修改" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
<div className="max-w-md rounded-xl bg-background p-6 shadow-lg"><h2 className="font-medium"></h2>
<p className="my-4 text-sm text-muted-foreground"></p>
<div className="flex flex-wrap gap-2"><Button disabled={busy || remote !== null || !draft.name.trim() || !draft.purpose.trim()} onClick={() => void save(true)}></Button>
<Button variant="outline" disabled={busy} onClick={finishLeaving}></Button>
<Button variant="ghost" onClick={() => { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); }}></Button></div></div>
</div>}
</section>;
}
export default function CloudAgents() {
const accountId = useAuthStore((state) => state.user?.userId);
const location = useLocation();
return <AgentWorkspace key={`${accountId ?? 'signed-out'}:${location.key}`} />;
}

View File

@@ -12,7 +12,8 @@ import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { getUserProfileDisplayName } from '@/stores/user-profile';
const moduleSelectionContent: Record<AiModuleId, { label: string; image: string; imageAlt: string }> = {
const moduleSelectionContent: Record<AiModuleId, { label: string; image?: string; imageAlt: string }> = {
cloud_agents: { label: 'Agents 智能体', imageAlt: '个人云智能体' },
programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' },
painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' },
robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' },
@@ -89,12 +90,14 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
<span className="module-option-card-frame" aria-hidden="true" />
<div className="module-option-card-surface relative flex h-full w-full rounded-lg bg-background">
<div className="module-option-card-image relative shrink-0">
<img
{content.image ? <img
src={content.image}
alt={content.imageAlt}
className={cn('module-option-card-image-media h-full w-full object-cover', !enabled && 'grayscale')}
draggable="false"
/>
/> : <div className="flex h-full w-full items-center justify-center bg-violet-50 text-violet-600">
<module.Icon className="h-12 w-12" strokeWidth={1.25} />
</div>}
</div>
<div className="module-option-card-content relative min-h-0 flex-1">
<p className="module-option-card-title font-medium tracking-[-0.02em]">{content.label}</p>