修复改完素材标签后聊天侧标签统计与候选不刷新
- App.tsx:新增跟随工作台壳清单快照的 effect(`manifestRef` + 同一 `projectId` 且内容确实变了才 `setManifest`), 让聊天侧 `@` 选择器的标签计数与候选在资源命令写入后跟上画布那份清单 - tests/resourceTagStatsRefresh.test.tsx(新增):真宿主链路用例 —— 资源画布「编辑标签」保存后, 聊天 `@` 选择器出现该标签 chip 与计数、并按它收窄候选;反向用例钉住未标注标签时不渲染标签 chip - docs/project-memory/shared-memory/decision-log.md:记录标签派生数据的三处来源与各自数据源、 根因(`initialProjectManifest` 只是 `useState` 初值,壳里换清单不会再进来)、修复口径与已知未覆盖项
This commit is contained in:
@@ -628,6 +628,30 @@ export function App({
|
||||
const [manifest, setManifest] = useState<GameCreationAppManifest>(
|
||||
initialProjectManifest ?? seedManifest,
|
||||
);
|
||||
const manifestRef = useRef(manifest);
|
||||
manifestRef.current = manifest;
|
||||
/**
|
||||
* 跟随工作台壳的清单快照。
|
||||
*
|
||||
* 资源命令(改标签 / 改类型 / 重命名 / 删素材)由资源画布写入,写入后壳重读一次
|
||||
* manifest、按 CAS 归并进 `currentProjectContext`,再把归并结果继续以这个 prop 传下来。
|
||||
* 但它是 `useState` 的**初值**:壳里换了新清单不会再进来,于是聊天侧(`@` 选择器的
|
||||
* 标签统计与候选、`@` 候选菜单)一直用挂载时那份旧清单 —— 用户改完标签,画布已经
|
||||
* 更新,聊天侧却还是旧标签。
|
||||
*
|
||||
* 这里把壳那份快照补进聊天侧状态,判据只有两条:同一项目、内容确实变了
|
||||
* (按内容的短路是必要的:聊天侧自己的写入会被壳原样回传,只比身份会让两边
|
||||
* 无意义地互相推一轮)。壳那份快照始终由磁盘重读 + CAS 归并得到,因此不会把
|
||||
* 聊天侧带到更旧的版本上。
|
||||
*/
|
||||
useEffect(() => {
|
||||
const snapshot = initialProjectManifest;
|
||||
const current = manifestRef.current;
|
||||
if (!snapshot || snapshot === current) return;
|
||||
if (snapshot.projectId !== current.projectId) return;
|
||||
if (JSON.stringify(snapshot) === JSON.stringify(current)) return;
|
||||
setManifest(snapshot);
|
||||
}, [initialProjectManifest]);
|
||||
const [projectStatus, setProjectStatus] = useState(
|
||||
eagerSupervisorProject ? '已初始化' : '未初始化',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { useState } from 'react';
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
App,
|
||||
createGameCreationAppManifest,
|
||||
createProjectSupervisorRuntimeHarness,
|
||||
expect,
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
ProjectDevelopmentView,
|
||||
render,
|
||||
screen,
|
||||
vi,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const PROJECT_PATH = '/tmp/tag-stats-refresh-project';
|
||||
const PROJECT_ID = 'tag-stats-refresh-project';
|
||||
|
||||
/**
|
||||
* 磁盘清单夹具:资源画布与聊天侧都从 `get_local_game_manifest` 读它,
|
||||
* 素材标签写入命令按 Rust `update_manifest_asset_classification_at` 的语义
|
||||
* 只改目标条目的 `category` / `tags` 并推进一次 revision。
|
||||
*/
|
||||
const disk = {
|
||||
manifest: null as GameCreationAppManifest | null,
|
||||
revision: 3,
|
||||
};
|
||||
|
||||
function createFixtureManifest(): GameCreationAppManifest {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
PROJECT_ID,
|
||||
'标签统计刷新项目',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'asset-hero',
|
||||
kind: 'character',
|
||||
category: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
source: { kind: 'generated', resourceId: 'hero-resource' },
|
||||
},
|
||||
{
|
||||
id: 'asset-bg',
|
||||
kind: 'scene',
|
||||
category: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/bg.png',
|
||||
source: { kind: 'generated', resourceId: 'bg-resource' },
|
||||
},
|
||||
];
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function graphFor(manifest: GameCreationAppManifest) {
|
||||
const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`);
|
||||
return {
|
||||
resourceIds,
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
connectionIndex: resourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
dependencyDepths: resourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
dependencyDepth: 0,
|
||||
})),
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 真宿主接线:与 `WorkspaceLauncherShell` 完全同形 —— 壳持有清单状态,
|
||||
* 画布与聊天(`ProjectSupervisor`)都是它的消费者,写入后用
|
||||
* `onManifestChange(projectPath, next)` 回写这一份状态。
|
||||
*
|
||||
* 这里只把壳换成用例自己的 `useState`(壳那份 CAS 归并由
|
||||
* `workspaceLauncherManifestMerge.test.tsx` 单独钉住),画布、聊天输入区、
|
||||
* 标签统计与候选全部是被测的真实实现。
|
||||
*/
|
||||
function TagStatsHost({ planningStartMode }: { planningStartMode: boolean }) {
|
||||
const [manifest, setManifest] = useState(() => {
|
||||
const initial = createFixtureManifest();
|
||||
disk.manifest = initial;
|
||||
return initial;
|
||||
});
|
||||
disk.manifest = manifest;
|
||||
const onManifestChange = (_path: string, next: GameCreationAppManifest) =>
|
||||
setManifest(next);
|
||||
|
||||
return (
|
||||
<ProjectDevelopmentView
|
||||
projectName={manifest.name}
|
||||
projectPath={PROJECT_PATH}
|
||||
manifest={manifest}
|
||||
attachments={[]}
|
||||
recentRunStatus={null}
|
||||
recentRunStopReason={null}
|
||||
onHomeOpen={() => undefined}
|
||||
onProjectsOpen={() => undefined}
|
||||
onManifestChange={onManifestChange}
|
||||
supervisor={
|
||||
<App
|
||||
initialProjectPath={PROJECT_PATH}
|
||||
initialProjectManifest={manifest}
|
||||
projectSupervisorOnly
|
||||
planningStartMode={planningStartMode}
|
||||
onManifestChange={onManifestChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function installHostTauri() {
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
const classificationWrites: Array<Record<string, unknown>> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: disk.revision };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
if (!disk.manifest) throw new Error('missing manifest fixture');
|
||||
return disk.manifest;
|
||||
}
|
||||
if (command === 'update_local_project_resource_classification') {
|
||||
const input = (args?.input ?? {}) as {
|
||||
assetId?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
classificationWrites.push(structuredClone(input));
|
||||
const base = disk.manifest;
|
||||
if (!base) throw new Error('missing manifest fixture');
|
||||
disk.revision += 1;
|
||||
const nextManifest: GameCreationAppManifest = {
|
||||
...base,
|
||||
assets: base.assets.map((asset) =>
|
||||
asset.id === input.assetId
|
||||
? ({
|
||||
...asset,
|
||||
category: input.category,
|
||||
tags: input.tags ?? [],
|
||||
} as (typeof base.assets)[number])
|
||||
: asset,
|
||||
),
|
||||
};
|
||||
disk.manifest = nextManifest;
|
||||
const asset = nextManifest.assets.find(
|
||||
(entry) => entry.id === input.assetId,
|
||||
);
|
||||
if (!asset) throw new Error(`missing asset ${String(input.assetId)}`);
|
||||
return { asset, committedProjectRevision: disk.revision };
|
||||
}
|
||||
if (
|
||||
command === 'read_local_project_resource_graph' ||
|
||||
command === 'read_local_project_resource_canvas_layout' ||
|
||||
command === 'read_local_project_resource_document' ||
|
||||
command === 'read_local_project_image_preview' ||
|
||||
command === 'read_local_project_text_preview' ||
|
||||
command === 'list_pending_local_project_resource_edits' ||
|
||||
command === 'update_local_project_resource_canvas_layout'
|
||||
) {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return graphFor(disk.manifest!);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: PROJECT_ID,
|
||||
mode: args?.mode,
|
||||
revision: 0,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'list_pending_local_project_resource_edits') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_project_resource_document') {
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
mediaType: 'text/markdown',
|
||||
byteLen: 1,
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
mediaType: 'image/png',
|
||||
byteLen: 1,
|
||||
dataUrl: 'data:image/png;base64,AA==',
|
||||
};
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: invoke as never },
|
||||
event: { listen: supervisorHarness.listen as never },
|
||||
};
|
||||
return { invoke, classificationWrites };
|
||||
}
|
||||
|
||||
/** 读出 `@` 选择器里的标签 chip:`标签名 + 计数` 两个文本节点拼在一起。 */
|
||||
function pickerTagChips() {
|
||||
const group = document.querySelector('[aria-label="素材筛选标签"]');
|
||||
if (!group) return [];
|
||||
return Array.from(group.querySelectorAll('button')).map(
|
||||
(chip) => chip.textContent ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
function pickerCandidateNames() {
|
||||
return Array.from(
|
||||
document.querySelectorAll(
|
||||
'.resource-reference-picker-list [role="option"]',
|
||||
),
|
||||
).map(
|
||||
(option) =>
|
||||
option.querySelector('strong')?.textContent ?? option.textContent,
|
||||
);
|
||||
}
|
||||
|
||||
async function openChatPicker() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '插入素材引用' }));
|
||||
return screen.findByRole('dialog', { name: '选择素材' });
|
||||
}
|
||||
|
||||
describe('改完素材标签后聊天 @ 选择器的标签统计与候选跟着刷新', () => {
|
||||
beforeEach(() => {
|
||||
disk.manifest = null;
|
||||
disk.revision = 3;
|
||||
});
|
||||
|
||||
it('在资源画布改完标签保存后,聊天 @ 选择器出现该标签及其计数,并按它收窄候选', async () => {
|
||||
const { classificationWrites } = installHostTauri();
|
||||
render(<TagStatsHost planningStartMode />);
|
||||
|
||||
// 先在画布上打开「编辑素材标签」面板改标签:与用户的操作路径一致。
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '打开角色与对象' }),
|
||||
);
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '编辑素材标签',
|
||||
});
|
||||
fireEvent.change(
|
||||
within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||||
{ target: { value: '主角' } },
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
|
||||
|
||||
await waitFor(() => expect(classificationWrites).toHaveLength(1));
|
||||
expect(classificationWrites[0]).toMatchObject({
|
||||
assetId: 'asset-hero',
|
||||
tags: ['主角'],
|
||||
});
|
||||
// 保存不关窗是既有约定(「添加」可连续执行),关窗走头部 ×。
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '关闭编辑素材标签' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
|
||||
);
|
||||
|
||||
// 保存后打开聊天输入区的 `@` 选择器:标签行就是依赖 manifest `tags` 的统计。
|
||||
const picker = await openChatPicker();
|
||||
await waitFor(() => expect(pickerTagChips()).toEqual(['主角1']));
|
||||
expect(pickerCandidateNames()).toEqual(['hero', 'bg']);
|
||||
|
||||
// 点选这个标签:候选要按新标签收窄(统计值 1 与候选集合同源)。
|
||||
fireEvent.click(within(picker).getByRole('button', { name: '主角' }));
|
||||
await waitFor(() => expect(pickerCandidateNames()).toEqual(['hero']));
|
||||
});
|
||||
|
||||
it('未标注标签时聊天 @ 选择器不渲染任何标签 chip', async () => {
|
||||
installHostTauri();
|
||||
render(<TagStatsHost planningStartMode />);
|
||||
|
||||
await openChatPicker();
|
||||
expect(pickerTagChips()).toEqual([]);
|
||||
expect(pickerCandidateNames()).toEqual(['hero', 'bg']);
|
||||
});
|
||||
});
|
||||
@@ -8537,3 +8537,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 影响范围:`src-tauri/src/project/manifest.rs`、`src-tauri/src/project/manifest/version_binding_rewrite_tests.rs`(新增)、`src-tauri/src/project/version_resource_replacement.rs`、`src-tauri/src/tests/version_resource_replacement.rs`、`src/features/resource-canvas/resourceVersionReplacement{Model,Transport}.ts`、`view/project-development/index.tsx`、`src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx`、`apps/ai-game-creator-shell/src/styles.css`。
|
||||
- 验证方式:放行通道定向 7 条 + 替换定向 8 条 + `shared-contracts` 20 条;**变异验证四条**(去掉"未放行版本整条相等"→两周转红;放行集合改成整个版本数组→"未放行版本"转红;去掉长度检查→"不增不删"转红;准入删掉 category→硬门禁与候选两条转红,均已实测并还原)。前端 13 条(模型 9 + 真链路 4)。门禁:AGC 全量 1231 passed / 4 skipped / 0 failed、共享美术画布组件 1385 passed、`ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets`、`check:encoding`、`git diff --check` 全绿。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.2 / §5.3 / §5.4 存储边界 / §7.8 验收)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(同章节)、`docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`、Issue #309。
|
||||
|
||||
## 2026-09-12 改完素材标签聊天侧不刷新:聊天宿主跟随工作台壳的清单快照
|
||||
|
||||
- 背景:用户报「对素材修改标签之后统计信息不会更新(例如聊天框里的点选)」。核实后的现状:依赖 manifest `assets[].tags` 的派生数据只有三处 —— `@` 选择器的标签 chip 计数与候选(`ResourceReferenceInput` 的 `scopeTagItems` / `pickerReferences` ← `resourceReferences.ts::resourceReferenceTagLibrary` ← 共享 `buildGameCreationAppAssetTagLibrary`)、资源画布筛选浮层的标签库(`resourceCanvasFilterModel.ts::buildResourceFilterTagOptions`)、资源卡与信息浮层的小字(`resourceCanvasInfoModel.ts`)。后两者都由 `ProjectDevelopmentView` 的 `manifest` prop 派生,写入后重读即生效;**只有聊天侧停在旧清单上**。
|
||||
- 根因(机制 + 证据):`App`(聊天宿主,由壳以 `ProjectSupervisor` 传入)持有自己的一份 `manifest` state,初值取自 `initialProjectManifest`。壳(`WorkspaceLauncherShell`)在资源命令(改标签 / 改类型 / 重命名 / 删素材)后确实重读一次 manifest 并按 CAS 归并进 `currentProjectContext`,也把归并结果继续以 `initialProjectManifest` 传下来 —— 但那是 `useState` 的初值,**壳里换了新清单不会再进来**:`App` 里该 prop 只出现在两处 `useState(... ?? seedManifest)` 初始化上,没有任何跟随它的同步,`manifest` 只由聊天侧自己的动作(`refreshManifest` / 自身命令回执)改写;而资源画布那条写入既不经过聊天侧,`update_local_project_resource_classification` 也不发 `game-creator-manifest-invalidated`(该事件只由 `agent/runtime_driver/entrypoints.rs` 的 Runtime emitter 发送)。于是 `chatProjectAssets` 每帧都是同一份旧 `manifest.assets` ⇒ `assetReferences` / `scopeReferences` / `scopeTagItems` 逐层命中旧值,标签 chip 与候选都不动。证据:新增用例在资源画布改完标签保存后,聊天 `@` 选择器的标签行为空(`expected [] to deeply equal ['主角1']`),而画布侧同一份清单已是 `tags: ['主角']`。
|
||||
- 决策:在 `App` 里给这份壳快照补一条同步(`useEffect` + `manifestRef`):同一 `projectId`、内容确实变了才 `setManifest`;聊天侧自己的写入会被壳原样回传,因此必须按内容短路(只比身份会让两边无意义地互相推一轮)。不新增第二份标签统计口径、不在业务页再造一份清单状态、不靠整页刷新或强制重挂载;壳那份快照始终来自磁盘重读 + CAS 归并,所以不会把聊天侧带到更旧的版本上。
|
||||
- 已知未覆盖(**本轮有意不做**):`?supervisor-chat` 独立聊天窗口(`main.tsx` 仅在 `import.meta.env.DEV` 下开)没有壳、拿不到这份 prop,仍只靠 Tauri 失效事件刷新。要覆盖它得在 Rust 写入命令后补发 `game-creator-manifest-invalidated`(另一条因果点,且无法在 vitest 里先红后绿)。
|
||||
- 影响范围:`apps/ai-game-creator-shell/src/App.tsx`(新增跟随 effect + `manifestRef`)、`apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx`(新增真宿主链路用例)。manifest 契约、Rust、SpacetimeDB 均不动。
|
||||
- 验证方式:`npm run test -- apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx`(先红:`expected [] to deeply equal ['主角1']`;修复后 2 条绿)+ 11 个资源相关套件 124 条 + `appSurface.test.ts` 415 条 + `npm run typecheck` + `npm --prefix apps/ai-game-creator-shell run typecheck` + `npm run check:encoding` + `git diff --check`。变异验证(已实测并还原):把跟随 effect 改回「不跟随壳快照」→ 新增用例第一条变红(`expected [] to deeply equal ['主角1']`)。
|
||||
- 关联文档:无(本轮不涉及跨端契约、PRD 或后端口径变化)。
|
||||
|
||||
Reference in New Issue
Block a user