From ca80cb28d2e47eb2dd9fb88dafdac9679857eda7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 18 Sep 2026 15:57:32 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E7=99=BB=E8=AE=B0=E9=A6=96=E6=89=B9?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=E7=94=BB=E5=B8=83=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E9=81=97=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从master已合并的PR412建立后续修复范围。 区分动画与编辑等缺陷、卡片与任务浮层需求及未验证风险,不宣称已修复。 --- .../【待办】画布验收后续修复-2026-09-18.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md diff --git a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md new file mode 100644 index 000000000..fad84c88d --- /dev/null +++ b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md @@ -0,0 +1,23 @@ +# 画布验收后续修复 + +状态:待排查与方案确认。承接 Issue #411;首批修复已由 PR #412 合入,基准为 master `9a21690fe`。本提交仅登记后续范围,不代表以下问题已经修复。 + +## 优先排查 + +- 动画生成持续失败:读取具体操作错误及原请求状态后再定位,不盲目重复付费生成。 +- AI 润色无变化;恢复原文退出编辑面板、再次打开丢稿。 +- 文档全文预览入口不可用或不可发现,需在当前版本复验。 +- 标签面板无法查看更多标签;资源面板多选后卡死。 +- 未完成编辑缺少继续入口;快速编辑的素材名称校验报错。 + +## 后续需求 + +- 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。 +- 生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。 +- 素材替换关系与画布点选替换;聊天拖拽批量引用、复制对话保留有效引用。 + +## 边界与验收 + +先核对测试包/提交,避免旧包反馈与已合入修复混淆。每个问题先复现、明确原因与最小修复,再补回归和独立 review;复杂需求按现有规范驱动流程推进。首批本地通过不等于远程 CI 或真实 Provider 验收通过,遗留 Rust shard 2 失败仍需跟踪实际 SHA。不得为验证擅自提交付费生成、部署或修改他人项目。 + +完成条件:逐项记录修复提交、实际执行的检查和客户端验收;长期规则回归主规范,全部关闭后删除本待办。 From 1e17d2c852fe6eb670e80c5ce1aebef8e01408f7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 20 Sep 2026 17:36:02 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=AC=AC=E4=B8=80=E6=89=B9=EF=BC=9A=E6=B6=A6?= =?UTF-8?q?=E8=89=B2=E5=9B=9E=E5=8C=85=E6=8F=90=E7=A4=BA=E3=80=81=E6=9C=AA?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E5=90=8D=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口 润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化 共享聊天输入区显示润色提示,并让提示随用户手改提示词失效 快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉 恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目 账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用 派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘 名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮 浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉 恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式 空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码 补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离 同步 decision-log 与画布验收待办的进展说明 --- .../ResourceReferenceInput.tsx | 26 +- .../project-workspace/usePromptPolish.ts | 39 +- .../ResourcePromptPolishSlot.tsx | 27 +- .../resourceCanvasQuickEditModel.ts | 172 +++++ apps/ai-game-creator-shell/src/styles.css | 19 +- .../src/view/project-development/index.tsx | 395 +++++++++- .../project-development/resourceEditModel.ts | 121 ++- .../tests/chatPromptPolish.test.tsx | 16 + .../projectResourceLiveIntegration.test.tsx | 80 +- .../resourceCanvasFloatingDismiss.test.tsx | 206 +++++ .../resourceCanvasQuickEditDraft.test.tsx | 707 ++++++++++++++++++ .../resourceCanvasQuickEditModel.test.ts | 207 +++++ .../tests/resourceEditModel.test.ts | 224 ++++++ .../tests/resourcePromptPolishSlot.test.tsx | 130 ++++ .../tests/usePromptPolish.test.tsx | 125 +++- .../shared-memory/decision-log.md | 18 + .../【待办】画布验收后续修复-2026-09-18.md | 11 + .../useImageCanvasFloatingOptionDismiss.ts | 45 +- 18 files changed, 2522 insertions(+), 46 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourcePromptPolishSlot.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index e6a54d3fa..684522fe5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -758,8 +758,15 @@ function ResourceReferenceEditor({ const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; + /** + * 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」: + * 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉, + * 否则刚显示出来的提示会被自己的回填立刻清掉。 + */ + const appliedPromptRef = useRef(null); const applyPromptText = useCallback( (text: string) => { + appliedPromptRef.current = text; flushSync(() => { onChange({ text, references: liveDraftRef.current.references }); }); @@ -781,13 +788,25 @@ function ResourceReferenceEditor({ const { polishing, error: polishError, + notice: polishNotice, originalText: polishedOriginalText, polish: runPolish, restoreOriginal: restoreOriginalPrompt, clearError: clearPolishError, + clearNotice: clearPolishNotice, reset: resetPromptPolish, } = promptPolishState; + useEffect(() => { + if ( + appliedPromptRef.current !== null && + value === appliedPromptRef.current + ) { + return; + } + clearPolishNotice(); + }, [clearPolishNotice, value]); + const polishPrompt = useCallback(async () => { await runPolish(); }, [runPolish]); @@ -1082,13 +1101,16 @@ function ResourceReferenceEditor({ ) : null} {/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */} - {showPolishAction && !reminderOpen && (polishing || polishError) ? ( + {/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */} + {showPolishAction && + !reminderOpen && + (polishing || polishError || polishNotice) ? ( - {polishing ? '润色中…' : polishError} + {polishing ? '润色中…' : (polishError ?? polishNotice)} ) : null} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts index 76b02b421..e8506823b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts @@ -2,6 +2,15 @@ import { useCallback, useRef, useState } from 'react'; import { requestChatPromptPolish } from './chatPromptPolish'; +/** + * 回包与原文一字不差时的提示语。 + * + * 平台侧有时会把同一句话原样还回来(AGC-004:快速编辑里点「AI 润色」后文案毫无变化, + * 用户以为按钮没反应或已经改过)。这种情况必须给一条说得清的提示,而且**不能**落下 + * 原文快照——没有可回退的变化,就不该冒出「恢复原文」这种假入口。 + */ +export const PROMPT_POLISH_UNCHANGED_NOTICE = 'AI 润色结果与原文相同,未做修改'; + /** * 润色回填前的规范化结果。 * @@ -41,14 +50,19 @@ export type UsePromptPolishOptions = { export type UsePromptPolishResult = { polishing: boolean; error: string | null; - /** 最近一次成功回填的截断提示;没有截断时为 null。 */ + /** 最近一次成功回包的提示:截断说明,或「与原文相同,未做修改」;两者都没有时为 null。 */ notice: string | null; - /** 首次成功润色时落下的原文快照;非空时宿主渲染「恢复原文」。 */ + /** + * 首次**真的改动了文本**的润色时落下的原文快照;非空时宿主渲染「恢复原文」。 + * 回包与原文相同的那些次不落快照(没什么可恢复的)。 + */ originalText: string | null; /** 润色并在成功时回填,返回回填后的文本;失败返回 null 并保留原文。 */ polish: (options?: PromptPolishRunOptions) => Promise; restoreOriginal: () => void; clearError: () => void; + /** 用户自己改了提示词:把上一轮往返留下的提示(截断 / 与原文相同)收掉。 */ + clearNotice: () => void; /** 清掉往返状态(草稿清空、面板换资源时用)。 */ reset: () => void; }; @@ -56,6 +70,10 @@ export type UsePromptPolishResult = { /** * 提示词润色的状态机:失败保留原文、首次成功落原文快照、反复润色只覆盖结果。 * + * 成功但回包与原文逐字相同(规范化前后都没变)时不算「润色出了新东西」:不落原文快照、 + * 不回填宿主状态,只给 {@link PROMPT_POLISH_UNCHANGED_NOTICE} 这条明确反馈, + * 免得用户把「按钮点了没反应」误当成功能坏了、或者以为文案已经被改过。 + * * 聊天输入区与资源侧(生成素材 / 快速编辑)共用这一份;它与界面无关,也不碰共享组件, * 宿主自己决定文本存在哪里、怎么回填。Tauri 调用固定在 AGC 侧 * (`requestChatPromptPolish`),共享 composer 只接一个可选的注入位。 @@ -108,6 +126,18 @@ export function usePromptPolish({ const normalized = normalizeResult ? normalizeResult(polished) : { text: polished }; + if (normalized.text === prompt) { + // 最终要写回宿主的文本与当前文本逐字相同:既没有新内容可回填,也没有可回退的 + // 变化,因此不落原文快照、不写宿主状态——`applyPrompt` 在宿主侧还有「提示词变了 + // 就重铸请求身份」这类副作用,回填一份没变的文本会平白作废一次请求身份。 + // + // 判据必须是**规范化之后**的文本,不能只看回包:润色结果被按长度上限截回原文时 + // (`truncateResourceEditPrompt` 是切片不是 trim),回包与原文不同、写回去却一字 + // 未变,那会渲染出一枚点了等于没点的「恢复原文」,正是要消灭的那种假入口。 + // 截断提示优先——它解释的是「这次为什么没变」。 + setNotice(normalized.notice ?? PROMPT_POLISH_UNCHANGED_NOTICE); + return normalized.text; + } // 原文快照只在第一次成功润色时落下,因此「恢复原文」永远回到最初原文。 setOriginalText((current) => current ?? prompt); setNotice(normalized.notice ?? null); @@ -150,6 +180,10 @@ export function usePromptPolish({ setError(null); }, []); + const clearNotice = useCallback(() => { + setNotice(null); + }, []); + const reset = useCallback(() => { // 作废在飞请求:宿主清空草稿 / 换资源后,迟到的润色结果不许再回填。 requestIdRef.current += 1; @@ -166,6 +200,7 @@ export function usePromptPolish({ polish, restoreOriginal, clearError, + clearNotice, reset, }; } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx index b7a6ed284..5625cfcc5 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx @@ -1,4 +1,5 @@ import { Loader2, RotateCcw, Sparkles } from 'lucide-react'; +import { useCallback, useEffect, useRef } from 'react'; import { type LocalProjectResourceEditKind, @@ -30,14 +31,38 @@ export function ResourcePromptPolishSlot({ disabled = false, applyPrompt, }: ResourcePromptPolishSlotProps) { + /** + * 我们自己写回去的那一份文本。用于区分「润色回填导致的 prop 变化」与「用户手改」: + * 只有后者该把上一轮往返留下的提示收掉,否则刚显示出来的提示会被自己的回填清掉。 + */ + const appliedPromptRef = useRef(null); + // 宿主回调存 ref:包一层只为记账,不该让这份包装每次渲染都换身份、 + // 进而把 `usePromptPolish` 的 `polish` 也跟着重造。 + const applyPromptRef = useRef(applyPrompt); + applyPromptRef.current = applyPrompt; + const applyPromptAndTrack = useCallback((text: string) => { + appliedPromptRef.current = text; + applyPromptRef.current(text); + }, []); const polish = usePromptPolish({ readPrompt: () => prompt, - applyPrompt, + applyPrompt: applyPromptAndTrack, canPolish: () => !disabled, resolveContext: () => resourceAssetPromptPolishContext(subject), normalizeResult: (polished) => truncateResourceEditPrompt(polished, editKind), }); + const { clearNotice } = polish; + useEffect(() => { + if ( + appliedPromptRef.current !== null && + prompt === appliedPromptRef.current + ) { + return; + } + // 手改过提示词:上一轮「与原文相同 / 已截断」的结论不再描述当前这段文本。 + clearNotice(); + }, [clearNotice, prompt]); const statusText = polish.polishing ? '润色中…' : (polish.error ?? polish.notice); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts index 5d5535c5b..8c558ec75 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts @@ -1,6 +1,7 @@ import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes'; import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel'; +import type { ChatReference } from '../project-workspace/resourceReferences'; /** * AGC 资源卡快速编辑面板的初始草稿。 @@ -13,3 +14,174 @@ export function createResourceQuickEditPanelDraft( ): QuickEditPanelState { return createQuickEditPanelDraft(sourceLayer, {}); } + +/** + * 一份还没提交的快速编辑草稿。 + * + * 这是**宿主会话内存**里的一笔记录,不是正式业务状态:不落盘、不进资源编辑账本、 + * 不参与对账。它存在的唯一理由是面板被点外部 / Esc 收起、或用户切到另一张素材卡之后, + * 用户刚写的提示词与 `@` 引用还能原样回到面板里;正式的可恢复事实仍然只由原生 + * `list_pending_local_project_resource_edits` / `resume_local_project_resource_edit` + * 那条既有账本链路负责。 + */ +export type ResourceQuickEditDraft = { + /** 提示词原文(含 `@显示名` 字面量,与聊天同源)。 */ + readonly prompt: string; + /** 与提示词配套的 `@` 引用,回填输入区 chip。 */ + readonly references: readonly ChatReference[]; + /** 最后一次改动时间(恢复列表按新→旧排序)。 */ + readonly updatedAt: number; +}; + +/** + * 资源**路径** → 未提交草稿。 + * + * 键必须是投影的稳定身份,也就是 `ProjectResource.path`:资源投影本来就按 `path` 去重 + * (`resourceProjectionModel` 的 `uniqueByPath`),而资源 ID 会变——任务产物被正规化登记成 + * 正式素材后,同一张卡会从 `task:<任务>:<路径>` 变成 `asset:`。拿 ID 当键时,只要重投影 + * 不是「打开中这一笔快速编辑」自己触发(换素材卡收起面板、Agent 或外部编辑器把同一路径登记成 + * 资产),草稿就会挂在一个再也点不到的键上:恢复入口按 id 找不到而把它静默过滤,重开面板也按 + * 新卡 id 查不到,用户刚写的提示词凭空消失。 + */ +export type ResourceQuickEditDraftStore = ReadonlyMap< + string, + ResourceQuickEditDraft +>; + +/** + * 空草稿表:只在项目身份变化时用它整体清空。 + * + * 走工厂而不是导出一个共享常量:这个表会直接交给 `useState`,一旦哪天有人改成原地 + * `set` / `delete`,模块级单例就会跨面板、跨项目串味。每次调用都拿到一张新表。 + */ +export function createEmptyResourceQuickEditDraftStore(): ResourceQuickEditDraftStore { + return new Map(); +} + +/** 提示词与引用都空才算「没有草稿」,不留一条空记录去占用恢复入口。 */ +export function isResourceQuickEditDraftEmpty(draft: { + readonly prompt: string; + readonly references: readonly unknown[]; +}): boolean { + return draft.prompt.trim().length === 0 && draft.references.length === 0; +} + +export function readResourceQuickEditDraft( + store: ResourceQuickEditDraftStore, + key: string | null | undefined, +): ResourceQuickEditDraft | undefined { + return key ? store.get(key) : undefined; +} + +/** + * 写入一笔草稿补丁。 + * + * - 只给 `prompt` 或只给 `references` 时,另一头沿用该键下既有记录(输入区两条 + * 状态各自上报,谁都不该把对方清掉); + * - 合并结果为空就不留记录,避免恢复入口里堆一条点开空面板的条目。 + * + * 没有「换键」这一步:键是资源路径,正规化把卡片换成 `asset:…` 也不改路径,草稿不需要 + * 跟着投影搬家(换键逻辑本身正是「搬家搬丢了」的来源)。 + */ +export function writeResourceQuickEditDraft( + store: ResourceQuickEditDraftStore, + input: { + readonly key: string; + readonly prompt?: string; + readonly references?: readonly ChatReference[]; + readonly updatedAt: number; + }, +): ResourceQuickEditDraftStore { + const current = store.get(input.key); + const next: ResourceQuickEditDraft = { + prompt: input.prompt ?? current?.prompt ?? '', + references: input.references ?? current?.references ?? [], + updatedAt: input.updatedAt, + }; + const result = new Map(store); + if (isResourceQuickEditDraftEmpty(next)) { + result.delete(input.key); + return result; + } + result.set(input.key, next); + return result; +} + +/** 恢复入口里的一行未提交草稿。 */ +export type ResourceQuickEditDraftEntry = { + /** 资源路径——`writeResourceQuickEditDraft` 的键,也是回填 / 丢弃时要用的键。 */ + readonly key: string; + readonly label: string; + readonly prompt: string; + readonly updatedAt: number; +}; + +/** + * 恢复入口里的未提交草稿:只列**此刻还在当前投影里**的素材(按路径匹配,与卡片 id 无关), + * 按最后改动时间从新到旧。 + * + * 资源已经不在项目里的草稿没有可继续的落点,不该继续占着入口;但表里那份记录不在这里删, + * 删了就分不清「资源暂时不可见」和「用户已经不要这笔草稿」——由「继续编辑」与用户显式 + * 丢弃两处负责清理。 + */ +export function listResourceQuickEditDraftEntries( + store: ResourceQuickEditDraftStore, + resources: readonly { + readonly path: string; + readonly label: string; + }[], +): ResourceQuickEditDraftEntry[] { + const labelByPath = new Map( + resources.map((resource) => [resource.path, resource.label] as const), + ); + const entries: ResourceQuickEditDraftEntry[] = []; + for (const [key, draft] of store) { + const label = labelByPath.get(key); + if (label === undefined) { + continue; + } + entries.push({ + key, + label, + prompt: draft.prompt, + updatedAt: draft.updatedAt, + }); + } + return entries.sort((left, right) => right.updatedAt - left.updatedAt); +} + +/** 成功提交 / 用户显式丢弃后抹掉这笔草稿。 */ +export function dropResourceQuickEditDraft( + store: ResourceQuickEditDraftStore, + key: string | null | undefined, +): ResourceQuickEditDraftStore { + if (!key || !store.has(key)) { + return store; + } + const result = new Map(store); + result.delete(key); + return result; +} + +/** + * 把记录下来的草稿盖回刚造出来的面板草稿:模型 / 比例 / 尺寸等仍取当下的默认值, + * 只有用户真正改过的提示词与引用回填,避免用一份过期面板把当前默认档位也一起还原。 + */ +export function restoreResourceQuickEditPanelDraft( + panelDraft: QuickEditPanelState, + draft: ResourceQuickEditDraft | undefined, +): QuickEditPanelState { + return draft ? { ...panelDraft, prompt: draft.prompt } : panelDraft; +} + +/** 恢复入口里的提示词摘要:只做一行展示,不改变原草稿内容。 */ +export function resourceQuickEditDraftPromptExcerpt( + prompt: string, + limit = 48, +): string { + const text = prompt.trim().replace(/\s+/gu, ' '); + if (!text) { + return '(尚未填写提示词)'; + } + return text.length > limit ? `${text.slice(0, limit)}…` : text; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 41309f57f..b1d17d524 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -9590,7 +9590,7 @@ iframe.preview-frame { background: #fff; } -.game-resource-recovery-item > div { +.game-resource-recovery-item > div:first-child { display: grid; min-width: 0; gap: 4px; @@ -9606,7 +9606,18 @@ iframe.preview-frame { color: #9a7d70; } +/* + * 一行里的多枚动作收在一个容器里:条目是 `space-between` 的 flex,动作不收拢就会被 + * 摊到中间,和原生条目右对齐的单枚动作对不齐(移动端尤其明显)。 + */ +.game-resource-recovery-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + .game-resource-recovery-item > button, +.game-resource-recovery-actions > button, .game-resource-recovery-error > button { min-height: 34px; padding: 0 13px; @@ -9618,6 +9629,7 @@ iframe.preview-frame { } .game-resource-recovery-item > button:disabled, +.game-resource-recovery-actions > button:disabled, .game-resource-recovery-error > button:disabled { cursor: wait; opacity: 0.56; @@ -9652,9 +9664,14 @@ iframe.preview-frame { } .game-resource-recovery-item > button, + .game-resource-recovery-actions, .game-resource-recovery-error > button { width: 100%; } + + .game-resource-recovery-actions { + flex-direction: column; + } } .game-approval-dialog > header button { diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 9f550aeca..d402680a9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -200,7 +200,17 @@ import { undoResourceCanvasHistory, } from '../../features/resource-canvas/resourceCanvasHistoryModel'; import { ResourceCanvasPanelView } from '../../features/resource-canvas/ResourceCanvasPanelView'; -import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; +import { + createEmptyResourceQuickEditDraftStore, + createResourceQuickEditPanelDraft, + dropResourceQuickEditDraft, + listResourceQuickEditDraftEntries, + readResourceQuickEditDraft, + resourceQuickEditDraftPromptExcerpt, + type ResourceQuickEditDraftStore, + restoreResourceQuickEditPanelDraft, + writeResourceQuickEditDraft, +} from '../../features/resource-canvas/resourceCanvasQuickEditModel'; import { canNormalizeResourceIntoManifestAsset, projectResourceToCanvasLayer, @@ -337,10 +347,10 @@ import { ResourceDocumentPreviewDialog } from './ResourceDocumentPreviewDialog'; import { canonicalProjectedResourceMediaType, createResourceEditRequestIdentity, - defaultCharacterAnimationResourceName, - defaultDerivedResourceName, + resolveDerivedResourceNameCheck, resolveProjectCharacterAnimationCapability, resolveResourceEditRequestIdentity, + type ResourceEditNameCheck, type ResourceEditRequestIdentity, } from './resourceEditModel'; import { ResourceFilterPanel } from './ResourceFilterPanel'; @@ -584,6 +594,37 @@ function pendingResourceEditCreatedAtLabel(createdAt: number) { }).format(new Date(createdAt * 1000)); } +/** + * 未提交草稿的「最后改动时间」标签。 + * + * 账本里的 `createdAt` 是**秒**(Rust 侧口径),宿主内存里的草稿时间戳是 `Date.now()` 的 + * **毫秒**;直接丢给上面那个格式化器会再乘 1000,显示成某年 03/25 02:35 这种完全无关的 + * 日期。换算收在这一处。 + */ +function resourceQuickEditDraftUpdatedAtLabel(updatedAt: number) { + return pendingResourceEditCreatedAtLabel(Math.floor(updatedAt / 1000)); +} + +/** + * 「管理未完成编辑」入口的按钮文案。 + * + * 原生账本读取失败时也把本会话草稿的条数说出来:两边来源不同(一个在 Rust 账本里、 + * 一个只在这次打开客户端期间存在),账本读不出来不代表手头的草稿也没有,用户不该 + * 因为一次读取失败就以为「没有未完成的编辑」。 + */ +function resourceRecoveryTriggerLabel(input: { + readonly loadFailed: boolean; + readonly pendingCount: number; + readonly draftCount: number; +}) { + if (input.loadFailed) { + return input.draftCount > 0 + ? `恢复队列读取失败(另有本会话草稿 ${input.draftCount} 条)` + : '恢复队列读取失败'; + } + return `管理未完成编辑 (${input.pendingCount + input.draftCount})`; +} + export type ProjectAgentRuntimeSummary = { group: GameCreationAppAgentGroup; label: string; @@ -2122,6 +2163,18 @@ export default function ProjectDevelopmentView({ useState('idle'); const [pendingResourceEditsError, setPendingResourceEditsError] = useState(''); + /** + * 快速编辑里**还没提交**的草稿:宿主会话内存态,不落盘、不进资源编辑账本,也不参与 + * 对账(正式的可恢复事实只有原生 `list_pending_local_project_resource_edits` / + * `resume_local_project_resource_edit` 那条既有链路)。点外部 / Esc 收起面板、或用户 + * 切到另一张素材卡,都不再无条件丢掉刚写的提示词;重开面板原样回填,也能从「管理未完成 + * 编辑」里继续。键是资源**路径**(投影的稳定身份,见 `resourceCanvasQuickEditModel` + * 的类型注释),正规化把卡片换成 `asset:…` 不影响它。 + */ + const [resourceQuickEditDrafts, setResourceQuickEditDrafts] = + useState( + createEmptyResourceQuickEditDraftStore, + ); const [platformSessionGeneration, setPlatformSessionGeneration] = useState( currentPlatformSessionGeneration, ); @@ -2323,8 +2376,19 @@ export default function ProjectDevelopmentView({ const characterAnimationPanelRef = useRef(null); characterAnimationPanelRef.current = characterAnimationPanel; + /** + * 打开中的快速编辑面板,这一笔草稿挂在哪个资源路径下。 + * + * 收起面板或换素材卡时清空。键取路径而不是投影 id:正规化(`task:…` → `asset:…`) + * 换掉的是 id,路径不变,所以这里不需要任何「跟着重投影搬家」的纠偏。 + */ + const resourceQuickEditDraftPathRef = useRef(null); + /** 未提交草稿表的最新值:只在事件与回调里读,不随每次按键重建回调。 */ + const resourceQuickEditDraftsRef = useRef(resourceQuickEditDrafts); + resourceQuickEditDraftsRef.current = resourceQuickEditDrafts; const closeResourceQuickEditPanel = useCallback(() => { + resourceQuickEditDraftPathRef.current = null; setQuickEditPanel(null); setQuickEditSourceLayer(null); resourceQuickEditRequestRef.current = null; @@ -4711,6 +4775,10 @@ export default function ProjectDevelopmentView({ setPendingResourceEditActionIds(new Set()); pendingResourceEditsReadEpochRef.current += 1; setSelectedResourceIds([]); + // 未提交草稿是会话内存态、按资源路径归属:换项目后这些路径一个都不再成立, + // 整体清空,不能让上一个项目的草稿在新项目里冒出来。 + resourceQuickEditDraftPathRef.current = null; + setResourceQuickEditDrafts(createEmptyResourceQuickEditDraftStore()); setQuickEditPanel(null); setQuickEditSourceLayer(null); setResourceCanvasHistory(clearResourceCanvasHistory); @@ -7199,6 +7267,40 @@ export default function ProjectDevelopmentView({ ); marqueeHitTestLayersRef.current = marqueeHitTestLayers; + /** + * 当前子画布里那张卡的图层:与 `selectedResourceCard` 同一份判据(子画布 + 可见)。 + * + * 「继续编辑」用它把面板**当场**开到卡旁边,不必等选中态、也不用先切栏目再等一帧: + * 卡片已经在当前画布里时,这一刻就有一张可锚定的图层。卡片不在当前画布(别的栏目、 + * 总览态或被搜索挡住)时返回 null,由调用处走既有的定位链。 + */ + const resolveVisibleResourceLayer = useCallback( + (resourceId: string): CanvasLayer | null => { + if (resourceBookView === 'main') { + return null; + } + const card = resourceBookScenePlan + .flatMap((group) => group.cards) + .find( + (entry) => + entry.resource.id === resourceId && + entry.presentation === 'child' && + visibleResourceIds.has(entry.resource.id), + ); + if (!card) { + return null; + } + return projectResourceToCanvasLayer({ + resource: card.resource, + x: card.layout.x, + y: card.layout.y, + width: card.layout.width, + height: card.layout.height, + }); + }, + [resourceBookScenePlan, resourceBookView, visibleResourceIds], + ); + const selectedResourcePreviewIdentity = selectedResourceId ? (resourceCardPreviews.identityByResourceId.get(selectedResourceId) ?? null) @@ -7319,6 +7421,33 @@ export default function ProjectDevelopmentView({ [manifest, onManifestChange, projectPath], ); + /** + * 记一笔未提交草稿。 + * + * 输入区两条状态各自上报(提示词、`@` 引用),这里只做合并写:给谁就更新谁,另一头 + * 沿用该键下既有记录,谁都不会把对方抹掉;两头都空时不留记录,恢复入口里不会出现 + * 一条点开空面板的条目。 + */ + const rememberResourceQuickEditDraft = useCallback( + (patch: { + readonly prompt?: string; + readonly references?: readonly ChatReference[]; + }) => { + const key = resourceQuickEditDraftPathRef.current; + if (!key) { + return; + } + setResourceQuickEditDrafts((current) => + writeResourceQuickEditDraft(current, { + ...patch, + key, + updatedAt: Date.now(), + }), + ); + }, + [], + ); + const openResourceQuickEditPanel = useCallback( (layer: CanvasLayer) => { const resource = canvasResources.find((item) => item.id === layer.id); @@ -7329,10 +7458,22 @@ export default function ProjectDevelopmentView({ applyResourceCardFloatingPanelOpen('quick-edit'); setQuickEditSourceLayer(layer); const panelDraft = createResourceQuickEditPanelDraft(layer); - setQuickEditPanel(panelDraft); - setQuickEditReferences([]); + // 这张素材还有没提交的草稿就原样回填:点外部 / Esc 收起面板、或换过素材卡再回来, + // 用户刚写的提示词与 `@` 引用都还在。模型 / 比例 / 尺寸仍取当下默认值,不用一份 + // 过期面板把默认档位也一起还原。查的是**路径**:卡片正规化后 id 会变,路径不会。 + const remembered = readResourceQuickEditDraft( + resourceQuickEditDraftsRef.current, + resource.path, + ); + const restoredPanelDraft = restoreResourceQuickEditPanelDraft( + panelDraft, + remembered, + ); + resourceQuickEditDraftPathRef.current = resource.path; + setQuickEditPanel(restoredPanelDraft); + setQuickEditReferences(remembered ? [...remembered.references] : []); resourceQuickEditRequestRef.current = { - ...createResourceEditRequestIdentity(panelDraft.prompt), + ...createResourceEditRequestIdentity(restoredPanelDraft.prompt), sourceLayerId: layer.id, normalizedAssetId: null, expectedProjectRevision: null, @@ -7342,25 +7483,94 @@ export default function ProjectDevelopmentView({ [applyResourceCardFloatingPanelOpen, canvasResources], ); + /** + * 恢复入口里的未提交草稿:按**路径**与当前投影对上号,再按最后改动时间从新到旧。 + * 与卡片 id 无关,所以正规化换掉 id 之后这笔草稿照样在入口里、照样能继续。 + */ + const resourceQuickEditDraftEntries = useMemo( + () => + listResourceQuickEditDraftEntries( + resourceQuickEditDrafts, + canvasResources, + ), + [canvasResources, resourceQuickEditDrafts], + ); + + /** + * 「继续编辑」一笔未提交草稿。 + * + * 卡片就在当前子画布里时当场重开面板(提示词与 `@` 引用由 `openResourceQuickEditPanel` + * 回填);不在当前画布(别的栏目、总览态或被搜索挡住)时先按既有栏目导航把它切到眼前并 + * 选中,草稿留在原位,从工具栏重开快速编辑即可继续。 + * + * 开面板走一个 0ms 定时器而不是当帧直开:这次点击发生在恢复弹窗(画布边界之外)里, + * 「点外部 / Esc 关闭」的 document 监听若在同一帧挂上,会把这次点击当成点外部,刚开的 + * 面板会被自己人立刻收掉。等这一轮事件派发彻底结束再开,时序上就没有这个窗口。 + */ + const resumeResourceQuickEditDraft = useCallback( + (draftPath: string) => { + const resource = canvasResources.find((item) => item.path === draftPath); + if (!resource) { + setResourceQuickEditDrafts((current) => + dropResourceQuickEditDraft(current, draftPath), + ); + return; + } + closeResourceRecoveryPanel(); + handleResourceSelect(resource.id); + const layer = resolveVisibleResourceLayer(resource.id); + if (layer) { + window.setTimeout(() => openResourceQuickEditPanel(layer), 0); + return; + } + if (resource.category !== activePageCategory) { + selectResourceCanvasPage(resource.category); + } + setResourceWorkbenchNotice( + `已定位到「${resource.label}」,打开快速编辑即可继续未完成的编辑`, + ); + }, + [ + activePageCategory, + canvasResources, + closeResourceRecoveryPanel, + handleResourceSelect, + openResourceQuickEditPanel, + resolveVisibleResourceLayer, + selectResourceCanvasPage, + ], + ); + + /** 用户在恢复入口里显式丢弃一笔本地草稿。 */ + const discardResourceQuickEditDraft = useCallback((key: string) => { + setResourceQuickEditDrafts((current) => + dropResourceQuickEditDraft(current, key), + ); + }, []); + /** * 润色回填(或用户改写)快速编辑提示词。 * * 与共享 composer 的 `resetFailedDialogStatus` 同口径:提示词变了就不再是上一次 * 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。 */ - const applyResourceQuickEditPrompt = useCallback((text: string) => { - setQuickEditPanel((current) => - current - ? { - ...current, - prompt: text, - status: current.status === 'failed' ? 'idle' : current.status, - errorMessage: - current.status === 'failed' ? undefined : current.errorMessage, - } - : current, - ); - }, []); + const applyResourceQuickEditPrompt = useCallback( + (text: string) => { + rememberResourceQuickEditDraft({ prompt: text }); + setQuickEditPanel((current) => + current + ? { + ...current, + prompt: text, + status: current.status === 'failed' ? 'idle' : current.status, + errorMessage: + current.status === 'failed' ? undefined : current.errorMessage, + } + : current, + ); + }, + [rememberResourceQuickEditDraft], + ); /** * 快速编辑提示词输入区的整体草稿(文本 + `@` 引用)。 @@ -7371,9 +7581,11 @@ export default function ProjectDevelopmentView({ const applyResourceQuickEditDraft = useCallback( (draft: ChatComposerDraft) => { setQuickEditReferences(draft.references); + // 提示词那一半由 `applyResourceQuickEditPrompt` 顺带记账,这里只补 `@` 引用。 + rememberResourceQuickEditDraft({ references: draft.references }); applyResourceQuickEditPrompt(draft.text); }, - [applyResourceQuickEditPrompt], + [applyResourceQuickEditPrompt, rememberResourceQuickEditDraft], ); /** @@ -7442,6 +7654,46 @@ export default function ProjectDevelopmentView({ } return; } + /* + 派生资源名必须在**发请求之前**按 Rust 的同一条门禁校验:Rust + `normalize_resource_edit_name`(`resource_editor.rs:802-808`)对「trim 后为空 / 超过 120 + 码点 / 含控制字符」的名字整条拒绝,而这个名字是前端用源资源 label 拼出来的(Agent 回执 + 的 label 直接取任务标题,可能很长或带换行)。 + + 放在这里而不是拼 payload 时:`resolveResourceDeriveSource` 会先做 + `normalize_local_project_raster_resource` 正规化写盘,等拼 `assetName` 才发现名字不合法, + 项目里已经多出一条素材了。不截断也是同一条口径——截到 120 会让同前缀的两个长源名塌成 + 同一个名字,宁可先拦住让用户改名。 + */ + // 这一步包在 try 里:预检是纯函数,但源 label 形状异常时它同样会抛。抛出去这个 + // onSubmit 的 promise 就成了未处理拒绝,面板停在原状、按钮表现为「点了没反应」, + // 与下面 `resolveResourceDeriveSource` 的失败口径不一致。 + let nameCheck: ResourceEditNameCheck; + try { + nameCheck = resolveDerivedResourceNameCheck(resource, 'edit'); + } catch (error) { + setQuickEditPanel((current) => + current + ? { + ...current, + status: 'failed', + errorMessage: + error instanceof Error ? error.message : String(error), + } + : current, + ); + return; + } + // 解构出局部常量:`error` 在 setState 回调里要能保持收窄(对象属性收窄不跨闭包)。 + const { name: derivedName, error: nameError } = nameCheck; + if (nameError) { + setQuickEditPanel((current) => + current + ? { ...current, status: 'failed', errorMessage: nameError } + : current, + ); + return; + } const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); const sourceLayerId = layer.id; @@ -7496,7 +7748,7 @@ export default function ProjectDevelopmentView({ producerTaskId: null, sourceVersionId: null, prompt, - assetName: defaultDerivedResourceName(resource), + assetName: derivedName, }, }, ), @@ -7526,6 +7778,10 @@ export default function ProjectDevelopmentView({ resourceId: `asset:${result.asset.id}`, completed: false, }; + // 正式的可恢复事实已经由原 operation 账本接管,本地草稿再留着只会在恢复入口里 + // 多出一条重复条目。键是源素材路径,正规化换掉卡片 id 也不影响这一笔的归属。 + discardResourceQuickEditDraft(resource.path); + resourceQuickEditDraftPathRef.current = null; setQuickEditPanel(null); setQuickEditSourceLayer(null); resourceQuickEditRequestRef.current = null; @@ -7543,6 +7799,7 @@ export default function ProjectDevelopmentView({ ); } }, [ + discardResourceQuickEditDraft, manifest, onManifestChange, projectPath, @@ -7649,6 +7906,41 @@ export default function ProjectDevelopmentView({ } return; } + /* + 与快速编辑同一条口径:角色动画的派生名是「源名 + -角色动画」,同样由 Rust + `normalize_resource_edit_name`(`resource_editor.rs:802-808`)把关。校验放在 + `resolveResourceDeriveSource` 之前,避免先正规化写盘再发现名字不合法;不合法时只回写 + 面板失败态,不发派生请求、不扣费。 + */ + // 与快速编辑同样包一层:预检自己抛错时也要有可见失败,不能变成未处理拒绝。 + let nameCheck: ResourceEditNameCheck; + try { + nameCheck = resolveDerivedResourceNameCheck( + resource, + 'character-animation', + ); + } catch (error) { + setCharacterAnimationPanel((current) => + current + ? { + ...current, + status: 'failed', + errorMessage: + error instanceof Error ? error.message : String(error), + } + : current, + ); + return; + } + const { name: derivedName, error: nameError } = nameCheck; + if (nameError) { + setCharacterAnimationPanel((current) => + current + ? { ...current, status: 'failed', errorMessage: nameError } + : current, + ); + return; + } const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); const sourceLayerId = layer.id; @@ -7700,7 +7992,7 @@ export default function ProjectDevelopmentView({ producerTaskId: null, sourceVersionId: null, prompt, - assetName: defaultCharacterAnimationResourceName(resource), + assetName: derivedName, }, }, ), @@ -9050,7 +9342,13 @@ export default function ProjectDevelopmentView({