补齐画布验收第一批:润色回包提示、未提交草稿恢复与派生名预检
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s

AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口
润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化
共享聊天输入区显示润色提示,并让提示随用户手改提示词失效
快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉
恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目
账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用
派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘
名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮
浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉
恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式
空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码
补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离
同步 decision-log 与画布验收待办的进展说明
This commit is contained in:
2026-09-20 17:36:02 +08:00
parent ca80cb28d2
commit 1e17d2c852
18 changed files with 2522 additions and 46 deletions
@@ -758,8 +758,15 @@ function ResourceReferenceEditor({
const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled;
/**
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
* 否则刚显示出来的提示会被自己的回填立刻清掉。
*/
const appliedPromptRef = useRef<string | null>(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}
</div>
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
{showPolishAction &&
!reminderOpen &&
(polishing || polishError || polishNotice) ? (
<span
className="resource-reference-input-status"
role="status"
aria-live="polite"
>
{polishing ? '润色中…' : polishError}
{polishing ? '润色中…' : (polishError ?? polishNotice)}
</span>
) : null}
<LexicalTypeaheadMenuPlugin<ResourceMentionOption>
@@ -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<string | null>;
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,
};
}
@@ -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<string | null>(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);
@@ -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>`。拿 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;
}
+18 -1
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
@@ -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`,即 C0U+0000U+001F)、
* DELU+007F)与 C1U+0080U+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;
}
/**
@@ -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 });
@@ -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(<DerivedWorkbench includeArt artName={longName} />);
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(<DerivedWorkbench includeCharacter characterName={longName} />);
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,
@@ -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<HTMLDivElement | null>(null);
const [restoreVisible, setRestoreVisible] = useState(true);
const [panelOpen, setPanelOpen] = useState(true);
useImageCanvasFloatingOptionDismiss({
isOpen: panelOpen,
boundaryRefs: [canvasRef],
onDismiss: () => {
onDismiss();
setPanelOpen(false);
},
});
return (
<div ref={canvasRef} data-testid="self-unmount-canvas">
{panelOpen ? (
<div role="dialog" aria-label="快速编辑浮层面板">
{restoreVisible ? (
<button type="button" onClick={() => setRestoreVisible(false)}>
</button>
) : null}
</div>
) : null}
</div>
);
}
/**
* 同一种形状的**同步**版本:目标节点在自己的原生 click 监听里被 `remove()`。
*
* 边界判定必须在事件派发最早期完成——等冒泡到 `document` 再拿 `event.target` 现算,
* 节点已经脱离 DOM`element.contains(target)` 恒为 false,「点浮层内部」就被误判成
* 「点外部」,面板被自己的按钮关掉。这条用例是 AGC-005 的回归判据:
* 冻结判定接上之前它是红的。
*/
function NativeSelfRemovingOverlayHarness({
onDismiss,
}: {
onDismiss: () => void;
}) {
const canvasRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(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 (
<div ref={canvasRef} data-testid="native-self-unmount-canvas">
{panelOpen ? (
<div role="dialog" aria-label="快速编辑浮层面板">
<button ref={buttonRef} type="button">
</button>
</div>
) : null}
</div>
);
}
/**
* 宿主自己 portal 到 `body` 的浮层(`@` 资源选择器 / 候选菜单)由 `isInsideExtraOverlay`
* 登记;菜单项点完即卸载时同样不能被判成点外部——portal 语义必须与边界 DOM 一视同仁。
*/
function ExtraOverlaySelfRemovalHarness({
onDismiss,
}: {
onDismiss: () => void;
}) {
const canvasRef = useRef<HTMLDivElement | null>(null);
const itemRef = useRef<HTMLButtonElement | null>(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 (
<>
<div ref={canvasRef} data-testid="registered-overlay-canvas">
<div role="dialog" aria-label="快速编辑浮层面板" />
</div>
{createPortal(
<div className="resource-reference-picker">
<button ref={itemRef} type="button">
</button>
</div>,
document.body,
)}
</>
);
}
describe('点外部关闭的边界判据', () => {
test('点会自卸载的按钮不算点外部,面板不退出', () => {
const onDismiss = vi.fn();
render(<SelfUnmountingOverlayHarness onDismiss={onDismiss} />);
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(<NativeSelfRemovingOverlayHarness onDismiss={onDismiss} />);
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(<ExtraOverlaySelfRemovalHarness onDismiss={onDismiss} />);
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(<NativeSelfRemovingOverlayHarness onDismiss={onDismiss} />);
fireEvent.click(document.body);
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(
screen.queryByRole('dialog', { name: '快速编辑浮层面板' }),
).toBeNull();
});
});
describe('画布滚轮归属判据', () => {
/**
* 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿
File diff suppressed because it is too large Load Diff
@@ -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('一二三');
});
});
@@ -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);
});
});
@@ -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<string, unknown>,
) => Promise<unknown>;
/**
* 平台侧润色的桩:`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 (
<>
<ResourcePromptPolishSlot
subject="图片素材的快速编辑提示词"
editKind="image-reference"
prompt={text}
applyPrompt={setText}
/>
{/* 刻意用 span`<output>` 自带 status 角色,会和 slot 的状态行撞名。 */}
<span aria-label="宿主提示词">{text}</span>
{/* 宿主输入框里的手改:与 AGC 面板里改提示词同一条路径(改的是宿主状态)。 */}
<button type="button" onClick={() => setText('把角色头发改成蓝色')}>
</button>
</>
);
}
describe('ResourcePromptPolishSlot', () => {
test('回包与原文相同:状态行说清「未做修改」,也不给「恢复原文」假入口', async () => {
const invoke = installPolishInvoke('把角色头发改成红色');
render(<PolishSlotHarness prompt="把角色头发改成红色" />);
fireEvent.click(screen.getByRole('button', { name: 'AI 润色' }));
// 验收项 AGC-004 的用户可见面:必须有一条说得清的反馈,而不是静默什么都不发生。
await waitFor(() =>
expect(screen.getByRole('status').textContent).toBe(
'AI 润色结果与原文相同,未做修改',
),
);
expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', {
prompt: '把角色头发改成红色',
context: expect.stringContaining('图片素材的快速编辑提示词'),
});
// 文本没变,也就没有可回退的变化:不渲染「恢复原文」。
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
'把角色头发改成红色',
);
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
});
test('用户接着手改提示词:上一轮的提示收起,宿主文本就是用户写的那份', async () => {
installPolishInvoke('把角色头发改成红色');
render(<PolishSlotHarness prompt="把角色头发改成红色" />);
fireEvent.click(screen.getByRole('button', { name: 'AI 润色' }));
await waitFor(() =>
expect(screen.getByRole('status').textContent).toBe(
'AI 润色结果与原文相同,未做修改',
),
);
// 宿主文本被用户改写:上一轮「与原文相同」的结论不再描述当前这段话,提示收起,
// 免得用户把上一轮的结论当成对现在这段文本的判断。
fireEvent.click(screen.getByRole('button', { name: '模拟用户手改' }));
await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
'把角色头发改成蓝色',
);
});
test('回包不同:「恢复原文」把原输入回填给宿主,并收起自己与提示', async () => {
installPolishInvoke('把角色头发设定改为亮红色');
render(<PolishSlotHarness prompt="把角色头发改成红色" />);
fireEvent.click(screen.getByRole('button', { name: 'AI 润色' }));
const restore = await screen.findByRole('button', { name: '恢复原文' });
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
'把角色头发设定改为亮红色',
);
fireEvent.click(restore);
// 验收项 AGC-005 的动作面:恢复原文 = 回填最初原文,快照被消费掉,按钮与提示一起收干净。
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
'把角色头发改成红色',
);
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
expect(screen.queryByRole('status')).toBeNull();
});
});
@@ -2,7 +2,10 @@
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { usePromptPolish } from '../src/features/project-workspace/usePromptPolish';
import {
PROMPT_POLISH_UNCHANGED_NOTICE,
usePromptPolish,
} from '../src/features/project-workspace/usePromptPolish';
afterEach(cleanup);
@@ -10,6 +13,7 @@ function renderPolish({
requestPolish,
applyPrompt = vi.fn(),
normalizeResult,
readPrompt = () => '原始需求',
}: {
requestPolish: (
prompt: string,
@@ -20,10 +24,11 @@ function renderPolish({
text: string;
notice?: string | null;
};
readPrompt?: () => string;
}) {
return renderHook(() =>
usePromptPolish({
readPrompt: () => '原始需求',
readPrompt,
applyPrompt,
requestPolish,
normalizeResult,
@@ -126,4 +131,120 @@ describe('usePromptPolish', () => {
expect(applyPrompt).toHaveBeenCalledWith('润色后的需求');
expect(result.current.originalText).toBe('原始需求');
});
test('成功回包与当前 prompt 逐字相同:给「未做修改」提示,不落快照也不回填', async () => {
const applyPrompt = vi.fn();
const { result } = renderPolish({
requestPolish: async () => '原始需求',
applyPrompt,
});
let returned: string | null = null;
await act(async () => {
returned = await result.current.polish();
});
// 验收项 AGC-004:润色结果就是原文时必须说得清,不能让用户以为按钮没反应、
// 或者以为文案已经被改过。
expect(returned).toBe('原始需求');
expect(result.current.notice).toBe(PROMPT_POLISH_UNCHANGED_NOTICE);
expect(result.current.notice).toBe('AI 润色结果与原文相同,未做修改');
expect(result.current.error).toBeNull();
expect(result.current.polishing).toBe(false);
// 没有可回退的变化 ⇒ 不落原文快照,宿主也就不会渲染「恢复原文」这个假入口。
expect(result.current.originalText).toBeNull();
// 不回填:宿主侧的回填还牵着「提示词变了就重铸请求身份」这类副作用,这次什么都没变。
expect(applyPrompt).not.toHaveBeenCalled();
});
test('回包被上限截回原文:算「与原文相同」,提示改说截断原因', async () => {
const applyPrompt = vi.fn();
const { result } = renderPolish({
// 原文正好顶到长度上限、模型又在原文后面追加了内容:切片后与原文逐字相同。
requestPolish: async () => '原始需求加上很长很长的尾巴',
applyPrompt,
normalizeResult: () => ({ text: '原始需求', notice: '已按长度上限截断' }),
});
await act(async () => {
await result.current.polish();
});
// 判据是「最终要写回宿主的文本」而不是回包字符串:写回去一字未变,就没有可回退的变化,
// 不该为它落快照、渲染一枚点了等于没点的「恢复原文」;截断提示优先,说清这次为什么没变。
expect(result.current.notice).toBe('已按长度上限截断');
expect(result.current.notice).not.toBe(PROMPT_POLISH_UNCHANGED_NOTICE);
expect(applyPrompt).not.toHaveBeenCalled();
expect(result.current.originalText).toBeNull();
});
test('规范化确实改动了内容:照旧回填并给规范化提示(对照)', async () => {
const applyPrompt = vi.fn();
const { result } = renderPolish({
requestPolish: async () => '润色后的需求加上要被截掉的尾巴',
applyPrompt,
normalizeResult: () => ({
text: '润色后的需求',
notice: '已按长度上限截断',
}),
});
await act(async () => {
await result.current.polish();
});
expect(result.current.notice).toBe('已按长度上限截断');
expect(applyPrompt).toHaveBeenCalledWith('润色后的需求');
expect(result.current.originalText).toBe('原始需求');
});
test('clearNotice 收掉上一轮提示,快照与错误不受影响', async () => {
const { result } = renderPolish({
requestPolish: async () => '润色后的需求',
});
await act(async () => {
await result.current.polish();
});
expect(result.current.originalText).toBe('原始需求');
await act(async () => {
result.current.clearNotice();
});
// 用户手改提示词只作废「上一轮的结论」,不该顺手把「恢复原文」也取消掉。
expect(result.current.notice).toBeNull();
expect(result.current.originalText).toBe('原始需求');
});
test('先真润色再遇到「回包等于原文」:提示互不覆盖,快照与恢复入口都留着', async () => {
let current = '原始需求';
const applied: string[] = [];
// 第二次回包等于第一次回填后的当前文本。
const results = ['润色后的需求', '润色后的需求'];
const { result } = renderHook(() =>
usePromptPolish({
readPrompt: () => current,
applyPrompt: (text) => {
current = text;
applied.push(text);
},
requestPolish: async () => results.shift() ?? null,
}),
);
await act(async () => {
await result.current.polish();
});
expect(applied).toEqual(['润色后的需求']);
expect(result.current.originalText).toBe('原始需求');
// 第二次回包就是当前文本:只换提示,宿主状态与原文快照都不动。
await act(async () => {
await result.current.polish();
});
expect(result.current.notice).toBe('AI 润色结果与原文相同,未做修改');
expect(result.current.originalText).toBe('原始需求');
expect(applied).toEqual(['润色后的需求']);
});
});
@@ -3,6 +3,24 @@
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
## 2026-09-18 AGC 未提交快速编辑草稿按资源路径归属,正式恢复账本不动
- 背景:画布验收项 AGC-006/023 要求「点外部 / Esc 收起面板、或换素材卡」不再无条件丢弃用户刚写的提示词与 `@` 引用,于是宿主内存里多了一份未提交草稿表(`resourceCanvasQuickEditModel.ts``ResourceQuickEditDraftStore`),与原生 `list_pending_local_project_resource_edits` / `resume_local_project_resource_edit` 那条正式可恢复账本**并存**。
- 决策:草稿键是投影的稳定身份 `ProjectResource.path`,不是投影 id。资源投影本来就按 path 去重(`resourceProjectionModel``uniqueByPath`),而 id 会变——任务产物经 `normalize_local_project_raster_resource` 登记成正式素材后,同一张卡从 `task:<任务>:<路径>` 变成 `asset:<id>`。用 id 当键时,任何**不是打开中这一笔快速编辑自身触发**的重投影(换素材卡收起面板、Agent 或外部编辑器把同一路径登记成资产)都会让草稿落到再也点不到的键上:恢复入口按 id 过滤后静默丢弃、重开面板按新 id 查不到。按路径归属后不需要任何「跟着投影搬家」的换键逻辑,那条逻辑本身就是搬丢的来源。
- 决策:本地草稿是**会话内存态**——不落盘、不进账本、不参与对账;共享入口「管理未完成编辑」同时列出来源不同的两种条目,草稿条目显式标注「本会话未提交,关闭客户端不保留」,账本读取失败时仍报出本会话草稿条数,避免用户把两者当成同一种持久事实。
- 决策:`ResourcePromptPolishSlot` 与共享聊天输入区(`ResourceReferenceInput`)共用 `usePromptPolish`;「与原文相同」以**规范化之后要写回宿主的文本**为准(回包被长度上限截回原文同样算没变化),此时不落原文快照、不回填宿主,只给提示,避免出现点了等于没点的「恢复原文」假入口。
- 边界:发送前提醒面板里「AI 润色并发送」遇到原样回包仍按用户意图直接提交,本轮按产品取舍保留(记录在 `docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md`)。
- 落地:`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts`(工厂空表 / 按路径写入读取丢弃 / `listResourceQuickEditDraftEntries`)、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(草稿接线与恢复入口)、`apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx``apps/ai-game-creator-shell/src/features/project-workspace/{usePromptPolish.ts,ResourceReferenceInput.tsx}`;回归见 `tests/resourceCanvasQuickEditModel.test.ts``tests/resourceCanvasQuickEditDraft.test.tsx`(含「面板收起后旁路重投影」用例,改回 id 作键即红灯)、`tests/usePromptPolish.test.tsx``tests/resourcePromptPolishSlot.test.tsx``tests/chatPromptPolish.test.tsx`
## 2026-09-18 派生资源名称在前端镜像 Rust 门禁,宁可按原话拦下也不截断
- 背景:快速编辑(`-编辑版`)与角色动画(`-角色动画`)的派生资源名由前端按源资源 label 拼出,直接进 `derive_local_project_resource``input.assetName`Rust `normalize_resource_edit_name``resource_editor.rs:802-808`)要求 `trim` 后非空、码点数 1..=120 且不含控制字符,任一不满足即整条请求失败。源 label 取任务标题(Agent 回执)或长生成文件名时,用户必须提交一次才看到「派生资源名称必须在 1..=120 字符内且不能包含控制字符」。这是画布验收遗留项 AGC-025 的成因之一。
- 决策:前端只做镜像,不另造中文语义——上限 120、`Cc` 控制字符判定(C0 / DEL / C1`Cf` 继续放行)、`trim` 后判空、按 Unicode 码点计长(Rust `chars().count()`,不是 JS UTF-16 `.length`)四条与 Rust 逐条对齐,提示文案与 Rust 返回串逐字相同;两份判定由跨语言测试直接读 Rust 源码对表。
- 决策:名称不合法时**阻止并提示**,不截断。截到 120 会让同前缀的两个长源名塌成同一个名字,把一次可见失败换成一次静默重名;默认名函数因此保持返回不合法原名,由检查结果里的 `error` 让调用方先停下。
- 落地:`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts``RESOURCE_EDIT_NAME_MAX_LENGTH` / `RESOURCE_EDIT_NAME_INVALID_NOTICE` / `resolveResourceEditNameCheck` / `resolveDerivedResourceNameCheck(resource, 'edit' | 'character-animation')`,纯函数与跨语言对表见 `tests/resourceEditModel.test.ts`;两种后缀共用同一张后缀表,避免「-编辑版」与「-角色动画」再次分叉。
- 接线:`index.tsx``submitResourceQuickEdit``submitResourceCharacterAnimation` 都在**调 `resolveResourceDeriveSource` 之前**做检查(正规化写盘发生在那一步之后),不合法时只把 `status: 'failed'` 与 Rust 原话写进各自的浮层面板并 return,不发派生请求;`assetName` 用检查结果里的 `name`。回归见 `tests/projectResourceLiveIntegration.test.tsx` 的两条「源名超 120 码点」用例与既有的合法名校验 `source-art-编辑版` / `hero-角色动画`
- 边界:资源画布的图片类生成走另一条门禁(`commands.rs``LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS`,文案「素材名称超出安全边界」),不在本条口径内;提示词上限仍是 `resourceEditPromptMaxLength` 那一份,本决策不动它。
## 2026-09-18 Provider 瞬态重试次数严格按设置执行(游戏开发 Agent 与策划 Agent 不再被档位收进区间)
- 背景:AGC 客户端此前把 `agentLlm.<agent>.maxRetries` 按运行档位重新收进固定区间——`autonomous-game-build` 档位(自主构建的游戏开发 Agent 及其继承档位的专业子 Agent)被抬到 12~16`standard` 档位(含立项策划入口的策划 Agent 与普通 Agent 对话)被压到最多 3;瞬态分类里的上游 400 还在同一预算上再收窄到 2 次。现场把 `maxRetries` 设成 5 时,游戏开发与策划两条链路都不按设置执行。
@@ -10,6 +10,17 @@
- 标签面板无法查看更多标签;资源面板多选后卡死。
- 未完成编辑缺少继续入口;快速编辑的素材名称校验报错。
## 进展
### 2026-09-20 第一批(已提交到本分支,待客户端验收)
已完成并在本机跑通:AI 润色原样回包提示与「恢复原文」不再丢稿(AGC-004/005)、快速编辑未提交草稿的保留与继续入口(AGC-006/023)、派生资源名称的前端镜像校验与提交前拦截(AGC-025)。
- 本机执行过:`npx vitest run` 定向 9 个文件(138 用例全绿)、`npm --prefix apps/ai-game-creator-shell run typecheck``npm run check:encoding``git diff --check`、改动文件的 prettier / eslint,以及 `appSurface` 等 20 个渲染工作台的用例文件。仅本地;远程 CI 与真实客户端验收都还没跑。
- 本轮独立 review 的 4 条 P2 已处理:草稿身份改为资源路径(原先按投影 id,旁路重投影后草稿会静默消失)、润色判据改为「规范化后要写回的文本」、共享聊天输入区显示润色提示、本会话草稿与账本条目在入口上区分来源;P3 处理了名称预检的抛错兜底、恢复入口按钮对齐、提示随编辑失效、空表改工厂、跨语言用例按 `import.meta.url` 解析路径。
- 明确保留未改:发送前提醒面板里「AI 润色并发送」遇到原样回包仍直接提交(按用户意图发出去,不做二次拦截)。
- 仍未做:动画生成失败的真实复现与定位、文档全文入口、标签面板查看更多标签、多选卡死,以及卡片全类型重设计、画布内任务浮层、替换交互与聊天引用这批后续需求。
## 后续需求
- 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
type FloatingOptionBoundaryRef = {
readonly current: HTMLElement | null;
@@ -107,19 +107,48 @@ export function useImageCanvasFloatingOptionDismiss({
restoreFocusRef,
isInsideExtraOverlay,
}: UseImageCanvasFloatingOptionDismissOptions) {
/**
* 本次点击「算不算点在浮层里」的冻结判定。
*
* 判定必须在事件派发的最早期做,不能在冒泡到 `document` 时才拿 `event.target` 现算:
* 浮层里的按钮可能在**这一次点击**里把自己卸载掉(AGC 画布验收 AGC-005:快速编辑的
* 「恢复原文」被点后原文快照清空、按钮不再渲染,验收视频里表现为整个编辑面板连同选中
* 一起被收掉)。目标节点一旦脱离 DOM`element.contains(target)` 一律为 false
* 「点浮层内部」就被误判成「点外部」,面板被自己的按钮关掉。
*
* 捕获阶段挂在 `document` 上是最早的一站(React 的委托监听在 root 容器上,位置更靠内),
* 此刻 DOM 还是用户看到的那一份;判定口径本身不变,仍是
* {@link isEventInsideFloatingOverlay}(边界 DOM + portal 到 body 的浮层登记)。
* 真正触发关闭仍然留在冒泡阶段,宿主其它处理器看到的事件顺序与既有行为逐字一致。
*/
const clickVerdictRef = useRef<{
event: MouseEvent;
inside: boolean;
} | null>(null);
useEffect(() => {
if (!isOpen || typeof document === 'undefined') {
return undefined;
}
const resolveInside = (event: MouseEvent) =>
isEventInsideFloatingOverlay(event.target, {
boundaryRefs,
isInsideExtraOverlay,
});
const handleClickCapture = (event: MouseEvent) => {
clickVerdictRef.current = { event, inside: resolveInside(event) };
};
const handleClick = (event: MouseEvent) => {
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
if (
isEventInsideFloatingOverlay(event.target, {
boundaryRefs,
isInsideExtraOverlay,
})
) {
const frozen = clickVerdictRef.current;
clickVerdictRef.current = null;
// 捕获阶段没跑到(例如事件不是这条派发路径派出来的)时退回现算,行为与改动前一致。
const isInside =
frozen && frozen.event === event ? frozen.inside : resolveInside(event);
if (isInside) {
return;
}
onDismiss();
@@ -144,9 +173,11 @@ export function useImageCanvasFloatingOptionDismiss({
}
};
document.addEventListener('click', handleClickCapture, true);
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClickCapture, true);
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};