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

@@ -40,6 +40,7 @@ const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').t
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
const AiHardware = lazy(() => import('./pages/AiHardware').then(({ AiHardware: component }) => ({ default: component })));
const CloudAgents = lazy(() => import('./pages/CloudAgents'));
const Settings = lazy(() => import('./pages/Settings').then(({ Settings: component }) => ({ default: component })));
const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component })));
const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component })));
@@ -457,6 +458,7 @@ function App() {
<Route path="/image-canvas" element={<ImageCanvas />} />
<Route path="/image-prompts/*" element={<Navigate to="/image-canvas" replace />} />
<Route path="/ai-hardware" element={<AiHardware />} />
<Route path="/cloud-agents/*" element={<CloudAgents />} />
<Route path="/workbench/:projectId" element={<Workbench />} />
<Route path="/models" element={<Models />} />
<Route path="/settings/*" element={<Settings />} />

View File

@@ -781,7 +781,13 @@ export function Sidebar({
</div>
</>
) : (
<div data-testid="sidebar-robot-navigation" className="px-2 py-3">
activeModule === 'cloud_agents' ? <div data-testid="sidebar-cloud-agents-navigation" className="px-2 py-3">
<button type="button" aria-label="我的智能体" onClick={() => navigate('/cloud-agents')}
className={cn('flex min-h-10 w-full items-center gap-2 rounded-lg bg-brand-soft px-2 py-2 text-left text-sm font-semibold', sidebarCollapsed && 'justify-center px-0')}>
<Bot className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && <span></span>}
</button>
</div> : <div data-testid="sidebar-robot-navigation" className="px-2 py-3">
<button
type="button"
aria-label="机器人工作台"

View File

@@ -1,10 +1,10 @@
import type { LucideIcon } from 'lucide-react';
import { Bot, Code2, Paintbrush } from 'lucide-react';
import { Bot, Code2, Paintbrush, Sparkles } from 'lucide-react';
import type { ModuleAccess, ModuleAccessKey } from '../../shared/module-access';
export const AI_MODULE_SELECTION_PATH = '/module-select';
export type AiModuleId = 'programming' | 'painting' | 'robot';
export type AiModuleId = 'programming' | 'painting' | 'robot' | 'cloud_agents';
export type AiModuleDefinition = {
id: AiModuleId;
@@ -46,12 +46,23 @@ export const aiModules: readonly AiModuleDefinition[] = [
Icon: Bot,
switcherLabel: 'Robot 机器',
},
{
id: 'cloud_agents',
title: 'Makelore Agents',
subtitle: 'AI 智能体',
description: '配置专属智能体,从定义用途与角色开始',
route: '/cloud-agents',
enabled: true,
Icon: Sparkles,
switcherLabel: 'Agents 智能体',
},
];
const moduleAccessKeyById: Record<AiModuleId, ModuleAccessKey> = {
programming: 'programming',
painting: 'design',
robot: 'robot',
cloud_agents: 'cloud_agents',
};
const PROGRAMMING_ROUTE_PREFIXES = [
@@ -95,6 +106,7 @@ export function isProgrammingProviderRoute(pathname: string): boolean {
}
export function getGuardedAiModuleForPath(pathname: string): AiModuleId | null {
if (matchesRoute(pathname, '/cloud-agents')) return 'cloud_agents';
if (pathname === '/image-canvas'
|| pathname.startsWith('/image-canvas/')
|| pathname === '/image-prompts'

View File

@@ -0,0 +1,16 @@
import { hostApiFetch } from './host-api';
import { CLOUD_AGENTS_PATH, type CloudAgentDraft, type CloudAgentPage, type CreateCloudAgent, type SaveCloudAgentDraft } from '../../shared/cloud-agents';
export const cloudAgentsApi = {
list: (cursor: string | null = null) => hostApiFetch<CloudAgentPage>(
CLOUD_AGENTS_PATH + '/agents' + (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''),
),
get: (slug: string) => hostApiFetch<CloudAgentDraft>(`${CLOUD_AGENTS_PATH}/agents/${encodeURIComponent(slug)}`),
create: (input: CreateCloudAgent) => hostApiFetch<CloudAgentDraft>(CLOUD_AGENTS_PATH + '/agents', {
method: 'POST', body: JSON.stringify(input),
}),
save: (slug: string, input: SaveCloudAgentDraft) => hostApiFetch<CloudAgentDraft>(
`${CLOUD_AGENTS_PATH}/agents/${encodeURIComponent(slug)}/draft`,
{ method: 'PATCH', body: JSON.stringify(input) },
),
};

View File

@@ -3,7 +3,7 @@
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { createHashRouter, RouterProvider } from 'react-router-dom';
import App from './App';
import './i18n';
import './styles/globals.css';
@@ -17,10 +17,10 @@ if (platform) {
initializeDefaultTransports();
const router = createHashRouter([{ path: '*', element: <App /> }]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
<RouterProvider router={router} />
</React.StrictMode>,
);

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>