预览激活回接「运行」入口,聊天 @ 清单打开选择器时按需重读
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m49s
Project CI / Backend tests (pull_request) Successful in 5m7s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m7s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m15s
Project CI / Native shell tests (pull_request) Successful in 6m37s
Project CI / Frontend tests (pull_request) Successful in 3m8s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / Repository checks (pull_request) Successful in 1m56s

- App.tsx 新增 activateRunningPreview,运行入口先问 Rust 要活体预览(activate_local_game_preview),命中就只切客户端运行视图
- App.tsx 的 executeRunLocal 命中活体预览时经 DirectProject 聊天的 announce 播报「已切换到客户端运行视图」,不再重复调用 start_local_game_preview;stopped / 非本项目 / 权限位要求确认一律回落到重新启动
- check-config.mjs 从 native-only 白名单移除 activate_local_game_preview 并同步注释(该门禁要求 App invoke 与白名单互斥)
- DirectProjectComposer 新增 onReferencePickerOpen,工具条按钮与附件菜单两处选择器入口打开前先刷新清单
- DirectProjectChatView 用 useDirectProjectManifest 的 refresh 重读聊天自己那份 @ 清单,不新增壳到聊天的清单推送通道
- 新增 tests/previewActivation.test.tsx,钉住切视图不重启 / stopped 回落重启 / 权限确认回落重启三条路径
- decision-log 记录本轮两处回归的背景、决策、取舍与验证方式
This commit is contained in:
2026-09-21 23:09:43 +08:00
parent a11cdf13d7
commit 5315b233c8
6 changed files with 232 additions and 4 deletions
@@ -200,11 +200,11 @@ const allowedUncalledTauriCommands = [
'read_agc_plugin_panel',
'set_agc_plugin_enabled',
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 / 预览 /
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
// native-only 登记,避免孤儿检查一直报错。
'activate_local_game_preview',
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
'build_local_project_index',
'control_agent_run',
'create_local_project_checkpoint',
+47
View File
@@ -39,6 +39,7 @@ import type {
LocalConversationResult,
LocalGameProjectRevisionStatus,
LocalPreviewResult,
LocalPreviewStatus,
LocalProjectFileResult,
LocalProjectKind,
PendingUiConfirmation,
@@ -1717,6 +1718,39 @@ export function App({
}
}
/**
* 预览已经在跑时先切视图,不重启预览服务。
*
* `activate_local_game_preview` 是 Rust 侧的「这条预览还活着、且属于这个项目」闸门:
* 它只核对内存 registry 里的状态并回传可用的 loopback 地址,前端据此进客户端运行视图。
* 同一个动作过去由工作台壳的 `/preview open` 聊天命令承担,那条命令链随 Supervisor
* 前端链路一起退役,行为改由「运行」入口承接,结果经 DirectProject 聊天的 `announce`
* 交给聊天自己的消息流。返回 `null` 表示没有可复用的活体预览,调用方照旧重启预览。
*/
async function activateRunningPreview(
invoke: TauriInvoke,
nextProjectPath: string,
): Promise<LocalPreviewResult | null> {
try {
const status = await invoke<LocalPreviewStatus>(
'activate_local_game_preview',
{ projectPath: nextProjectPath },
);
if (
status.status !== 'running' ||
!status.url ||
!status.port ||
!status.root
) {
return null;
}
return { url: status.url, port: status.port, root: status.root };
} catch {
// 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。
return null;
}
}
async function executeRunLocal(announceToChat: boolean) {
const invoke = resolveTauriInvoke();
if (!invoke) {
@@ -1734,6 +1768,19 @@ export function App({
}
try {
const activePreview = await activateRunningPreview(
invoke,
nextProjectPath,
);
if (activePreview) {
updateClientPreview(activePreview);
if (announceToChat) {
announceProjectChatMessage(
`已切换到客户端运行视图:${activePreview.url}`,
);
}
return;
}
const previewResult = await invoke<LocalPreviewResult>(
'start_local_game_preview',
{ projectPath: nextProjectPath },
@@ -231,6 +231,11 @@ export function DirectProjectChatView({
turnCancelling={turnCancelling}
onCancelQueuedTurn={cancelQueuedTurn}
onCancelTurn={() => void cancelTurn()}
onReferencePickerOpen={() => {
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
if (projectPath) void refresh(projectPath);
}}
onRemoveAttachment={removeAttachment}
onSubmit={submit}
onUploadFiles={(files) => void uploadFiles(files)}
@@ -44,6 +44,7 @@ export function DirectProjectComposer({
turnCancelling,
onCancelQueuedTurn,
onCancelTurn,
onReferencePickerOpen,
onRemoveAttachment,
onSubmit,
onUploadFiles,
@@ -59,6 +60,14 @@ export function DirectProjectComposer({
turnCancelling: boolean;
onCancelQueuedTurn: (id: string) => void;
onCancelTurn: () => void;
/**
* 打开 `@` 选择器前刷新一次项目清单。
*
* `@` 的标签统计与候选来自聊天自己订阅的那份清单(`useDirectProjectManifest`),
* 而素材标签、改名、删除这些写入走的是资源画布的通道,聊天侧收不到任何信号;
* 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。
*/
onReferencePickerOpen?: () => void;
onRemoveAttachment: (index: number) => void;
onSubmit: (content: DirectCodexUserContentPart[]) => boolean;
onUploadFiles: (files: readonly File[]) => void;
@@ -139,14 +148,20 @@ export function DirectProjectComposer({
aria-label="插入素材引用"
title="插入素材引用"
disabled={busy}
onClick={() => composerRef.current?.openPicker()}
onClick={() => {
onReferencePickerOpen?.();
composerRef.current?.openPicker();
}}
>
<AtSign size={15} aria-hidden="true" />
</button>
<ComposerAttachmentMenu
disabled={busy || modelValidating}
onPickFiles={(files) => void onUploadFiles(files)}
onOpenReferencePicker={() => composerRef.current?.openPicker()}
onOpenReferencePicker={() => {
onReferencePickerOpen?.();
composerRef.current?.openPicker();
}}
/>
</div>
<div className="project-chat-composer-controls-right">
@@ -0,0 +1,153 @@
/** @vitest-environment jsdom */
/**
* 运行入口「预览已经在跑就切视图」的回归用例。
*
* 背景:工作台壳过去用 `/preview open` 聊天命令切到已经在跑的客户端运行视图,那条命令链
* 随 Project Supervisor 前端链路整体退役;按 ADR`docs/adr/【ADR】DirectProject独立聊天
* 容器与工作台钱包布局-2026-09-18.md`),这个动作由 DirectProject 的「运行」入口承接,
* 结果经聊天自己的 `announce` 交给聊天的本地消息流。
*
* 因此运行入口进入客户端运行视图前先问 Rust 要活体预览:`activate_local_game_preview`
* 只核对内存 registry 并回传 loopback 地址,活着就复用(不重启预览服务),没活着
* stopped / 不属于这个项目 / 权限位要求确认)才回落到 `start_local_game_preview`。
*
* 这条行为过去只剩 `scripts/check-native-shells.mjs` 的源码守卫与 Rust 实现,前端没有
* 用例;一旦有人再把它当死链路删掉,守卫会先报「missing await invoke」而不知道现场。
*/
import { beforeEach, describe, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
App,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
expect,
render,
screen,
waitFor,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/preview-activation-project';
const PROJECT_ID = 'preview-activation-project';
const PREVIEW_URL = 'http://127.0.0.1:43210/game/index.html';
function createFixtureManifest(): GameCreationAppManifest {
return createGameCreationAppManifest(PROJECT_ID, '预览切换项目');
}
/**
* `activate_local_game_preview` 的替身由用例给定:它决定「这条预览还活着吗」,
* 其余命令沿用聊天 harness,运行入口之外的链路保持真实形状。
*/
function installTauri(activateLocalGamePreview: () => unknown) {
const manifest = createFixtureManifest();
const chatHarness = createProjectChatRuntimeHarness({
projectPath: PROJECT_PATH,
});
const invokeCounts = new Map<string, number>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
invokeCounts.set(command, (invokeCounts.get(command) ?? 0) + 1);
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_project_revision') {
return { revision: 3 };
}
if (command === 'activate_local_game_preview') {
return activateLocalGamePreview();
}
if (command === 'start_local_game_preview') {
return { url: PREVIEW_URL, port: 43210, root: PROJECT_PATH };
}
return chatHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: chatHarness.listen as never },
};
return { invoke, invokeCounts };
}
/** 「运行」入口由工作台壳的播放请求驱动,这里直接用同一形状的请求触发。 */
function renderRunningProjectChat() {
return render(
<App
initialProjectPath={PROJECT_PATH}
initialProjectManifest={createFixtureManifest()}
onPlayRequestHandled={vi.fn()}
playRequest={{ projectPath: PROJECT_PATH, requestId: 1 }}
/>,
);
}
/** 运行结果由聊天自己的本地消息流展示,这里按对话容器文本断言。 */
async function expectChatAnnouncement(text: string) {
const surface = await screen.findByLabelText('陶泥儿项目对话');
await waitFor(() => expect(surface.textContent ?? '').toContain(text));
}
beforeEach(() => {
window.history.pushState({}, '', '/');
});
describe('运行入口切到已经在跑的客户端预览', () => {
it('预览还活着时只切视图,不重启预览服务', async () => {
const { invoke, invokeCounts } = installTauri(() => ({
status: 'running',
url: PREVIEW_URL,
port: 43210,
root: PROJECT_PATH,
}));
renderRunningProjectChat();
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', {
projectPath: PROJECT_PATH,
}),
);
await expectChatAnnouncement(`已切换到客户端运行视图:${PREVIEW_URL}`);
expect(invokeCounts.get('start_local_game_preview') ?? 0).toBe(0);
});
it('预览不在跑时回落到重新启动预览', async () => {
const { invokeCounts } = installTauri(() => ({
status: 'stopped',
url: null,
port: null,
root: null,
}));
renderRunningProjectChat();
await waitFor(() =>
expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan(
0,
),
);
await expectChatAnnouncement(
`运行通过,已载入客户端运行视图:${PREVIEW_URL}`,
);
});
it('权限位要求确认时不把拒绝当错误,直接回落到重新启动预览', async () => {
const { invokeCounts } = installTauri(() => {
throw new Error('需要先确认预览权限');
});
renderRunningProjectChat();
await waitFor(() =>
expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan(
0,
),
);
await expectChatAnnouncement(
`运行通过,已载入客户端运行视图:${PREVIEW_URL}`,
);
});
});
@@ -8,6 +8,14 @@
- 代价与取舍:master 用例里钉住 `.project-supervisor-composer-controls``项目总控消息` 的断言改为当前类名 `.project-chat-composer-controls` 与「立项策划消息」;本分支 CSS 的 `project-supervisor-* → project-chat-*` 改名对 master 新增规则同样生效。`workspaceProjectKind` 在两条读它的路径(壳层 Cocos 插件门禁、Supervisor 提交路由)都退役后删除,插件可用性改由插件宿主自判。`.env` 的本地私有改动不进入本次合并。
- 验证方式:`apps/ai-game-creator-shell` `npm run typecheck` 与仓库 `npm run typecheck``npx vitest run apps/ai-game-creator-shell/tests`168 个文件,除 5 个 jsdom `localStorage` 环境失败文件与 1 条本分支既有的 `resourceTagStatsRefresh` 失败外全绿,`appSurface.test.ts` 198 passed / 13 skipped / 0 failed)、`npm run check:encoding``npm run check:doc-index``git diff --check`、改动文件 eslint 0 error。Rust 侧定向 `cargo test --bin genarrative-ai-game-creator-shell -- direct_codex_user_item | skill_pack:: | sessions::developer_project_file_and_memory_writes_advance_project_revision` 全过;整套 Rust 分片在本容器有 55 条环境性失败(`/sbin -> usr/bin``command.exec` 沙箱的 merged-usr 预检失败,报 `command.exec sandbox unavailable`),与本次合并无关:这些用例所在模块与 master 逐字相同。
## 2026-09-21 合并后两处回归:预览激活回接「运行」入口,聊天 `@` 清单按需重读
- 背景:合并 `origin/master` 后 CI 报两处回归。① `scripts/check-native-shells.mjs``AI game creator client preview activation drifted: missing await invoke<LocalPreviewStatus>('activate_local_game_preview'`Supervisor 前端链路退役时(`0224ef7b9``pendingCommand` 死链那一步)把 `activate_local_game_preview` 的前端调用方一起带走,而这条守卫与 Rust `preview.rs` 的实现都还在。② `resourceTagStatsRefresh` 用例失败:聊天 `@` 选择器的标签统计与候选读 `useDirectProjectManifest` 自己那份清单,资源画布的标签写入既不发 `game-creator-manifest-invalidated`Rust 只在 agent 驱动的写入上发),壳也不再往聊天推清单快照,于是用户改完标签打开 `@` 仍看到旧标签。
- 决策:① 预览激活按 ADR 由「运行」入口承接:`executeRunLocal` 先调 `activate_local_game_preview`Rust 侧只核对内存 registry 与项目归属),返回 running 且拿到 loopback 地址就只切客户端运行视图并经 `DirectProjectChatHandle.announce` 说一句 `已切换到客户端运行视图:<url>`,不再重复 `start_local_game_preview`stopped / 不属于本项目 / 权限位要求确认都当「没有可复用预览」,回落到重新启动。同时把 `activate_local_game_preview``scripts/check-config.mjs` 的 native-only 白名单删除(该门禁要求 App invoke 与白名单互斥)。② 聊天 `@` 选择器打开时按需重读清单:`DirectProjectComposer` 的两处选择器入口(工具条按钮与附件菜单项)都先触发 `onReferencePickerOpen``openPicker`,由 `DirectProjectChatView``useDirectProjectManifest``refresh` 重读;不新增壳→聊天的清单推送通道,也不改 ADR 里「聊天自己订阅清单」的归属。
- 代价与取舍:编辑器内直接敲 `@` 的候选菜单读同一份清单,本轮没有纳入按需重读(它只列素材名、不显示标签统计)。更根治的做法是让资源写入在 Rust 侧广播 `game-creator-manifest-invalidated`,本轮不做,先按现成的刷新入口收口。
- 影响范围:`apps/ai-game-creator-shell/src/App.tsx``src/view/project-development/chat/DirectProjectChatView.tsx``.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx``scripts/check-config.mjs`;新增 `tests/previewActivation.test.tsx`(3 条:活体预览只切视图不重启、stopped 回落重启、权限确认回落重启;第一条在去掉接线后确实失败)。
- 验证方式:`npx vitest run apps/ai-game-creator-shell/tests`(除 5 个 jsdom `localStorage` 环境失败文件外全绿:`previewActivation` 3/3、`resourceTagStatsRefresh` 2/2、`runVersionSwitchEventSubscription` 2/2、`resourceReferenceInput` 34/34、`appSurface.test.ts` 198 passed / 13 skipped)、`node scripts/check-native-shells.mjs --groups=contract``npm run ai-game-creator-shell:typecheck``npm run check:encoding``git diff --check`、改动文件 prettier / eslint 全过。
## 2026-09-21 渠道进安装身份:不同渠道的 AGC 包体在同一台设备并存
- 背景:渠道此前只决定更新端点(`plugins.updater.endpoints`)与渲染层平台 origin`productName` / `identifier` 与渠道无关,于是所有渠道共用 `%LOCALAPPDATA%\陶泥儿` 安装目录、同一个卸载项(`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\陶泥儿`,另有 `HKCU\Software\genarrative\陶泥儿`)以及同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录。本机 0.1.48 安装实测:主程序二进制里只有 1 处 `agc/dev-win/latest.json`、0 处 release 端点,说明渠道在产物里只体现为端点。后果是后装的渠道静默顶掉先装的渠道,并接管更新端点、平台服务器与本地登录态/项目数据。