186 lines
16 KiB
TypeScript
186 lines
16 KiB
TypeScript
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>;
|
||
}
|