From 09fb8bc0766ce9640e1c916a0fd1d5bec6ab698b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 17:26:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=85=A8=E5=B1=8F=E9=A2=84=E8=A7=88=E4=B8=8E?= =?UTF-8?q?=E5=8F=AF=E6=94=B6=E8=B5=B7=E7=9A=84=E4=BF=A1=E6=81=AF=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行画面右下角新增「全屏预览」:新增 `useElementFullscreen`,只把画面那一格(`.game-run-preview`)送进全屏,按钮态只认 `fullscreenchange`,宿主没有 Fullscreen API 时整枚入口不渲染。 - 底部信息栏改为「有内容才存在」:运行页选中资源时自动展开,可手动收起到只剩一行开合按钮,清空选中后整栏不渲染;只有一栏内容时卡片铺满整行。 - 「数值微调」在没有登记表前不渲染区域标题与卡片,随这一栏删除其 label / input 样式声明。 - 新增 `tests/runPreviewFullscreen.test.tsx`;AGC 子集补信息栏开合用例,并把「没有内容仍渲染两张空卡片」的旧断言改成整栏不渲染。 - 同步 PRD §3.4、技术方案运行表现层条目与 project-memory 决策记录。 --- .../project-workspace/useElementFullscreen.ts | 75 +++++++++ apps/ai-game-creator-shell/src/styles.css | 110 +++++++++++--- .../src/view/project-development/index.tsx | 111 +++++++++++--- .../appSurface/project-development.suite.ts | 12 +- .../tests/runPreviewFullscreen.test.tsx | 142 ++++++++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 3 +- .../shared-memory/decision-log.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 8 files changed, 420 insertions(+), 43 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts create mode 100644 apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts b/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts new file mode 100644 index 000000000..2235ebb5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/useElementFullscreen.ts @@ -0,0 +1,75 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +export type ElementFullscreenController = { + /** 要全屏的那一格;挂在它的 `ref` 上。 */ + ref: RefObject; + isFullscreen: boolean; + isSupported: boolean; + toggleFullscreen: () => void; +}; + +/** + * 运行画面「全屏预览」用的**元素级**全屏。 + * + * 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的 + * 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。 + * + * 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉 + * (`fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮—— + * 一枚点了没反应的全屏按钮比没有按钮更糟。 + * + * 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为 + * 卸载补退出逻辑;按钮态只认 `fullscreenchange`,Esc 与宿主自己退出都会回落。 + */ +export function useElementFullscreen< + T extends HTMLElement, +>(): ElementFullscreenController { + const elementRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const isSupported = + typeof document !== 'undefined' && + document.fullscreenEnabled !== false && + typeof document.documentElement?.requestFullscreen === 'function'; + + useEffect(() => { + if (typeof document === 'undefined') { + return undefined; + } + // 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在 + // 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。 + const sync = () => { + const element = elementRef.current; + setIsFullscreen( + element !== null && document.fullscreenElement === element, + ); + }; + document.addEventListener('fullscreenchange', sync); + sync(); + return () => document.removeEventListener('fullscreenchange', sync); + }, []); + + const toggleFullscreen = useCallback(() => { + const element = elementRef.current; + if (!element) { + return; + } + const ownerDocument = element.ownerDocument; + // 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等, + // 不需要先判断当前全屏的是不是自己。 + if (ownerDocument.fullscreenElement) { + void ownerDocument.exitFullscreen?.()?.catch(() => {}); + return; + } + // 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样; + // 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。 + void element.requestFullscreen?.()?.catch(() => {}); + }, []); + + return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen }; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 75c0b2d0e..a598a836b 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8566,6 +8566,43 @@ iframe.preview-frame { min-height: 0; } +/* + * 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都 + * 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮 + * 在全屏里照旧可用,用户点它就能退出来。 + */ +.game-run-preview-fullscreen { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid rgb(255 255 255 / 28%); + border-radius: 10px; + background: rgb(12 14 20 / 62%); + color: #fff; + cursor: pointer; + backdrop-filter: blur(6px); +} + +.game-run-preview-fullscreen:hover, +.game-run-preview-fullscreen:focus-visible { + background: rgb(12 14 20 / 84%); + outline: 0; +} + +/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */ +.game-run-preview:fullscreen { + min-height: 0; + border: 0; + border-radius: 0; + background: #05070b; +} + .game-run-preview-empty { display: grid; align-content: center; @@ -8588,13 +8625,61 @@ iframe.preview-frame { overflow-wrap: anywhere; } +/* + * 运行页签的底部信息栏。没有内容时整栏不渲染(判据在 `project-development/index.tsx`), + * 收起态只剩上面那一行开合按钮——条目卡片的 156px 最小高度不会再变成一片空白色块。 + * + * 卡片只在**有内容**时渲染:一栏也铺满整行,不留半张空位。原先「数值微调」那一栏没有登记表 + * (前端没有数据源),按用户口径没有功能就先不渲染,它的字段样式(label / input)随这一栏一起 + * 删掉;登记表接进来时样式与卡片一起回来。 + */ .game-run-panels { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.game-run-panels-controls { + display: flex; + justify-content: flex-end; +} + +.game-run-panels-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 10px; + border: 1px solid #eadbd4; + border-radius: 999px; + background: #fff; + color: #62483d; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} + +.game-run-panels-toggle:hover, +.game-run-panels-toggle:focus-visible { + border-color: #dfb59f; + background: #fdf1ea; + outline: 0; +} + +.game-run-panels-toggle-chevron { + transition: transform 120ms ease; +} + +.game-run-panels-toggle-chevron.is-collapsed { + transform: rotate(-90deg); +} + +.game-run-panels-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 10px; } -.game-run-panels > section { +.game-run-panels-body > section { display: grid; align-content: start; gap: 8px; @@ -8616,7 +8701,6 @@ iframe.preview-frame { } .game-run-panels p, -.game-run-panels label, .game-run-panels dt, .game-run-panels dd { margin: 0; @@ -8644,24 +8728,6 @@ iframe.preview-frame { overflow-wrap: anywhere; } -.game-run-panels label { - display: grid; - grid-template-columns: minmax(0, 1fr) 88px; - align-items: center; - gap: 8px; -} - -.game-run-panels input { - min-width: 0; - height: 30px; - padding: 0 8px; - border: 1px solid #eadbd4; - border-radius: 8px; - background: #faf7f5; - color: #8f7d75; - font-size: 10px; -} - .game-workbench-chat { display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -10006,7 +10072,7 @@ iframe.preview-frame { min-width: 460px; } - .game-run-panels { + .game-run-panels-body { grid-template-columns: 1fr; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 5aea932d5..4117d42ba 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, + ChevronDown, Crosshair, ExternalLink, Eye, @@ -34,6 +35,7 @@ import { LayoutGrid, ListFilter, Maximize2, + Minimize2, Minus, Music2, PackageOpen, @@ -141,6 +143,7 @@ import { resourceLabelResolver, resourceReferenceCategoryLabel, } from '../../features/project-workspace/resourceReferences'; +import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { type ResourceCanvasAssetGenerationPanelDraft, @@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({ }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); + // 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台, + // 所以按钮与 ref 都归运行表现层自己持有。 + const runPreviewFullscreen = useElementFullscreen(); const [resourceBookState, dispatchResourceBook] = useReducer( resourceBookReducer, initialResourceBookState, @@ -3000,6 +3006,23 @@ export default function ProjectDevelopmentView({ const selectedResource = canvasResources.find((resource) => resource.id === selectedResourceId) ?? null; + /** + * 运行页签底部信息栏(`.game-run-panels`)的开合。 + * + * 只有一条判据:**有没有内容**——当前是「运行页里选中了一张资源」,「信息展示」渲染的是它 + * 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里 + * 手动收 / 展则一直有效,不会被别的渲染重开。 + * + * 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等 + * 后端编辑态登记表接进来后,它与它的内容一起进这个判据。 + */ + const runPanelsHaveContent = selectedResource !== null; + const [runPanelsExpanded, setRunPanelsExpanded] = + useState(runPanelsHaveContent); + const runPanelsBodyId = useId(); + useEffect(() => { + setRunPanelsExpanded(runPanelsHaveContent); + }, [runPanelsHaveContent]); /** * 画布选中工具栏「编辑标签」的入口判定: * - 选中 1 项:既有的单素材标签编辑(增删标签行为不变); @@ -11002,7 +11025,7 @@ export default function ProjectDevelopmentView({ ) : (
-
+
{embeddedPreviewUrl ? ( 点击顶部播放按钮后将在这里直接运行游戏
)} + {/* + 全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与 + 底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染, + 宿主没有 Fullscreen API 时也不渲染(见 `useElementFullscreen`)。 + */} + {embeddedPreviewUrl && runPreviewFullscreen.isSupported ? ( + + ) : null}
-
-
-
-
- {selectedResource ? ( - + {/* + 底部信息栏:有内容才存在,没有内容就自动收起(整栏不渲染)。 + 收起态只留下这一行开合按钮,展开态才渲染条目卡片——卡片的 156px 最小高度 + 因此不会再变成一片空白色块。 + */} + {runPanelsHaveContent ? ( +
+
+ +
+ {runPanelsExpanded ? ( +
+
+
+
+ {selectedResource ? ( + + ) : null} +
+
) : null} -
-
-
-
-
-
+ + ) : null}
)} {/* diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 1d30225a8..ac6c2852c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3886,6 +3886,11 @@ export function registerProjectWorkbenchFoundationTests() { fireEvent.click(runTab); const runInfoPanel = screen.getByLabelText('资源信息面板'); expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows); + // 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。 + fireEvent.click(screen.getByRole('button', { name: '收起信息栏' })); + expect(screen.queryByLabelText('资源信息面板')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '展开信息栏' })); + expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); // 画布浮层只属于画布:切到运行视图后不再渲染。 expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); }); @@ -6456,8 +6461,11 @@ export function registerProjectWorkbenchFoundationTests() { 'allow-scripts allow-same-origin allow-forms allow-pointer-lock', ); expect(screen.queryByLabelText('测试切片控件')).toBeNull(); - expect(screen.getByLabelText('资源信息面板')).not.toBeNull(); - expect(screen.getByLabelText('数值微调面板')).not.toBeNull(); + // 底部信息栏没有内容就整栏不渲染:此时既没有选中资源(信息展示没有字段),也没有 + // 已登记的微调项。留着两张空卡片正是验收现场那半条「空信息栏白占一块高度」。 + expect(screen.queryByLabelText('资源信息面板')).toBeNull(); + expect(screen.queryByLabelText('数值微调面板')).toBeNull(); + expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull(); fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click(screen.getByRole('button', { name: '按类型' })); diff --git a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx new file mode 100644 index 000000000..2b289d564 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx @@ -0,0 +1,142 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; + +const PREVIEW_URL = 'http://127.0.0.1:4173/'; + +function installInvoke() { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: [], + referenceEdges: [], + taskFlows: [], + categories: [], + diagnostics: [], + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }), + }, + } as unknown as typeof window.__TAURI__; +} + +function renderRunView() { + installInvoke(); + const manifest = createGameCreationAppManifest( + 'run-preview-fullscreen', + '运行页全屏预览', + ); + render( + 项目总控} + onHomeOpen={vi.fn()} + onProjectsOpen={vi.fn()} + onNotice={vi.fn()} + />, + ); +} + +/** + * jsdom 完全没有 Fullscreen API(`document.fullscreenEnabled` / `requestFullscreen` / + * `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来: + * 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。 + */ +function installFullscreenHost() { + let fullscreenElement: Element | null = null; + const requestFullscreen = vi.fn(function (this: Element) { + // 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。 + // eslint-disable-next-line @typescript-eslint/no-this-alias + fullscreenElement = this; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + const exitFullscreen = vi.fn(() => { + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + return Promise.resolve(); + }); + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: exitFullscreen, + }); + Object.defineProperty(Element.prototype, 'requestFullscreen', { + configurable: true, + writable: true, + value: requestFullscreen, + }); + return { requestFullscreen, exitFullscreen }; +} + +afterEach(() => { + document.body.innerHTML = ''; + Reflect.deleteProperty(document, 'fullscreenElement'); + Reflect.deleteProperty(document, 'exitFullscreen'); + Reflect.deleteProperty(Element.prototype, 'requestFullscreen'); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('运行页「全屏预览」', () => { + it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => { + const { requestFullscreen, exitFullscreen } = installFullscreenHost(); + renderRunView(); + + const button = await screen.findByRole('button', { name: '全屏预览' }); + // 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。 + const stage = document.querySelector('.game-run-preview'); + expect(stage).not.toBeNull(); + expect(button.closest('.game-run-preview')).toBe(stage); + expect(button.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(button); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + // 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。 + expect(document.fullscreenElement).toBe(stage); + const exitButton = await screen.findByRole('button', { + name: '退出全屏预览', + }); + expect(exitButton.getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(exitButton); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + expect( + (await screen.findByRole('button', { name: '全屏预览' })).getAttribute( + 'aria-pressed', + ), + ).toBe('false'); + }); + + it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => { + renderRunView(); + + await screen.findByTitle('运行页全屏预览 游戏运行画面'); + expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull(); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index cdf6b4e73..a0eeccd0e 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -75,7 +75,8 @@ - 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。 - 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 -- 运行视窗下方继续保留“信息展示”和“数值微调”区域标题及原有面板高度;没有真实资源信息或已登记微调项时,内容区域保持空白,不显示示例字段、默认数值、未载入控件或功能说明,也不得因内容为空压缩两个面板。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 +- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 +- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是运行页里选中了资源,渲染它的只读字段):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 - 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1b42991f..3dc97ef45 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏 + +- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。 +- 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。 +- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 运行页里选中了资源,信息展示渲染它的只读字段):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。 +- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。 +- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1924 项)、编码检查与 `git diff --check` 通过;并用真实 Chromium 对工作台冒烟:右下角按钮只把画面那一格送进全屏且可退出、选中资源后信息栏自动展开、收起后画面变高、清空选中后整栏消失。 + ## 2026-09-23 AGC 发布前先守可运行原型门禁 - 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 10b7897e9..ee894dd0a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -974,7 +974,7 @@ game-project/ - 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。 -- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 +- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(运行页选中资源,渲染只读素材信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。 From 16361bfbe1767679d5a3ebaa240b0cdd12dcbfad Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 18:38:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E5=87=BA?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E5=90=8E=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E4=B8=8D=E5=9B=9E=E7=BC=A9=EF=BC=8C=E5=B9=B6=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=A0=8F=E5=BC=80=E5=90=88=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resolveLocalGamePreviewFitLayout` 增加回退:上报的内容尺寸等于它被接受时的容器尺寸(自适应页面把视口原样报回)时不算内容高水位,直接按容器取画布——修复「全屏预览退出后画布仍保持全屏比例、永久带黑边」;真比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。 - 该回退的已知残余(固定尺寸页面恰好等于接受时的容器且之后不上报新尺寸会被裁)与「内容尺寸没变就改认新容器」这条更差尝试一起写进代码注释与 decision-log;根治方向记在桥/协议侧。 - `tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具,补「退出全屏回到容器尺寸」(改前必红)与「容器缩小后按新内容尺寸重新适配」两条用例。 - 信息栏手动态改为按资源 id 在渲染期派生(去掉 effect 回写),修掉「刚有内容那一帧先画收起态」的抖动,并让 `onClick` 写入的字段与派生读的字段一致。 - AGC 子集删掉恒真的「数值微调面板不存在」断言,保留「卡片不渲染」与「开合行不渲染」两条独立事实。 - 文档同步:PRD §3.4、技术方案与 decision-log 的信息栏判据口径改为「资源选中态」,并记录全屏回归的根因、修法、残余边界与桥协议根治方向;2026-08-23 适配条目补一条指向本次收口。 --- .../LocalGamePreviewFrame.tsx | 18 +- .../src/view/project-development/index.tsx | 23 ++- .../appSurface/project-development.suite.ts | 5 +- .../tests/localGamePreviewFrame.test.ts | 192 ++++++++++++------ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- .../shared-memory/decision-log.md | 7 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 7 files changed, 176 insertions(+), 73 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 59faebc5e..880adb5c3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -186,7 +186,23 @@ export function resolveLocalGamePreviewFitLayout( ): LocalGamePreviewFitLayout { const containerWidth = Math.max(1, container.width); const containerHeight = Math.max(1, container.height); - if (!content) { + // 上报的「内容尺寸」等于它被接受时的容器尺寸,说明这个页面没有超出视口的固有内容——自适应的 + // 全屏游戏(Phaser `Scale.RESIZE` 那类)就是这样,桥把视口原样报回来。这份数字不携带固有尺寸, + // 不能当高水位:否则运行视口一放大(例如全屏预览)就把画布钉在那个尺寸上,退出全屏后画布仍按 + // 全屏比例被缩进小容器、两边留出黑边,且再也回不去(iframe 视口不变 ⇒ 桥不会再报新尺寸)。 + // 这种页面继续让画布跟着容器走;真的比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。 + // + // 已知残余(不修,因为它与上面这条在数据上不可区分):某个**固定尺寸**页面恰好等于它被接受时 + // 的容器(1px 内),且缩小容器后上报的内容尺寸再不变,就会一直按容器取画布——页面自身溢出被 + // `overflow: hidden` 裁掉。改成「内容尺寸没变也把这条记录改认新容器」会反过来让上面那种自适应 + // 页面的过渡期上报(内容仍是放大前的旧值、视口已是新容器)被当成固有尺寸,全屏那类问题原样 + // 复现(实测过)。AGC 的桥对溢出文档才报出更大的内容尺寸,实测自适应与固定画布两种页面都报 + // 「内容 = 视口」,所以按自适应优先。 + if ( + !content || + (Math.abs(content.contentWidth - content.viewportWidth) < 1 && + Math.abs(content.contentHeight - content.viewportHeight) < 1) + ) { return { width: containerWidth, height: containerHeight, scale: 1 }; } const width = Math.max(containerWidth, content.contentWidth); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 4117d42ba..3b03ac6b0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -3013,16 +3013,24 @@ export default function ProjectDevelopmentView({ * 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里 * 手动收 / 展则一直有效,不会被别的渲染重开。 * + * 手动态不挂 effect、也不按下标存活,而是在渲染期按**资源 id** 判定:手动的收 / 展只对 + * 做出这个动作时的那张资源有效,换资源或清空后回到默认(有内容就展开)。这样「刚有内容」 + * 的那一帧就已经是展开态——挂 effect 回写会先画一帧收起态再展开,画面高度会抖一下。 + * * 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等 * 后端编辑态登记表接进来后,它与它的内容一起进这个判据。 */ const runPanelsHaveContent = selectedResource !== null; - const [runPanelsExpanded, setRunPanelsExpanded] = - useState(runPanelsHaveContent); + const [runPanelsManualState, setRunPanelsManualState] = useState<{ + resourceId: string | null; + expanded: boolean; + } | null>(null); + const runPanelsExpanded = + runPanelsManualState !== null && + runPanelsManualState.resourceId === selectedResourceId + ? runPanelsManualState.expanded + : runPanelsHaveContent; const runPanelsBodyId = useId(); - useEffect(() => { - setRunPanelsExpanded(runPanelsHaveContent); - }, [runPanelsHaveContent]); /** * 画布选中工具栏「编辑标签」的入口判定: * - 选中 1 项:既有的单素材标签编辑(增删标签行为不变); @@ -11085,7 +11093,10 @@ export default function ProjectDevelopmentView({ aria-expanded={runPanelsExpanded} aria-controls={runPanelsBodyId} onClick={() => - setRunPanelsExpanded((current) => !current) + setRunPanelsManualState({ + resourceId: selectedResourceId, + expanded: !runPanelsExpanded, + }) } > + ({ + ...containerRect, + x: 0, + y: 0, + top: 0, + right: containerRect.width, + bottom: containerRect.height, + left: 0, + toJSON: () => ({}), + }) as DOMRect, + ); + const previousResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} + unobserve() {} + disconnect() {} + }; + const view = render( + createElement(LocalGamePreviewFrame, { + preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, + title: 'preview', + }), + ); + const iframe = view.getByTitle('preview') as HTMLIFrameElement; + return { + iframe, + resizeTo(next: { width: number; height: number }) { + containerRect = next; + act(() => { + if (!resizeCallback) { + throw new Error('ResizeObserver was not registered'); + } + resizeCallback([], {} as ResizeObserver); + }); + }, + reportSize(size: LocalGamePreviewContentSize) { + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + origin: 'http://127.0.0.1:1234', + source: iframe.contentWindow, + data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, ...size }, + }), + ); + }); + }, + cleanup() { + view.unmount(); + window.ResizeObserver = previousResizeObserver; + rectSpy.mockRestore(); + }, + }; +} + describe('local game preview viewport fitting', () => { it('does not reset the fitted iframe to native size while its container resizes', () => { - let containerRect = { width: 800, height: 500 }; - let resizeCallback: ResizeObserverCallback | null = null; - const rectSpy = vi - .spyOn(HTMLElement.prototype, 'getBoundingClientRect') - .mockImplementation( - () => - ({ - ...containerRect, - x: 0, - y: 0, - top: 0, - right: containerRect.width, - bottom: containerRect.height, - left: 0, - toJSON: () => ({}), - }) as DOMRect, - ); - const previousResizeObserver = window.ResizeObserver; - window.ResizeObserver = class { - constructor(callback: ResizeObserverCallback) { - resizeCallback = callback; - } - - observe() {} - unobserve() {} - disconnect() {} - }; - - const view = render( - createElement(LocalGamePreviewFrame, { - preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, - title: 'preview', - }), - ); - const iframe = view.getByTitle('preview') as HTMLIFrameElement; - act(() => { - window.dispatchEvent( - new MessageEvent('message', { - origin: 'http://127.0.0.1:1234', - source: iframe.contentWindow, - data: { - type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, - contentWidth: 800, - contentHeight: 835, - viewportWidth: 800, - viewportHeight: 500, - }, - }), - ); + const frame = renderFittedFrame({ width: 800, height: 500 }); + frame.reportSize({ + contentWidth: 800, + contentHeight: 835, + viewportWidth: 800, + viewportHeight: 500, }); - expect(iframe.style.height).toBe('835px'); + expect(frame.iframe.style.height).toBe('835px'); - containerRect = { width: 1000, height: 600 }; - act(() => { - if (!resizeCallback) throw new Error('ResizeObserver was not registered'); - resizeCallback([], {} as ResizeObserver); + frame.resizeTo({ width: 1000, height: 600 }); + expect(frame.iframe.style.width).toBe('1000px'); + expect(frame.iframe.style.height).toBe('835px'); + + frame.cleanup(); + }); + + it('returns the fitted iframe to the container after the host viewport shrinks', () => { + // 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着 + // 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。 + const frame = renderFittedFrame({ width: 1416, height: 808 }); + frame.reportSize({ + contentWidth: 1416, + contentHeight: 808, + viewportWidth: 1416, + viewportHeight: 808, }); - expect(iframe.style.width).toBe('1000px'); - expect(iframe.style.height).toBe('835px'); + expect(frame.iframe.style.width).toBe('1416px'); + expect(frame.iframe.style.height).toBe('808px'); - view.unmount(); - window.ResizeObserver = previousResizeObserver; - rectSpy.mockRestore(); + frame.resizeTo({ width: 1015, height: 660 }); + expect(frame.iframe.style.width).toBe('1015px'); + expect(frame.iframe.style.height).toBe('660px'); + + frame.cleanup(); + }); + + it('refits to the reported content size after the container shrinks', () => { + // 容器缩小后先按容器取画布(上一次的内容尺寸已不能代表当前容器),页面在新容器上重新量出 + // 更大的内容(真的溢出)时,画布回到 `max(容器, 内容)` 并把整幅内容等比缩小。 + const frame = renderFittedFrame({ width: 1015, height: 660 }); + frame.reportSize({ + contentWidth: 1015, + contentHeight: 660, + viewportWidth: 1015, + viewportHeight: 660, + }); + expect(frame.iframe.style.width).toBe('1015px'); + + frame.resizeTo({ width: 800, height: 520 }); + expect(frame.iframe.style.width).toBe('800px'); + + frame.reportSize({ + contentWidth: 1200, + contentHeight: 900, + viewportWidth: 800, + viewportHeight: 520, + }); + expect(frame.iframe.style.width).toBe('1200px'); + expect(frame.iframe.style.height).toBe('900px'); + expect( + Number(/scale\(([\d.]+)\)/u.exec(frame.iframe.style.transform)?.[1]), + ).toBeCloseTo(520 / 900, 10); + + frame.cleanup(); }); it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index a0eeccd0e..14b25c309 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -76,7 +76,7 @@ - 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。 - 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `` / `` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。 - 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮。 -- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是运行页里选中了资源,渲染它的只读字段):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 +- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。 - 数值修改立即写入当前项目的编辑态配置。 - 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 39a8d52e3..1123b8cdf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,9 +4,11 @@ - 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。 - 决策一:新增 `useElementFullscreen`(`apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**(`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`,Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。 -- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 运行页里选中了资源,信息展示渲染它的只读字段):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。 +- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 存在资源选中态——在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息展示渲染它的只读字段;运行画面上的「点选素材」只往对话插入引用,不改选中):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。手动态按资源 id 在渲染期派生(不挂 effect 回写):手动收 / 展只对做出动作时的那张资源有效,换到别的资源回到默认(有内容即展开),同一张资源即使清空选中后再选回也仍记得上一次的手动状态;这样「刚有内容」的那一帧就已经是展开态,不会先画一帧收起态再展开。 - 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。 -- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1924 项)、编码检查与 `git diff --check` 通过;并用真实 Chromium 对工作台冒烟:右下角按钮只把画面那一格送进全屏且可退出、选中资源后信息栏自动展开、收起后画面变高、清空选中后整栏消失。 +- 决策四(同日收口全屏回归):用户报「退出全屏后画布仍保持全屏比例」。根因不在全屏本身,而在运行画面的尺寸上报回灌——自适应页面把视口原样报回(内容尺寸 = 容器尺寸),宿主把它当成「内容高水位」,`resolveLocalGamePreviewFitLayout` 的 `max(容器, 内容)` 就把画布钉在全屏那一帧的尺寸上;退出后 iframe 视口不再变化,桥也不会再上报,于是永远回不去(实测 1015×660 → 全屏 1416×808 → 退出仍是 1416×808、缩放到 0.72,画面按全屏比例缩成一条带黑边的窄幅)。修法:内容尺寸与它被接受时的容器尺寸在两个轴上都相等(<1px)时不算高水位,直接按容器尺寸给画布;真比容器高的页面(内容 ≠ 视口,桥注入的原始动机)仍按原生尺寸缩放显示。回归用例 `tests/localGamePreviewFrame.test.ts` 的 `returns the fitted iframe to the container after the host viewport shrinks`(改前必红,实测 1416px vs 1015px)。 +- 决策四的残余边界(明确不修):若某个**固定尺寸**页面恰好等于它被接受时的容器尺寸,且缩小容器后它上报的内容尺寸再不变,就会一直按容器取画布(页面自身溢出被裁)。评审提过「内容尺寸没变也把这条记录改认新容器」,我实现后又**实测回退**了:那条过渡期上报(内容还是放大前的旧值、视口已是缩小后的容器)会被当成固有尺寸,全屏那类问题原样复现且同样永久(iframe 回到旧尺寸后桥不再上报)。两者在宿主拿到的数据上不可区分,按 AGC 常态(桥对自适应与「固定画布但自适应文档」两类页面实测都报「内容 = 视口」)选自适应优先;页面报告新内容尺寸时立即回到 `max(容器, 内容)` 等比缩小(用例 `refits to the reported content size after the container shrinks`)。根治方向在桥 / 协议侧:尺寸消息再带一个「本页是否视口耦合」的布尔(桥内部已有逐元素耦合采样与排除耦合后的边界),拟合直接按它判定,不必用两个数字相等去猜——属桥与协议的独立变更,本 PR 不做。 +- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);`tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具并补上面两条用例;AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染(`数值微调面板` 这条已随删除面消失的 label 断言同步删掉,避免恒真)。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1812 项,合并上游退役提交后的口径)、编码检查与 `git diff --check` 通过;并用真实 Chromium(挂同一份组件 + 客户端真实注入的尺寸桥脚本,`fullbleed` 与 `fixed` 两种游戏页)冒烟:右下角按钮只把画面那一格送进全屏且可退出、退出后画布缩回容器尺寸、选中资源后信息栏自动展开(190px)、收起后画面变高(26px→636px)、再展开恢复。 ## 2026-09-23 最近项目检查失败不进终态 @@ -8394,6 +8396,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。 - 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。 - 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。 +- 补充(2026-09-23):上面「容器 resize 期间保留内容尺寸」只对**真比容器高 / 宽**的页面成立。上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回。否则运行画面被放大一次(例如「全屏预览」)就会把画布钉在那个尺寸上,退出后仍按全屏比例缩进小容器,且 iframe 视口不变 ⇒ 桥不再上报 ⇒ 永远回不去。判据与回归用例见 2026-09-23 两条条目(`resolveLocalGamePreviewFitLayout` 的回退分支与 `returns the fitted iframe to the container after the host viewport shrinks`)。 ## 2026-08-23 Direct Codex 显式重生成与切片一等资源 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index fe9ac39be..bc4f31924 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -967,7 +967,7 @@ game-project/ - 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category`(`UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。 -- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(运行页选中资源,渲染只读素材信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 +- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(判据是资源画布或浮层资源面板的资源选中态,切到运行页签后仍保留;信息栏渲染该资源的只读信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `` / ``。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;但上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回,否则全屏预览退出后画布会被钉在全屏那一帧的尺寸上。preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。 - 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。 - 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。 - 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。