From b28f6f0444e496f7d5bb7da9c8f21856a46fced3 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 18 Sep 2026 10:32:12 +0800 Subject: [PATCH] fix(agents): surface unapplied draft changes and publish action --- .../20260918-agent-apply-visible-5a83c1e2.md | 55 ++++++++ README.md | 2 +- src/pages/CloudAgents/CloudAccess.tsx | 31 ++--- src/pages/CloudAgents/DraftEditor.tsx | 46 ++++--- src/pages/CloudAgents/useCloudPublication.ts | 64 ++++++++++ tests/e2e/cloud-agents.spec.ts | 20 ++- tests/unit/cloud-agents-guidance.test.tsx | 11 +- tests/unit/cloud-agents-page.test.tsx | 119 ++++++++++++++++++ tests/unit/cloud-agents-workflows.test.tsx | 9 +- 9 files changed, 316 insertions(+), 41 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260918-agent-apply-visible-5a83c1e2.md create mode 100644 src/pages/CloudAgents/useCloudPublication.ts diff --git a/.project-docs/30-worklog/tasks/20260918-agent-apply-visible-5a83c1e2.md b/.project-docs/30-worklog/tasks/20260918-agent-apply-visible-5a83c1e2.md new file mode 100644 index 0000000..5f2de99 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260918-agent-apply-visible-5a83c1e2.md @@ -0,0 +1,55 @@ +# Task: Make saved agent changes and apply action visible + +## Identity + +- Task ID: 20260918-agent-apply-visible-5a83c1e2 +- Mode: Feature +- Branch: codex/20260918-agent-apply-visible-5a83c1e2-agent-apply-visible +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260918-agent-apply-visible-5a83c1e2 +- Base commit: c878ba940c42693251de5650ed4dc39d7cf279e0 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Make the existing publish/apply action directly available in the MakeLore assistant workspace header. +- Distinguish unsaved draft, saved-but-unapplied draft, confirmed applied revision, and unknown/failed application state. +- Renderer components, focused tests, Electron fixture, and README only; no backend, model activation, billing, or channel routing changes. + +## Intent And Constraints + +- User confirmed that applying the latest changes fixed the WeChat model error; requested a more visible interaction. +- Continue using the existing Host API publish/access contracts. Saving remains separate from applying. +- Compare the current draft revision with the draft_revision of the current published version, not its version number. +- Share one in-flight/idempotent application operation between the header and access panel; retries retain the original input. +- Preserve draft inputs, current trial, immutable published versions, and first-publication behavior. +- Concurrent Task Gate and Planning Gate passed before implementation. Peer task records were inspected read-only; historical incomplete records were treated as unknown coordination state, without a concrete dependency on this owned change. + +## Outcome + +- Added the prominent header action and amber pending-change explanation on all workspace tabs. +- Successful application removes the pending notice; unchanged applied drafts disable repeat application. +- Read failures remain visible and retryable; a successful publish followed by failed status refresh remains confirmed and cannot accidentally publish again. +- Existing access page reuses the same operation and recovery state. +- Updated README and verified the actual Electron interface at 1366x768 and 1024x768. +- No merge, push, deployment, or worktree cleanup performed. + +## Verification + +- pnpm 10.33.4 install --frozen-lockfile passed; lockfile unchanged. +- pnpm run typecheck passed. +- Scoped ESLint on changed implementation and test files passed. +- Four relevant unit files: 51 tests passed. Added a final successful-publish/failed-refresh regression, then reran cloud-agents-page.test.tsx: 24 tests passed (52 distinct related tests across the final files). +- pnpm run build:vite passed. Existing Browserslist age and bundle-size/import warnings remain. +- Electron cloud-agents.spec.ts passed: creation, initial publication, save/apply, compact viewport, chat and schedules. +- Inspected cloud-agent-apply-visible.png and cloud-agent-apply-compact.png: header action and saved/unapplied state are visible with no overlap. +- Reviewed the complete implementation diff and new publication hook. Server contracts were source-checked in the companion read-only diagnostic tasks; no live cloud changes were made. + +## Follow-ups + +- Integrate this feature branch and rebuild the desktop client when requested. +- Production deployment is not part of this change. + +## Promotion Candidates + +- None. This is an interaction improvement using existing publication semantics; README records current product behavior. No canonical architecture or domain changes are proposed. diff --git a/README.md b/README.md index 7a0c59c..34b75dc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 -- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。编辑区以“我的要求”和“高级设置”两个 Tab 切换,默认显示基础要求;高级设置包含能力、知识与限制,返回时保留上次分类,切换保留未保存的输入及当前试用。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,分享与应用仍单独开启。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。 +- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、按账号分享或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;分享/API 调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。编辑区以“我的要求”和“高级设置”两个 Tab 切换,默认显示基础要求;高级设置包含能力、知识与限制,返回时保留上次分类,切换保留未保存的输入及当前试用。已开始使用的助手在顶部显示“修改已保存,尚未应用”或“已应用最新修改”,并提供显眼的“应用最新修改”按钮;保存只更新草稿,应用沿用同一发布操作,失败重试保留原操作身份。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,分享与应用仍单独开启。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/pages/CloudAgents/CloudAccess.tsx b/src/pages/CloudAgents/CloudAccess.tsx index 719ab98..f953fe2 100644 --- a/src/pages/CloudAgents/CloudAccess.tsx +++ b/src/pages/CloudAgents/CloudAccess.tsx @@ -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(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, 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

{access?.published_version ? '助手已准备好' : '准备好使用自己的助手了吗?'}

{access?.published_version ? '应用最新修改后,新的对话会使用这次保存的要求。已有对话保留原来的版本。' : '开始使用后,只有你自己能用。以后还可以回来修改要求。'}

分享给别人或接入应用,需要另外开启。

- +

自己使用、分享使用、API 调用、自动任务和个人微信渠道产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销访问。

{access?.published_version &&
{access.enabled ? '智能体已启用' : '智能体已停用'}
} {error &&

{error}

} + {publication.error &&

{publication.error}

} {notice &&

{notice}

} {access?.published_version && <> diff --git a/src/pages/CloudAgents/DraftEditor.tsx b/src/pages/CloudAgents/DraftEditor.tsx index 74fb43c..883c58b 100644 --- a/src/pages/CloudAgents/DraftEditor.tsx +++ b/src/pages/CloudAgents/DraftEditor.tsx @@ -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(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(); const pending = useCloudPendingState(); const formalPending = useCloudPendingState(); - const hasPending = pending.hasPending || formalPending.hasPending; const alive = useRef(true); const saving = useRef(false); const configurationScroll = useRef(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 })} />

{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}

- {busy ? '正在保存…' : dirty ? '有未保存修改' : '云端已保存'} - + {saved.published_version && } {!saved.published_version && } + {saved.published_version && (needsApply || dirty) &&
+

{dirty ? '修改尚未保存和应用' : '这次修改还没有应用到正在使用的助手'}

+

保存只更新草稿。请点击顶部“应用最新修改”,再到微信等渠道使用。已开始的任务保持原配置。

+
} + {publication.error && tab !== 'access' &&

{publication.error}

} {error &&

{error}

} {remote &&

云端已有修订 {remote.draft_revision},本地输入仍保留

@@ -188,13 +212,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt } { if (!open && !busy) { setLeaving(false); if (blocker.state === 'blocked') blocker.reset(); } }}> diff --git a/src/pages/CloudAgents/useCloudPublication.ts b/src/pages/CloudAgents/useCloudPublication.ts new file mode 100644 index 0000000..5fd0134 --- /dev/null +++ b/src/pages/CloudAgents/useCloudPublication.ts @@ -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(null); + const [appliedRevision, setAppliedRevision] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [pending, setPending] = useState(null); + const operation = useRef(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; diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index d76dabe..4fb4f90 100644 --- a/tests/e2e/cloud-agents.spec.ts +++ b/tests/e2e/cloud-agents.spec.ts @@ -12,6 +12,7 @@ test('personal cloud Agent creation, draft save and leave protection in Electron BrowserWindow.getAllWindows()[0].setSize(1366, 768); let draft: Record | null = null; let recent: unknown = null; + const versions: { version: number; draft_revision: number; created_at: string }[] = []; let knowledgeCatalogLoads = 0; let knowledgeCreated = false; let knowledgeUploaded = false; @@ -68,8 +69,8 @@ test('personal cloud Agent creation, draft save and leave protection in Electron if (body.operation === 'channelSelfCallers') return result({ items: [] }); if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] }); if (body.operation === 'threads') return result({ threads: [], next_offset: null }); - if (body.operation === 'publish') { if (draft) draft.published_version = 1; return result({ version: 1, draft_revision: 2 }); } - if (body.operation === 'access') return result({ published_version: draft?.published_version, enabled: true, share_url: 'niancode://agents/' + draft?.slug, versions: [], grants: [], applications: [] }); + if (body.operation === 'publish') { const version = versions.length + 1; if (draft) draft.published_version = version; versions.push({ version, draft_revision: input.expected_revision, created_at: new Date().toISOString() }); return result({ version, draft_revision: input.expected_revision }); } + if (body.operation === 'access') return result({ published_version: draft?.published_version, enabled: true, share_url: 'niancode://agents/' + draft?.slug, versions, grants: [], applications: [] }); if (body.operation === 'costs') return result({ items: [], next_offset: null, unit: '词元点数', summary: { count: 0, settled_points: '0.00', pending_points: '0.00' } }); if (body.operation === 'schedules') return result({ jobs }); if (body.operation === 'createSchedule') { const job = { ...input, id: 'job-1', agent_slug: draft?.slug, next_run_at: '2026-09-11T01:00:00Z', runs: [] }; jobs.push(job); return result(job); } @@ -272,6 +273,21 @@ test('personal cloud Agent creation, draft save and leave protection in Electron await page.getByRole('button', { name: '确认开始使用' }).click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByRole('region', { name: '智能体对话', exact: true }).getByRole('heading', { name: '创作建议' })).toHaveCount(0); + await page.getByRole('button', { name: '编辑', exact: true }).click(); + await page.getByLabel('名称', { exact: true }).fill('我的新版写作搭档'); + await expect(page.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: '保存草稿', exact: true }).click(); + await expect(page.getByText('修改已保存,尚未应用', { exact: true })).toBeVisible(); + const applyButton = page.getByRole('button', { name: '应用最新修改', exact: true }); + await expect(applyButton).toBeInViewport(); + await page.screenshot({ path: testInfo.outputPath('cloud-agent-apply-visible.png') }); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1024, 768)); + await expect(applyButton).toBeInViewport(); + await page.screenshot({ path: testInfo.outputPath('cloud-agent-apply-compact.png') }); + await applyButton.click(); + await expect(page.getByRole('button', { name: '已应用', exact: true })).toBeDisabled(); + await expect(page.getByText('修改已保存,尚未应用', { exact: true })).toHaveCount(0); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1366, 768)); await page.getByRole('button', { name: '对话', exact: true }).click(); await page.getByRole('region', { name: '智能体对话', exact: true }).getByLabel('消息', { exact: true }).fill('每天早上八点半给我三个创作方向'); await page.getByRole('region', { name: '智能体对话', exact: true }).getByRole('button', { name: '发送', exact: true }).click(); diff --git a/tests/unit/cloud-agents-guidance.test.tsx b/tests/unit/cloud-agents-guidance.test.tsx index 4c3dc09..62d14bd 100644 --- a/tests/unit/cloud-agents-guidance.test.tsx +++ b/tests/unit/cloud-agents-guidance.test.tsx @@ -3,7 +3,14 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudApproval, CloudChat } from '@/pages/CloudAgents/CloudChat'; import { AgentRequirements } from '@/pages/CloudAgents/AgentRequirements'; -import { CloudAccessPanel } from '@/pages/CloudAgents/CloudAccess'; +import { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess'; + +import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication'; + +function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) { + const publication = useCloudPublication(props.slug, props.revision, props.onPublished); + return ; +} const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), download: vi.fn(), recovery: vi.fn(), remember: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); @@ -36,7 +43,7 @@ it('starts private use through the existing publication action and retains its i let attempts = 0; api.call.mockImplementation(async (operation, input) => { if (operation === 'access') return { published_version: published ? 1 : null, enabled: true, versions: [], grants: [], applications: [] }; - if (operation === 'publish') { if (++attempts === 1) throw new Error('连接中断'); published = true; return { version: 1 }; } + if (operation === 'publish') { if (++attempts === 1) throw new Error('连接中断'); published = true; return { version: 1, draft_revision: input.expected_revision }; } if (operation === 'costs') return { items: [], next_offset: null, summary: {} }; if (operation === 'channelBindings') return { items: [] }; return base(operation, input); diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index d06dbbd..02a97fb 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -47,6 +47,125 @@ it('does not label disabled or archived assistants as ready to use', async () => expect(screen.queryByText('可以使用')).not.toBeInTheDocument(); }); +function publishedAssistant(appliedRevision = 3) { + let current = { ...draft, draft_revision: 4, published_version: 8, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'model-a' } }; + let applied = appliedRevision; + const base = api.call.getMockImplementation()!; + api.list.mockImplementation(async () => ({ agents: [current], next_cursor: null })); + api.recovery.mockResolvedValue({ recent: { slug: draft.slug, mode: 'preview', draft_revision: 4 }, pending: [] }); + api.get.mockImplementation(async () => current); + api.save.mockImplementation(async (_slug, input) => (current = { ...current, ...input, draft_revision: current.draft_revision + 1 })); + api.call.mockImplementation(async (operation, input) => { + if (operation === 'access') return { published_version: current.published_version, enabled: true, + versions: [{ version: 1, draft_revision: 1, created_at: '' }, { version: current.published_version, draft_revision: applied, created_at: '' }], grants: [], applications: [] }; + if (operation === 'publish') { + applied = input.expected_revision; + current = { ...current, published_version: current.published_version + 1 }; + return { version: current.published_version, draft_revision: applied }; + } + if (operation === 'catalog') return { models: [{ id: 'model-a', name: '模型甲' }], resources: {}, pricing: null }; + if (operation === 'channelBindings') return { items: [], available_accounts: [] }; + if (operation === 'costs') return { items: [], summary: {}, next_offset: null }; + return base(operation, input); + }); +} + +it('shows saved but unapplied changes and applies directly from the editor header', async () => { + publishedAssistant(); + open(); + await screen.findByTestId('cloud-agent-editor'); + await screen.findByText('修改已保存,尚未应用'); + expect(screen.getByText(/保存只更新草稿/)).toBeVisible(); + expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false); + fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true })); + await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); + expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')).toEqual([ + ['publish', expect.objectContaining({ slug: draft.slug, expected_revision: 4 })], + ]); + expect(screen.queryByText('修改已保存,尚未应用')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '编辑', exact: true })).toHaveAttribute('aria-current', 'page'); +}); + +it('uses the published draft revision, requires saving edits, and keeps trial inputs when applying', async () => { + publishedAssistant(4); + open(); + await screen.findByTestId('cloud-agent-editor'); + await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); + fireEvent.change(screen.getByLabelText('消息'), { target: { value: '保留试用问题' } }); + fireEvent.change(screen.getByLabelText('特别要求'), { target: { value: '新的要求' } }); + expect(screen.getByText('修改尚未保存和应用')).toBeVisible(); + expect(screen.getByRole('button', { name: '应用最新修改', exact: true })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + await screen.findByText('修改已保存,尚未应用'); + expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false); + fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true })); + await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); + expect(screen.getByLabelText('消息')).toHaveValue('保留试用问题'); + expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')[0][1]).toMatchObject({ expected_revision: 5 }); +}); + +it('shares the original failed publication between the header and access page', async () => { + publishedAssistant(); + const base = api.call.getMockImplementation()!; + let attempts = 0; + api.call.mockImplementation(async (operation, input) => { + if (operation === 'publish' && ++attempts === 1) throw new Error('连接中断'); + return base(operation, input); + }); + open(); + await screen.findByTestId('cloud-agent-editor'); + await screen.findByText('修改已保存,尚未应用'); + fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true })); + await screen.findByText(/连接中断/); + expect(screen.getByRole('button', { name: '重试本次应用' })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '使用与分享' })); + fireEvent.click(await screen.findByRole('button', { name: '重试本次发布' })); + await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); + const calls = api.call.mock.calls.filter(([operation]) => operation === 'publish'); + expect(calls).toHaveLength(2); + expect(calls[0][1]).toEqual(calls[1][1]); +}); + +it('does not call an unread publication status up to date and offers a read retry', async () => { + publishedAssistant(4); + const base = api.call.getMockImplementation()!; + let fail = true; + api.call.mockImplementation(async (operation, input) => { + if (operation === 'access' && fail) throw new Error('状态读取失败'); + return base(operation, input); + }); + open(); + await screen.findByTestId('cloud-agent-editor'); + await screen.findByText(/状态读取失败/); + expect(screen.getByText('应用状态待确认')).toBeVisible(); + expect(screen.queryByRole('button', { name: '已应用', exact: true })).not.toBeInTheDocument(); + fail = false; + fireEvent.click(screen.getByRole('button', { name: '刷新应用状态' })); + await waitFor(() => expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled()); + expect(api.call.mock.calls.some(([operation]) => operation === 'publish')).toBe(false); +}); + +it('keeps a successful application confirmed when its following status refresh fails', async () => { + publishedAssistant(); + const base = api.call.getMockImplementation()!; + let published = false; + api.call.mockImplementation(async (operation, input) => { + if (operation === 'access' && published) throw new Error('状态刷新失败'); + const result = await base(operation, input); + if (operation === 'publish') published = true; + return result; + }); + open(); + await screen.findByText('修改已保存,尚未应用'); + fireEvent.click(screen.getByRole('button', { name: '应用最新修改', exact: true })); + await screen.findByText(/状态刷新失败/); + expect(screen.getByRole('button', { name: '已应用', exact: true })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '重试本次应用' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '刷新应用状态' })); + await waitFor(() => expect(api.call.mock.calls.filter(([operation]) => operation === 'access')).toHaveLength(3)); + expect(api.call.mock.calls.filter(([operation]) => operation === 'publish')).toHaveLength(1); +}); + it('protects a custom requirement before a name or purpose has been entered', async () => { const { router } = open(); fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); diff --git a/tests/unit/cloud-agents-workflows.test.tsx b/tests/unit/cloud-agents-workflows.test.tsx index 23691da..d9f7fdc 100644 --- a/tests/unit/cloud-agents-workflows.test.tsx +++ b/tests/unit/cloud-agents-workflows.test.tsx @@ -1,11 +1,18 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudChat } from '@/pages/CloudAgents/CloudChat'; -import { CloudAccessPanel } from '@/pages/CloudAgents/CloudAccess'; +import { CloudAccessPanel as AccessPanel } from '@/pages/CloudAgents/CloudAccess'; import { CloudSchedules } from '@/pages/CloudAgents/CloudSchedules'; import { CloudBudgetEditor, CloudCosts } from '@/pages/CloudAgents/CloudCosts'; import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents'; +import { useCloudPublication } from '@/pages/CloudAgents/useCloudPublication'; + +function CloudAccessPanel(props: { slug: string; revision: number; onPublished: () => void }) { + const publication = useCloudPublication(props.slug, props.revision, props.onPublished); + return ; +} + const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), upload: vi.fn(), download: vi.fn(), remember: vi.fn(), recovery: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; }