fix(agents): surface unapplied draft changes and publish action
This commit is contained in:
@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button';
|
||||
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 type { CloudApplication, CloudKey } from '../../../shared/cloud-agents';
|
||||
import type { CloudPublication } from './useCloudPublication';
|
||||
import { CloudCosts } from './CloudCosts';
|
||||
import { CloudLifecycle } from './CloudLifecycle';
|
||||
import type { CloudAgentDraft } from '../../../shared/cloud-agents';
|
||||
@@ -12,28 +13,22 @@ import type { CloudAgentOperations } from '../../../shared/cloud-agents';
|
||||
import { cloudStatus } from './CloudChat';
|
||||
|
||||
const errorText = (e: unknown) => e instanceof Error ? e.message : '操作失败,请重试';
|
||||
export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activeView = true }: { slug: string; revision: number; onPublished: () => void; onChanged?: (draft: CloudAgentDraft) => void; activeView?: boolean }) {
|
||||
const [access, setAccess] = useState<Access | null>(null);
|
||||
export function CloudAccessPanel({ slug, revision, publication, onChanged, activeView = true }: { slug: string; revision: number; publication: CloudPublication; onChanged?: (draft: CloudAgentDraft) => void; activeView?: boolean }) {
|
||||
const { access, refresh, publish } = publication;
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<{ account_id: string; display_name: string; username: string }[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [acting, setBusy] = useState(false);
|
||||
const busy = acting || publication.busy;
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const publishOperation = useRef<{ operation_id: string; expected_revision: number } | null>(null);
|
||||
const applicationOperation = useRef<{ operation_id: string; name: string } | null>(null);
|
||||
usePendingCloudInput(busy || Boolean(name.trim()) || Boolean(publishOperation.current));
|
||||
usePendingCloudInput(busy || Boolean(name.trim()));
|
||||
const alive = useRef(true);
|
||||
const refresh = useCallback(async () => {
|
||||
const result = await cloudAgentsApi.call('access', { slug });
|
||||
if (!alive.current) return;
|
||||
setAccess(result);
|
||||
}, [slug]);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
refresh().catch(e => { if (alive.current) setError(errorText(e)); });
|
||||
return () => { alive.current = false; };
|
||||
}, [refresh]);
|
||||
}, []);
|
||||
const act = async (task: () => Promise<unknown>, success?: string) => {
|
||||
if (busy) return;
|
||||
setBusy(true); setError(''); setNotice('');
|
||||
@@ -41,13 +36,6 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
|
||||
catch(e) { if (alive.current) setError(errorText(e)); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
};
|
||||
const publish = () => act(async () => {
|
||||
const input = publishOperation.current ?? { operation_id: crypto.randomUUID(), expected_revision: revision };
|
||||
publishOperation.current = input;
|
||||
await cloudAgentsApi.call('publish', { slug, ...input });
|
||||
publishOperation.current = null;
|
||||
onPublished();
|
||||
}, '已准备好,新的对话将使用这次保存的要求');
|
||||
const createApplication = () => act(async () => {
|
||||
const input = applicationOperation.current ?? { operation_id: crypto.randomUUID(), name: name.trim() };
|
||||
applicationOperation.current = input;
|
||||
@@ -65,13 +53,14 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
|
||||
<h2 className="font-medium">{access?.published_version ? '助手已准备好' : '准备好使用自己的助手了吗?'}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{access?.published_version ? '应用最新修改后,新的对话会使用这次保存的要求。已有对话保留原来的版本。' : '开始使用后,只有你自己能用。以后还可以回来修改要求。'}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">分享给别人或接入应用,需要另外开启。</p>
|
||||
</div><Button disabled={busy || !access} onClick={() => void publish()}>{publishOperation.current ? '重试本次发布' : access?.published_version ? '应用最新修改' : '确认开始使用'}</Button></div>
|
||||
</div><Button disabled={busy || !access || (!publication.pending && publication.appliedRevision === revision)} onClick={() => void publish()}>{publication.busy ? '正在应用…' : publication.pending ? '重试本次发布' : publication.appliedRevision === revision ? '已应用最新修改' : access?.published_version ? '应用最新修改' : '确认开始使用'}</Button></div>
|
||||
<p className="mt-4 text-sm leading-6">自己使用、分享使用、API 调用、自动任务和个人微信渠道产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销访问。</p>
|
||||
{access?.published_version && <div className="mt-4 flex items-center gap-3 text-sm"><span>{access.enabled ? '智能体已启用' : '智能体已停用'}</span>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('setEnabled', { slug, enabled: !access.enabled }))}>
|
||||
{access.enabled ? '停用智能体' : '启用智能体'}</Button></div>}
|
||||
</section>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}>刷新状态</Button></p>}
|
||||
{publication.error && <p role="alert" className="text-sm text-destructive">{publication.error} <Button variant="ghost" onClick={() => void refresh()}>刷新应用状态</Button></p>}
|
||||
{notice && <p role="status" className="text-sm text-muted-foreground">{notice}</p>}
|
||||
{access?.published_version && <>
|
||||
<LinkedChannels slug={slug} activeView={activeView} />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||||
import { DesktopSplit } from './DesktopSplit';
|
||||
import { AgentRequirements } from './AgentRequirements';
|
||||
import { agentExamples } from './agent-guidance';
|
||||
import { useCloudPublication } from './useCloudPublication';
|
||||
|
||||
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
|
||||
type AdvancedCategory = 'capabilities' | 'knowledge' | 'limits';
|
||||
@@ -35,7 +36,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
|
||||
const [previewActivation, setPreviewActivation] = useState(0);
|
||||
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
|
||||
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [savingDraft, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const initialTab = recent?.mode === 'preview' ? 'configuration' : initial.published_version ? 'chat' : 'configuration';
|
||||
@@ -48,13 +49,25 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
|
||||
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 configurationScroll = useRef<HTMLDivElement>(null);
|
||||
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 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;
|
||||
const hasPending = pending.hasPending || formalPending.hasPending || Boolean(publication.pending);
|
||||
const canSave = dirty && !busy && !remote && Boolean(draft.name.trim() && draft.purpose.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; }; }, []);
|
||||
@@ -111,11 +124,17 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
|
||||
onChange={event => setDraft({ ...draft, name: event.target.value })} />
|
||||
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}</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 ? '保存草稿' : '已保存'}
|
||||
<span role="status" className={`shrink-0 text-xs ${needsApply || dirty ? 'text-amber-800' : 'text-muted-foreground'}`}>{savingDraft ? '正在保存…' : dirty ? '有未保存修改' : saved.published_version ? applicationStatus : '云端已保存'}</span>
|
||||
<Button className="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="min-h-10 shrink-0" disabled={busy || dirty || remote !== null || !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} onClick={() => navigate('access')}>开始使用</Button>}
|
||||
</header>
|
||||
<nav aria-label="智能体工作区" className="agent-workspace-nav">
|
||||
@@ -123,6 +142,11 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
|
||||
<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>
|
||||
{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>}
|
||||
{publication.error && tab !== 'access' && <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>
|
||||
@@ -188,13 +212,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }
|
||||
<div hidden={tab !== 'access'} className="agent-management-view">
|
||||
{dirty && <div className="mb-4 space-y-3 text-sm"><p>先保存你的要求,再开始使用。当前试用会保留。</p><Button disabled={!canSave} onClick={() => void save()}>保存修改</Button></div>}
|
||||
<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 });
|
||||
if (!saved.published_version && result.published_version) navigate('chat');
|
||||
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
|
||||
}} />}
|
||||
{visited.has('access') && <CloudAccessPanel activeView={tab === 'access'} slug={saved.slug} revision={saved.draft_revision} publication={publication} 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); }} />}
|
||||
</fieldset>
|
||||
</div>
|
||||
<Dialog open={leaving || blocker.state === 'blocked'} onOpenChange={open => { if (!open && !busy) { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); } }}>
|
||||
|
||||
64
src/pages/CloudAgents/useCloudPublication.ts
Normal file
64
src/pages/CloudAgents/useCloudPublication.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudAccess, CloudAgentOperations } from '../../../shared/cloud-agents';
|
||||
|
||||
type Publication = CloudAgentOperations['publish']['output'];
|
||||
type PublishInput = { operation_id: string; expected_revision: number };
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : '操作失败,请重试';
|
||||
|
||||
/** One publication operation serves both the workspace header and access panel. */
|
||||
export function useCloudPublication(slug: string, revision: number, onPublished: (result: Publication) => void) {
|
||||
const [access, setAccess] = useState<CloudAccess | null>(null);
|
||||
const [appliedRevision, setAppliedRevision] = useState<number | null>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [pending, setPending] = useState<PublishInput | null>(null);
|
||||
const operation = useRef<PublishInput | null>(null);
|
||||
const publishing = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const reads = useRef(0);
|
||||
const refresh = useCallback(async () => {
|
||||
const read = ++reads.current;
|
||||
try {
|
||||
const result = await cloudAgentsApi.call('access', { slug });
|
||||
if (!alive.current || read !== reads.current) return;
|
||||
setAccess(result);
|
||||
setAppliedRevision(result.published_version
|
||||
? result.versions.find(item => item.version === result.published_version)?.draft_revision
|
||||
: null);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
if (alive.current && read === reads.current) setError(errorText(failure));
|
||||
}
|
||||
}, [slug]);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
void refresh();
|
||||
return () => { alive.current = false; };
|
||||
}, [refresh, revision]);
|
||||
|
||||
const publish = async () => {
|
||||
if (publishing.current) return;
|
||||
publishing.current = true;
|
||||
const input = operation.current ?? { operation_id: crypto.randomUUID(), expected_revision: revision };
|
||||
operation.current = input; setPending(input);
|
||||
reads.current++; setBusy(true); setError('');
|
||||
try {
|
||||
const result = await cloudAgentsApi.call('publish', { slug, ...input });
|
||||
operation.current = null;
|
||||
if (!alive.current) return;
|
||||
setPending(null);
|
||||
setAppliedRevision(result.draft_revision);
|
||||
onPublished(result);
|
||||
await refresh();
|
||||
} catch (failure) {
|
||||
if (alive.current) setError(errorText(failure));
|
||||
} finally {
|
||||
publishing.current = false;
|
||||
if (alive.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
return { access, appliedRevision, busy, error, pending, publish, refresh };
|
||||
}
|
||||
|
||||
export type CloudPublication = ReturnType<typeof useCloudPublication>;
|
||||
Reference in New Issue
Block a user