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

228 lines
18 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, 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>;
}