feat: 改造云智能体桌面编辑与试用工作区

This commit is contained in:
2026-09-12 17:39:31 +08:00
parent 6b92591129
commit dca6deab29
16 changed files with 623 additions and 222 deletions

View File

@@ -28,6 +28,7 @@ export function MainLayout() {
const isPaintingModule = activeModule === 'painting';
const isCanvasWorkspace = location.pathname === '/image-canvas';
const isChatWorkspace = location.pathname === '/chat';
const isCloudAgents = activeModule === 'cloud_agents';
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;
@@ -77,6 +78,7 @@ export function MainLayout() {
{/* Title bar: drag region on macOS, icon + controls on Windows */}
<TitleBar
integrated
pageTitle={isCloudAgents ? '智能体工作区' : undefined}
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule}
showSidebarControls={!isCanvasWorkspace}
@@ -97,8 +99,8 @@ export function MainLayout() {
data-testid="main-content"
className={cn(
'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background',
isPaintingModule ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
isChatWorkspace && !isPaintingModule && 'basis-0 overflow-hidden p-0',
(isPaintingModule || isCloudAgents) ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
(isChatWorkspace || isCloudAgents) && !isPaintingModule && 'basis-0 overflow-hidden p-0',
)}
>
<Outlet />

View File

@@ -5,7 +5,7 @@ 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 { CloudBudgetEditor, CloudCosts } from './CloudCosts';
import { CloudCosts } from './CloudCosts';
import { CloudLifecycle } from './CloudLifecycle';
import type { CloudAgentDraft } from '../../../shared/cloud-agents';
import type { CloudAgentOperations } from '../../../shared/cloud-agents';
@@ -99,7 +99,6 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged }: { s
<p className="mt-3 text-xs leading-6 text-muted-foreground"> request_id run_id 使</p>
</details>}
</section>
<CloudBudgetEditor slug={slug} />
<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>

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MessageSquarePlus, Send, Square } from 'lucide-react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { History, MessageSquarePlus, Send, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
import { CloudFiles } from './CloudFiles';
import './desktop-workspace.css';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
@@ -18,7 +19,9 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching',
const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作';
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
export function CloudChat({ slug, previewRevision, initialThread, initialThreadId, onSchedule }: { slug: string; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) {
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true }: { slug: string; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) {
const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision);
const [showHistory, setShowHistory] = useState(false);
const [threads, setThreads] = useState<CloudThread[]>([]);
const [thread, setThread] = useState<CloudThread | null>(initialThread ?? null);
const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID());
@@ -40,8 +43,9 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending);
const generation = useRef(0);
const alive = useRef(true);
const bottom = useRef<HTMLDivElement>(null);
const entry = useRef({ initialThread, initialThreadId });
const transcript = useRef<HTMLDivElement>(null);
const following = useRef(true);
const entry = useRef({ initialThread, initialThreadId, revision: previewRevision });
const mode = previewRevision === undefined ? 'published' : 'preview';
const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => {
let page = await cloudAgentsApi.call('history', { thread_id: id });
@@ -55,7 +59,6 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null);
if (page.run) setThreads(current => current.map(item => item.thread_id === id
? { ...item, run_id: page.run!.agent_run_id, status: !active(page.run!.status) && page.queued_requests?.length ? 'queued' : page.run!.status, unread: false } : item));
if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined);
}, []);
useEffect(() => {
alive.current = true;
@@ -69,17 +72,15 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
page = { ...more, threads: [...page.threads, ...more.threads] };
}
if (!alive.current || g !== generation.current) return;
const items = page.threads.filter(t => t.mode === mode && (mode !== 'preview' || t.draft_revision === previewRevision));
const items = page.threads.filter(t => t.mode === mode);
setThreads(items);
const pointer = recent?.slug === slug && recent.mode === mode
&& (mode !== 'preview' || recent.draft_revision === previewRevision) ? recent : null;
const pointer = recent?.slug === slug && recent.mode === mode ? recent : null;
const wanted = entry.current.initialThreadId ?? pointer?.thread_id;
let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items[0]);
let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items.find(t => mode !== 'preview' || t.draft_revision === entry.current.revision));
if (wanted && !latest) {
let archived = await cloudAgentsApi.call('threads', { slug, archived: true });
for (;;) {
latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode
&& (mode !== 'preview' || t.draft_revision === previewRevision));
latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode);
if (latest || archived.next_offset === null) break;
archived = await cloudAgentsApi.call('threads', { slug, archived: true, offset: archived.next_offset });
}
@@ -88,6 +89,7 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
}
if (wanted && !latest) setError('上次对话已不可用,可选择其他对话或开始新对话');
if (latest) {
if (mode === 'preview') setPreviewRevision(latest.draft_revision ?? entry.current.revision);
setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id);
await readHistory(latest.thread_id, g);
}
@@ -96,15 +98,19 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
};
void open();
return () => { alive.current = false; generation.current++; };
}, [slug, mode, previewRevision, readHistory]);
}, [slug, mode, readHistory]);
useEffect(() => {
if (loading) return;
if (loading || !activeView) return;
let live = true;
void cloudAgentsApi.remember({ slug, mode, thread_id: thread?.thread_id, draft_revision: previewRevision })
.catch(e => { if (live) setError(message(e)); });
return () => { live = false; };
}, [slug, mode, thread?.thread_id, previewRevision, loading]);
}, [slug, mode, thread?.thread_id, previewRevision, loading, activeView]);
useEffect(() => {
if (activeView && thread && run) void cloudAgentsApi.call('viewed', { thread_id: thread.thread_id, run_id: run.agent_run_id }).catch(() => undefined);
}, [activeView, thread, run]);
useEffect(() => {
if (!request || request.run_id || !active(request.status)) return;
@@ -186,19 +192,21 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
}, 5000);
return () => { live = false; source?.close(); window.clearInterval(timer); };
}, [runId, running, thread?.thread_id, request?.thread_id, readHistory]);
useEffect(() => { bottom.current?.scrollIntoView?.({ block: 'end' }); }, [history?.messages.length, liveText, interrupt]);
useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveText, interrupt]);
const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision;
const rememberThread = (next: CloudThread) => {
setThread(next);
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
};
const send = async () => {
if (busy || running || thread?.archived || (!intent && !query.trim())) return;
if (busy || running || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))) return;
const input = intent ?? {
request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(),
attachment_file_ids: attachmentIds,
...(previewRevision === undefined ? {} : { expected_revision: previewRevision }),
};
following.current = true;
setBusy(true); setIntent(input); setError('');
const g = generation.current;
try {
@@ -206,15 +214,17 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
if (!alive.current || g !== generation.current) return;
setRequest(accepted); setIntent(null); setQuery(''); setLiveText(''); setAttachmentIds([]);
rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80),
mode, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false });
mode, draft_revision: previewRevision, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false });
await readHistory(accepted.thread_id, g);
} catch (e) { if (alive.current && g === generation.current) setError(message(e)); }
finally { if (alive.current && g === generation.current) setBusy(false); }
};
const choose = async (next: CloudThread | null) => {
const choose = async (next: CloudThread | null, keepQuery = false) => {
following.current = true;
if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? latestRevision);
const g = ++generation.current;
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery('');
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); if (!keepQuery) setQuery('');
setAttachmentIds([]);
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
};
@@ -225,17 +235,28 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
else if (runId) await cloudAgentsApi.call('cancelRun', { run_id: runId });
} catch (e) { setError(message(e)); } finally { setBusy(false); }
};
return <CloudPendingContext.Provider value={childrenPending.report}><section className="flex min-h-[480px] flex-col rounded-xl bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
<div className="flex min-h-12 items-center justify-between gap-3 border-b border-border pb-3">
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
<div className="agent-chat-toolbar"><div className="flex min-h-12 shrink-0 items-center justify-between gap-3 border-b border-border py-2">
<div className="min-w-0 text-sm">{previewRevision === undefined ? '正式对话' : '草稿预览 · 修订 ' + previewRevision}
<p className="mt-1 text-xs text-muted-foreground">{run?.version && `版本 ${run.version} · `}使</p></div>
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>
<p className="mt-1 text-xs text-muted-foreground">{run?.version && ` · 版本 ${run.version}`}</p></div>
<div className="flex shrink-0"><Button variant="ghost" size="icon" aria-label="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History className="h-4 w-4" /></Button>
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>{headerAction}</div>
</div>
{threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
{showHistory && threads.length === 0 && <p className="py-2 text-xs text-muted-foreground"></p>}
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
value={thread?.thread_id ?? ''} disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}>
<option value=""></option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
<option value=""></option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.mode === 'preview' ? `修订 ${t.draft_revision ?? previewRevision} · ` : ''}{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
</select>}
<div className="my-4 max-h-[55vh] min-h-56 flex-1 space-y-5 overflow-y-auto pr-2">
{stalePreview && <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border py-2 text-xs leading-5">
<p> {latestRevision} {previewRevision}</p>
<Button title="保留未发送文字,已有对话可从历史中查看" size="sm" variant="outline" disabled={!previewReady || busy || loading || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending}
onClick={() => void choose(null, true)}>稿</Button>
{(Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending) && <p className="mt-1 text-muted-foreground"></p>}
{!previewReady && <p className="text-muted-foreground">稿</p>}
</div>}
</div>
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll"
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="space-y-5 py-5 pr-2">
{loading ? <p role="status" className="text-sm text-muted-foreground"></p>
: !history?.messages.length && !liveText && <div className="py-12 text-center text-sm leading-7 text-muted-foreground"><br /></div>}
{history?.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
@@ -256,8 +277,7 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
{interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
}} />}
<div ref={bottom} />
</div>
{queued.length > 0 && <div className="mb-3 space-y-2 rounded-lg bg-muted/40 p-3 text-sm">
<p> {queued.length} </p>
{queued.map((item, index) => <div key={item.request_id} className="flex items-center justify-between">
@@ -278,23 +298,25 @@ export function CloudChat({ slug, previewRevision, initialThread, initialThreadI
catch (e) { setError(message(e)); } finally { setBusy(false); }
}}></Button>
</div>}
<CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
</div></div>
<div className="agent-chat-files"><CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading || Boolean(thread?.archived)} ensureThread={async () => {
if (thread) return thread.thread_id;
const created = await cloudAgentsApi.call('createThread', { slug, thread_id: clientId, preview: previewRevision !== undefined, expected_revision: previewRevision });
rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode,
rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode, draft_revision: previewRevision,
status: 'idle', updated_at: new Date().toISOString(), run_id: null, unread: false });
return created.thread_id;
}} />
}} /></div>
{leaving && <div className="mb-3 flex flex-wrap items-center gap-2 text-sm"><span></span>
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}></Button><Button variant="ghost" onClick={() => setLeaving(false)}></Button></div>}
<form className="rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
<Textarea aria-label="消息" className="min-h-20 resize-none border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)} />
<form data-testid="cloud-chat-composer" className="agent-chat-composer rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
<Textarea aria-label="消息" className="h-20 min-h-20 max-h-40 resize-y border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); void send(); } }} />
<div className="mt-2 flex items-center justify-between gap-3">
<p role="status" className="text-xs text-muted-foreground">{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p>
{running ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" /></Button>
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && !query.trim())}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
</div>
</form>
</section></CloudPendingContext.Provider>;

View File

@@ -4,14 +4,18 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CloudKnowledgePanel } from './CloudKnowledge';
import { CloudResources } from './CloudResources';
import { CloudBudgetEditor } from './CloudCosts';
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
export function ConfigurationFields({ slug, value, disabled, onChange }: {
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all' }: {
section?: 'all' | 'instructions' | 'capabilities' | 'knowledge' | 'limits';
slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void;
}) {
const [catalog, setCatalog] = useState<CloudCatalog | null>(null);
const [error, setError] = useState('');
const [refresh, setRefresh] = useState(0);
const [search, setSearch] = useState('');
const [choosing, setChoosing] = useState<string | null>(null);
useEffect(() => {
let live = true;
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
@@ -20,10 +24,10 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
}, [refresh]);
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
return <div className="space-y-6">
<div className="flex items-center justify-between"><h2 className="font-medium"></h2>
<div hidden={section === 'instructions' || section === 'limits'} className="flex items-center justify-between"><h2 className="font-medium">{section === 'knowledge' ? '知识库' : '能力'}</h2>
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}></Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<label className="block space-y-2 text-sm"><span></span>
<div hidden={section !== 'all' && section !== 'capabilities'} className="space-y-4"><label className="block space-y-2 text-sm"><span></span>
<select aria-label="模型" className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
value={value.model} onChange={e => change('model', e.target.value)}>
<option value=""></option>
@@ -33,17 +37,20 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
{catalog?.pricing && <p className="text-xs leading-5 text-muted-foreground tabular-nums">
{catalog.pricing.unit_size.toLocaleString()} {catalog.pricing.points_per_unit}
</p>}
</div>
{(['tools', 'knowledges', 'mcps', 'skills', 'subagents'] as const).map(key => {
const labels = { tools: '工具', knowledges: '知识库', mcps: 'MCP 服务', skills: 'Skills', subagents: '子智能体' };
const options = catalog?.resources[key] ?? [];
const missing = value[key].filter(id => !options.some(option => option.key === id));
return <fieldset key={key} disabled={disabled} className="space-y-2">
return <fieldset key={key} disabled={disabled} hidden={section !== 'all' && section !== (key === 'knowledges' ? 'knowledge' : 'capabilities')} className="space-y-2">
<legend className="mb-2 text-sm font-medium">{labels[key]} <span className="font-normal text-muted-foreground">· {value[key].length}</span></legend>
{!options.length && !missing.length && <p className="text-xs text-muted-foreground">{catalog ? '当前没有可用资源' : '正在读取资源…'}</p>}
<div className="max-h-48 overflow-auto rounded-lg bg-muted/30">
{[...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))].map(option =>
<Button variant="outline" size="sm" onClick={() => { setChoosing(choosing === key ? null : key); setSearch(''); }}>{choosing === key ? '完成选择' : '选择' + labels[key]}</Button>
{choosing === key && <Input aria-label={'搜索' + labels[key]} placeholder={'搜索' + labels[key]} value={search} onChange={e => setSearch(e.target.value)} />}
<div className="max-h-64 overflow-auto rounded-lg bg-muted/30">
{[...options, ...missing.map(id => ({ key: id, name: id + '(已不可用)', description: '取消选择后重新保存' }))].filter(option => choosing === key ? (option.name + ' ' + (option.description ?? '')).toLowerCase().includes(search.toLowerCase()) : value[key].includes(option.key)).map(option =>
<label key={option.key} className="flex min-h-10 cursor-pointer items-start gap-3 px-3 py-2 text-sm hover:bg-muted/50">
<input className="mt-1 h-4 w-4 accent-violet-600" type="checkbox" checked={value[key].includes(option.key)}
<input className="mt-1 h-4 w-4 accent-primary" type="checkbox" checked={value[key].includes(option.key)}
onChange={e => {
const selected = e.target.checked ? [...value[key], option.key] : value[key].filter(id => id !== option.key);
onChange({ ...value, [key]: selected, ...(key === 'skills' ? { preload_skills: value.preload_skills.filter(id => selected.includes(id)) } : {}) });
@@ -56,18 +63,25 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} /> Skills</label>}
</fieldset>;
})}
<div hidden={section !== 'all' && section !== 'capabilities'} className="space-y-4">
<p className="text-xs leading-5 text-muted-foreground">使</p>
<CloudResources onChanged={() => setRefresh(v => v + 1)} />
</div>
<div hidden={section !== 'all' && section !== 'knowledge'}>
<CloudKnowledgePanel slug={slug} onDeleted={id => { change('knowledges', value.knowledges.filter(key => key !== id)); setRefresh(v => v + 1); }} onCreated={kb => {
change('knowledges', [...new Set([...value.knowledges, kb.kb_id])]);
setRefresh(v => v + 1);
}} />
</div>
<div hidden={section !== 'all' && section !== 'limits'} className="space-y-6">
<CloudBudgetEditor slug={slug} />
<h2 className="font-medium"></h2><p className="text-xs text-muted-foreground">稿</p>
<label className="block space-y-2 text-sm"><span></span>
<select className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled}
value={value.tool_approval_mode} onChange={e => change('tool_approval_mode', e.target.value === 'always_trust' ? 'always_trust' : 'default')}>
<option value="default"></option><option value="always_trust"></option>
</select></label>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-4">
{([{ key: 'max_execution_steps', label: '最多执行步数', min: 1, max: 300 },
{ key: 'max_output_tokens', label: '单次输出词元上限', min: 1, max: 32768 },
{ key: 'max_run_seconds', label: '单次运行时限(秒)', min: 10, max: 3600 }] as const).map(item =>
@@ -75,5 +89,6 @@ export function ConfigurationFields({ slug, value, disabled, onChange }: {
<Input type="number" min={item.min} max={item.max} value={value[item.key]} disabled={disabled}
onChange={e => change(item.key, Number(e.target.value))} /></label>)}
</div>
</div>
</div>;
}

View File

@@ -0,0 +1,58 @@
import { useRef, useState, type ReactNode } from 'react';
import { Columns2, Maximize2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import './desktop-workspace.css';
const preferenceKey = 'makelore.cloud-agent-editor-ratio';
export function DesktopSplit({ configuration, preview }: { configuration: ReactNode; preview: (action: ReactNode) => ReactNode }) {
const root = useRef<HTMLDivElement>(null);
const [ratio, setRatio] = useState(() => {
try { const saved = Number(localStorage.getItem(preferenceKey)); return saved >= 25 && saved <= 75 ? saved : 46; }
catch { return 46; }
});
const [focus, setFocus] = useState<'configuration' | 'preview' | null>(null);
const [compactPanel, setCompactPanel] = useState<'configuration' | 'preview'>('configuration');
const resize = (next: number) => {
const width = (root.current?.clientWidth ?? 0) - 8;
if (width < 720) return;
const clamped = Math.max(25, 340 / width * 100, Math.min(75, 100 - 380 / width * 100, next));
setRatio(clamped);
try { localStorage.setItem(preferenceKey, String(clamped)); } catch { /* Layout remains usable without persistence. */ }
};
const panelToggle = (panel: 'configuration' | 'preview') => <Button variant="ghost" size="icon" className="h-10 w-10"
aria-label={focus === panel ? '恢复分栏' : panel === 'configuration' ? '展开配置' : '展开试用'}
onClick={() => setFocus(focus === panel ? null : panel)}>{focus === panel ? <Columns2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}</Button>;
return <div className="agent-split-container" onKeyDown={e => {
if (e.key === 'Escape') setFocus(null);
if (e.key === 'F6' && root.current) {
const panels = [...root.current.querySelectorAll<HTMLElement>('.agent-config-pane, .agent-preview-pane')].filter(panel => panel.offsetParent !== null);
const current = panels.findIndex(panel => panel.contains(document.activeElement));
const next = panels[(current + 1) % panels.length];
const target = [...(next?.querySelectorAll<HTMLElement>('textarea:not(:disabled), input:not(:disabled), button:not(:disabled)') ?? [])].find(el => el.offsetParent !== null);
if (target) { e.preventDefault(); target.focus(); }
}
}}>
<div className="agent-compact-switch" aria-label="工作区面板">
<Button variant={compactPanel === 'configuration' ? 'secondary' : 'ghost'} onClick={() => { setFocus(null); setCompactPanel('configuration'); }}></Button>
<Button variant={compactPanel === 'preview' ? 'secondary' : 'ghost'} onClick={() => { setFocus(null); setCompactPanel('preview'); }}></Button>
</div>
<div ref={root} className="agent-split" data-focus={focus ?? ''} data-compact-panel={compactPanel}
style={{ gridTemplateColumns: focus ? 'minmax(0, 1fr)' : `minmax(340px, ${ratio}fr) 8px minmax(380px, ${100 - ratio}fr)` }}>
<section className="agent-config-pane" aria-label="草稿配置">
<div className="agent-pane-heading"><span></span>{panelToggle('configuration')}</div>
{configuration}
</section>
<div role="separator" aria-label="调整配置与试用宽度" aria-orientation="vertical" tabIndex={0}
aria-valuemin={25} aria-valuemax={75} aria-valuenow={Math.round(ratio)} className="agent-divider"
onDoubleClick={() => resize(46)}
onPointerDown={e => { e.preventDefault(); e.currentTarget.focus(); e.currentTarget.setPointerCapture(e.pointerId); }}
onPointerMove={e => { if (e.currentTarget.hasPointerCapture(e.pointerId) && root.current) resize((e.clientX - root.current.getBoundingClientRect().left) / (root.current.clientWidth - 8) * 100); }}
onPointerUp={e => e.currentTarget.releasePointerCapture(e.pointerId)}
onKeyDown={e => { if (['ArrowLeft', 'ArrowRight', 'Home'].includes(e.key)) { e.preventDefault(); resize(e.key === 'Home' ? 46 : ratio + (e.key === 'ArrowLeft' ? -2 : 2)); } }} />
<section className="agent-preview-pane" aria-label="试用工作区">
{preview(panelToggle('preview'))}
</section>
</div>
</div>;
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import { ArrowLeft, Check, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent, type CloudThread, type CloudScheduleProposal } from '../../../shared/cloud-agents';
import { ConfigurationFields } from './ConfigurationFields';
import { CloudChat } from './CloudChat';
import { CloudAccessPanel } from './CloudAccess';
import { CloudSchedules } from './CloudSchedules';
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
import { DesktopSplit } from './DesktopSplit';
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
type Category = 'instructions' | 'capabilities' | 'knowledge' | 'limits';
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
export function DraftEditor({ initial, recent, onBack, onSaved }: {
initial: CloudAgentDraft; recent: CloudRecent | null; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
}) {
const [saved, setSaved] = useState(initial);
const [draft, setDraft] = useState(initial);
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [leaving, setLeaving] = useState(false);
const initialTab = recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration';
const [tab, setTab] = useState<Tab>(initialTab);
const [visited, setVisited] = useState<Set<Tab>>(() => new Set([initialTab]));
const [category, setCategory] = useState<Category>('instructions');
const [openedThread, setOpenedThread] = useState<CloudThread>();
const [scheduleProposal, setScheduleProposal] = useState<CloudScheduleProposal>();
const pending = useCloudPendingState();
const formalPending = useCloudPendingState();
const hasPending = pending.hasPending || formalPending.hasPending;
const alive = useRef(true);
const saving = useRef(false);
const dirty = hasDraftChanges(draft, saved);
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.trim());
const navigate = (next: Tab) => { setVisited(current => new Set([...current, next])); setTab(next); };
const blocker = useBlocker(dirty || busy || hasPending);
const finishLeaving = () => { if (blocker.state === 'blocked') blocker.proceed(); else onBack(); };
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
if (tab === 'configuration' && !saved.configuration?.model) {
void cloudAgentsApi.remember({ slug: saved.slug, mode: 'preview', draft_revision: saved.draft_revision })
.catch(e => { if (alive.current) setError(errorMessage(e)); });
}
}, [tab, saved.slug, saved.draft_revision, saved.configuration?.model]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty || busy || hasPending) { event.preventDefault(); event.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty, busy, hasPending]);
const save = async (exit = false) => {
if (!canSave || saving.current) return;
saving.current = true; 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,
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
});
if (!alive.current) return;
setSaved(result); if (result.configuration?.model) setPreviewCreated(true);
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, system_prompt: current.system_prompt, configuration: current.configuration });
setRemote(null); onSaved(result);
if (exit) finishLeaving();
} catch (failure) {
if (!alive.current) return;
setError(errorMessage(failure));
try {
const latest = await cloudAgentsApi.get(saved.slug);
if (alive.current && latest.draft_revision !== saved.draft_revision) setRemote(latest);
} catch { /* Keep the failed save and local input available for retry. */ }
} finally { saving.current = false; if (alive.current) setBusy(false); }
};
useEffect(() => {
const shortcut = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); }
};
window.addEventListener('keydown', shortcut);
return () => window.removeEventListener('keydown', shortcut);
});
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop" data-testid="cloud-agent-editor">
<header className="agent-workspace-header">
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0" aria-label="我的智能体" disabled={busy}
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
<div className="min-w-0 flex-1">
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
onChange={event => setDraft({ ...draft, name: event.target.value })} />
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? `已发布 v${saved.published_version}` : '私人草稿'} · {saved.draft_revision}</p>
</div>
<span role="status" className="shrink-0 text-xs text-muted-foreground">{busy ? '正在保存…' : dirty ? '有未保存修改' : '云端已保存'}</span>
<Button size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿Ctrl / ⌘ S">
{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>
<nav aria-label="智能体工作区" className="agent-workspace-nav">
{([['configuration', '编辑'], ['chat', '对话'], ['tasks', '自动任务']] as const).map(([key, label]) =>
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} onClick={() => navigate(key)}>{label}</Button>)}
<Button className="ml-auto" variant={tab === 'access' ? 'secondary' : 'ghost'} aria-current={tab === 'access' ? 'page' : undefined} onClick={() => navigate('access')}>访</Button>
</nav>
{error && <p role="alert" className="shrink-0 px-6 py-2 text-sm text-destructive">{error}</p>}
{remote && <div className="max-h-52 shrink-0 space-y-2 overflow-auto border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm">
<p className="font-medium"> {remote.draft_revision}</p>
<details><summary className="cursor-pointer">稿</summary><p>{remote.name} · {remote.purpose}</p><pre className="whitespace-pre-wrap font-sans">{remote.system_prompt}</pre></details>
<div className="flex flex-wrap gap-2"><Button variant="outline" onClick={() => { setSaved(remote); if (remote.configuration?.model) setPreviewCreated(true); setDraft(remote); setRemote(null); setError(''); }}>使稿</Button>
<Button variant="outline" onClick={() => { setSaved(remote); if (remote.configuration?.model) setPreviewCreated(true); setRemote(null); setError(''); }}></Button></div>
</div>}
<div hidden={tab !== 'configuration'} className="agent-workspace-view">
{visited.has('configuration') && <DesktopSplit configuration={<>
<nav aria-label="配置分类" className="agent-config-tabs">{([['instructions', '指令'], ['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
<Button key={key} variant={category === key ? 'secondary' : 'ghost'} aria-current={category === key ? 'page' : undefined} onClick={() => setCategory(key)}>{label}</Button>)}</nav>
<div className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
<div hidden={category !== 'instructions'} className="space-y-6">
<label className="block space-y-2 text-sm"><span className="font-medium"></span><Textarea className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
onChange={event => setDraft({ ...draft, purpose: event.target.value })} placeholder="一句话介绍,它可以帮你做什么。" /></label>
<label className="block space-y-2 text-sm"><span className="font-medium"></span><Textarea className="min-h-[min(400px,45vh)] resize-y leading-7" maxLength={32000}
value={draft.system_prompt} onChange={event => setDraft({ ...draft, system_prompt: event.target.value })} placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
<p className="text-xs leading-6 text-muted-foreground">稿</p>
</div>
<ConfigurationFields slug={saved.slug} section={category} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
</fieldset></div>
</>} preview={expandAction => <>
{dirty && <p role="status" className="shrink-0 rounded-md bg-muted/50 px-3 py-2 text-xs leading-5 text-muted-foreground"></p>}
{!saved.configuration?.model && !previewCreated ? <div className="my-auto p-6 text-sm leading-7">{expandAction}<h2 className="font-medium"></h2>
<p className="mt-2 text-muted-foreground"></p><Button variant="outline" className="mt-4" onClick={() => setCategory('capabilities')}></Button>
<p className="mt-4 text-xs text-muted-foreground"></p></div>
: <CloudChat slug={saved.slug} previewRevision={saved.draft_revision} activeView={tab === 'configuration'} headerAction={expandAction} previewReady={Boolean(saved.configuration?.model)} />}
</>} />}
</div>
<div hidden={tab !== 'chat'} className="agent-workspace-view p-6">
{visited.has('chat') && (saved.published_version ? <CloudPendingContext.Provider value={formalPending.report}>
<CloudChat key={openedThread?.thread_id ?? 'published'} slug={saved.slug} initialThread={openedThread} activeView={tab === 'chat'}
onSchedule={proposal => { setScheduleProposal(proposal); navigate('tasks'); }} /></CloudPendingContext.Provider>
: <p className="m-auto text-sm text-muted-foreground"></p>)}
</div>
<div hidden={tab !== 'tasks'} className="agent-management-view">
{visited.has('tasks') && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => {
if (formalPending.hasPending) { setError('正式对话还有未完成的输入,请先处理后再打开任务对话。'); navigate('chat'); return; }
setOpenedThread(thread); navigate('chat');
}} />}
</div>
<div hidden={tab !== 'access'} className="agent-management-view">
{dirty && <p className="mb-4 text-sm text-muted-foreground">稿访</p>}
<fieldset disabled={dirty || busy || remote !== null}>
{visited.has('access') && <CloudAccessPanel slug={saved.slug} revision={saved.draft_revision} onChanged={result => { setSaved(result); if (result.configuration?.model) setPreviewCreated(true); setDraft(current => !hasDraftChanges(current, saved) ? result : { ...current, draft_revision: result.draft_revision, published_version: result.published_version }); onSaved(result); }} onPublished={() => {
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
const publication = { published_version: result.published_version, enabled: result.enabled };
setSaved(current => ({ ...current, ...publication })); setDraft(current => ({ ...current, ...publication })); onSaved({ ...saved, ...publication });
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
}} />}
</fieldset>
</div>
<Dialog open={leaving || blocker.state === 'blocked'} onOpenChange={open => { if (!open && !busy) { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); } }}>
<DialogContent><DialogTitle></DialogTitle>
<DialogDescription>{hasPending ? '还有未发送的内容或未确认的操作。离开会丢弃当前输入;已被云端接受的任务仍会继续,可在活动中查看。' : '保存后离开,或放弃这次修改。'}</DialogDescription>
<div className="flex flex-wrap gap-2">{!hasPending && <Button disabled={!canSave} 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>
</DialogContent>
</Dialog>
</section></CloudPendingContext.Provider>;
}

View File

@@ -0,0 +1,37 @@
.agent-desktop { display: flex; flex: 1; flex-direction: column; height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
.agent-desktop > aside { max-height: 25%; min-height: 0; overflow: auto; }
.agent-desktop [hidden] { display: none !important; }
.agent-workspace-header { display: flex; flex-shrink: 0; align-items: center; gap: 12px; min-height: 64px; padding: 8px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-nav { display: flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 6px 20px; border-bottom: 1px solid hsl(var(--border)); }
.agent-workspace-view { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
.agent-management-view { flex: 1; min-height: 0; overflow: auto; padding: 24px; }
.agent-management-view > * { max-width: 1000px; margin-inline: auto; }
.agent-split-container { display: flex; flex: 1; flex-direction: column; container-type: inline-size; min-height: 0; overflow: hidden; }
.agent-split { display: grid; flex: 1; min-height: 0; overflow: hidden; }
.agent-config-pane, .agent-preview-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
.agent-pane-heading { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; height: 44px; padding: 0 16px 0 24px; font-size: 13px; font-weight: 500; }
.agent-config-tabs { display: flex; flex-shrink: 0; gap: 4px; padding: 0 20px 10px; border-bottom: 1px solid hsl(var(--border)); }
.agent-config-scroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 24px; }
.agent-preview-pane { padding: 8px 20px 16px 12px; }
.agent-preview-pane > .agent-pane-heading { padding-inline: 0; }
.agent-divider { touch-action: none; cursor: col-resize; background: hsl(var(--border) / .45); border-inline: 3px solid hsl(var(--background)); }
.agent-divider:hover, .agent-divider:focus-visible { background: hsl(var(--primary) / .6); outline: none; }
.agent-split[data-focus="configuration"] > .agent-preview-pane,
.agent-split[data-focus="preview"] > .agent-config-pane,
.agent-split:not([data-focus=""]) > .agent-divider { display: none; }
.agent-compact-switch { display: none; }
.agent-chat { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; color: hsl(var(--foreground)); }
.agent-chat-scroll { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.agent-chat-toolbar { flex: 0 1 auto; min-height: 0; max-height: 35%; overflow: auto; overscroll-behavior: contain; }
.agent-chat-files { flex: 0 1 auto; min-height: 0; max-height: 28%; overflow: auto; overscroll-behavior: contain; }
.agent-chat-files details { margin: 8px 0; padding: 0 8px; border: 0; }
.agent-chat-composer { flex-shrink: 0; }
.agent-chat-composer textarea { height: clamp(48px, 12vh, 80px); min-height: 48px; max-height: min(160px, 25vh); }
@container (max-width: 759px) {
.agent-compact-switch { display: flex; flex-shrink: 0; gap: 4px; padding: 8px 20px; }
.agent-split { grid-template-columns: minmax(0, 1fr) !important; }
.agent-divider { display: none; }
.agent-split[data-focus=""][data-compact-panel="configuration"] > .agent-preview-pane,
.agent-split[data-focus=""][data-compact-panel="preview"] > .agent-config-pane { display: none; }
.agent-preview-pane { padding-inline: 24px; }
}

View File

@@ -1,17 +1,15 @@
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 { ArrowLeft, Plus, 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 { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
import { ConfigurationFields } from './ConfigurationFields';
import { type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
import { DraftEditor } from './DraftEditor';
import './desktop-workspace.css';
import { CloudChat } from './CloudChat';
import { CloudAccessPanel } from './CloudAccess';
import { CloudSchedules } from './CloudSchedules';
import type { CloudScheduleProposal } from '../../../shared/cloud-agents';
import { CloudOverview } from './CloudOverview';
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
import { CloudRecovery } from './CloudRecovery';
@@ -105,14 +103,14 @@ function AgentWorkspace() {
setCreating(false);
};
if (selected) return <>{recovery}<DraftEditor key={selected.slug} initial={selected} recent={recent} onBack={() => {
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} recent={recent} onBack={() => {
setSelected(null);
clearRecent();
}} onSaved={accepted} /></>;
if (shared || activity) return <>{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></>;
}} onSaved={accepted} /></div>;
if (shared || activity) return <div className="agent-desktop">{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
return (
<section className="mx-auto max-w-5xl pb-10" data-testid="cloud-agents-page">
<section className="h-full overflow-auto p-6 pb-10" data-testid="cloud-agents-page">
{recovery}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-6">
<div>
@@ -223,7 +221,7 @@ function SharedConversation({ shared, activity, recent, onBack }: {
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [pending.hasPending]);
return <CloudPendingContext.Provider value={pending.report}><section className="mx-auto max-w-4xl pb-10">
return <CloudPendingContext.Provider value={pending.report}><section className="agent-desktop p-6">
<Button variant="ghost" className="mb-5" onClick={() => pending.hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="mr-2 h-4 w-4" /></Button>
<h1 className="mb-5 text-xl font-semibold">{shared?.name ?? activity?.title}</h1>
<CloudChat key={shared?.slug ?? activity?.thread_id} slug={shared?.slug ?? activity!.agent_slug} initialThread={activity ?? undefined}
@@ -240,138 +238,6 @@ function SharedConversation({ shared, activity, recent, onBack }: {
}
function DraftEditor({ initial, recent, onBack, onSaved }: {
initial: CloudAgentDraft; recent: CloudRecent | null; 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);
type Tab = 'chat' | 'tasks' | 'configuration' | 'access';
const [tab, setTab] = useState<Tab>(recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration');
const [nextTab, setNextTab] = useState<Tab | null>(null);
const [openedThread, setOpenedThread] = useState<CloudThread | undefined>(undefined);
const [scheduleProposal, setScheduleProposal] = useState<CloudScheduleProposal | undefined>();
const pending = useCloudPendingState();
const alive = useRef(true);
const dirty = draft.name !== saved.name || draft.purpose !== saved.purpose || draft.system_prompt !== saved.system_prompt
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
useEffect(() => {
if (!saved.configuration?.model && !saved.published_version) {
void cloudAgentsApi.remember({ slug: saved.slug, mode: 'preview', draft_revision: saved.draft_revision })
.catch(e => { if (alive.current) setError(errorMessage(e)); });
}
}, [saved.slug, saved.draft_revision, saved.configuration?.model, saved.published_version]);
const blocker = useBlocker(dirty || busy || pending.hasPending);
const finishLeaving = () => {
if (blocker.state === 'blocked') blocker.proceed();
else if (nextTab) { setOpenedThread(undefined); setTab(nextTab); setNextTab(null); setLeaving(false); setDraft(saved); }
else onBack();
};
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty || pending.hasPending) { event.preventDefault(); event.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty, pending.hasPending]);
const save = async (exit = false) => {
if (busy || pending.hasPending) 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,
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
});
if (!alive.current) return;
setSaved(result); setDraft(result); setRemote(null); onSaved(result);
if (exit) {
if (nextTab && blocker.state !== 'blocked') { setTab(nextTab); setNextTab(null); setLeaving(false); }
else 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 <CloudPendingContext.Provider value={pending.report}><section className="mx-auto max-w-6xl pb-10" data-testid="cloud-agent-editor">
<Button variant="ghost" className="mb-5 -ml-3" disabled={busy} onClick={() => dirty || pending.hasPending ? 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>
{tab === 'configuration' && <Button disabled={busy || pending.hasPending || !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>
<nav aria-label="智能体工作区" className="mb-6 flex flex-wrap gap-2">
{([['chat', '对话'], ['tasks', '自动任务'], ['configuration', '配置'], ['access', '发布与访问']] as const).map(([key, label]) =>
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} disabled={busy}
onClick={() => { if (key === tab) return; if (dirty || pending.hasPending) { setNextTab(key); setLeaving(true); } else { setOpenedThread(undefined); setTab(key); } }}>{label}</Button>)}
</nav>
{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>}
{tab === 'chat' && (saved.published_version ? <CloudChat slug={saved.slug} initialThread={openedThread}
onSchedule={proposal => { setScheduleProposal(proposal); setTab('tasks'); }} />
: <p className="rounded-xl bg-muted/30 p-8 text-sm text-muted-foreground"></p>)}
{tab === 'tasks' && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => { setOpenedThread(thread); setTab('chat'); }} />}
{tab === 'access' && <CloudAccessPanel slug={saved.slug} revision={saved.draft_revision} onChanged={result => { setSaved(result); setDraft(result); onSaved(result); }} onPublished={() => {
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
const publication = { published_version: result.published_version, enabled: result.enabled };
setSaved(current => ({ ...current, ...publication }));
setDraft(current => ({ ...current, ...publication }));
onSaved({ ...saved, ...publication });
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
}} />}
{tab === 'configuration' && <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.8fr)]">
<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>
<ConfigurationFields slug={saved.slug} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
onChange={configuration => setDraft({ ...draft, configuration })} />
</div>
<aside className="space-y-3 text-sm leading-6 text-muted-foreground">
{dirty && <p role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900">
使 {saved.draft_revision}{pending.hasPending ? '还有未完成的输入或操作,完成或清空后再保存。' : '保存后将开始新一轮预览。'}
</p>}
{!saved.configuration?.model ? <div className="rounded-xl bg-muted/30 p-6"><h2 className="font-medium text-foreground"></h2>
<p className="mt-3"></p>
<p className="mt-3">稿</p></div>
: <CloudChat key={saved.draft_revision} slug={saved.slug} previewRevision={saved.draft_revision} />}
</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">{pending.hasPending ? '还有未发送的内容或未确认的操作。离开会丢弃当前输入;已被云端接受的任务仍会继续,可在活动中查看。' : '保存后离开,或放弃这次修改。'}</p>
<div className="flex flex-wrap gap-2">{!pending.hasPending && <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); setNextTab(null); if (blocker.state === 'blocked') blocker.reset(); }}></Button></div></div>
</div>}
</section></CloudPendingContext.Provider>;
}
export default function CloudAgents() {
const accountId = useAuthStore((state) => state.user?.userId);
const location = useLocation();