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

186 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 { CloudChannelConversations } from './CloudChannelConversations';
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 [conversationSource, setConversationSource] = useState<'desktop' | 'wechat'>('desktop');
const [wechatVisited, setWechatVisited] = useState(false);
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}>
<nav aria-label="对话来源" className="mb-3 flex shrink-0 gap-2">
<Button variant={conversationSource === 'desktop' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'desktop'} onClick={() => setConversationSource('desktop')}>客户端对话</Button>
<Button variant={conversationSource === 'wechat' ? 'secondary' : 'ghost'} disabled={formalPending.hasPending && conversationSource !== 'wechat'} onClick={() => { setWechatVisited(true); setConversationSource('wechat'); }}>微信对话</Button>
</nav>
<div hidden={conversationSource !== 'desktop'} className="flex min-h-0 flex-1 flex-col">
<CloudChat key={openedThread?.thread_id ?? 'published'} slug={saved.slug} initialThread={openedThread} activeView={tab === 'chat' && conversationSource === 'desktop'}
onSchedule={proposal => { setScheduleProposal(proposal); navigate('tasks'); }} /></div>
<div hidden={conversationSource !== 'wechat'} className="min-h-0 flex-1 overflow-auto">{wechatVisited && <CloudChannelConversations slug={saved.slug} activeView={tab === 'chat' && conversationSource === 'wechat'} />}</div>
</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); setConversationSource('desktop'); 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 activeView={tab === 'access'} 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>;
}