228 lines
18 KiB
TypeScript
228 lines
18 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { useBlocker } from 'react-router-dom';
|
||
import { ArrowLeft, Check, ImagePlus, Loader2, MessageSquareText, Pencil, Save, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent } from '../../../shared/cloud-agents';
|
||
import { ConfigurationFields } from './ConfigurationFields';
|
||
import { CloudChat } from './CloudChat';
|
||
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||
import { AgentRequirements } from './AgentRequirements';
|
||
import { CloudAvatar, CloudAvatarEditor } from './CloudAvatar';
|
||
import { useCloudPublication } from './useCloudPublication';
|
||
import { AgentConfigHeading } from './AgentConfigHeading';
|
||
import './agent-config.css';
|
||
|
||
type Tab = 'configuration' | 'chat';
|
||
const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draft.name !== saved.name || (draft.icon ?? null) !== (saved.icon ?? null) || 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, startingPrompt, entryView }: {
|
||
startingPrompt?: string;
|
||
entryView?: 'configuration' | 'chat';
|
||
initial: CloudAgentDraft; recent: CloudRecent | null; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
|
||
}) {
|
||
const [saved, setSaved] = useState(initial);
|
||
const [draft, setDraft] = useState(() => startingPrompt === undefined ? initial : { ...initial, system_prompt: startingPrompt });
|
||
const [editorTab, setEditorTab] = useState('instructions');
|
||
const [uploading, setUploading] = useState(false);
|
||
const [editingAvatar, setEditingAvatar] = useState(false);
|
||
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
|
||
const [savingDraft, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [leaving, setLeaving] = useState(false);
|
||
const initialTab = entryView ?? (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 [attachmentHeader, setAttachmentHeader] = useState<HTMLDivElement | null>(null);
|
||
const pending = useCloudPendingState();
|
||
const formalPending = useCloudPendingState();
|
||
const alive = useRef(true);
|
||
const saving = useRef(false);
|
||
const configurationScroll = useRef<HTMLDivElement>(null);
|
||
const nameInput = useRef<HTMLInputElement>(null);
|
||
const dirty = hasDraftChanges(draft, saved);
|
||
const navigate = (next: Tab) => { setVisited(current => new Set([...current, next])); setTab(next); };
|
||
const publication = useCloudPublication(saved.slug, saved.draft_revision, result => {
|
||
const next = { ...saved, published_version: result.version };
|
||
setSaved(current => ({ ...current, published_version: result.version }));
|
||
setDraft(current => ({ ...current, published_version: result.version }));
|
||
onSaved(next);
|
||
if (!saved.published_version) navigate('chat');
|
||
});
|
||
const busy = savingDraft || publication.busy || uploading;
|
||
const hasPending = pending.hasPending || formalPending.hasPending || Boolean(publication.pending);
|
||
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim());
|
||
const needsApply = Boolean(saved.published_version) && publication.appliedRevision !== undefined
|
||
&& publication.appliedRevision !== saved.draft_revision;
|
||
const applicationStatus = publication.busy ? '正在应用…' : publication.pending ? '应用结果待确认'
|
||
: publication.appliedRevision === undefined ? '应用状态待确认' : needsApply ? '修改已保存,尚未应用' : '已应用最新修改';
|
||
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') {
|
||
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,
|
||
icon: draft.icon ?? null,
|
||
configuration: draft.configuration ?? EMPTY_CLOUD_CONFIGURATION,
|
||
});
|
||
if (!alive.current) return;
|
||
setSaved(result);
|
||
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, icon: current.icon, icon_url: current.icon_url, purpose: current.purpose, system_prompt: current.system_prompt, configuration: current.configuration });
|
||
setRemote(null); onSaved(result);
|
||
if (exit) finishLeaving();
|
||
return result.draft_revision;
|
||
} 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); }
|
||
};
|
||
const startUsing = () => {
|
||
if (busy || saving.current) return;
|
||
if (remote) { navigate('configuration'); setError('云端已有新修改,请先处理保存冲突。'); return; }
|
||
if (!draft.configuration.model) {
|
||
navigate('configuration');
|
||
setEditorTab('advanced');
|
||
if (configurationScroll.current) configurationScroll.current.scrollTop = 0;
|
||
setError('请先在高级设置中选择模型。'); return;
|
||
}
|
||
if (dirty) { navigate('configuration'); setError('请先保存草稿,再开始使用。'); return; }
|
||
if (!publication.access) { setError('暂时无法确认智能体状态,请刷新后重试。'); return; }
|
||
setError('');
|
||
void publication.publish();
|
||
};
|
||
useEffect(() => {
|
||
const shortcut = (event: KeyboardEvent) => {
|
||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); if (tab === 'configuration') void save(); }
|
||
};
|
||
window.addEventListener('keydown', shortcut);
|
||
return () => window.removeEventListener('keydown', shortcut);
|
||
});
|
||
|
||
return <CloudPendingContext.Provider value={pending.report}><section className={`agent-desktop${tab === 'chat' ? ' agent-chat-editor' : ''}`} data-testid="cloud-agent-editor"
|
||
data-configuration-settled={!dirty && !busy && !publication.pending && !publication.error && !remote && publication.appliedRevision === saved.draft_revision}>
|
||
<header className={`agent-workspace-header${tab === 'chat' ? ' agent-chat-workspace-header' : ''}`}>
|
||
<Button variant="ghost" size="icon" className="h-10 w-10 shrink-0" aria-label={tab === 'chat' ? '返回智能体列表' : '我的智能体'} disabled={busy}
|
||
onClick={() => dirty || hasPending ? setLeaving(true) : onBack()}><ArrowLeft className="h-4 w-4" /></Button>
|
||
{tab === 'chat' ? <>
|
||
<CloudAvatar src={saved.icon_url} name={saved.name} identity={saved.slug} className="agent-chat-header-avatar" />
|
||
<h2 className="agent-chat-header-name" title={saved.name}>{saved.name}</h2>
|
||
<div ref={setAttachmentHeader} className="agent-chat-header-files" />
|
||
</> : <>
|
||
<Button variant="ghost" className="agent-chat-identity-avatar relative h-12 w-12 shrink-0 p-0" aria-label="修改头像" title="修改头像" disabled={busy} onClick={() => setEditingAvatar(true)}>
|
||
<CloudAvatar src={draft.icon_url} name={draft.name} identity={saved.slug} />
|
||
<span className="absolute -bottom-1 -right-1 rounded-full border-2 border-background bg-primary p-1 text-white"><ImagePlus aria-hidden="true" className="h-3 w-3" /></span>
|
||
</Button>
|
||
<div className="agent-workspace-identity min-w-0 flex-1">
|
||
<div className="flex max-w-96 items-center gap-1">
|
||
<Input ref={nameInput} aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
|
||
className="h-8 min-w-0 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 })} />
|
||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-muted-foreground" aria-label="修改名称" title="修改名称" disabled={busy} onClick={() => nameInput.current?.focus()}><Pencil aria-hidden="true" className="h-4 w-4" /></Button>
|
||
</div>
|
||
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的智能体' : '配置智能体'}</p>
|
||
</div>
|
||
<span role="status" className={`agent-chat-config-status shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
|
||
<Button className="agent-chat-save-action min-h-10" size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
|
||
{savingDraft ? <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" />}
|
||
{savingDraft ? '保存中…' : dirty ? '保存草稿' : '已保存'}
|
||
</Button>
|
||
{saved.published_version && <Button className="agent-chat-apply-action min-h-10 shrink-0" disabled={busy || dirty || remote !== null || !saved.configuration.model || !publication.access || (!publication.pending && publication.appliedRevision === saved.draft_revision)}
|
||
title={dirty ? '请先保存草稿,再应用修改' : '将已保存的修改应用到正在使用的助手'}
|
||
onClick={() => void publication.publish()}>
|
||
{publication.busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{publication.busy ? '正在应用…' : publication.pending ? '应用' : !dirty && publication.appliedRevision === saved.draft_revision ? '已应用' : '应用最新修改'}
|
||
</Button>}
|
||
{!saved.published_version && <Button className="min-h-10" variant="outline" disabled={busy || remote !== null || !publication.access} onClick={startUsing}>
|
||
{publication.busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{publication.busy ? '正在开始…' : publication.pending ? '应用' : '开始使用'}
|
||
</Button>}
|
||
</>}
|
||
</header>
|
||
{tab === 'configuration' && saved.published_version && (needsApply || dirty) && <div className="shrink-0 border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm text-amber-900">
|
||
<p className="font-medium">{dirty ? '修改尚未保存和应用' : '这次修改还没有应用到正在使用的助手'}</p>
|
||
<p className="mt-1 text-xs leading-5">保存只更新草稿。请点击顶部“应用最新修改”,再到微信等渠道使用。已开始的任务保持原配置。</p>
|
||
</div>}
|
||
{tab === 'configuration' && publication.error && <p role="alert" className="shrink-0 px-6 py-2 text-sm text-destructive">{publication.error} <Button variant="ghost" disabled={busy} onClick={() => void publication.refresh()}>刷新应用状态</Button></p>}
|
||
{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); setDraft(remote); setRemote(null); setError(''); }}>使用最新草稿</Button>
|
||
<Button variant="outline" onClick={() => { setSaved(remote); setRemote(null); setError(''); }}>保留我的内容,继续编辑</Button></div>
|
||
</div>}
|
||
<div hidden={tab !== 'configuration'} className="agent-workspace-view">
|
||
{visited.has('configuration') && <Tabs value={editorTab} onValueChange={next => {
|
||
setEditorTab(next);
|
||
if (configurationScroll.current) configurationScroll.current.scrollTop = 0;
|
||
}} className="flex min-h-0 flex-1 flex-col">
|
||
<div className="agent-config-tabs agent-editor-tabs">
|
||
<TabsList aria-label="编辑设置" className="h-auto w-full justify-start">
|
||
<TabsTrigger value="instructions" className="flex-1 gap-2 data-[state=active]:text-primary"><Sparkles aria-hidden="true" className="h-4 w-4" />介绍与提示词</TabsTrigger>
|
||
<TabsTrigger value="advanced" className="flex-1 gap-2 data-[state=active]:text-primary"><SlidersHorizontal aria-hidden="true" className="h-4 w-4" />高级设置</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
{/* Keep the shared form mounted so switching tabs preserves unfinished resource edits. */}
|
||
<TabsContent value={editorTab} className="m-0 flex min-h-0 flex-1 flex-col">
|
||
<div ref={configurationScroll} className="agent-config-scroll agent-editor-scroll"><fieldset disabled={busy} className="agent-configuration-form">
|
||
<div hidden={editorTab !== 'instructions'} className="agent-config-stack">
|
||
<section className="agent-config-card" data-tone="blue">
|
||
<AgentConfigHeading title="一句话介绍" icon={MessageSquareText} note="选填" />
|
||
<Input aria-label="一句话介绍" maxLength={2000} value={draft.purpose}
|
||
placeholder="简单介绍一下它,可以不填" onChange={event => setDraft({ ...draft, purpose: event.target.value })} />
|
||
</section>
|
||
<AgentRequirements value={draft.system_prompt} onChange={system_prompt => setDraft({ ...draft, system_prompt })} />
|
||
</div>
|
||
<ConfigurationFields slug={saved.slug} section={editorTab === 'instructions' ? 'instructions' : 'all'} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
|
||
onChange={configuration => setDraft({ ...draft, configuration })} />
|
||
</fieldset></div>
|
||
</TabsContent>
|
||
</Tabs>}
|
||
</div>
|
||
<div hidden={tab !== 'chat'} className="agent-workspace-view agent-conversation-workspace">
|
||
{visited.has('chat') && <CloudPendingContext.Provider value={formalPending.report}>
|
||
{saved.published_version ? <CloudChat key="published" slug={saved.slug} assistantName={saved.name} assistantAvatarUrl={saved.icon_url} activeView={tab === 'chat'} attachmentHeader={attachmentHeader} />
|
||
: <div className="m-auto space-y-4 text-center text-sm"><p>保存配置后,开始使用这个智能体。</p><Button disabled={busy || remote !== null || !publication.access} onClick={startUsing}>{publication.pending ? '重试开始使用' : '开始使用'}</Button></div>}
|
||
</CloudPendingContext.Provider>}
|
||
</div>
|
||
<Dialog open={editingAvatar} onOpenChange={open => { if (!uploading) setEditingAvatar(open); }}>
|
||
<DialogContent><DialogTitle>修改头像</DialogTitle><DialogDescription>选择这个智能体的头像。</DialogDescription>
|
||
<CloudAvatarEditor value={draft} name={draft.name} identity={saved.slug} disabled={savingDraft || publication.busy} uploading={uploading}
|
||
onUploading={setUploading} onChange={avatar => setDraft(current => ({ ...current, ...avatar }))} />
|
||
<Button disabled={uploading} onClick={() => setEditingAvatar(false)}>完成</Button>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<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>;
|
||
}
|