From 1e17d2c852fe6eb670e80c5ce1aebef8e01408f7 Mon Sep 17 00:00:00 2001
From: Suzumiya
Date: Sun, 20 Sep 2026 17:36:02 +0800
Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=BB=E5=B8=83=E9=AA=8C?=
=?UTF-8?q?=E6=94=B6=E7=AC=AC=E4=B8=80=E6=89=B9=EF=BC=9A=E6=B6=A6=E8=89=B2?=
=?UTF-8?q?=E5=9B=9E=E5=8C=85=E6=8F=90=E7=A4=BA=E3=80=81=E6=9C=AA=E6=8F=90?=
=?UTF-8?q?=E4=BA=A4=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E4=B8=8E=E6=B4=BE?=
=?UTF-8?q?=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({
整理画布
+ {/*
+ 未提交草稿与原生恢复队列共用这一枚入口:两者都是「没做完的资源编辑」,
+ 多开一枚按钮只会让用户分不清该点哪个。计数是两者之和,本地草稿也在
+ 弹窗里逐条列出、可继续或丢弃。
+ */}
{pendingResourceEdits.length > 0 ||
+ resourceQuickEditDraftEntries.length > 0 ||
pendingResourceEditsLoadState === 'failed' ? (
) : null}
{/*
@@ -10534,7 +10834,7 @@ export default function ProjectDevelopmentView({
未完成的资源编辑
-
选择任意原 operation 继续或处置
+
选择任意条目继续、处置或丢弃
) : null}
+ {/*
+ 未提交的快速编辑草稿排在最前:它们还没有 operation,最接近「刚刚在写的那一段」,
+ 继续就是原地把面板连同提示词一起打开;处置是显式丢弃。
+ */}
+ {resourceQuickEditDraftEntries.length > 0 ? (
+
+ {resourceQuickEditDraftEntries.map((entry) => (
+
+
+ {entry.label}
+
+ 快速编辑草稿 · 本会话未提交,关闭客户端不保留 ·{' '}
+ {resourceQuickEditDraftUpdatedAtLabel(entry.updatedAt)}
+
+
+ {resourceQuickEditDraftPromptExcerpt(entry.prompt)}
+
+
+
+
+
+
+
+ ))}
+
+ ) : null}
+
{pendingResourceEdits.length > 0 ? (
{pendingResourceEdits.map((pending) => {
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
index 4d739bec6..012d18452 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
@@ -208,8 +208,122 @@ export function resourceBaseName(
return resource.label.replace(/\.[^.]+$/u, '').trim();
}
+/**
+ * 派生资源名上限:与 Rust `normalize_resource_edit_name`
+ * (`src-tauri/src/project/resource_editor.rs:802-808`)逐值同口径。
+ */
+export const RESOURCE_EDIT_NAME_MAX_LENGTH = 120;
+
+/**
+ * 名称不合法时给用户看的那一句:与 Rust 的返回文案逐字一致(同文件 :805)。
+ *
+ * 前端只做镜像、不另造一套中文语义:提交前拦住和提交后失败看到的是同一句话,
+ * 用户不必靠比对两边措辞去猜这是不是同一个问题。
+ */
+export const RESOURCE_EDIT_NAME_INVALID_NOTICE =
+ '派生资源名称必须在 1..=120 字符内且不能包含控制字符';
+
+/**
+ * 名称检查结果:只有 `error === null` 才允许进入生成请求。
+ *
+ * 不合法时 `name` 原样保留(已 trim),**绝不返回截断名**:超长名截到 120 会让两个不同的
+ * 长源名塌成同一个名字(同前缀的两张图就是这种情况),那是把一次可见失败换成一次静默重名。
+ */
+export type ResourceEditNameCheck = {
+ name: string;
+ error: string | null;
+};
+
+/**
+ * 按 Unicode 码点算长度:Rust 侧是 `value.chars().count()`(同文件 :804),
+ * 不是 JS 的 UTF-16 `.length`——`'😀'.repeat(120)` 的 `.length` 是 240,码点数才是 120。
+ * 用 `.length` 会把 Rust 明确接受的名字在前端误判成非法。
+ */
+function resourceNameLength(value: string) {
+ return [...value].length;
+}
+
+/**
+ * Rust `char::is_control()` 的口径:Unicode 通用类别 `Cc`,即 C0(U+0000–U+001F)、
+ * DEL(U+007F)与 C1(U+0080–U+009F)。
+ *
+ * 用码点比较而不是控制字符正则:`Cc` 之外(例如 `Cf` 格式符)Rust 是放行的,前端也不额外
+ * 收窄——这份实现的职责是镜像,不是借机加一条客户端自有规则。
+ */
+function isResourceNameControlCodePoint(codePoint: number) {
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
+}
+
+function resourceNameHasControlCharacter(value: string) {
+ for (const character of value) {
+ if (isResourceNameControlCodePoint(character.codePointAt(0) ?? 0)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * 提交前按 Rust 的同一条规则检查派生资源名:先 `trim`,再要求非空、码点数 1..=120,
+ * 且不含控制字符。首尾的换行 / 制表符会被 `trim` 掉,因此不算违规——与 Rust 逐条一致。
+ *
+ * 调用方拿到 `error` 非空时必须在发请求**之前**停下并把它显示给用户(不合法时 `name`
+ * 只是 trim 后的原样名,不能拿它去提交)。
+ */
+export function resolveResourceEditNameCheck(
+ name: string,
+): ResourceEditNameCheck {
+ const trimmed = name.trim();
+ if (
+ trimmed.length === 0 ||
+ resourceNameLength(trimmed) > RESOURCE_EDIT_NAME_MAX_LENGTH ||
+ resourceNameHasControlCharacter(trimmed)
+ ) {
+ return { name: trimmed, error: RESOURCE_EDIT_NAME_INVALID_NOTICE };
+ }
+ return { name: trimmed, error: null };
+}
+
+/**
+ * 现役两种派生资源名后缀:快速编辑产出「-编辑版」,角色动画产出「-角色动画」。
+ *
+ * 后缀表是唯一来源,默认名与提交前检查共用它,避免「-编辑版」与「-角色动画」两条路径
+ * 再次各自演化出不同的拼接口径。
+ */
+export const DERIVED_RESOURCE_NAME_SUFFIXES = {
+ edit: '-编辑版',
+ 'character-animation': '-角色动画',
+} as const;
+
+export type DerivedResourceNameKind =
+ keyof typeof DERIVED_RESOURCE_NAME_SUFFIXES;
+
+/**
+ * 派生资源名的默认值 + 校验:去扩展名的基名(空则兜底「资源」)拼上对应后缀再检查。
+ *
+ * 后缀本身只有 4~5 字,所以超限几乎总是源名太长(例如 Agent 回执的 label 直接取任务标题)
+ * 或源名里带控制字符(任务标题里的换行就是)。
+ */
+export function resolveDerivedResourceNameCheck(
+ resource: ProjectResource,
+ kind: DerivedResourceNameKind,
+): ResourceEditNameCheck {
+ const base = resourceBaseName(resource) || '资源';
+ return resolveResourceEditNameCheck(
+ `${base}${DERIVED_RESOURCE_NAME_SUFFIXES[kind]}`,
+ );
+}
+
+/**
+ * 快速编辑派生资源的默认名称。
+ *
+ * 只负责拼名字,不校验也不截断:名字是否能在 Rust 侧通过由
+ * `resolveDerivedResourceNameCheck(resource, 'edit')` 回答,调用方必须在提交前用它拦下
+ * 不可用的名字。这里保持返回不合法原名(而不是悄悄截到 120),是为了让上层的检查
+ * 拿到真实长度并给出提示。
+ */
export function defaultDerivedResourceName(resource: ProjectResource) {
- return `${resourceBaseName(resource) || '资源'}-编辑版`;
+ return resolveDerivedResourceNameCheck(resource, 'edit').name;
}
/**
@@ -217,11 +331,14 @@ export function defaultDerivedResourceName(resource: ProjectResource) {
*
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
+ *
+ * 同样只是默认名,提交前必须走
+ * `resolveDerivedResourceNameCheck(resource, 'character-animation')`。
*/
export function defaultCharacterAnimationResourceName(
resource: ProjectResource,
) {
- return `${resourceBaseName(resource) || '资源'}-角色动画`;
+ return resolveDerivedResourceNameCheck(resource, 'character-animation').name;
}
/**
diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
index 696fccadb..a391d6ed2 100644
--- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
+++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx
@@ -291,6 +291,22 @@ describe('聊天输入区 AI 润色与发送前提醒', () => {
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
});
+ test('回包与原文相同时聊天输入区也给提示,且不冒出「恢复原文」', async () => {
+ const user = userEvent.setup();
+ installPolishingInvoke('原本的需求');
+ renderComposer({ initialText: '原本的需求' });
+
+ await user.click(screen.getByRole('button', { name: 'AI 润色' }));
+
+ // 聊天输入区与资源侧共用 `usePromptPolish`:修好状态机还不够,共享输入区必须把
+ // notice 渲染出来,否则这三个宿主里「点了没反应」看起来还是按钮坏了。
+ expect(
+ await screen.findByText('AI 润色结果与原文相同,未做修改'),
+ ).not.toBeNull();
+ expect(await composerText()).toBe('原本的需求');
+ expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
+ });
+
test('holds a long prompt behind the reminder panel and sends the original on demand', async () => {
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
index ef129cd12..cdf569437 100644
--- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
+++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
@@ -15,6 +15,7 @@ import {
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
+import { RESOURCE_EDIT_NAME_INVALID_NOTICE } from '../src/view/project-development/resourceEditModel';
import {
act,
createGameCreationAppManifest,
@@ -259,9 +260,17 @@ function ClassificationWorkbench() {
function DerivedWorkbench({
includeArt = false,
includeCharacter = false,
+ artName = 'source-art.png',
+ characterName = 'hero.png',
}: {
includeArt?: boolean;
includeCharacter?: boolean;
+ /**
+ * 源文件名可替换:派生资源名按源资源 label 拼出,用长名夹具复现
+ * `normalize_resource_edit_name` 的 120 码点门禁(AGC-025)。
+ */
+ artName?: string;
+ characterName?: string;
}) {
const initial = createGameCreationAppManifest(
'live-canvas-project',
@@ -281,7 +290,7 @@ function DerivedWorkbench({
id: 'source-art',
kind: 'art-image',
mediaType: 'image/png',
- localPath: 'assets/source-art.png',
+ localPath: `assets/${artName}`,
source: { kind: 'generated', resourceId: 'art-resource' },
},
]
@@ -293,7 +302,7 @@ function DerivedWorkbench({
kind: 'character',
category: 'character' as const,
mediaType: 'image/png',
- localPath: 'assets/hero.png',
+ localPath: `assets/${characterName}`,
source: { kind: 'generated', resourceId: 'hero-resource' },
},
]
@@ -840,6 +849,8 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
expect(deriveCalls[0]?.editKind).toBe('image-reference');
expect(deriveCalls[0]?.generationMode).toBe('derive');
+ // 名字合法时出站 assetName 就是提交前校验过的那个名字(默认名,逐字未改)。
+ expect(deriveCalls[0]?.assetName).toBe('source-art-编辑版');
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
@@ -891,6 +902,71 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
});
+ /**
+ * AGC-025:派生资源名由源资源 label 拼出,Rust `normalize_resource_edit_name`
+ * (`resource_editor.rs:802-808`)对「超 120 码点 / 含控制字符 / trim 后为空」的名字整条
+ * 拒绝。前端必须在**任何正规化写盘与派生请求之前**用同一句中文回写面板失败态,否则用户得
+ * 提交一次才知道名字不合法,项目里还可能先多出一条正规化素材。
+ */
+ it('快速编辑:源名超 120 码点时在派生请求之前拦下,并回写面板错误态', async () => {
+ const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
+ const { invoke, deriveCalls } = installTauri();
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
+ fireEvent.click(await findResourceSelectButton(longName));
+ const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
+ fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
+ const panel = await screen.findByRole('dialog', { name: '快速编辑图片' });
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把角色头发设定改为红色',
+ );
+ fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ );
+ // 零写盘、零远程请求:派生请求一次没发,正规化也没有被调用。
+ expect(deriveCalls).toHaveLength(0);
+ expect(
+ invoke.mock.calls.some(
+ ([command]) => command === 'normalize_local_project_raster_resource',
+ ),
+ ).toBe(false);
+ });
+
+ it('角色动画:源名超 120 码点时同样在派生请求之前拦下', async () => {
+ const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
+ const { invoke, deriveCalls } = installTauri();
+ render(
);
+
+ fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
+ fireEvent.click(await findResourceSelectButton(longName));
+ const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
+ fireEvent.click(within(toolbar).getByRole('button', { name: '生成动画' }));
+ const panel = await screen.findByRole('dialog', {
+ name: '角色动画生成面板',
+ });
+ await setComposerText(
+ within(panel).getByLabelText('动画描述'),
+ '角色挥手打招呼',
+ );
+ fireEvent.click(
+ within(panel).getByRole('button', { name: /生成[\d.]+泥点/ }),
+ );
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ );
+ expect(deriveCalls).toHaveLength(0);
+ expect(
+ invoke.mock.calls.some(
+ ([command]) => command === 'normalize_local_project_raster_resource',
+ ),
+ ).toBe(false);
+ });
+
it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => {
const { deriveCalls, polishCalls } = installTauri({
failFirstDerive: true,
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
index 98ee615bc..db87b2e61 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
@@ -2,6 +2,7 @@
import type { CanvasLayer } from '@genarrative/image-canvas-core';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
@@ -407,6 +408,211 @@ describe('资源画布浮层关闭时机', () => {
});
});
+/**
+ * 「恢复原文」的形状:浮层里的按钮在**这一次点击**里把自己卸掉
+ * (原文快照被消费 ⇒ 按钮不再渲染)。AGC-005 的验收视频里,这一下把整个快速编辑面板
+ * 连同画布选中一起收掉了。
+ *
+ * 这里用 React 的 `onClick`(应用里的真实形状)复现。
+ */
+function SelfUnmountingOverlayHarness({
+ onDismiss,
+}: {
+ onDismiss: () => void;
+}) {
+ const canvasRef = useRef
(null);
+ const [restoreVisible, setRestoreVisible] = useState(true);
+ const [panelOpen, setPanelOpen] = useState(true);
+
+ useImageCanvasFloatingOptionDismiss({
+ isOpen: panelOpen,
+ boundaryRefs: [canvasRef],
+ onDismiss: () => {
+ onDismiss();
+ setPanelOpen(false);
+ },
+ });
+
+ return (
+
+ {panelOpen ? (
+
+ {restoreVisible ? (
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 同一种形状的**同步**版本:目标节点在自己的原生 click 监听里被 `remove()`。
+ *
+ * 边界判定必须在事件派发最早期完成——等冒泡到 `document` 再拿 `event.target` 现算,
+ * 节点已经脱离 DOM,`element.contains(target)` 恒为 false,「点浮层内部」就被误判成
+ * 「点外部」,面板被自己的按钮关掉。这条用例是 AGC-005 的回归判据:
+ * 冻结判定接上之前它是红的。
+ */
+function NativeSelfRemovingOverlayHarness({
+ onDismiss,
+}: {
+ onDismiss: () => void;
+}) {
+ const canvasRef = useRef(null);
+ const buttonRef = useRef(null);
+ const [panelOpen, setPanelOpen] = useState(true);
+
+ useImageCanvasFloatingOptionDismiss({
+ isOpen: panelOpen,
+ boundaryRefs: [canvasRef],
+ onDismiss: () => {
+ onDismiss();
+ setPanelOpen(false);
+ },
+ });
+
+ useEffect(() => {
+ const button = buttonRef.current;
+ if (!button) {
+ return undefined;
+ }
+ const handleClick = () => {
+ button.remove();
+ };
+ button.addEventListener('click', handleClick);
+ return () => button.removeEventListener('click', handleClick);
+ }, [panelOpen]);
+
+ return (
+
+ {panelOpen ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 宿主自己 portal 到 `body` 的浮层(`@` 资源选择器 / 候选菜单)由 `isInsideExtraOverlay`
+ * 登记;菜单项点完即卸载时同样不能被判成点外部——portal 语义必须与边界 DOM 一视同仁。
+ */
+function ExtraOverlaySelfRemovalHarness({
+ onDismiss,
+}: {
+ onDismiss: () => void;
+}) {
+ const canvasRef = useRef(null);
+ const itemRef = useRef(null);
+ const [panelOpen, setPanelOpen] = useState(true);
+
+ useImageCanvasFloatingOptionDismiss({
+ isOpen: panelOpen,
+ boundaryRefs: [canvasRef],
+ onDismiss: () => {
+ onDismiss();
+ setPanelOpen(false);
+ },
+ isInsideExtraOverlay: (target) =>
+ target instanceof Element &&
+ target.closest('.resource-reference-picker') !== null,
+ });
+
+ useEffect(() => {
+ const item = itemRef.current;
+ if (!item) {
+ return undefined;
+ }
+ const handleClick = () => {
+ item.remove();
+ };
+ item.addEventListener('click', handleClick);
+ return () => item.removeEventListener('click', handleClick);
+ }, [panelOpen]);
+
+ if (!panelOpen) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {createPortal(
+
+
+
,
+ document.body,
+ )}
+ >
+ );
+}
+
+describe('点外部关闭的边界判据', () => {
+ test('点会自卸载的按钮不算点外部,面板不退出', () => {
+ const onDismiss = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '恢复原文' }));
+
+ expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
+ ).toBeTruthy();
+ });
+
+ test('按钮在自己这次点击里被同步卸载,也仍算点在浮层内部', () => {
+ const onDismiss = vi.fn();
+ render();
+
+ const button = screen.getByRole('button', { name: '恢复原文' });
+ fireEvent.click(button);
+
+ // 目标确实是在这次点击里卸载的——判定不能因此翻成「点外部」。
+ expect(button.isConnected).toBe(false);
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
+ ).toBeTruthy();
+ });
+
+ test('登记的 portal 浮层项自己卸载也算内部,宿主面板不被误关', () => {
+ const onDismiss = vi.fn();
+ render();
+
+ const item = screen.getByRole('button', { name: '引用素材' });
+ fireEvent.click(item);
+
+ expect(item.isConnected).toBe(false);
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
+ ).toBeTruthy();
+ });
+
+ test('点边界外照旧立即关闭(对照)', () => {
+ const onDismiss = vi.fn();
+ render();
+
+ fireEvent.click(document.body);
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ expect(
+ screen.queryByRole('dialog', { name: '快速编辑浮层面板' }),
+ ).toBeNull();
+ });
+});
+
describe('画布滚轮归属判据', () => {
/**
* 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx
new file mode 100644
index 000000000..48a8143a1
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx
@@ -0,0 +1,707 @@
+/** @vitest-environment jsdom */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
+import {
+ createGameCreationAppManifest,
+ findResourceSelectButton,
+ fireEvent,
+ installResizeObserverStub,
+ ProjectDevelopmentView,
+ React,
+ render,
+ screen,
+ setComposerText,
+ waitFor,
+ within,
+} from './appSurface/harness';
+
+vi.mock('@tauri-apps/api/core', async () => ({
+ ...(await vi.importActual(
+ '@tauri-apps/api/core',
+ )),
+ invoke: (command: string, args?: Record) =>
+ window.__TAURI__!.core.invoke(command, args),
+}));
+
+const PROJECT_PATH = '/tmp/quick-edit-draft';
+const PROJECT_ID = 'quick-edit-draft-project';
+const CANVAS_TASK_ART_PATH = 'assets/generated/task-art.png';
+
+/**
+ * 两张同栏目图片 + 一份文档素材。
+ *
+ * 两张图片落在同一个「待归类」栏目里,才测得出**换素材卡**时草稿各归各的;文档素材
+ * 供快速编辑的 `@` 引用选择器取用——引用的回填与提示词一样属于这份草稿。
+ */
+function createDraftManifest(): GameCreationAppManifest {
+ const manifest = createGameCreationAppManifest(
+ PROJECT_ID,
+ '快速编辑草稿测试',
+ );
+ manifest.assets = [
+ {
+ id: 'source-art',
+ kind: 'art-image',
+ mediaType: 'image/png',
+ localPath: 'assets/source-art.png',
+ source: { kind: 'generated', resourceId: 'art-resource' },
+ },
+ {
+ id: 'source-art-2',
+ kind: 'art-image',
+ mediaType: 'image/png',
+ localPath: 'assets/source-art-2.png',
+ source: { kind: 'generated', resourceId: 'art-resource-2' },
+ },
+ {
+ id: 'source-rules',
+ kind: 'game-rules',
+ mediaType: 'text/markdown',
+ localPath: 'docs/rules.md',
+ source: { kind: 'generated', resourceId: 'rules-resource' },
+ },
+ ];
+ return manifest;
+}
+
+/**
+ * 画布产物(还没登记成正式素材的任务产物)那一份清单。
+ *
+ * 卡片 id 是 `task:canvas-task:assets/generated/task-art.png`,提交快速编辑时先走
+ * `normalize_local_project_raster_resource` 正规化成 `asset:`——卡片被重投影走、
+ * 面板的 `sourceLayerId` 仍停在旧 id 上,这正是草稿键必须跟着投影走的那条路。
+ */
+function createCanvasTaskArtManifest(): GameCreationAppManifest {
+ const manifest = createGameCreationAppManifest(
+ PROJECT_ID,
+ '快速编辑草稿测试',
+ );
+ manifest.assets = [
+ {
+ id: 'source-rules',
+ kind: 'game-rules',
+ mediaType: 'text/markdown',
+ localPath: 'docs/rules.md',
+ source: { kind: 'generated', resourceId: 'rules-resource' },
+ },
+ ];
+ manifest.tasks = [
+ ...manifest.tasks,
+ {
+ id: 'canvas-task',
+ title: '画布生成',
+ group: 'art',
+ role: 'Artisan',
+ status: 'completed',
+ dependencies: [],
+ artifacts: [CANVAS_TASK_ART_PATH],
+ acceptanceCriteria: ['画布产物落在画布上'],
+ },
+ ];
+ return manifest;
+}
+
+type PendingResourceEditFixture = {
+ operationId: string;
+ editKind: string;
+ sourceResourceId: string;
+ assetName: string;
+ phase: string;
+ createdAt: number;
+};
+
+/**
+ * 工作台跑真实链路所需的最小原生桩:资源图、画布布局、预览读取与恢复队列读取。
+ * 派生走成功分支,用于验证「提交成功后这一笔草稿作废」。
+ */
+function installInvoke(
+ options: {
+ pendingResourceEdits?: PendingResourceEditFixture[];
+ /** 与工作台同一份清单工厂:两边必须是同一份初始 manifest。 */
+ createManifest?: () => GameCreationAppManifest;
+ /** 派生一律失败:用于把面板留在失败态、只验证草稿的去向。 */
+ failDerive?: boolean;
+ } = {},
+) {
+ const createManifest = options.createManifest ?? createDraftManifest;
+ let revision = 0;
+ let manifest = createManifest();
+ const deriveCalls: Array> = [];
+ const normalizeCalls: Array> = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'get_local_game_project_revision') {
+ return { revision };
+ }
+ if (command === 'read_local_project_resource_graph') {
+ const resourceIds = (
+ args?.resources as Array<{ resourceId: string }>
+ ).map((item) => item.resourceId);
+ return {
+ resourceIds,
+ referenceEdges: [],
+ taskFlows: [],
+ producerAssignments: [],
+ dependencyDepths: resourceIds.map((resourceId) => ({
+ resourceId,
+ dependencyDepth: 0,
+ })),
+ connectionIndex: resourceIds.map((resourceId) => ({
+ resourceId,
+ upstreamReferenceResourceIds: [],
+ downstreamReferenceResourceIds: [],
+ referenceEdgeIds: [],
+ taskFlowIds: [],
+ })),
+ unresolvedReferenceResourceIds: [],
+ cyclicResourceIds: [],
+ cyclicTaskIds: [],
+ producerMappingTruncated: false,
+ };
+ }
+ if (
+ command === 'read_local_project_resource_canvas_layout' ||
+ command === 'update_local_project_resource_canvas_layout'
+ ) {
+ const layout = {
+ schemaVersion: 'game-creator-resource-layout.v1',
+ projectId: PROJECT_ID,
+ mode: args?.mode,
+ revision: Number(args?.expectedRevision ?? 0) + 1,
+ positions: args?.positions ?? [],
+ updatedAt: 1,
+ };
+ return command.startsWith('update_')
+ ? { status: 'updated', layout }
+ : layout;
+ }
+ if (command === 'read_local_project_image_preview') {
+ return {
+ path: String(args?.relativePath ?? ''),
+ mediaType: 'image/png',
+ byteLen: 12,
+ dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB',
+ };
+ }
+ if (command === 'read_local_project_text_preview') {
+ return {
+ path: String(args?.relativePath ?? ''),
+ mediaType: 'text/markdown',
+ byteLen: 5,
+ content: '# 规则',
+ };
+ }
+ if (command === 'list_pending_local_project_resource_edits') {
+ return structuredClone(options.pendingResourceEdits ?? []);
+ }
+ if (command === 'list_local_project_asset_generations') {
+ return [];
+ }
+ if (command === 'get_local_game_manifest') {
+ return manifest;
+ }
+ if (command === 'normalize_local_project_raster_resource') {
+ const input = structuredClone(
+ (args?.input ?? {}) as Record,
+ );
+ normalizeCalls.push(input);
+ const asset = {
+ id: 'normalized-task-art',
+ kind: 'art-image',
+ mediaType: 'image/png',
+ localPath: CANVAS_TASK_ART_PATH,
+ source: {
+ kind: 'canvas' as const,
+ resourceId: String(input.sourceResourceId),
+ taskId: 'canvas-task',
+ },
+ };
+ revision += 1;
+ manifest = { ...manifest, assets: [...manifest.assets, asset] };
+ return {
+ committedProjectRevision: revision,
+ asset,
+ manifest,
+ };
+ }
+ if (command === 'derive_local_project_resource') {
+ const input = structuredClone(
+ (args?.input ?? {}) as Record,
+ );
+ deriveCalls.push(input);
+ if (options.failDerive) {
+ throw new Error('result-unknown: 测试网络中断');
+ }
+ const operationId = String(input.operationId);
+ const assetId = `edit-${operationId}`;
+ const asset = {
+ id: assetId,
+ kind: 'art-image',
+ mediaType: 'image/png',
+ localPath: `assets/edits/${operationId}-art.png`,
+ source: {
+ kind: 'generated' as const,
+ resourceId: `local-asset:${assetId}`,
+ referenceResourceIds: ['art-resource'],
+ },
+ };
+ revision += 1;
+ manifest = { ...manifest, assets: [...manifest.assets, asset] };
+ return {
+ operationId,
+ editKind: input.editKind,
+ sourceResourceId: 'art-resource',
+ committedProjectRevision: revision,
+ asset,
+ version: null,
+ manifest,
+ };
+ }
+ throw new Error(`unexpected command: ${command}`);
+ },
+ );
+ (
+ window as unknown as {
+ __TAURI__?: { core?: { invoke?: typeof invoke } };
+ }
+ ).__TAURI__ = { core: { invoke } };
+ return { invoke, deriveCalls, normalizeCalls };
+}
+
+/** 宿主由状态持有 manifest,派生结果才能真的回写并重投影(不是断言桩)。 */
+function DraftWorkbench({
+ createManifest = createDraftManifest,
+ externalAssetPath = null,
+}: {
+ createManifest?: () => GameCreationAppManifest;
+ /**
+ * 打开一枚测试用的「外部登记素材」按钮:模拟 Agent / 外部编辑器把某个路径登记成
+ * 正式素材。走的是同一条投影链路(manifest 变更 → 卡片重投影),与快速编辑自己
+ * 触发的正规化无关——这正是「别的流程换了卡片 id」的那条旁路。
+ */
+ externalAssetPath?: string | null;
+} = {}) {
+ const [manifest, setManifest] = React.useState(createManifest);
+ return React.createElement(
+ React.Fragment,
+ null,
+ externalAssetPath
+ ? React.createElement(
+ 'button',
+ {
+ type: 'button',
+ onClick: () =>
+ setManifest((current) => ({
+ ...current,
+ assets: [
+ ...current.assets,
+ {
+ id: 'external-art',
+ kind: 'art-image',
+ mediaType: 'image/png',
+ localPath: externalAssetPath,
+ source: {
+ kind: 'generated' as const,
+ resourceId: 'external-art-resource',
+ },
+ },
+ ],
+ })),
+ },
+ '外部登记素材',
+ )
+ : null,
+ React.createElement(ProjectDevelopmentView, {
+ projectName: manifest.name,
+ projectPath: PROJECT_PATH,
+ manifest,
+ attachments: [],
+ recentRunStatus: null,
+ recentRunStopReason: null,
+ supervisor: React.createElement('div', null, '项目总控'),
+ onHomeOpen: vi.fn(),
+ onProjectsOpen: vi.fn(),
+ onManifestChange: (
+ _path: string,
+ nextManifest: GameCreationAppManifest,
+ ) => setManifest(nextManifest),
+ }),
+ );
+}
+
+/** 切栏目:左侧大纲导航已删除,一律走「资源总览」的栏目缩略卡片。 */
+async function openResourceBookCategory(label: string) {
+ if (document.querySelector('[data-resource-book-view="child"]')) {
+ fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-resource-book-view="main"]'),
+ ).not.toBeNull(),
+ );
+ }
+ fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-resource-book-view="child"]'),
+ ).not.toBeNull(),
+ );
+}
+
+async function openQuickEditPanel(fileName: string) {
+ fireEvent.click(await findResourceSelectButton(fileName));
+ const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
+ fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
+ return await screen.findByRole('dialog', { name: '快速编辑图片' });
+}
+
+/** 面板里的提示词文本(`@` 引用只活在 chip 里,不重复出现在文本里)。 */
+function panelPromptText(panel: HTMLElement) {
+ return within(panel).getByLabelText('快速编辑提示词').textContent ?? '';
+}
+
+/** 关掉画布浮层:Esc 与点外部同一条既有链路(`clearResourceCanvasFocus`)。 */
+async function dismissPanel() {
+ fireEvent.keyDown(document, { key: 'Escape' });
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
+ );
+}
+
+afterEach(() => {
+ delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
+});
+
+describe('快速编辑草稿的保留与恢复', () => {
+ it('Esc 收起面板后重开:提示词与 @ 引用都回到输入区', async () => {
+ installResizeObserverStub();
+ installInvoke();
+ render(React.createElement(DraftWorkbench));
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('source-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把夜色改成星空',
+ );
+ // `@` 引用也是这一笔草稿的一部分:插入一枚再收起面板。
+ fireEvent.click(
+ within(panel).getByRole('button', { name: '插入素材引用' }),
+ );
+ const picker = await screen.findByRole('dialog', { name: '选择素材' });
+ fireEvent.click(within(picker).getByRole('option', { name: /^rules/u }));
+ fireEvent.click(within(picker).getByRole('button', { name: '插入引用' }));
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-resource-reference-id="source-rules"]'),
+ ).not.toBeNull(),
+ );
+
+ await dismissPanel();
+
+ const reopened = await openQuickEditPanel('source-art.png');
+ expect(panelPromptText(reopened)).toContain('把夜色改成星空');
+ expect(
+ document.querySelector('[data-resource-reference-id="source-rules"]'),
+ ).not.toBeNull();
+ });
+
+ it('换素材卡时两笔草稿各归各的,切回来不串也不丢', async () => {
+ installResizeObserverStub();
+ installInvoke();
+ render(React.createElement(DraftWorkbench));
+
+ await openResourceBookCategory('待归类');
+ const firstPanel = await openQuickEditPanel('source-art.png');
+ await setComposerText(
+ within(firstPanel).getByLabelText('快速编辑提示词'),
+ '第一张的草稿',
+ );
+
+ // 面板开着直接换卡:宿主换选中时收起面板,草稿必须留在第一张名下。
+ const secondPanel = await openQuickEditPanel('source-art-2.png');
+ expect(secondPanel).not.toBe(firstPanel);
+ await waitFor(() =>
+ expect(panelPromptText(secondPanel)).not.toContain('第一张的草稿'),
+ );
+ await setComposerText(
+ within(secondPanel).getByLabelText('快速编辑提示词'),
+ '第二张的草稿',
+ );
+
+ await dismissPanel();
+ expect(
+ panelPromptText(await openQuickEditPanel('source-art.png')),
+ ).toContain('第一张的草稿');
+ await dismissPanel();
+ expect(
+ panelPromptText(await openQuickEditPanel('source-art-2.png')),
+ ).toContain('第二张的草稿');
+ });
+
+ it('恢复入口按草稿计数,「继续编辑」重开面板并回填,丢弃后入口消失', async () => {
+ installResizeObserverStub();
+ installInvoke();
+ render(React.createElement(DraftWorkbench));
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('source-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把角色头发改成红色',
+ );
+ await dismissPanel();
+
+ // 未提交草稿同样要有继续入口:入口计数把本地草稿算进去。
+ fireEvent.click(
+ await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
+ );
+ const recovery = await screen.findByRole('dialog', {
+ name: '未完成的资源编辑',
+ });
+ expect(within(recovery).getByText('source-art.png')).not.toBeNull();
+ // 语义标注:草稿只在这次打开客户端期间存在,不能和账本里正式的可恢复事实看起来一样。
+ expect(within(recovery).getByText(/^快速编辑草稿/u).textContent).toContain(
+ '快速编辑草稿 · 本会话未提交,关闭客户端不保留 · ',
+ );
+ // 时间戳口径:草稿存的是 `Date.now()` 毫秒,标签复用账本那条秒级格式化器,
+ // 少一次换算就会把毫秒当成秒、显示成完全无关的日期——这里按「就是今天」钉住。
+ expect(within(recovery).getByText(/^快速编辑草稿/u).textContent).toContain(
+ new Intl.DateTimeFormat('zh-CN', {
+ month: '2-digit',
+ day: '2-digit',
+ }).format(new Date()),
+ );
+ expect(within(recovery).getByText('把角色头发改成红色')).not.toBeNull();
+
+ fireEvent.click(within(recovery).getByRole('button', { name: '继续编辑' }));
+ const resumed = await screen.findByRole('dialog', {
+ name: '快速编辑图片',
+ });
+ expect(panelPromptText(resumed)).toContain('把角色头发改成红色');
+ await dismissPanel();
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
+ );
+ fireEvent.click(
+ within(
+ await screen.findByRole('dialog', { name: '未完成的资源编辑' }),
+ ).getByRole('button', { name: '丢弃草稿' }),
+ );
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('button', { name: /管理未完成编辑/u }),
+ ).toBeNull(),
+ );
+ });
+
+ it('草稿所在的素材不在当前栏目时:先切栏目定位,再从工具栏重开继续', async () => {
+ installResizeObserverStub();
+ installInvoke();
+ render(React.createElement(DraftWorkbench));
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('source-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把夜色改成星空',
+ );
+ await dismissPanel();
+
+ // 人在别的栏目:草稿条目仍在入口里,点「继续编辑」先把栏目切回去。
+ await openResourceBookCategory('文档');
+ fireEvent.click(
+ await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
+ );
+ fireEvent.click(
+ within(
+ await screen.findByRole('dialog', { name: '未完成的资源编辑' }),
+ ).getByRole('button', { name: '继续编辑' }),
+ );
+ await waitFor(() =>
+ expect(
+ document.querySelector(
+ '[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active[data-resource-book-category="unclassified"]',
+ ),
+ ).not.toBeNull(),
+ );
+ expect(
+ await screen.findByText(/已定位到「source-art\.png」/u),
+ ).not.toBeNull();
+
+ const resumed = await openQuickEditPanel('source-art.png');
+ expect(panelPromptText(resumed)).toContain('把夜色改成星空');
+ });
+
+ it('正规化换掉卡片投影后,草稿跟着资源走、重开在正式素材上继续', async () => {
+ installResizeObserverStub();
+ const { deriveCalls, normalizeCalls } = installInvoke({
+ createManifest: createCanvasTaskArtManifest,
+ failDerive: true,
+ });
+ render(
+ React.createElement(DraftWorkbench, {
+ createManifest: createCanvasTaskArtManifest,
+ }),
+ );
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('task-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把夜色改成星空',
+ );
+ fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
+ // 先正规化成正式素材、再派生失败:卡片已经重投影,面板仍停在失败态。
+ await waitFor(() => expect(normalizeCalls).toHaveLength(1));
+ await waitFor(() => expect(deriveCalls).toHaveLength(1));
+ await waitFor(() =>
+ expect(
+ document.querySelector(
+ '[data-resource-card-id="asset:normalized-task-art"]',
+ ),
+ ).not.toBeNull(),
+ );
+ expect(
+ document.querySelector(
+ '[data-resource-card-id="task:canvas-task:assets/generated/task-art.png"]',
+ ),
+ ).toBeNull();
+ expect(await within(panel).findByRole('alert')).not.toBeNull();
+
+ await dismissPanel();
+
+ // 旧卡片已经不在画布上:草稿按**路径**归属,新投影的正式素材卡照样命中,否则这一笔
+ // 就再也点不到。
+ const reopened = await openQuickEditPanel('task-art.png');
+ expect(panelPromptText(reopened)).toContain('把夜色改成星空');
+ });
+
+ /**
+ * 旁路重投影:换卡片 id 的不是「打开中这一笔快速编辑」自己,而是 Agent / 外部编辑器把
+ * 同一路径登记成正式素材。草稿是会话内存态、面板已经收起,没有任何「跟着投影搬家」的机会,
+ * 只有键本身就是稳定身份(路径)时才不会变成查不到的孤儿草稿。
+ */
+ it('面板收起后卡片被旁路重投影:草稿仍在入口里,重开照样回填', async () => {
+ installResizeObserverStub();
+ installInvoke({
+ createManifest: createCanvasTaskArtManifest,
+ failDerive: true,
+ });
+ render(
+ React.createElement(DraftWorkbench, {
+ createManifest: createCanvasTaskArtManifest,
+ externalAssetPath: CANVAS_TASK_ART_PATH,
+ }),
+ );
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('task-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把夜色改成星空',
+ );
+ // 收起面板:这一笔草稿只留在宿主内存里,没有任何打开中的面板替它盯投影。
+ await dismissPanel();
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: '外部登记素材' }),
+ );
+ await waitFor(() =>
+ expect(
+ document.querySelector(
+ '[data-resource-card-id="task:canvas-task:' +
+ CANVAS_TASK_ART_PATH +
+ '"]',
+ ),
+ ).toBeNull(),
+ );
+ expect(
+ document.querySelector('[data-resource-card-id="asset:external-art"]'),
+ ).not.toBeNull();
+
+ // 入口里那一笔还在(旧实现按投影 id 过滤,这里会整条消失)。
+ fireEvent.click(
+ await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
+ );
+ const recovery = await screen.findByRole('dialog', {
+ name: '未完成的资源编辑',
+ });
+ expect(within(recovery).getByText('task-art.png')).not.toBeNull();
+ fireEvent.click(
+ within(recovery).getByRole('button', { name: '关闭资源编辑恢复面板' }),
+ );
+
+ const reopened = await openQuickEditPanel('task-art.png');
+ expect(panelPromptText(reopened)).toContain('把夜色改成星空');
+ });
+
+ it('原生恢复队列与本地草稿共用同一枚入口,两边都能继续', async () => {
+ installResizeObserverStub();
+ installInvoke({
+ pendingResourceEdits: [
+ {
+ operationId: '99999999-9999-4999-8999-999999999999',
+ editKind: 'image',
+ sourceResourceId: 'art-resource',
+ assetName: '未完成的派生图.png',
+ phase: 'media-downloaded',
+ createdAt: 1,
+ },
+ ],
+ });
+ render(React.createElement(DraftWorkbench));
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('source-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把夜色改成星空',
+ );
+ await dismissPanel();
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: '管理未完成编辑 (2)' }),
+ );
+ const recovery = await screen.findByRole('dialog', {
+ name: '未完成的资源编辑',
+ });
+ // 两边语义不同:本地草稿是「继续编辑」,原生账本是「继续原编辑」。
+ expect(
+ within(recovery).getByRole('button', { name: '继续编辑' }),
+ ).not.toBeNull();
+ expect(
+ within(recovery).getByRole('button', { name: '继续原编辑' }),
+ ).not.toBeNull();
+ });
+
+ it('提交成功后这一笔草稿作废,重开是空面板', async () => {
+ installResizeObserverStub();
+ const { deriveCalls } = installInvoke();
+ render(React.createElement(DraftWorkbench));
+
+ await openResourceBookCategory('待归类');
+ const panel = await openQuickEditPanel('source-art.png');
+ await setComposerText(
+ within(panel).getByLabelText('快速编辑提示词'),
+ '把夜色改成星空',
+ );
+ fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
+ await waitFor(() => expect(deriveCalls).toHaveLength(1));
+ // 先证明这条草稿真的写进了出站请求,下面的「重开是空面板」才不是空断言。
+ expect(deriveCalls[0]?.prompt).toBe('把夜色改成星空');
+ await waitFor(() =>
+ expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
+ );
+ // 正式的可恢复事实已经由原 operation 账本接管,本地草稿不再占着恢复入口。
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('button', { name: /管理未完成编辑/u }),
+ ).toBeNull(),
+ );
+
+ const reopened = await openQuickEditPanel('source-art.png');
+ expect(panelPromptText(reopened)).not.toContain('把夜色改成星空');
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts
new file mode 100644
index 000000000..4f9c6caf8
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest';
+
+import type { CanvasLayer } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
+import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
+import {
+ createEmptyResourceQuickEditDraftStore,
+ createResourceQuickEditPanelDraft,
+ dropResourceQuickEditDraft,
+ isResourceQuickEditDraftEmpty,
+ listResourceQuickEditDraftEntries,
+ readResourceQuickEditDraft,
+ resourceQuickEditDraftPromptExcerpt,
+ restoreResourceQuickEditPanelDraft,
+ writeResourceQuickEditDraft,
+} from '../src/features/resource-canvas/resourceCanvasQuickEditModel';
+
+const SOURCE_LAYER: CanvasLayer = {
+ id: 'asset:source-art',
+ resourceId: 'asset:source-art',
+ title: 'source-art.png',
+ src: '/source-art.png',
+ x: 0,
+ y: 0,
+ width: 512,
+ height: 512,
+ originalWidth: 1024,
+ originalHeight: 1024,
+ zIndex: 1,
+ sourceType: 'upload',
+};
+
+const RULES_REFERENCE: ChatReference = {
+ type: 'resource',
+ resourceId: 'rules-resource',
+ kind: 'game-rules',
+ mediaType: 'text/markdown',
+ label: 'rules',
+ category: 'document',
+ tags: [],
+ source: 'asset-picker',
+};
+
+describe('快速编辑草稿表', () => {
+ it('提示词与引用各自上报时互相补齐,不改动原表', () => {
+ const withPrompt = writeResourceQuickEditDraft(
+ createEmptyResourceQuickEditDraftStore(),
+ { key: 'asset:source-art', prompt: '把夜色改成星空', updatedAt: 10 },
+ );
+ const withReferences = writeResourceQuickEditDraft(withPrompt, {
+ key: 'asset:source-art',
+ references: [RULES_REFERENCE],
+ updatedAt: 20,
+ });
+
+ // 只补引用那一次不能把提示词清掉,反之亦然:输入区两条状态是分别上报的。
+ expect(
+ readResourceQuickEditDraft(withReferences, 'asset:source-art'),
+ ).toEqual({
+ prompt: '把夜色改成星空',
+ references: [RULES_REFERENCE],
+ updatedAt: 20,
+ });
+ expect(readResourceQuickEditDraft(withPrompt, 'asset:source-art')).toEqual({
+ prompt: '把夜色改成星空',
+ references: [],
+ updatedAt: 10,
+ });
+ // 只读语义:写操作返回新表、源表不动;空表走工厂,每次都是独立的一张。
+ expect(withPrompt.size).toBe(1);
+ expect(createEmptyResourceQuickEditDraftStore().size).toBe(0);
+ expect(createEmptyResourceQuickEditDraftStore()).not.toBe(
+ createEmptyResourceQuickEditDraftStore(),
+ );
+ });
+
+ /**
+ * 键是资源**路径**,不是投影 id:正规化把卡片从 `task:…` 换成 `asset:…` 之后,
+ * 同一张卡的草稿必须在恢复入口里照样对得上号,否则用户刚写的提示词会静默消失。
+ */
+ it('草稿按路径归属,卡片换成 asset 投影后仍在入口里', () => {
+ const store = writeResourceQuickEditDraft(
+ createEmptyResourceQuickEditDraftStore(),
+ {
+ key: 'assets/generated/hero.png',
+ prompt: '改成红色头发',
+ references: [RULES_REFERENCE],
+ updatedAt: 10,
+ },
+ );
+ // 正规化前后是同一路径的两张卡;入口只认路径,落在哪一张上都该列出来。
+ expect(
+ listResourceQuickEditDraftEntries(store, [
+ { path: 'assets/generated/hero.png', label: 'hero.png(正式素材)' },
+ ]),
+ ).toEqual([
+ {
+ key: 'assets/generated/hero.png',
+ label: 'hero.png(正式素材)',
+ prompt: '改成红色头发',
+ updatedAt: 10,
+ },
+ ]);
+ // 资源真的不在项目里时(删掉了)才不入列表,记录本身不在这一步删。
+ expect(listResourceQuickEditDraftEntries(store, [])).toEqual([]);
+ expect(store.size).toBe(1);
+ });
+
+ it('恢复入口按最后改动时间从新到旧', () => {
+ const store = writeResourceQuickEditDraft(
+ writeResourceQuickEditDraft(createEmptyResourceQuickEditDraftStore(), {
+ key: 'assets/older.png',
+ prompt: '早写的',
+ updatedAt: 10,
+ }),
+ { key: 'assets/newer.png', prompt: '后写的', updatedAt: 30 },
+ );
+
+ expect(
+ listResourceQuickEditDraftEntries(store, [
+ { path: 'assets/older.png', label: 'older.png' },
+ { path: 'assets/newer.png', label: 'newer.png' },
+ ]).map((entry) => entry.key),
+ ).toEqual(['assets/newer.png', 'assets/older.png']);
+ });
+
+ it('提示词与引用都清空后不留条目', () => {
+ const store = writeResourceQuickEditDraft(
+ createEmptyResourceQuickEditDraftStore(),
+ {
+ key: 'asset:source-art',
+ prompt: '把夜色改成星空',
+ updatedAt: 10,
+ },
+ );
+ const cleared = writeResourceQuickEditDraft(store, {
+ key: 'asset:source-art',
+ prompt: '',
+ updatedAt: 20,
+ });
+
+ expect(
+ isResourceQuickEditDraftEmpty({ prompt: ' ', references: [] }),
+ ).toBe(true);
+ expect(
+ readResourceQuickEditDraft(cleared, 'asset:source-art'),
+ ).toBeUndefined();
+ expect(cleared.size).toBe(0);
+ });
+
+ it('丢弃只删目标键,未记录过时原样返回', () => {
+ const store = writeResourceQuickEditDraft(
+ createEmptyResourceQuickEditDraftStore(),
+ {
+ key: 'asset:a',
+ prompt: 'A 的草稿',
+ updatedAt: 10,
+ },
+ );
+ const withTwo = writeResourceQuickEditDraft(store, {
+ key: 'asset:b',
+ prompt: 'B 的草稿',
+ updatedAt: 20,
+ });
+ const dropped = dropResourceQuickEditDraft(withTwo, 'asset:a');
+
+ expect(readResourceQuickEditDraft(dropped, 'asset:a')).toBeUndefined();
+ expect(readResourceQuickEditDraft(dropped, 'asset:b')?.prompt).toBe(
+ 'B 的草稿',
+ );
+ expect(dropResourceQuickEditDraft(dropped, 'asset:missing')).toBe(dropped);
+ expect(dropResourceQuickEditDraft(dropped, null)).toBe(dropped);
+ expect(readResourceQuickEditDraft(dropped, null)).toBeUndefined();
+ });
+
+ it('回填只覆盖提示词,模型与比例仍取当下默认值', () => {
+ const panelDraft = createResourceQuickEditPanelDraft(SOURCE_LAYER);
+ const restored = restoreResourceQuickEditPanelDraft(panelDraft, {
+ prompt: '把夜色改成星空',
+ references: [],
+ updatedAt: 10,
+ });
+
+ expect(restored.prompt).toBe('把夜色改成星空');
+ expect(restored.model).toBe(panelDraft.model);
+ expect(restored.aspectRatio).toBe(panelDraft.aspectRatio);
+ expect(restored.size).toBe(panelDraft.size);
+ expect(restored.status).toBe('idle');
+ // 没有草稿时面板原样返回:默认面板不该被这一步改写。
+ expect(restoreResourceQuickEditPanelDraft(panelDraft, undefined)).toBe(
+ panelDraft,
+ );
+ });
+
+ it('摘要压缩空白并按上限截断', () => {
+ expect(resourceQuickEditDraftPromptExcerpt('')).toBe('(尚未填写提示词)');
+ expect(resourceQuickEditDraftPromptExcerpt(' \n ')).toBe(
+ '(尚未填写提示词)',
+ );
+ expect(resourceQuickEditDraftPromptExcerpt('把夜色\n改成 星空')).toBe(
+ '把夜色 改成 星空',
+ );
+ expect(resourceQuickEditDraftPromptExcerpt('一二三四五', 3)).toBe(
+ '一二三…',
+ );
+ expect(resourceQuickEditDraftPromptExcerpt('一二三', 3)).toBe('一二三');
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts
index 6dc8bfb85..bffdf31a9 100644
--- a/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceEditModel.test.ts
@@ -1,10 +1,20 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
import { describe, expect, it } from 'vitest';
import {
canonicalProjectedResourceMediaType,
+ defaultCharacterAnimationResourceName,
+ defaultDerivedResourceName,
+ DERIVED_RESOURCE_NAME_SUFFIXES,
+ resolveDerivedResourceNameCheck,
resolveProjectCharacterAnimationCapability,
resolveProjectResourceEditCapability,
+ resolveResourceEditNameCheck,
resolveResourceEditRequestIdentity,
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ RESOURCE_EDIT_NAME_MAX_LENGTH,
RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE,
resourceEditPromptMaxLength,
truncateResourceEditPrompt,
@@ -280,3 +290,217 @@ describe('全类型资源编辑能力模型', () => {
).toBeNull();
});
});
+
+/**
+ * Rust 源码路径按**本文件位置**解析,不按仓库根 cwd 解析:这个用例是跨语言对表,
+ * 从别的工作目录单跑这一个文件时 `ENOENT` 会把「两边分叉」误报成一次环境问题。
+ */
+const RUST_RESOURCE_EDITOR_PATH = fileURLToPath(
+ new URL('../src-tauri/src/project/resource_editor.rs', import.meta.url),
+);
+
+/**
+ * Rust `normalize_resource_edit_name` 的函数体原文。
+ *
+ * 派生资源名的门禁在 Rust 侧,前端这份只是镜像;一旦两边数字或文案分叉,用户就会先看到
+ * 一句「提交前拦下」的话、再被后端换成另一句话,所以这里直接拿源码对表。
+ */
+function rustResourceEditNameNormalizer() {
+ const source = readFileSync(RUST_RESOURCE_EDITOR_PATH, 'utf8');
+ const start = source.indexOf('fn normalize_resource_edit_name(');
+ expect(start).toBeGreaterThan(-1);
+ const body = source.slice(start, source.indexOf('\n}\n', start));
+ expect(body.length).toBeGreaterThan(0);
+ return body;
+}
+
+/** 只换 label 的 Agent 回执资源:AGC 侧 label 直接取任务标题,是最容易触发名称门禁的一类。 */
+function labelResource(label: string) {
+ return resource({
+ id: 'agent-result:design:message-1',
+ category: 'document',
+ path: '专业 Agent 文本回执',
+ subtype: 'agent-result',
+ producerTaskId: null,
+ content: '回执',
+ label,
+ });
+}
+
+/** 默认名函数与 kind 的对应关系,供「默认名 == 检查名」的断言复用。 */
+function defaultDerivedNameFor(
+ target: ProjectResource,
+ kind: 'edit' | 'character-animation',
+) {
+ return kind === 'edit'
+ ? defaultDerivedResourceName(target)
+ : defaultCharacterAnimationResourceName(target);
+}
+
+describe('派生资源名称:提交前镜像 Rust 的 1..=120 与禁止控制字符', () => {
+ /**
+ * Rust 是权威:`value.trim()` → 非空 → `chars().count() > 120` → `char::is_control`,
+ * 任一不满足就 `Err`,并且**不截断**(没有 `truncate` / `chars().take(...)`)。
+ * 前端镜像必须逐条对齐,包括「宁可按原话拒绝、也不截成可能重名的名字」这条口径。
+ */
+ it('上限与提示文案逐字取自 Rust 的 normalize_resource_edit_name', () => {
+ const body = rustResourceEditNameNormalizer();
+
+ expect(body).toMatch(/\bvalue\.trim\(\)/u);
+ expect(body).toMatch(/value\.is_empty\(\)/u);
+ expect(body).toMatch(/char::is_control/u);
+ expect(body).toMatch(/return Err\(/u);
+ expect(body).not.toMatch(/truncate|\.take\(/u);
+
+ const limit = /chars\(\)\.count\(\)\s*>\s*(\d+)/u.exec(body);
+ expect(limit?.[1]).toBe(String(RESOURCE_EDIT_NAME_MAX_LENGTH));
+
+ const notice = /"([^"]*派生资源名称[^"]*)"/u.exec(body);
+ expect(notice?.[1]).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ });
+
+ it('恰好 120 码点放行,多一个字就按 Rust 原话拦住且不截断', () => {
+ const exactlyLimit = '字'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH);
+ expect(resolveResourceEditNameCheck(exactlyLimit)).toEqual({
+ name: exactlyLimit,
+ error: null,
+ });
+
+ const overLimit = `${exactlyLimit}多`;
+ const overCheck = resolveResourceEditNameCheck(overLimit);
+ expect(overCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ // 提示里报的长度上限必须是拦下它的那个数字,且原样返回(不返回截断名)。
+ expect(RESOURCE_EDIT_NAME_INVALID_NOTICE).toContain(
+ `1..=${RESOURCE_EDIT_NAME_MAX_LENGTH}`,
+ );
+ expect(overCheck.name).toBe(overLimit);
+ });
+
+ it('先 trim 再判空:首尾空白不算违规,只有空白则拦下', () => {
+ expect(resolveResourceEditNameCheck(' 小猫咪-编辑版 ')).toEqual({
+ name: '小猫咪-编辑版',
+ error: null,
+ });
+ expect(resolveResourceEditNameCheck('').error).toBe(
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ );
+ expect(resolveResourceEditNameCheck(' ').error).toBe(
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ );
+ expect(resolveResourceEditNameCheck('\n\t').error).toBe(
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ );
+ });
+
+ it('拒绝 Cc 控制字符(含内嵌换行 / 制表符),但放行 Rust 也放行的 Cf 格式符', () => {
+ for (const control of ['\u0000', '\u0007', '\u001f', '\u007f', '\u009f']) {
+ expect(
+ resolveResourceEditNameCheck(`角色${control}形象-编辑版`).error,
+ `U+${control.codePointAt(0)!.toString(16)}`,
+ ).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ }
+ // 任务标题里的换行是真实成因:label 直接取任务标题,内嵌换行原样传下来。
+ for (const embedded of ['\n', '\r', '\t']) {
+ expect(
+ resolveResourceEditNameCheck(`生成角色${embedded}正面形象-编辑版`)
+ .error,
+ ).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ }
+ // 首尾换行被 trim 掉,Rust 同样放行——不能在这里多收窄一条客户端自有规则。
+ expect(
+ resolveResourceEditNameCheck('\n角色形象-编辑版\n').error,
+ ).toBeNull();
+ expect(
+ resolveResourceEditNameCheck('角色形象\u200b-编辑版').error,
+ ).toBeNull();
+ });
+
+ it('按码点计长(chars().count() 口径),不按 UTF-16 长度误判', () => {
+ const emojiLimit = '😀'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH);
+ // UTF-16 长度是 240,码点数是 120:Rust 接受,前端不许拦。
+ expect(emojiLimit.length).toBe(RESOURCE_EDIT_NAME_MAX_LENGTH * 2);
+ expect(resolveResourceEditNameCheck(emojiLimit).error).toBeNull();
+ expect(
+ resolveResourceEditNameCheck(
+ `${'字'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH - 5)}${'😀'.repeat(5)}`,
+ ).error,
+ ).toBeNull();
+ expect(resolveResourceEditNameCheck(`${emojiLimit}😀`).error).toBe(
+ RESOURCE_EDIT_NAME_INVALID_NOTICE,
+ );
+ });
+
+ it('编辑版与角色动画共用同一张后缀表,默认名与检查名逐个相等', () => {
+ expect(DERIVED_RESOURCE_NAME_SUFFIXES).toEqual({
+ edit: '-编辑版',
+ 'character-animation': '-角色动画',
+ });
+ const hero = resource({
+ id: 'asset:hero',
+ category: 'unclassified',
+ path: 'assets/hero.png',
+ label: 'hero.png',
+ mediaType: 'image/png',
+ manifestAssetId: 'hero',
+ });
+ expect(resolveDerivedResourceNameCheck(hero, 'edit')).toEqual({
+ name: 'hero-编辑版',
+ error: null,
+ });
+ expect(
+ resolveDerivedResourceNameCheck(hero, 'character-animation'),
+ ).toEqual({ name: 'hero-角色动画', error: null });
+ // 默认名与检查名是同一份拼接结果,调用方不必各自拼一遍。
+ expect(defaultDerivedResourceName(hero)).toBe('hero-编辑版');
+ expect(defaultCharacterAnimationResourceName(hero)).toBe('hero-角色动画');
+ // 去扩展名 + 空基名兜底的口径不变。
+ expect(defaultDerivedResourceName(labelResource('.png'))).toBe(
+ '资源-编辑版',
+ );
+ expect(defaultDerivedResourceName(labelResource('theme.mp3'))).toBe(
+ 'theme-编辑版',
+ );
+ });
+
+ /**
+ * 两个后缀都要处理:快速编辑的「-编辑版」和角色动画的「-角色动画」是同一个源名的两种
+ * 派生。后缀只多 4~5 个字,所以超限的源名在两条路径上都必须被拦下,而不是只拦一条。
+ */
+ it('超长源名在编辑版与角色动画两条路径上都被拦下,不截成可能重名的名字', () => {
+ const base = '生成角色正面半身战斗姿态设定稿'.repeat(8);
+ const first = labelResource(`${base}A.png`);
+ const second = labelResource(`${base}B.png`);
+
+ for (const kind of ['edit', 'character-animation'] as const) {
+ const firstCheck = resolveDerivedResourceNameCheck(first, kind);
+ const secondCheck = resolveDerivedResourceNameCheck(second, kind);
+ expect(firstCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ expect(secondCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ // 不截断:检查结果仍是原样的长名(否则两个同前缀的长名会被截成同一个可用名字)。
+ expect([...firstCheck.name].length).toBeGreaterThan(
+ RESOURCE_EDIT_NAME_MAX_LENGTH,
+ );
+ expect(firstCheck.name).not.toBe(secondCheck.name);
+ expect(firstCheck.name).toBe(defaultDerivedNameFor(first, kind));
+ }
+ });
+
+ /**
+ * 真实成因:`agent-result` 资源的 label 直接取任务标题(`resourceProjectionModel`),
+ * 用户写的长标题或带换行的标题会原样进入派生名。
+ */
+ it('任务标题型源名(超长 / 带换行)在派生前就给出同一句中文提示', () => {
+ const longTitle = `${'把主角改成夜行侠并加上战斗姿态'.repeat(9)}.png`;
+ expect(
+ resolveDerivedResourceNameCheck(labelResource(longTitle), 'edit').error,
+ ).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+
+ const multilineTitle = '生成角色\n正面形象.png';
+ expect(
+ resolveDerivedResourceNameCheck(
+ labelResource(multilineTitle),
+ 'character-animation',
+ ).error,
+ ).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourcePromptPolishSlot.test.tsx b/apps/ai-game-creator-shell/tests/resourcePromptPolishSlot.test.tsx
new file mode 100644
index 000000000..099703bc2
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourcePromptPolishSlot.test.tsx
@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { useState } from 'react';
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+import { ResourcePromptPolishSlot } from '../src/features/resource-canvas/ResourcePromptPolishSlot';
+
+type TauriInvoke = (
+ command: string,
+ args?: Record,
+) => Promise;
+
+/**
+ * 平台侧润色的桩:`ResourcePromptPolishSlot` 走 `usePromptPolish` 的默认实现
+ * (`polish_local_project_prompt`),所以这里和生成素材面板的既有用例一样,
+ * 用 `window.__TAURI__` 打桩,不碰真实 Provider。
+ */
+function installPolishInvoke(result: string) {
+ const invoke = vi.fn(async (command: string) => {
+ if (command !== 'polish_local_project_prompt') {
+ throw new Error(`unexpected invoke ${command}`);
+ }
+ return result;
+ });
+ (
+ window as unknown as { __TAURI__?: { core?: { invoke: TauriInvoke } } }
+ ).__TAURI__ = { core: { invoke } };
+ return invoke;
+}
+
+afterEach(() => {
+ cleanup();
+ delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
+});
+
+/**
+ * 宿主侧的最小接线:slot 本身是受控的,提示词文本存在宿主里
+ * (与 AGC 快速编辑面板 / 生成素材弹窗同一形状),`output` 只是把宿主状态显示出来。
+ */
+function PolishSlotHarness({ prompt }: { prompt: string }) {
+ const [text, setText] = useState(prompt);
+ return (
+ <>
+
+ {/* 刻意用 span:`