修复 AGC 画布素材卡「引用」无消费者:新增活跃聊天输入区注册表(Issue #602) #605

Open
suzmii wants to merge 11 commits from fix/agc-canvas-reference-insert into master
30 changed files with 1185 additions and 118 deletions
@@ -28,7 +28,22 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str =
"game-creator-direct-active-turns-changed";
static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0);
thread_local! {
/// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主
/// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随
/// `TurnReservation::drop` 起整轮),并会在任意时刻广播「运行中的项目」变了。断言要观测的
/// 是本测试自己触发的通知,不该被别的后台广播串台。
///
/// 这里必须留在测试线程作用域内:退役 `runtime_driver` 时这条口径曾被降级回进程级
/// `AtomicU64`,于是 `agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change`
/// 又回到偶发(同一片内前一个用例留下的后台回合广播进采样窗口)。
///
/// 代价:计数改成线程作用域后,**别的线程**上的重复 / 丢失通知不再被这条用例覆盖(那是
/// 宿主后台回合的行为,本身就不该由单线程断言口径表达);要覆盖跨线程序,应另加用例
/// 观察回合身份/序列号,而不是把计数器退回进程级。
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell<u64> =
const { std::cell::Cell::new(0) };
}
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
@@ -54,7 +69,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
pub(crate) fn emit_direct_active_turns_changed() {
let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1;
#[cfg(test)]
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel);
let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1));
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
@@ -66,7 +81,7 @@ pub(crate) fn emit_direct_active_turns_changed() {
#[cfg(test)]
pub(crate) fn direct_active_turns_event_test_count() -> u64 {
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire)
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get)
}
pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
@@ -630,12 +630,20 @@ fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() {
let root = directory.path();
init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project")
.expect("initialize project");
// leader 输出 READY 后 **等** 同组后台子进程,不自己先退出。terminate 必须先送到 trampoline
// 的 target group、再让组内后代把延迟清理跑完,这条断言才有确定性:leader 一旦先自然退出,
// trampoline 的 800ms 宽限就开始计时,客户端只要在那之后才发出 terminate(CI 高负载下调度
// 完全可能 >800ms),会话就会先以 `exited` 收口,客户端再 terminate 只能读到既成事实。
// 后台子进程仍留在同一进程组里、仍靠 TERM 触发延迟 400ms 的 marker 清理,语义不变。
// 注意:这条用例的确定性靠的是「trap 里 400ms 清理 < 800ms 宽限」的时间余量(实测 0.41s),
// 不是真正的同步原语;若以后清理耗时逼近或超过宽限,应把 marker 拆成「收到 TERM 即时落盘 +
// 延迟内容」两步,或让宽限可注入,而不是放宽断言。
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
"(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(),
"(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; wait".to_string(),
],
".",
30,
+39 -3
View File
@@ -71,6 +71,7 @@ import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from './features/project-summary/projectSummary';
import { insertChatReferences } from './features/project-workspace/activeChatComposer';
import { importDesignFiles } from './features/project-workspace/importDesignFiles';
import {
needsInitializedChatProject,
@@ -78,10 +79,13 @@ import {
} from './features/project-workspace/projectCommandPolicy';
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
import {
type ChatReference,
directCodexContentToLegacyContentDto,
hasMeaningfulDirectCodexContent,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
type ResourceReferenceInsertEventDetail,
type ResourceReferenceInsertManyEventDetail,
} from './features/project-workspace/resourceReferences';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { readGamePublishAvailability } from './services/gameDistributionPublish';
@@ -1273,22 +1277,54 @@ export function App({
}
useEffect(() => {
/*
画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。
落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、
策划链路挂策划面,事件本身不携带这个判断。
插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索,
否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西,
也就不能把原因指到输入区上。
*/
const insertReferences = (references: readonly ChatReference[]) => {
if (references.length === 0) return;
if (insertChatReferences(references)) return;
if (import.meta.env.DEV) {
console.warn(
'[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)',
);
}
};
const handleResourceReferenceInsert = (event: Event) => {
const detail = (event as CustomEvent<ResourceReferenceInsertEventDetail>)
.detail;
if (!detail?.reference) return;
chatComposerRef.current?.insertReferences([detail.reference]);
chatComposerRef.current?.focus();
insertReferences([detail.reference]);
};
const handleResourceReferenceInsertMany = (event: Event) => {
const detail = (
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
).detail;
insertReferences(detail?.references ?? []);
};
window.addEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
return () =>
window.addEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
handleResourceReferenceInsertMany,
);
return () => {
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
handleResourceReferenceInsertMany,
);
};
}, []);
/**
@@ -0,0 +1,66 @@
import type { ChatReference } from './resourceReferences';
/**
* 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。
*
* `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、
* 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见
* 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板
* 内部用,不进这里——注册表只负责跨面板的引用插入。
*/
export type ActiveChatComposerHandle = {
insertReferences: (references: ChatReference[]) => boolean;
focus: () => void;
};
/**
* 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面,
* 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。
*/
let activeChatComposer: ActiveChatComposerHandle | null = null;
/**
* 注册当前挂载的聊天输入区,返回注销函数。
*
* 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换),
* 旧注销不能把新句柄一起清掉。
*
* 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`):
* 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义
* ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。
*/
export function registerActiveChatComposer(
handle: ActiveChatComposerHandle,
): () => void {
if (activeChatComposer && activeChatComposer !== handle) {
if (import.meta.env.DEV) {
console.warn(
'[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个',
);
}
}
activeChatComposer = handle;
return () => {
if (activeChatComposer === handle) {
activeChatComposer = null;
}
};
}
/**
* 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。
*
* 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去
* (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听)
* 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。
*/
export function insertChatReferences(
references: readonly ChatReference[],
): boolean {
if (references.length === 0) return false;
const handle = activeChatComposer;
if (!handle) return false;
if (!handle.insertReferences([...references])) return false;
handle.focus();
return true;
}
@@ -4,17 +4,22 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
import type { PendingUiConfirmation } from '../../../app/types';
import { projectNameFromPath } from '../../../features/agent-runtime';
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
import { userItemFromContent } from '../../../features/project-workspace/resourceReferences';
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
import { ApprovalModeDialog } from '../ApprovalModeDialog';
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer';
import {
DirectProjectComposer,
type DirectProjectComposerHandle,
} from './components/DirectProjectComposer/DirectProjectComposer';
import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation';
import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog';
import {
@@ -103,6 +108,7 @@ export function DirectProjectChatView({
}: DirectProjectChatViewProps) {
const { assets, projectId, refresh, versions } =
useDirectProjectManifest(projectPath);
const composerHandleRef = useRef<DirectProjectComposerHandle | null>(null);
const [runtimeNotice, setRuntimeNotice] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const [approvalOpen, setApprovalOpen] = useState(false);
@@ -212,6 +218,28 @@ export function DirectProjectChatView({
},
}));
/*
画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里
**当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路
互斥,所以同一时刻注册表里只有一个句柄。
注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面
在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences`
返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。
*/
useEffect(
() =>
registerActiveChatComposer({
insertReferences: (references) => {
const handle = composerHandleRef.current;
if (!handle) return false;
return handle.insertReferences(references);
},
focus: () => composerHandleRef.current?.focus(),
}),
[],
);
return (
<section
className="project-chat-surface is-direct-codex"
@@ -252,6 +280,7 @@ export function DirectProjectChatView({
</div>
) : null}
<DirectProjectComposer
ref={composerHandleRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
@@ -1,10 +1,18 @@
import { ArrowUp, AtSign } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
type Ref,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ActiveChatComposerHandle } from '../../../../../features/project-workspace/activeChatComposer';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
@@ -21,7 +29,10 @@ import {
ResourceReferencePicker,
type ResourceReferencePickerHandle,
} from '../../../../../features/project-workspace/ResourceReferencePicker';
import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences';
import {
assetsSignature,
type ChatReference,
} from '../../../../../features/project-workspace/resourceReferences';
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
import type { UserContentPart } from '../../generated/UserContentPart';
@@ -40,6 +51,12 @@ function draftAttachmentCount(draft: { content: UserContentPart[] }) {
).length;
}
/**
* 输入区对外的命令式出口:就是「活跃聊天输入区」注册表要的那两件事
* (见 `features/project-workspace/activeChatComposer.ts`)。
*/
export type DirectProjectComposerHandle = ActiveChatComposerHandle;
/**
* DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。
*
@@ -62,6 +79,7 @@ export function DirectProjectComposer({
onReferencePickerOpen,
onSubmit,
onUploadFiles,
ref,
}: {
assets: GameCreationAppAssetManifestEntry[];
versions?: GameIterationVersion[];
@@ -107,6 +125,11 @@ export function DirectProjectComposer({
files: readonly File[],
draftAttachmentCount: number,
) => Promise<DirectCodexTurnAttachment[]>;
/**
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
*/
ref?: Ref<DirectProjectComposerHandle>;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const composerRootRef = useRef<HTMLDivElement | null>(null);
@@ -166,6 +189,24 @@ export function DirectProjectComposer({
},
[onUploadFiles],
);
/*
对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值,
句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相;
`insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。
*/
useImperativeHandle(
ref,
() => ({
insertReferences: (references: ChatReference[]) => {
const handle = composerRef.current;
if (!handle) return false;
handle.insertReferences([...references]);
return true;
},
focus: () => composerRef.current?.focus(),
}),
[],
);
// 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管),
// 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。
const submitButton = (
@@ -1,6 +1,6 @@
import { ArrowUp, FileUp, Loader2 } from 'lucide-react';
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
import { useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent';
import type {
@@ -15,6 +15,7 @@ import {
projectNameFromPath,
projectRuntimeVisibleError,
} from '../../../features/agent-runtime';
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect';
import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider';
import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider';
@@ -133,6 +134,23 @@ export function PlanningChatView({
onDesignRetry,
}: PlanningChatViewProps) {
const designFileInputRef = useRef<HTMLInputElement>(null);
/*
策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处,
它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份
可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。
*/
useEffect(() => {
if (!composerRef) return;
return registerActiveChatComposer({
insertReferences: (references) => {
const handle = composerRef.current;
if (!handle) return false;
handle.insertReferences([...references]);
return true;
},
focus: () => composerRef.current?.focus(),
});
}, [composerRef]);
/*
策划输入盒的引用来源:**只注入资源与两个静默 provider**。
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
type ActiveChatComposerHandle,
insertChatReferences,
registerActiveChatComposer,
} from '../src/features/project-workspace/activeChatComposer';
import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
function resourceReference(resourceId: string): ChatReference {
return {
type: 'resource',
resourceId,
kind: 'character',
mediaType: 'image/png',
label: resourceId,
category: 'character',
tags: [],
source: 'resource-card',
};
}
/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */
function composerHandle(inserted = true): {
handle: ActiveChatComposerHandle;
insertReferences: ReturnType<typeof vi.fn>;
focus: ReturnType<typeof vi.fn>;
} {
const insertReferences = vi.fn(() => inserted);
const focus = vi.fn();
return { handle: { insertReferences, focus }, insertReferences, focus };
}
/**
* 「活跃聊天输入区」注册表的合同。
*
* 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据,
* 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与
* `appSurface/*.suite.ts`。
*/
describe('活跃聊天输入区注册表', () => {
// 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。
afterEach(() => {
vi.restoreAllMocks();
});
it('没有任何输入区挂载时插入返回 false', () => {
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('空批次不算成功,也不打扰已挂载的输入区', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([])).toBe(false);
expect(insertReferences).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => {
const { handle, insertReferences, focus } = composerHandle();
const unregister = registerActiveChatComposer(handle);
expect(
insertChatReferences([
resourceReference('hero'),
resourceReference('npc'),
]),
).toBe(true);
expect(insertReferences).toHaveBeenCalledTimes(1);
expect(insertReferences).toHaveBeenCalledWith([
expect.objectContaining({ resourceId: 'hero' }),
expect.objectContaining({ resourceId: 'npc' }),
]);
expect(focus).toHaveBeenCalledTimes(1);
unregister();
});
it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => {
const { handle, focus } = composerHandle(false);
const unregister = registerActiveChatComposer(handle);
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
expect(focus).not.toHaveBeenCalled();
unregister();
});
it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => {
// 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。
vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = composerHandle();
const second = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
const unregisterSecond = registerActiveChatComposer(second.handle);
// 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。
unregisterFirst();
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
unregisterSecond();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
});
it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = composerHandle();
const second = composerHandle();
const third = composerHandle();
const unregisterFirst = registerActiveChatComposer(first.handle);
expect(warn).not.toHaveBeenCalled();
// 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。
const unregisterSecond = registerActiveChatComposer(second.handle);
expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([
expect.stringContaining('第二个聊天输入区注册'),
]);
expect(insertChatReferences([resourceReference('hero')])).toBe(true);
expect(second.insertReferences).toHaveBeenCalledTimes(1);
// 再注册第三个(模拟又一条链路接管):同样留线索。
const unregisterThird = registerActiveChatComposer(third.handle);
expect(warn).toHaveBeenCalledTimes(2);
// 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。
unregisterFirst();
unregisterSecond();
expect(insertChatReferences([resourceReference('npc')])).toBe(true);
expect(third.insertReferences).toHaveBeenCalledTimes(1);
expect(first.insertReferences).not.toHaveBeenCalled();
expect(second.insertReferences).toHaveBeenCalledTimes(1);
unregisterThird();
expect(insertChatReferences([resourceReference('hero')])).toBe(false);
warn.mockRestore();
});
});
@@ -1,5 +1,6 @@
import type { Mock } from 'vitest';
import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences';
import {
act,
App,
@@ -935,4 +936,35 @@ export function registerDesignAgentSurfaceTests() {
expect(details[1]!.open).toBe(false);
await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程'));
});
it('画布派发的「引用」落进策划输入盒草稿', async () => {
const harness = createProjectChatRuntimeHarness({
initialSessionExists: false,
});
renderDesignAgent(harness);
const editor = await screen.findByLabelText('项目需求');
// 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由
// 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是
// 它在 `PlanningChatView` 这一侧也真的进了草稿。
act(() => {
dispatchResourceReferenceInsert({
type: 'resource',
resourceId: 'planning-hero',
kind: 'character',
mediaType: 'image/png',
label: 'hero.png',
category: 'character',
tags: [],
source: 'resource-card',
});
});
await waitFor(() =>
expect(
editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'),
).toHaveLength(1),
);
expect(editor.textContent).toContain('@hero.png');
});
}
@@ -11,7 +11,6 @@ import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
@@ -31,8 +30,10 @@ import {
} from '../resourceGenerationPromptTestUtils';
import {
act,
App,
cleanup,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
expect,
findResourceSelectButton,
fireEvent,
@@ -53,6 +54,11 @@ import {
within,
} from './harness';
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
const AppComponent = App as React.ComponentType<
NonNullable<React.ComponentProps<typeof App>>
>;
/**
* 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前
* `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义,
@@ -3608,7 +3614,7 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('工具条里的「引用」用键盘也能插进聊天输入框', async () => {
it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-reference',
'引用入口测试',
@@ -3623,6 +3629,13 @@ export function registerProjectWorkbenchFoundationTests() {
source: { kind: 'generated' },
});
let layoutRevision = 0;
const projectPath = '/tmp/workbench-resource-reference';
// 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`):
// 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。
const chatHarness = createProjectChatRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
@@ -3660,20 +3673,45 @@ export function registerProjectWorkbenchFoundationTests() {
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
};
}
throw new Error(`unexpected invoke ${command}`);
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_design_agent_runtime_mode') {
return null;
}
return chatHarness.invoke(command, args);
},
);
installTauriRuntime({ core: { invoke } });
installTauriRuntime({
core: { invoke },
event: { listen: chatHarness.listen },
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '引用入口测试',
projectPath: '/tmp/workbench-resource-reference',
projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
chat: React.createElement(AppComponent, {
initialProjectPath: projectPath,
}),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
@@ -3701,49 +3739,39 @@ export function registerProjectWorkbenchFoundationTests() {
'引用资源 hero.png',
);
const inserted: unknown[] = [];
const onInsert = (event: Event) => {
inserted.push(
(event as CustomEvent<{ reference: unknown }>).detail.reference,
);
};
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
try {
// 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip
// (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。
referenceButton.focus();
expect(document.activeElement).toBe(referenceButton);
await userEvent.setup().keyboard('{Enter}');
expect(inserted).toHaveLength(1);
// 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。
// 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。
const draftChipIds = () =>
Array.from(
document.querySelectorAll<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id]',
),
).map((chip) => chip.getAttribute('data-resource-reference-id'));
const composerEditor = () =>
document.querySelector<HTMLElement>(
'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]',
)!;
// 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。
const firstChipLabel = () =>
document.querySelector<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label',
)?.textContent;
// 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。
fireEvent.click(referenceButton);
expect(inserted).toHaveLength(2);
} finally {
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
}
expect(inserted).toEqual([
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
]);
// 键盘通路:聚焦后回车确认。
referenceButton.focus();
expect(document.activeElement).toBe(referenceButton);
await userEvent.setup().keyboard('{Enter}');
await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero']));
expect(firstChipLabel()).toBe('hero');
expect(composerEditor().textContent).toContain('@hero');
// 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。
fireEvent.click(referenceButton);
await waitFor(() =>
expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']),
);
// 插入后焦点回到输入盒,用户可以接着打字。
expect(document.activeElement).toBe(composerEditor());
});
it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => {
@@ -15,8 +15,11 @@ import {
type ResourceReferenceInsertManyEventDetail,
} from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel';
import {
App,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
fireEvent,
React,
render,
@@ -24,6 +27,11 @@ import {
} from './appSurface/harness';
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。
const AppComponent = App as React.ComponentType<
NonNullable<React.ComponentProps<typeof App>>
>;
/**
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
*
@@ -89,10 +97,21 @@ type LayoutWrite = {
positions: ProjectResourceCanvasPosition[];
};
function installTauri(): { layoutWrites: LayoutWrite[] } {
/** 画布与真实 DirectProject 聊天面共用的项目身份。 */
const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop';
function installTauri(manifest: GameCreationAppManifest): {
layoutWrites: LayoutWrite[];
} {
const layoutWrites: LayoutWrite[] = [];
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
// 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答,
// 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。
const chatHarness = createProjectChatRuntimeHarness({
projectPath: CANVAS_PROJECT_PATH,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
@@ -142,10 +161,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } {
if (command === 'list_local_project_asset_generations') {
return [];
}
return undefined;
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath: CANVAS_PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_design_agent_runtime_mode') {
return null;
}
return chatHarness.invoke(command, args);
},
);
installTauriRuntime({ core: { invoke } });
installTauriRuntime({
core: { invoke },
event: { listen: chatHarness.listen },
});
return { layoutWrites };
}
@@ -187,21 +229,34 @@ function collectReferenceInserts() {
};
}
async function mountCanvas() {
const tauri = installTauri();
async function mountCanvas(
options: {
/** 项目附件(无 manifest 登记的素材来源)。 */
attachments?: ProjectAttachmentResult[];
/** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */
categoryLabel?: string;
} = {},
) {
const { attachments = [], categoryLabel = '角色与对象' } = options;
const manifest = manifestFor('chat-reference-drop', [
characterAsset('drop-a', 'a.png'),
characterAsset('drop-b', 'b.png'),
]);
const tauri = installTauri(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/chat-reference-drop',
projectPath: CANVAS_PROJECT_PATH,
manifest,
attachments: [],
attachments,
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
// 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` →
// `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」,
// 所以这里不能再拿 `<div>项目总控</div>` 顶替。
chat: React.createElement(AppComponent, {
initialProjectPath: CANVAS_PROJECT_PATH,
}),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
@@ -222,7 +277,7 @@ async function mountCanvas() {
toJSON: () => ({}),
} as DOMRect);
act(() => window.dispatchEvent(new Event('resize')));
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` }));
await settle();
const chat = document.querySelector<HTMLElement>('.game-workbench-chat')!;
vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT);
@@ -236,6 +291,20 @@ function cardIn(manager: HTMLElement, resourceId: string) {
)!;
}
/**
* 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。
*
* 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿,
* 不能再读 window 上的一次 dispatch。
*/
function draftReferenceIds() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id]',
),
).map((chip) => chip.getAttribute('data-resource-reference-id') ?? '');
}
afterEach(() => {
resetTauriRuntime();
vi.restoreAllMocks();
@@ -281,6 +350,8 @@ describe('拖动素材到对话:批量 @ 引用', () => {
kind: 'character',
source: 'resource-card',
});
// 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。
await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a']));
// 拖到对话不是排版:一条坐标都不写。
expect(tauri.layoutWrites).toHaveLength(writesBefore);
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
@@ -327,6 +398,10 @@ describe('拖动素材到对话:批量 @ 引用', () => {
.map((reference) => reference.resourceId),
),
).toEqual(new Set(['drop-a', 'drop-b']));
// 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。
await waitFor(() =>
expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']),
);
expect(tauri.layoutWrites).toHaveLength(writesBefore);
dispose();
});
@@ -404,4 +479,81 @@ describe('拖动素材到对话:批量 @ 引用', () => {
expect(inserts).toHaveLength(0);
dispose();
});
it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => {
// 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。
const { manager, inserts, dispose } = await mountCanvas({
categoryLabel: '待归类',
attachments: [
{
fileName: 'raw-shot.png',
mediaType: 'image/png',
status: 'imported',
localPath: 'assets/raw-shot.png',
},
],
});
const card = cardIn(manager, 'attachment:assets/raw-shot.png');
expect(card).not.toBeNull();
fireEvent.click(card);
// 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。
expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull();
fireEvent.pointerDown(card, {
pointerId: 74,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 74,
buttons: 1,
clientX: 1000,
clientY: 300,
});
// 落点提示也说清原因,不承诺一次插不进去的引用。
expect(
screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'),
).not.toBeNull();
fireEvent.pointerUp(card, {
pointerId: 74,
button: 0,
clientX: 1000,
clientY: 300,
});
await settle();
expect(
screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'),
).not.toBeNull();
expect(inserts).toHaveLength(0);
expect(draftReferenceIds()).toEqual([]);
dispose();
});
it('空批次引用事件不报「没有可用的聊天输入区」', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { dispose } = await mountCanvas();
// 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return),
// 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。
act(() => {
window.dispatchEvent(
new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, {
detail: { references: [] },
}),
);
});
await settle();
expect(
warn.mock.calls
.map((call) => String(call[0]))
.filter((message) => message.includes('没有可用的聊天输入区')),
).toEqual([]);
expect(draftReferenceIds()).toEqual([]);
warn.mockRestore();
dispose();
});
});
+6 -1
View File
@@ -157,7 +157,12 @@ http {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
try_files $uri /index.html =404;
}
# 收银台深链 `/pay/<checkoutToken>`:只放行裸前缀会让真实收银台链接落到默认 location 变 404。
location ~* "^/pay/[^/]+/?$" {
try_files $uri /index.html =404;
}
# END GENARRATIVE MAIN SPA ROUTES
+1 -1
View File
@@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
+13 -1
View File
@@ -206,7 +206,19 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
return 503;
}
try_files $uri /index.html =404;
}
# 收银台深链 `/pay/<checkoutToken>`:token 由前端从最后一个路径段读取(payment.rs 生成该链接),
# 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。
location ~* "^/pay/[^/]+/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
+13 -1
View File
@@ -234,7 +234,19 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
return 503;
}
try_files $uri /index.html =404;
}
# 收银台深链 `/pay/<checkoutToken>`:token 由前端从最后一个路径段读取(payment.rs 生成该链接),
# 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。
location ~* "^/pay/[^/]+/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
@@ -311,6 +311,20 @@
},
"docs": ["主站 SPA allowlist", "失败回退 `/index.html`"]
},
{
"id": "pay_checkout_spa_fallback",
"samplePath": "/pay/checkout-token",
"expect": {
"kind": "static",
"root": "web",
"mode": "spa_fallback"
},
"nginx": {
"production": ["location ~* \"^/pay/[^/]+/?$\""],
"development": ["location ~* \"^/pay/[^/]+/?$\""]
},
"docs": ["收银台深链 `/pay/<checkoutToken>`", "前缀 + 恰好一个路径段"]
},
{
"id": "games_spa_fallback",
"samplePath": "/games/detail",
@@ -1,5 +1,13 @@
# 决策记录
## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602)
- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。
- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数,并检测到第二个输入区注册时留一条 dev 告警——不改运行时语义;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`(空批次直接返回:没有要插的东西,不能报成「没有可用的输入区」);返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。
- 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。
- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。
- 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。
## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359)
- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。
+35 -1
View File
@@ -2,6 +2,14 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上
- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。
- **原因**:画布侧只 `dispatchResourceReferenceInsert` / `dispatchResourceReferenceInsertMany`,而这两个 window 事件的**唯一**消费者是 `App.tsx` 里的 `chatComposerRef.current?.insertReferences(...)`,`chatComposerRef` 又只赋给 `PlanningChatView`;2026-09-22 DirectProject 拆分引入的 `directProjectMode` 提前 return 让普通项目整段跳过后面的策划面渲染 → ref 恒为 `null`,可选链把整次调用静默吞掉。同一批合并冲突还把 2026-09-21 新加的 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 监听整段丢掉,批量引用连消费者都没有。
- **处理(现行口径)**:`features/project-workspace/activeChatComposer.ts` 保存当前挂载的**那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数 / `insertChatReferences`),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥,同一时刻只有一个句柄);`App.tsx` 收敛为一处监听,单条与批量都走 `insertChatReferences`,返回 `false`(空批次 / 此刻没有输入区)时 dev 下 `console.warn`;`chatComposerRef` 只留给策划输入盒自己的 `getDraft` / `clear`。
- **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id="<assetId>"]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。
- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。
## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目
- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。
@@ -4153,7 +4161,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。
- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 验证:使用直接 bash target 启动同组后台子进程;leader 打印 READY 后用 `wait` 保持存活直到 terminate 真正到达(leader 若自己先退出,客户端调度就被拖进 800ms 宽限窗口,见 2026-10-04「graceful terminate 断言」条),仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。
## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时
@@ -6306,3 +6314,29 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现行口径**:见 `development-workflow.md` 的「AGC 测试类型门禁」;tests 必须 0 error,不引入基线或豁免,只改类型层。
- **环境提示**:Node 24+ 默认启用实验性 Web Storage,全局 `localStorage` 未配置即 `undefined`,会顶掉 vitest 0.34 jsdom 环境里的 Storage,`recentProjectsHook.test.tsx`、`gameDistributionPublish.test.ts` 在 Node 26 上失败(HEAD 即如此)。项目按 `@types/node ^22.14` 面向 Node 22,本机用 fnm 装 v22.23.3 并设为 default(`~/.configure/profile.d/fnm.sh` 在 shell 启动时 `eval "$(fnm env)"`),Node 22 不暴露该全局、jsdom 的 localStorage 正常,仓库无需任何改动。不要用 `--localstorage-file=…` 绕:那只是把 Node 自己的文件型 Storage 顶上来,多个用例文件共享同一份状态。
- **关联**:`apps/ai-game-creator-shell/tsconfig.tests.json`、`apps/ai-game-creator-shell/package.json`、`apps/ai-game-creator-shell/tests/`。
## 2026-10-04 tracing 的 callsite interest 是进程级缓存:并发测试会把 span 调用点缓存成 never,span 看起来"根本没产生"
- **现象**:`app::tests::http_tracing::unavailable_router_rejection_keeps_generated_context_and_headers` 偶发 `each rejected request should have one HTTP span left: 0 / right: 1`(`server-rs/crates/api-server/src/app.rs`),同一族断言在 `server-rs/crates/platform-llm/src/observability_tests.rs` 偶发 `provider_spans.len() == 1` 失败。同一批代码时而绿时而红,且失败用例都是最早跑的一批。
- **原因**:`span!`/`info_span!` 宏在调用点缓存 interest 为 `never` 时**静默返回空 span**,连 `new_span` 都不会调用(tracing 0.1.44 `macros.rs` 的 `span!` 分支)。而 `DefaultCallsite` 的 interest **只在调用点首次被命中时算一次**,且计算时用 `DISPATCHERS.rebuilder()`——进程里只注册过一个 dispatcher 时它会退化成 `dispatcher::get_default()`,即**命中线程自己的 dispatcher**(tracing-core 0.1.36 `callsite.rs` 的 `Rebuilder::JustOne`)。libtest 默认并发跑同一二进制里的上千个用例,没有 subscriber 的测试线程一旦抢到 `http.request` / `llm.request` 调用点的首次注册,就会把它永久缓存成 `never`。`with_subscriber` 只在**每次 poll** 设线程本地 dispatcher,纠正不了这个进程级缓存,于是"span 没产生"。
- **处理(现行口径)**:测试采集不要依赖 `with_subscriber`。改为在整个被测流程期间持有 scoped default(`tracing::subscriber::set_default`,其内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并用同一调用点预热探测到连续两轮采集成功为止;测试 subscriber 显式实现 `register_callsite`(目标 span 恒 `always`、其余 `sometimes`),避免自己的重建把其它调用点永久标记成 `never`。**不要**把断言改成"允许 0 个 span",**不要** sleep 赌时序。
- **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。
- **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。
## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台"
- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。
- **原因 1(通知计数串台)**:测试计数器 `DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT` 在 *2026-10-01 已按线程作用域隔离*(`thread_local! Cell`),但 2026-10-02 退役 `runtime_driver` 把这段接缝搬进 `agent/direct_events.rs` 时**降级回进程级 `static AtomicU64`**。`--test-threads=1` 只串行测试线程,宿主 `tauri::async_runtime` 的后台回合仍在自己的工作线程上广播「运行中的项目」变了,于是断言取到别的回合的广播。
- **原因 2(terminate 竞速)**:测试命令里 leader 打印 READY 后立刻 `exit 0`,同组后代仍存活,trampoline 从 leader 被回收那一刻开始 `PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS=800ms` 宽限;客户端只要在 leader 退出后 >800ms 才发出 terminate(CI 高负载下要跨 durable record 写盘、registry 注册、线程 spawn),会话已按 `exited` 收口,terminate 只能读到既成事实——不是产品缺陷,是测试赌了客户端调度。
- **处理(现行口径)**:①测试专用的通知计数必须留在测试线程作用域(`thread_local! Cell`),不要用进程级 Atomic;②graceful terminate 用例的 leader 打印 READY 后要用 `wait` 等后台子进程,让 terminate 必然落在会话仍 running 时(断言、trap、`sleep 0.4`、marker 名字都不改)。
- **验证**:①修复前把计数器临时改回 Atomic 时同一并行口径 42/50 红;修复后并行 50 次 0 红、`--test-threads=1` 200 次 0 红、CI 现场等价块(145 用例)3 次 0 红;②该用例是 `#[cfg(target_os = "linux")]`,Windows 本机跑不到,用真实 Linux 内核(WSL Alpine)验证命令形状:leader 活到 TERM、同组后代完成 400ms 延迟清理(marker=done,real 0.41s)、清理后组内零残留;CI 侧仍应跑 `node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 复核。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs`、`apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs`、`command_sandbox_trampoline.rs`。
## 2026-10-04 SPA 深链的前缀路由(`/pay/<checkoutToken>`)必须进 allowlist,裸前缀不够
- **现象**:只把 `/pay`、`/profile/payment` 加进 Nginx SPA allowlist 让门禁变绿,并不代表真实收银台链接能打开。`payment.rs` 生成的 `checkoutUrl` 是 `/pay/<checkoutToken>`,三份模板原先只有 `location ~* "^/(?:…|pay|profile|profile/payment|…)/?$"` 这条精确 location,深链落回默认 `location /` 的 `try_files $uri $uri/ =404` → **404**。
- **原因/代价**:前端 `resolveSelectionStageFromPath` 用 `startsWith('/pay/')` 判定并取最后一个路径段当 token,Nginx / Pingora 侧却只放行裸前缀(2026-10-03 的支付接入 commit 只改了前端路由源)。同一批漂移里还有一条被掩盖的失败:`check:nginx-spa-routes` 在 `npm run lint` 链里先跑,它红的时候看不到后面的 `check:pingora-route-parity` 也红(Pingora `MAIN_SPA_PATHS` 缺 `/pay`、`/profile/payment`)——修一条门禁时要把整条链跑到底,不要只看第一个红。
- **处理(现行口径)**:前缀路由的真相源是 `src/routing/activeAppPageRoutes.ts` 的 `APP_PREFIX_ROUTE_ENTRIES`。`scripts/check-nginx-spa-routes.mjs` 据此要求三份模板都写锚定前缀 location(`location ~* "^/pay/[^/]+/?$"`,只放行「前缀 + 恰好一个路径段」,裸前缀仍由精确 location 负责,并要求镜像精确 location 的维护闸);`check:pingora-route-parity` 要求 Rust 的 `MAIN_SPA_PREFIX_PATHS` 与 `is_main_spa_prefix_path` 同口径(大小写不敏感、多段与 `/payment/x` 这类同名邻居不收)。
- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。
- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` → 200 且正文与 `/` 同一份 `index.html`。
- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。
@@ -63,7 +63,7 @@ npm run check:pingora-release-readiness
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/<checkoutToken>` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。
`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。
@@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 |
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 |
| 主站 SPA 前缀路由 | 收银台深链 `/pay/<checkoutToken>` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 |
| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 |
SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。
@@ -1,9 +1,31 @@
# AGC 聊天素材引用
更新时间:2026-09-23
更新时间:2026-10-03
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
## 画布引用落到哪份输入区:活跃聊天输入区注册表(2026-10-03)
资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答:
- `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。
- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。
- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。
- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。
这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。
这条链路由以下用例守住(都渲染真实聊天面,断言草稿 DOM 而不是「事件被派发」):
| 契约 | 用例 |
| --- | --- |
| 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 |
| 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` |
| 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` |
| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 / 重复注册留线索 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` |
| 空批次事件不误报成「没有可用的聊天输入区」 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` 的「空批次引用事件」用例 |
| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 |
## 引用来源由宿主注入(2026-09-22)
`ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂):
@@ -60,7 +82,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
- 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。
- 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。
- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。
- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),由 `App.tsx` 的监听一次性交给当前挂载的输入区(见文首「活跃聊天输入区注册表」),草稿只重建一次、插入顺序即拖动集合顺序。2026-09-22 的合并冲突曾把这条监听整段丢掉(事件无消费者),2026-10-03 修复时补回。
## 附件进入正文(2026-09-22)
@@ -83,7 +105,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,
- 支持搜索、类型筛选和多选;
- 素材芯片可插入、编辑和删除;
- 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节);
- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;
- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;未登记素材(`manifestAssetId: null`)不渲染这枚按钮,拖到对话栏时落点提示与提示条给出「还没登记为项目资源」的原因;
- 普通项目(DirectProject)与立项策划两条链路都由 `App.tsx` 那一处监听 + 活跃聊天输入区注册表把引用落进当前挂载的输入盒草稿(2026-10-03 修复,见文首);
- 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用;
- 提交请求携带 canonical user message item;
- Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input;
@@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- 门禁:
```bash
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由)
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由)
npm run check:nginx-spa-routes
```
线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/<checkoutToken>` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。
本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games/<gameId>/` 验证:入口文档 200 `text/html`、`/games/<gameId>/assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。
#### 游戏分发可观测事件
+1 -1
View File
@@ -99,7 +99,7 @@
"check:production-api-deploy": "node scripts/check-production-api-deploy.mjs",
"check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs",
"check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs",
"check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs",
"check:nginx-spa-routes": "node --test scripts/check-nginx-spa-routes.test.mjs && node scripts/check-nginx-spa-routes.mjs",
"check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs",
"check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs",
"check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs",
+148 -14
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts';
const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx';
const APP_PREFIX_ROUTE_ENTRIES_PATTERN =
/const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u;
const COMPATIBILITY_ROUTES = [];
const NGINX_PATHS = [
'deploy/nginx/genarrative.conf',
@@ -86,7 +90,92 @@ function compareRouteSets(actualRoutes, expectedRoutes, label) {
}
}
function validateNginxRoutes(nginxPath, expectedRoutes) {
/**
* 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。
* 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。
*/
export function buildPrefixRouteNginxPattern(prefix) {
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
return `^${escapedPrefix}/[^/]+/?$`;
}
/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */
export function collectPrefixRoutePatternFailures(pattern, prefix) {
const failures = [];
const expected = buildPrefixRouteNginxPattern(prefix);
if (pattern !== expected) {
failures.push(
`前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`,
);
return failures;
}
const matcher = new RegExp(pattern, 'iu');
for (const sample of [
`${prefix}/checkout-token`,
`${prefix.toUpperCase()}/CheckOutToken/`,
]) {
if (!matcher.test(sample)) {
failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`);
}
}
for (const sample of [
prefix,
`${prefix}/`,
`${prefix}/two/segments`,
`${prefix}suffix/segment`,
]) {
if (matcher.test(sample)) {
failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`);
}
}
return failures;
}
export function collectExpectedPrefixRoutes() {
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
const entries = extractSourceBlock(
appPageRoutes,
APP_PREFIX_ROUTE_ENTRIES_PATTERN,
`${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`,
);
const routes = Array.from(
entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu),
(match) => ({ path: match[1], stage: match[2] }),
);
const uniqueRoutes = new Map(routes.map((route) => [route.path, route]));
for (const route of uniqueRoutes.values()) {
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) {
fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`);
}
}
return [...uniqueRoutes.values()].sort((left, right) =>
left.path.localeCompare(right.path),
);
}
function findRegexLocationBody(block, pattern) {
for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) {
if (match[1] !== pattern) {
continue;
}
const openBrace = match.index + match[0].length - 1;
let depth = 0;
for (let index = openBrace; index < block.length; index += 1) {
if (block[index] === '{') {
depth += 1;
} else if (block[index] === '}') {
depth -= 1;
if (depth === 0) {
return block.slice(openBrace + 1, index);
}
}
}
return null;
}
return null;
}
function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) {
const source = readFileSync(nginxPath, 'utf8');
const blockStart = source.indexOf(SPA_BLOCK_START);
const blockEnd = source.indexOf(SPA_BLOCK_END);
@@ -140,6 +229,44 @@ function validateNginxRoutes(nginxPath, expectedRoutes) {
}
}
// 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认
// location 变成 404(例如收银台 `/pay/<checkoutToken>`)。
const exactLocationBody = findRegexLocationBody(block, nginxPattern);
const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }';
for (const { path: prefix } of prefixRoutes) {
if (!expectedRoutes.includes(prefix)) {
fail(
`${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`,
);
continue;
}
const expectedPattern = buildPrefixRouteNginxPattern(prefix);
const prefixLocationBody = findRegexLocationBody(block, expectedPattern);
if (prefixLocationBody === null) {
fail(
`${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`,
);
continue;
}
for (const failure of collectPrefixRoutePatternFailures(
expectedPattern,
prefix,
)) {
fail(`${nginxPath} ${failure}`);
}
if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) {
fail(`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`);
}
if (
exactLocationBody?.includes(maintenanceGuard) &&
!prefixLocationBody.includes(maintenanceGuard)
) {
fail(
`${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`,
);
}
}
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
fail(
@@ -218,20 +345,27 @@ function validateMaintenanceInternalBypass() {
}
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
export const expectedPrefixRoutes = collectExpectedPrefixRoutes();
for (const nginxPath of NGINX_PATHS) {
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
}
validateMaintenanceInternalBypass();
const isMainModule =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (failures.length > 0) {
console.error('[check:nginx-spa-routes] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
if (isMainModule) {
for (const nginxPath of NGINX_PATHS) {
validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes);
}
process.exit(1);
}
validateMaintenanceInternalBypass();
console.log(
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`,
);
if (failures.length > 0) {
console.error('[check:nginx-spa-routes] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`,
);
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
buildPrefixRouteNginxPattern,
collectExpectedPrefixRoutes,
collectPrefixRoutePatternFailures,
} from './check-nginx-spa-routes.mjs';
test('前缀路由按「前缀 + 恰好一个路径段 + 可省略尾部斜杠」锚定,裸前缀交给精确 location', () => {
const pattern = buildPrefixRouteNginxPattern('/pay');
assert.equal(pattern, '^/pay/[^/]+/?$');
assert.deepEqual(collectPrefixRoutePatternFailures(pattern, '/pay'), []);
const matcher = new RegExp(pattern, 'iu');
assert.ok(matcher.test('/pay/checkout-token'));
assert.ok(matcher.test('/PAY/CheckOutToken/'));
assert.ok(!matcher.test('/pay'));
assert.ok(!matcher.test('/pay/'));
assert.ok(!matcher.test('/pay/two/segments'));
assert.ok(!matcher.test('/paycheckout/token'));
assert.ok(!matcher.test('/payment/token'));
});
test('把前缀路由写回精确匹配(只放行裸前缀)会被门禁拒绝', () => {
const failures = collectPrefixRoutePatternFailures('^/pay/?$', '/pay');
assert.equal(failures.length, 1);
assert.match(failures[0], /必须锚定为 \^\/pay\/\[\^\/\]\+\/\?\$/u);
});
test('过宽的裸前缀形状(会吞掉同名邻居路径)也会被门禁拒绝', () => {
const failures = collectPrefixRoutePatternFailures('^/pay', '/pay');
assert.equal(failures.length, 1);
assert.match(failures[0], /必须锚定为/u);
});
test('前缀路由真相源来自前端路由表且当前包含 /pay', () => {
const prefixRoutes = collectExpectedPrefixRoutes();
assert.ok(
prefixRoutes.some((route) => route.path === '/pay'),
'APP_PREFIX_ROUTE_ENTRIES 必须声明 /pay',
);
for (const route of prefixRoutes) {
assert.match(route.path, /^\//u);
assert.notEqual(route.stage.trim(), '');
}
});
+28 -1
View File
@@ -2,7 +2,10 @@
import { readFileSync } from 'node:fs';
import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs';
import {
expectedMainSpaRoutes,
expectedPrefixRoutes,
} from './check-nginx-spa-routes.mjs';
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
@@ -289,10 +292,34 @@ function validateRustMainSpaRoutes() {
}
}
function validateRustMainSpaPrefixPaths() {
const prefixBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!prefixBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。');
return;
}
const rustPrefixes = Array.from(
prefixBlock[1].matchAll(/"([^"]+)"/gu),
(match) => match[1],
);
const expected = expectedPrefixRoutes.map((route) => route.path);
const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix));
const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix));
if (missing.length > 0) {
fail(`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`);
}
if (extra.length > 0) {
fail(`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`);
}
}
validateMatrixShape();
validateRustTestUsesMatrix();
validateNginxLocationsAreCovered();
validateRustMainSpaRoutes();
validateRustMainSpaPrefixPaths();
if (failures.length > 0) {
console.error('[check:pingora-route-parity] FAILED');

Some files were not shown because too many files have changed in this diff Show More