feat: 改造云智能体桌面编辑与试用工作区
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
58
src/pages/CloudAgents/DesktopSplit.tsx
Normal file
58
src/pages/CloudAgents/DesktopSplit.tsx
Normal 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>;
|
||||
}
|
||||
175
src/pages/CloudAgents/DraftEditor.tsx
Normal file
175
src/pages/CloudAgents/DraftEditor.tsx
Normal 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>;
|
||||
}
|
||||
37
src/pages/CloudAgents/desktop-workspace.css
Normal file
37
src/pages/CloudAgents/desktop-workspace.css
Normal 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; }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user