From 95b08ac3e517512c7e8006181bca5241ce123586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:18:01 +0800 Subject: [PATCH 01/24] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=20Markdown=20=E6=B8=B2=E6=9F=93=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录三套项目聊天视图共享消息结构 明确 accumulatedText 流式正文与安全渲染边界 约定 Tailwind 内联样式和后续外链 TODO --- ...【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8e886e8a4..92b221082 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1274,3 +1274,12 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - `autonomous-game-build` 运行档位采用自主并行编排:manifest `dependencies` 仅作为上下文提示,不参与 ready 判定;代码、设计、美术、音频和发布 child 可以按调度器可用性并行启动。child 不要求固定 task ID、owner、parent-child lineage、run ID 或回执顺序,完成投影按同一项目事实幂等收束。 - 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。 - 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish`、`art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。 + +## 2026-09-04 AGC 项目聊天 Markdown 与流式回复渲染 + +- 项目开发工作台的 `ProjectWorkspaceChatPane`、`ProjectSupervisorView` 与 `SupervisorChatOnlyView` 继续共享 `ChatMessage` / `visibleMessages` 数据结构;聊天 Markdown 只作为前端表现层能力,不新增消息字段、持久化格式、后端 DTO 或事件协议。 +- 三个视图统一复用 `apps/ai-game-creator-shell/src/components/ChatMarkdownMessage`。assistant 历史消息和流式临时回复使用 `react-markdown + remark-gfm` 渲染;用户消息与命令草稿保持纯文本。调用方仍先执行现有的 `projectSupervisorVisibleConversationText` 安全文案归一化,再交给展示组件。 +- 流式回复以事件中的 `accumulatedText` 作为当前完整草稿:每次更新替换上一版临时正文,不在渲染层自行拼接 `deltaText`。既有 `runId` / sequence 去重、最终 assistant 持久化和历史回放语义保持不变。 +- Markdown 禁止原始 HTML;链接和图片只显示普通文本,不产生可点击或可加载的外部资源。后续若开放安全外链,必须另行评估协议白名单、窗口策略和审计边界,并保留实现 TODO。 +- Markdown 元素样式使用 Tailwind 内联 class,限定在聊天消息组件内部,不改全局 `.message`、资源文档预览或启动器全局 Agent 聊天。组件异常按单条消息回退纯文本,不能使整个聊天面板崩溃。 +- 流式更新沿用“仅在用户接近底部时跟随”的滚动语义;用户主动查看历史时不得被实时 Markdown 高度变化强制拉回底部。实现验收需覆盖 GFM、未闭合 Markdown 中间态、HTML/链接/图片安全、三视图一致性、流式去重、历史回放和桌面/移动视口。 -- 2.52.0 From 141eff203d402a0aeab5bbeef24579849a40dc8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:46:47 +0800 Subject: [PATCH 02/24] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=81=8A=E5=A4=A9=20Ma?= =?UTF-8?q?rkdown=20=E6=B6=88=E6=81=AF=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 assistant 消息提供 GFM 与流式 Markdown 渲染 限制 HTML、链接和图片为纯文本展示 使用 Tailwind 内联样式并提供单条消息降级 --- .../components/ChatMarkdownMessage/index.tsx | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx new file mode 100644 index 000000000..9920408ea --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -0,0 +1,141 @@ +import type { ErrorInfo, ReactNode } from 'react'; +import { Component } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +export type ChatMarkdownMessageProps = { + text: string; + role: 'assistant' | 'user'; + streaming?: boolean; +}; + +type MarkdownErrorBoundaryProps = { + fallbackText: string; + children: ReactNode; +}; + +type MarkdownErrorBoundaryState = { + hasError: boolean; +}; + +class MarkdownErrorBoundary extends Component< + MarkdownErrorBoundaryProps, + MarkdownErrorBoundaryState +> { + state: MarkdownErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): MarkdownErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(_error: unknown, _errorInfo: ErrorInfo) { + // Keep the original message visible without logging its potentially sensitive content. + } + + render() { + if (this.state.hasError) { + return ( + + {this.props.fallbackText} + + ); + } + return this.props.children; + } +} + +const markdownComponents: Components = { + a: ({ children }) => {children}, + img: ({ alt }) => {alt?.trim() ? `图片:${alt}` : '图片已省略'}, + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + p: ({ children }) =>

{children}

, + ul: ({ children }) => ( + + ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + code: ({ className, children, ...props }) => { + const isBlock = Boolean(className?.includes('language-')); + return isBlock ? ( + + {children} + + ) : ( + + {children} + + ); + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () => ( +
    + ), +}; + +export function ChatMarkdownMessage({ + text, + role, + streaming = false, +}: ChatMarkdownMessageProps) { + if (role === 'user') { + return {text}; + } + + return ( + +
    + + {text} + +
    +
    + ); +} -- 2.52.0 From dcdd13bc1cb0528245bd4354a0cd5bfe052b6142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:50:05 +0800 Subject: [PATCH 03/24] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=20Markdown=20=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一三个项目聊天视图的 assistant 消息展示 让 Direct Codex 流式回复复用 Markdown 组件 移除纯聊天视图的无条件滚动到底部行为 --- .../ProjectSupervisorView.tsx | 36 +++++++++++++------ .../ProjectWorkspaceChatPane.tsx | 30 +++++++++++----- .../SupervisorChatOnlyView.tsx | 35 ++++++++++-------- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 4dfe14403..5c2ef58e9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -16,6 +16,7 @@ import type { PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; +import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import { projectProfessionalAgentLabel, projectRuntimeVisibleError, @@ -142,15 +143,22 @@ export function ProjectSupervisorView({ ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    + + ))} {directCodex && (runtimePanelProps.controlBusy || Boolean(transientReply)) ? ( @@ -167,18 +175,26 @@ export function ProjectSupervisorView({ {directActivity || '陶泥儿正在处理'} {transientReply ? ( -

    {transientReply}

    + ) : null} ) : transientReply ? ( -

    - {transientReply} -

    + + ) : null} {directCodex ? null : isPlanningLaneRuntime( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index f70d648c7..60b9b11ba 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -26,6 +26,7 @@ import type { PendingCommand, PendingUiConfirmation, } from '../../app/types'; +import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import type { ProjectAgentResultSummary } from '../../view/project-development'; import { formatAgentRecentRuntimeTask, @@ -804,12 +805,19 @@ export function ProjectWorkspaceChatPane({ ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    +
    + +
    {message.draftCommand ? ( ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    + + ))} {transientReply ? ( -

    - {transientReply} -

    + + ) : null} {running && !transientReply ? (
    Date: Fri, 4 Sep 2026 11:52:12 +0800 Subject: [PATCH 04/24] =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=81=8A=E5=A4=A9=20Ma?= =?UTF-8?q?rkdown=20=E6=B8=B2=E6=9F=93=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 GFM、代码块和表格展示 覆盖用户纯文本与流式中间态 覆盖 HTML、链接和图片安全边界 --- .../tests/ChatMarkdownMessage.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx new file mode 100644 index 000000000..b679cd6d5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { ChatMarkdownMessage } from '../src/components/ChatMarkdownMessage'; + +describe('ChatMarkdownMessage', () => { + it('渲染 assistant 的 GFM 内容与代码块', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h2')?.textContent).toBe('标题'); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.querySelector('pre code')?.textContent).toContain( + 'const answer = 42;', + ); + }); + + it('保留流式 assistant 文本并标记 streaming 状态', () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-streaming="true"]')).toBeTruthy(); + expect(container.textContent).toContain('正在生成'); + expect(container.textContent).toContain('结果'); + }); + + it('用户消息保持纯文本,不解析 Markdown', () => { + const { container } = render( + , + ); + + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('code')).toBeNull(); + expect(container.textContent).toContain('**不要解析**'); + expect(container.textContent).toContain('`/read game`'); + }); + + it('不输出原始 HTML、可点击链接或图片节点', () => { + const { container } = render( + alert(1)\n\n[外链](https://example.com) ![示意图](x.png)' + } + />, + ); + + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain('外链'); + expect(container.textContent).toContain('图片:示意图'); + }); + + it('支持表格并允许窄视口横向滚动', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('table th')).toHaveLength(2); + expect(container.querySelector('div.overflow-x-auto')).toBeTruthy(); + }); +}); -- 2.52.0 From c0d851c36945330c316dcb6eee8e839a0b10a79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 11:57:59 +0800 Subject: [PATCH 05/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=81=8A=E5=A4=A9=20Ma?= =?UTF-8?q?rkdown=20=E7=BA=AF=E6=96=87=E6=9C=AC=E9=93=BE=E6=8E=A5=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让链接和图片渲染为直接文本节点 避免既有消息文本查询被 Markdown 节点拆分 补充后续安全外链 TODO --- .../src/components/ChatMarkdownMessage/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 9920408ea..a08779ac3 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -45,8 +45,9 @@ class MarkdownErrorBoundary extends Component< } const markdownComponents: Components = { - a: ({ children }) => {children}, - img: ({ alt }) => {alt?.trim() ? `图片:${alt}` : '图片已省略'}, + // TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。 + a: ({ children }) => children, + img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'), h1: ({ children }) => (

    {children}

    ), -- 2.52.0 From 31c37534b46e991bc51e0e51b309a17502a06bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:05:54 +0800 Subject: [PATCH 06/24] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=81=8A=E5=A4=A9=20Ma?= =?UTF-8?q?rkdown=20=E5=AE=89=E5=85=A8=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Direct Codex 流式正文恢复可访问标签 将 HTML 标签转义为可见纯文本 保留 Markdown 内容安全渲染边界 --- .../src/components/ChatMarkdownMessage/index.tsx | 12 +++++++++--- .../project-workspace/ProjectSupervisorView.tsx | 12 +++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index a08779ac3..50f8845d8 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -59,12 +59,12 @@ const markdownComponents: Components = { ), p: ({ children }) =>

    {children}

    , ul: ({ children }) => ( -
      {children}
    +
      {children}
    ), ol: ({ children }) => (
      {children}
    ), - li: ({ children }) =>
  • {children}
  • , + li: ({ children }) =>
  • - {children}
  • , blockquote: ({ children }) => (
    {children} @@ -112,6 +112,12 @@ const markdownComponents: Components = { ), }; +function escapeHtmlTagsForDisplay(text: string) { + return text.replace(/<\/?[A-Za-z][^>]*>/gu, (tag) => + tag.replaceAll('<', '<').replaceAll('>', '>'), + ); +} + export function ChatMarkdownMessage({ text, role, @@ -134,7 +140,7 @@ export function ChatMarkdownMessage({ remarkPlugins={[remarkGfm]} components={markdownComponents} > - {text} + {escapeHtmlTagsForDisplay(text)}
    diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 5c2ef58e9..de45d73d9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -175,11 +175,13 @@ export function ProjectSupervisorView({ {directActivity || '陶泥儿正在处理'} {transientReply ? ( - +
    + +
    ) : null} ) : transientReply ? ( -- 2.52.0 From 08ea5962acd8c4bd8815528cc7c8dbbd8c9d23ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:27:22 +0800 Subject: [PATCH 07/24] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=81=8A=E5=A4=A9=20Ma?= =?UTF-8?q?rkdown=20=E6=B5=8B=E8=AF=95=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整消息断言以适配 Markdown 段落和列表结构 补充预览快捷键消息气泡查找工具 完善流式 Markdown 样式并同步组件测试 --- .../components/ChatMarkdownMessage/index.tsx | 46 +++++----- .../tests/ChatMarkdownMessage.test.tsx | 1 - .../appSurface/project-commands.suite.ts | 8 +- .../assert-planning-and-status-shortcuts.ts | 71 ++++++++++----- .../assert-playtest-and-release-shortcuts.ts | 66 +++++++++----- .../assert-project-and-design-shortcuts.ts | 89 +++++++++++++------ .../assert-project-tools-and-preview.ts | 35 ++++---- .../preview-shortcuts/message-bubble.ts | 7 ++ 8 files changed, 210 insertions(+), 113 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 50f8845d8..139b15567 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -57,21 +57,25 @@ const markdownComponents: Components = { h3: ({ children }) => (

    {children}

    ), - p: ({ children }) =>

    {children}

    , + p: ({ children }) => ( +

    {children}

    + ), ul: ({ children }) => ( -
      {children}
    +
      {children}
    ), ol: ({ children }) => ( -
      {children}
    +
      + {children} +
    ), li: ({ children }) =>
  • - {children}
  • , blockquote: ({ children }) => ( -
    +
    {children}
    ), pre: ({ children }) => ( -
    +    
           {children}
         
    ), @@ -91,7 +95,7 @@ const markdownComponents: Components = { ); }, table: ({ children }) => ( -
    +
    {children}
    @@ -108,7 +112,14 @@ const markdownComponents: Components = { ), hr: () => ( -
    +
    + ), +}; + +const streamingMarkdownComponents: Components = { + ...markdownComponents, + p: ({ children }) => ( +

    {children}

    ), }; @@ -129,20 +140,15 @@ export function ChatMarkdownMessage({ return ( -
    - - {escapeHtmlTagsForDisplay(text)} - -
    + {escapeHtmlTagsForDisplay(text)} +
    ); } diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index b679cd6d5..ae877f8db 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -30,7 +30,6 @@ describe('ChatMarkdownMessage', () => { />, ); - expect(container.querySelector('[data-streaming="true"]')).toBeTruthy(); expect(container.textContent).toContain('正在生成'); expect(container.textContent).toContain('结果'); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index b5719be6c..f82a6183f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -3805,7 +3805,13 @@ export function registerProjectCommandTests() { submitChat('/files'); expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); - expect(screen.getByText(/- game\//)).not.toBeNull(); + expect( + screen.getByText( + (_, element) => + element?.tagName === 'LI' && + element.textContent?.trim() === '- game/', + ), + ).not.toBeNull(); expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); expect(screen.getByText(/- assets\/uploads\/hero\.png/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('list_local_project_files', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index d93100fa5..627b7aa60 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlanningAndStatusShortcutFlow( invoke: PreviewShortcutInvoke, @@ -70,7 +71,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/blockers'); const blockerMessages = await screen.findAllByText(/当前阻塞项:/); - const blockerMessage = blockerMessages[blockerMessages.length - 1]; + const blockerMessage = messageBubble( + blockerMessages[blockerMessages.length - 1], + ); expect(blockerMessage.textContent).toContain('项目:未命名游戏原型'); expect(blockerMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -170,7 +173,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/ready'); const readyMessages = await screen.findAllByText(/试玩就绪度:/); - const readyMessage = readyMessages[readyMessages.length - 1]; + const readyMessage = messageBubble(readyMessages[readyMessages.length - 1]); expect(readyMessage.textContent).toContain('项目:未命名游戏原型'); expect(readyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -270,7 +273,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/evidence'); const evidenceMessages = await screen.findAllByText(/验证证据台账:/); - const evidenceMessage = evidenceMessages[evidenceMessages.length - 1]; + const evidenceMessage = messageBubble( + evidenceMessages[evidenceMessages.length - 1], + ); expect(evidenceMessage.textContent).toContain('项目:未命名游戏原型'); expect(evidenceMessage.textContent).toContain( 'Run trace:已有 run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -377,7 +382,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/deps'); const dependencyMessages = await screen.findAllByText(/任务依赖链:/); - const dependencyMessage = dependencyMessages[dependencyMessages.length - 1]; + const dependencyMessage = messageBubble( + dependencyMessages[dependencyMessages.length - 1], + ); expect(dependencyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -479,7 +486,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/revise'); const revisionMessages = await screen.findAllByText(/改版草稿:/); - const revisionMessage = revisionMessages[revisionMessages.length - 1]; + const revisionMessage = messageBubble( + revisionMessages[revisionMessages.length - 1], + ); expect(revisionMessage.textContent).toContain('项目:未命名游戏原型'); expect(revisionMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -604,7 +613,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/privacy'); const privacyMessages = await screen.findAllByText(/隐私与导出边界:/); - const privacyMessage = privacyMessages[privacyMessages.length - 1]; + const privacyMessage = messageBubble( + privacyMessages[privacyMessages.length - 1], + ); expect(privacyMessage.textContent).toContain('项目:未命名游戏原型'); expect(privacyMessage.textContent).toContain( '本地目录:/tmp/authorized-game', @@ -616,10 +627,10 @@ export async function assertPlanningAndStatusShortcutFlow( '本地预览:未启动;仅限 127.0.0.1 本机访问', ); expect(privacyMessage.textContent).toContain( - '试玩包:尚未导出;只应包含 game/**、assets/** 和 exports/README.md', + '试玩包:尚未导出;只应包含 game/、assets/ 和 exports/README.md', ); expect(privacyMessage.textContent).toContain( - '内部文件:.agent/**、memory/**、日志、trace、配置和密钥不得进入试玩包', + '内部文件:.agent/、memory/、日志、trace、配置和密钥不得进入试玩包', ); expect(privacyMessage.textContent).toContain( '素材来源:2 个;上传 1 / 画板 1', @@ -697,7 +708,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/criteria'); expect(await screen.findByText(/当前验收标准:/)).not.toBeNull(); const criteriaMessages = screen.getAllByText(/当前验收标准:/); - const criteriaMessage = criteriaMessages[criteriaMessages.length - 1]; + const criteriaMessage = messageBubble( + criteriaMessages[criteriaMessages.length - 1], + ); expect(criteriaMessage.textContent).toContain( 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan)', ); @@ -740,7 +753,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/groups'); expect(await screen.findByText(/专业组进度:/)).not.toBeNull(); const groupMessages = screen.getAllByText(/专业组进度:/); - const groupMessage = groupMessages[groupMessages.length - 1]; + const groupMessage = messageBubble(groupMessages[groupMessages.length - 1]); expect(groupMessage.textContent).toContain( '美术组:完成 0/3 · active 0 · carry 0 · ready 1', ); @@ -793,7 +806,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/balance'); expect(await screen.findByText(/数值状态:/)).not.toBeNull(); const balanceMessages = screen.getAllByText(/数值状态:/); - const balanceMessage = balanceMessages[balanceMessages.length - 1]; + const balanceMessage = messageBubble( + balanceMessages[balanceMessages.length - 1], + ); expect(balanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(balanceMessage.textContent).toContain( 'balance-director:Director 确定数值口径 · 待处理', @@ -874,7 +889,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/budget'); expect(await screen.findByText(/运行预算:/)).not.toBeNull(); const budgetMessages = screen.getAllByText(/运行预算:/); - const budgetMessage = budgetMessages[budgetMessages.length - 1]; + const budgetMessage = messageBubble( + budgetMessages[budgetMessages.length - 1], + ); expect(budgetMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -923,7 +940,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/qa'); expect(await screen.findByText(/质量检查:/)).not.toBeNull(); const qaMessages = screen.getAllByText(/质量检查:/); - const qaMessage = qaMessages[qaMessages.length - 1]; + const qaMessage = messageBubble(qaMessages[qaMessages.length - 1]); expect(qaMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -988,7 +1005,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/changes'); expect(await screen.findByText(/最近变更:/)).not.toBeNull(); const changeMessages = screen.getAllByText(/最近变更:/); - const changeMessage = changeMessages[changeMessages.length - 1]; + const changeMessage = messageBubble( + changeMessages[changeMessages.length - 1], + ); expect(changeMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -1064,7 +1083,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/review'); expect(await screen.findByText(/评审状态:/)).not.toBeNull(); const reviewMessages = screen.getAllByText(/评审状态:/); - const reviewMessage = reviewMessages[reviewMessages.length - 1]; + const reviewMessage = messageBubble( + reviewMessages[reviewMessages.length - 1], + ); expect(reviewMessage.textContent).toContain('Evaluator:通过'); expect(reviewMessage.textContent).toContain('返工焦点:暂无'); expect(reviewMessage.textContent).toContain( @@ -1150,7 +1171,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/timeline'); expect(await screen.findByText(/项目时间线:/)).not.toBeNull(); const timelineMessages = screen.getAllByText(/项目时间线:/); - const timelineMessage = timelineMessages[timelineMessages.length - 1]; + const timelineMessage = messageBubble( + timelineMessages[timelineMessages.length - 1], + ); expect(timelineMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done', ); @@ -1209,7 +1232,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/handoff'); expect(await screen.findByText(/项目交接:/)).not.toBeNull(); const handoffMessages = screen.getAllByText(/项目交接:/); - const handoffMessage = handoffMessages[handoffMessages.length - 1]; + const handoffMessage = messageBubble( + handoffMessages[handoffMessages.length - 1], + ); expect(handoffMessage.textContent).toContain( '- Run:run-main-shortcut-trace', ); @@ -1335,14 +1360,14 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/todo'); const todoMessages = await screen.findAllByText(/下一轮小步:/); - const todoMessage = todoMessages[todoMessages.length - 1]; + const todoMessage = messageBubble(todoMessages[todoMessages.length - 1]); expect(todoMessage.textContent).toContain('项目:未命名游戏原型'); expect(todoMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(todoMessage.textContent).toContain('编排下一步:preview'); expect(todoMessage.textContent).toContain( - '1. ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', + 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', ); expect(todoMessage.textContent).toContain( '边界:只整理下一步;不读取任务文件;不启动 run;不修改项目', @@ -1428,7 +1453,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/plan'); const planMessages = await screen.findAllByText(/下一轮分工计划:/); - const planMessage = planMessages[planMessages.length - 1]; + const planMessage = messageBubble(planMessages[planMessages.length - 1]); expect(planMessage.textContent).toContain('项目:未命名游戏原型'); expect(planMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -1533,7 +1558,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/guide'); const guideMessages = await screen.findAllByText(/使用导引:/); - const guideMessage = guideMessages[guideMessages.length - 1]; + const guideMessage = messageBubble(guideMessages[guideMessages.length - 1]); expect(guideMessage.textContent).toContain('项目:未命名游戏原型'); expect(guideMessage.textContent).toContain('当前阶段:可导出'); expect(guideMessage.textContent).toContain( @@ -1627,7 +1652,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/progress'); const progressMessages = await screen.findAllByText(/项目进度:/); - const progressMessage = progressMessages[progressMessages.length - 1]; + const progressMessage = messageBubble( + progressMessages[progressMessages.length - 1], + ); expect(progressMessage.textContent).toContain('项目:未命名游戏原型'); expect(progressMessage.textContent).toContain('当前阶段:已生成'); expect(progressMessage.textContent).toMatch( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts index cbcc6989f..b829fe994 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlaytestAndReleaseShortcutFlow( invoke: PreviewShortcutInvoke, @@ -168,7 +169,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/listing'); const listingMessages = await screen.findAllByText(/作品页草稿:/); - const listingMessage = listingMessages[listingMessages.length - 1]; + const listingMessage = messageBubble( + listingMessages[listingMessages.length - 1], + ); expect(listingMessage.textContent).toContain('标题:未命名游戏原型'); expect(listingMessage.textContent).toContain( '一句话卖点:做一个厨房弹幕游戏', @@ -260,7 +263,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( ).length; submitChat('/playtest'); const playtestMessages = await screen.findAllByText(/试玩状态:/); - const playtestMessage = playtestMessages[playtestMessages.length - 1]; + const playtestMessage = messageBubble( + playtestMessages[playtestMessages.length - 1], + ); expect(playtestMessage.textContent).toContain( '原型:最近 run 已通过 run-main-shortcut-trace', ); @@ -327,26 +332,26 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/test-plan'); const testPlanMessages = await screen.findAllByText(/手动测试计划:/); - const testPlanMessage = testPlanMessages[testPlanMessages.length - 1]; + const testPlanMessage = messageBubble( + testPlanMessages[testPlanMessages.length - 1], + ); expect(testPlanMessage.textContent).toContain('项目:未命名游戏原型'); expect(testPlanMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(testPlanMessage.textContent).toContain( '当前证据:最近 run 已通过 run-main-shortcut-trace;预览 未启动;入口 未见 trace 产物', ); expect(testPlanMessage.textContent).toContain( - '1. 启动预览:/run 后确认首屏不空白', + '启动预览:/run 后确认首屏不空白', ); expect(testPlanMessage.textContent).toContain( - '2. 30 秒理解:目标、操作、得分/失败和重开可见', + '30 秒理解:目标、操作、得分/失败和重开可见', ); expect(testPlanMessage.textContent).toContain( - '3. 输入验证:键盘/点击/触屏至少一种可完成核心动作', + '输入验证:键盘/点击/触屏至少一种可完成核心动作', ); + expect(testPlanMessage.textContent).toContain('结局验证:胜利或失败后可重开'); expect(testPlanMessage.textContent).toContain( - '4. 结局验证:胜利或失败后可重开', - ); - expect(testPlanMessage.textContent).toContain( - '5. 回归检查:/mobile;/accessibility;/performance;/audio', + '回归检查:/mobile;/accessibility;/performance;/audio', ); expect(testPlanMessage.textContent).toContain( 'preview-readiness:程序组 / Preview 执行静态自检 · 待处理', @@ -430,7 +435,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/audience'); const audienceMessages = await screen.findAllByText(/首批试玩对象:/); - const audienceMessage = audienceMessages[audienceMessages.length - 1]; + const audienceMessage = messageBubble( + audienceMessages[audienceMessages.length - 1], + ); expect(audienceMessage.textContent).toContain('项目:未命名游戏原型'); expect(audienceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(audienceMessage.textContent).toContain( @@ -543,7 +550,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/invite'); const inviteMessages = await screen.findAllByText(/试玩邀请:/); - const inviteMessage = inviteMessages[inviteMessages.length - 1]; + const inviteMessage = messageBubble( + inviteMessages[inviteMessages.length - 1], + ); expect(inviteMessage.textContent).toContain('项目:未命名游戏原型'); expect(inviteMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(inviteMessage.textContent).toContain( @@ -721,7 +730,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/survey'); const surveyMessages = await screen.findAllByText(/试玩问卷:/); - const surveyMessage = surveyMessages[surveyMessages.length - 1]; + const surveyMessage = messageBubble( + surveyMessages[surveyMessages.length - 1], + ); expect(surveyMessage.textContent).toContain('项目:未命名游戏原型'); expect(surveyMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(surveyMessage.textContent).toContain( @@ -828,7 +839,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/cover'); const coverMessages = await screen.findAllByText(/封面与缩略图:/); - const coverMessage = coverMessages[coverMessages.length - 1]; + const coverMessage = messageBubble(coverMessages[coverMessages.length - 1]); expect(coverMessage.textContent).toContain('项目:未命名游戏原型'); expect(coverMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -937,7 +948,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/screenshots'); const screenshotMessages = await screen.findAllByText(/宣传截图:/); - const screenshotMessage = screenshotMessages[screenshotMessages.length - 1]; + const screenshotMessage = messageBubble( + screenshotMessages[screenshotMessages.length - 1], + ); expect(screenshotMessage.textContent).toContain('项目:未命名游戏原型'); expect(screenshotMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -1046,7 +1059,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/trailer'); const trailerMessages = await screen.findAllByText(/试玩短视频:/); - const trailerMessage = trailerMessages[trailerMessages.length - 1]; + const trailerMessage = messageBubble( + trailerMessages[trailerMessages.length - 1], + ); expect(trailerMessage.textContent).toContain('项目:未命名游戏原型'); expect(trailerMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(trailerMessage.textContent).toContain( @@ -1156,7 +1171,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/faq'); const faqMessages = await screen.findAllByText(/试玩 FAQ:/); - const faqMessage = faqMessages[faqMessages.length - 1]; + const faqMessage = messageBubble(faqMessages[faqMessages.length - 1]); expect(faqMessage.textContent).toContain('项目:未命名游戏原型'); expect(faqMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(faqMessage.textContent).toContain( @@ -1261,7 +1276,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/post'); const postMessages = await screen.findAllByText(/社区发布文案:/); - const postMessage = postMessages[postMessages.length - 1]; + const postMessage = messageBubble(postMessages[postMessages.length - 1]); expect(postMessage.textContent).toContain('项目:未命名游戏原型'); expect(postMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(postMessage.textContent).toContain( @@ -1372,7 +1387,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/store'); const storeMessages = await screen.findAllByText(/上架资料:/); - const storeMessage = storeMessages[storeMessages.length - 1]; + const storeMessage = messageBubble(storeMessages[storeMessages.length - 1]); expect(storeMessage.textContent).toContain('项目:未命名游戏原型'); expect(storeMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(storeMessage.textContent).toContain( @@ -1482,7 +1497,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/media-kit'); const mediaKitMessages = await screen.findAllByText(/媒体资料包:/); - const mediaKitMessage = mediaKitMessages[mediaKitMessages.length - 1]; + const mediaKitMessage = messageBubble( + mediaKitMessages[mediaKitMessages.length - 1], + ); expect(mediaKitMessage.textContent).toContain('项目:未命名游戏原型'); expect(mediaKitMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(mediaKitMessage.textContent).toContain( @@ -1589,8 +1606,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/release-notes'); const releaseNotesMessages = await screen.findAllByText(/试玩更新说明:/); - const releaseNotesMessage = - releaseNotesMessages[releaseNotesMessages.length - 1]; + const releaseNotesMessage = messageBubble( + releaseNotesMessages[releaseNotesMessages.length - 1], + ); expect(releaseNotesMessage.textContent).toContain('项目:未命名游戏原型'); expect(releaseNotesMessage.textContent).toContain( '一句话:做一个厨房弹幕游戏', @@ -1707,7 +1725,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/known-issues'); const knownIssueMessages = await screen.findAllByText(/已知问题清单:/); - const knownIssueMessage = knownIssueMessages[knownIssueMessages.length - 1]; + const knownIssueMessage = messageBubble( + knownIssueMessages[knownIssueMessages.length - 1], + ); expect(knownIssueMessage.textContent).toContain('项目:未命名游戏原型'); expect(knownIssueMessage.textContent).toContain( '当前状态:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed;预览 未启动', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index 1aba097b3..3f718cda2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectAndDesignShortcutFlow( invoke: PreviewShortcutInvoke, @@ -40,17 +41,31 @@ export async function assertProjectAndDesignShortcutFlow( expect(screen.queryByText(/art-secret/)).toBeNull(); submitChat('/agents'); + expect(await screen.findByText('Agent 状态:')).not.toBeNull(); expect( await screen.findByText( - /Agent 状态:[\s\S]*美术组 \/ Asset · 生成首版美术素材 · 待处理[\s\S]*LLM:已配置 · art-model/, + (_, element) => + element?.tagName === 'LI' && + element.textContent?.includes( + '美术组 / Asset · 生成首版美术素材 · 待处理', + ) === true, + ), + ).not.toBeNull(); + expect( + await screen.findByText( + (_, element) => + element?.tagName === 'LI' && + element.textContent?.includes('LLM:已配置 · art-model') === true, ), ).not.toBeNull(); submitChat('/agent-conversations'); - expect( - await screen.findByText( - /Agent 对话读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read \.agent\/conversations\/agents\/art-asset-plan\.jsonl/, - ), - ).not.toBeNull(); + const agentConversationMessage = + await screen.findByText('Agent 对话读取命令:'); + const agentConversationBubble = agentConversationMessage.closest('.message'); + expect(agentConversationBubble).not.toBeNull(); + expect(agentConversationBubble?.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read .agent/conversations/agents/art-asset-plan.jsonl', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向对话' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -63,11 +78,13 @@ export async function assertProjectAndDesignShortcutFlow( }), ); submitChat('/agent-memories'); - expect( - await screen.findByText( - /Agent 私有记忆读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read memory\/agents\/art\/asset\.md/, - ), - ).not.toBeNull(); + const agentMemoryMessage = + await screen.findByText('Agent 私有记忆读取命令:'); + const agentMemoryBubble = agentMemoryMessage.closest('.message'); + expect(agentMemoryBubble).not.toBeNull(); + expect(agentMemoryBubble?.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read memory/agents/art/asset.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向记忆' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -129,7 +146,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/goal'); expect(await screen.findByText(/创作目标:/)).not.toBeNull(); const goalMessages = screen.getAllByText(/创作目标:/); - const goalMessage = goalMessages[goalMessages.length - 1]; + const goalMessage = messageBubble(goalMessages[goalMessages.length - 1]); expect(goalMessage.textContent).toContain('Manifest:做一个厨房弹幕游戏'); expect(goalMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -187,7 +204,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/spec'); const specMessages = await screen.findAllByText(/创作规格包:/); - const specMessage = specMessages[specMessages.length - 1]; + const specMessage = messageBubble(specMessages[specMessages.length - 1]); expect(specMessage.textContent).toContain('项目:未命名游戏原型'); expect(specMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(specMessage.textContent).toContain( @@ -287,7 +304,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/mvp'); expect(await screen.findByText(/MVP 范围:/)).not.toBeNull(); const mvpMessages = screen.getAllByText(/MVP 范围:/); - const mvpMessage = mvpMessages[mvpMessages.length - 1]; + const mvpMessage = messageBubble(mvpMessages[mvpMessages.length - 1]); expect(mvpMessage.textContent).toContain('项目:未命名游戏原型'); expect(mvpMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mvpMessage.textContent).toContain( @@ -383,7 +400,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/pitch'); const pitchMessages = await screen.findAllByText(/试玩定位:/); - const pitchMessage = pitchMessages[pitchMessages.length - 1]; + const pitchMessage = messageBubble(pitchMessages[pitchMessages.length - 1]); expect(pitchMessage.textContent).toContain('项目:未命名游戏原型'); expect(pitchMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(pitchMessage.textContent).toContain( @@ -478,7 +495,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/demo'); const demoMessages = await screen.findAllByText(/试玩讲解稿:/); - const demoMessage = demoMessages[demoMessages.length - 1]; + const demoMessage = messageBubble(demoMessages[demoMessages.length - 1]); expect(demoMessage.textContent).toContain('项目:未命名游戏原型'); expect(demoMessage.textContent).toContain( '30 秒开场:这是《未命名游戏原型》,目标是做一个厨房弹幕游戏', @@ -578,7 +595,9 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/rules'); expect(await screen.findByText(/玩法操作:/)).not.toBeNull(); const controlMessages = screen.getAllByText(/玩法操作:/); - const controlMessage = controlMessages[controlMessages.length - 1]; + const controlMessage = messageBubble( + controlMessages[controlMessages.length - 1], + ); expect(controlMessage.textContent).toContain('项目:未命名游戏原型'); expect(controlMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(controlMessage.textContent).toContain( @@ -682,7 +701,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/tutorial'); const tutorialMessages = await screen.findAllByText(/新手引导:/); - const tutorialMessage = tutorialMessages[tutorialMessages.length - 1]; + const tutorialMessage = messageBubble( + tutorialMessages[tutorialMessages.length - 1], + ); expect(tutorialMessage.textContent).toContain('项目:未命名游戏原型'); expect(tutorialMessage.textContent).toContain('首屏目标:做一个厨房弹幕游戏'); expect(tutorialMessage.textContent).toContain( @@ -785,7 +806,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/mobile'); const mobileMessages = await screen.findAllByText(/移动试玩:/); - const mobileMessage = mobileMessages[mobileMessages.length - 1]; + const mobileMessage = messageBubble( + mobileMessages[mobileMessages.length - 1], + ); expect(mobileMessage.textContent).toContain('项目:未命名游戏原型'); expect(mobileMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mobileMessage.textContent).toContain( @@ -894,8 +917,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/compatibility'); const compatibilityMessages = await screen.findAllByText(/兼容性说明:/); - const compatibilityMessage = - compatibilityMessages[compatibilityMessages.length - 1]; + const compatibilityMessage = messageBubble( + compatibilityMessages[compatibilityMessages.length - 1], + ); expect(compatibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(compatibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1009,8 +1033,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/accessibility'); const accessibilityMessages = await screen.findAllByText(/可读性与无障碍:/); - const accessibilityMessage = - accessibilityMessages[accessibilityMessages.length - 1]; + const accessibilityMessage = messageBubble( + accessibilityMessages[accessibilityMessages.length - 1], + ); expect(accessibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(accessibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1132,8 +1157,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/localization'); const localizationMessages = await screen.findAllByText(/本地化与文案:/); - const localizationMessage = - localizationMessages[localizationMessages.length - 1]; + const localizationMessage = messageBubble( + localizationMessages[localizationMessages.length - 1], + ); expect(localizationMessage.textContent).toContain('项目:未命名游戏原型'); expect(localizationMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(localizationMessage.textContent).toContain( @@ -1262,8 +1288,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/performance'); const performanceMessages = await screen.findAllByText(/性能与加载:/); - const performanceMessage = - performanceMessages[performanceMessages.length - 1]; + const performanceMessage = messageBubble( + performanceMessages[performanceMessages.length - 1], + ); expect(performanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(performanceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(performanceMessage.textContent).toContain( @@ -1379,7 +1406,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/polish'); const polishMessages = await screen.findAllByText(/试玩前打磨:/); - const polishMessage = polishMessages[polishMessages.length - 1]; + const polishMessage = messageBubble( + polishMessages[polishMessages.length - 1], + ); expect(polishMessage.textContent).toContain('项目:未命名游戏原型'); expect(polishMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(polishMessage.textContent).toContain( @@ -1491,7 +1520,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/credits'); const creditMessages = await screen.findAllByText(/素材署名:/); - const creditMessage = creditMessages[creditMessages.length - 1]; + const creditMessage = messageBubble( + creditMessages[creditMessages.length - 1], + ); expect(creditMessage.textContent).toContain('当前资产:2 个'); expect(creditMessage.textContent).toContain('来源分布:上传 1 / 画板 1'); expect(creditMessage.textContent).toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index ce71e30ed..6194aae2b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -7,6 +7,7 @@ import { within, } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectToolsAndPreviewFlow( invoke: PreviewShortcutInvoke, @@ -36,7 +37,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/feedback'); const feedbackMessages = await screen.findAllByText(/试玩反馈:/); - const feedbackMessage = feedbackMessages[feedbackMessages.length - 1]; + const feedbackMessage = messageBubble( + feedbackMessages[feedbackMessages.length - 1], + ); expect(feedbackMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -125,7 +128,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/retention'); const retentionMessages = await screen.findAllByText(/复玩观察:/); - const retentionMessage = retentionMessages[retentionMessages.length - 1]; + const retentionMessage = messageBubble( + retentionMessages[retentionMessages.length - 1], + ); expect(retentionMessage.textContent).toContain('项目:未命名游戏原型'); expect(retentionMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(retentionMessage.textContent).toContain( @@ -233,7 +238,7 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/share'); const shareMessages = await screen.findAllByText(/试玩交付:/); - const shareMessage = shareMessages[shareMessages.length - 1]; + const shareMessage = messageBubble(shareMessages[shareMessages.length - 1]); expect(shareMessage.textContent).toContain('项目:未命名游戏原型'); expect(shareMessage.textContent).toContain('目录:/tmp/authorized-game'); expect(shareMessage.textContent).toContain( @@ -438,11 +443,11 @@ export async function assertProjectToolsAndPreviewFlow( expect(composerInput).toHaveProperty('value', '/read game/index.html'); await waitFor(() => expect(document.activeElement).toBe(composerInput)); submitChat('/run-artifacts'); - expect( - await screen.findByText( - /最近 Run 产物读取命令:[\s\S]*exports\/README\.md · 128B · fnv1a64:exports:\/read exports\/README\.md/, - ), - ).not.toBeNull(); + const runArtifactsHeading = + await screen.findByText('最近 Run 产物读取命令:'); + expect(messageBubble(runArtifactsHeading).textContent).toContain( + 'exports/README.md · 128B · fnv1a64:exports:/read exports/README.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取首个 Run 产物' })); expect(composerInput).toHaveProperty('value', '/read exports/README.md'); expect(invoke).not.toHaveBeenCalledWith( @@ -457,8 +462,9 @@ export async function assertProjectToolsAndPreviewFlow( submitChat('/passes'); const passArtifactMessages = await screen.findAllByText(/Agent 轮次产物读取命令:/); - const passArtifactMessage = - passArtifactMessages[passArtifactMessages.length - 1]; + const passArtifactMessage = messageBubble( + passArtifactMessages[passArtifactMessages.length - 1], + ); expect(passArtifactMessage.textContent).toContain( '.agent/passes/pass-1/agenda.md · 96B · fnv1a64:agenda', ); @@ -564,13 +570,8 @@ export async function assertProjectToolsAndPreviewFlow( expect(screen.getByText(/下一步:设计实现组 \/ Director/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Trace' })); - expect( - await screen.findByText( - (content) => - content.trimStart().startsWith('Run:run-main-shortcut-trace') && - content.includes('产物快照:'), - ), - ).not.toBeNull(); + const traceHeading = await screen.findByText(/Run:run-main-shortcut-trace/); + expect(messageBubble(traceHeading).textContent).toContain('产物快照:'); expect(screen.getByText(/产物快照:/)).not.toBeNull(); expect( screen.getByText(/exports\/README\.md · fnv1a64:exports/), diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts new file mode 100644 index 000000000..60bd9e43b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts @@ -0,0 +1,7 @@ +export function messageBubble(element: Element): HTMLElement { + const bubble = element.closest('.message'); + if (!bubble) { + throw new Error('Expected chat message bubble'); + } + return bubble; +} -- 2.52.0 From da3033db2167ee63c658714fe03f830ed18e465b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 12:54:03 +0800 Subject: [PATCH 08/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=8A=82=E7=82=B9=E5=B1=9E=E6=80=A7=E6=B3=84?= =?UTF-8?q?=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 react-markdown code 组件属性中剔除 node 元数据。 新增回归测试,确保渲染后的代码节点不输出 node 属性。 --- .../src/components/ChatMarkdownMessage/index.tsx | 2 +- .../tests/ChatMarkdownMessage.test.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 139b15567..8e2545024 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -79,7 +79,7 @@ const markdownComponents: Components = { {children}
    ), - code: ({ className, children, ...props }) => { + code: ({ className, children, node: _node, ...props }) => { const isBlock = Boolean(className?.includes('language-')); return isBlock ? ( diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index ae877f8db..1c293be74 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -21,6 +21,19 @@ describe('ChatMarkdownMessage', () => { ); }); + it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('pre code')?.getAttribute('node'), + ).toBeNull(); + }); + it('保留流式 assistant 文本并标记 streaming 状态', () => { const { container } = render( Date: Fri, 4 Sep 2026 12:55:41 +0800 Subject: [PATCH 09/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E4=BB=A3=E7=A0=81=E5=9B=B4=E6=A0=8F=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将包含换行的无语言围栏内容识别为代码块。 新增回归测试,确保其使用块级代码样式而非内联样式。 --- .../src/components/ChatMarkdownMessage/index.tsx | 4 +++- .../tests/ChatMarkdownMessage.test.tsx | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 8e2545024..8edc680a9 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -80,7 +80,9 @@ const markdownComponents: Components = { ), code: ({ className, children, node: _node, ...props }) => { - const isBlock = Boolean(className?.includes('language-')); + const isBlock = + Boolean(className?.includes('language-')) || + String(children).includes('\n'); return isBlock ? ( {children} diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index 1c293be74..d37929a32 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -34,6 +34,19 @@ describe('ChatMarkdownMessage', () => { ).toBeNull(); }); + it('无语言标记的多行围栏代码仍使用代码块样式', () => { + const { container } = render( + , + ); + + const code = container.querySelector('pre code'); + expect(code?.className).toContain('whitespace-pre'); + expect(code?.className).not.toContain('rounded'); + }); + it('保留流式 assistant 文本并标记 streaming 状态', () => { const { container } = render( Date: Fri, 4 Sep 2026 13:14:05 +0800 Subject: [PATCH 10/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E5=88=97=E8=A1=A8=E7=BC=A9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 Markdown 无序列表按嵌套层级增加内边距 补充多层无序列表渲染回归测试 --- .../components/ChatMarkdownMessage/index.tsx | 40 ++++++++++++++----- .../tests/ChatMarkdownMessage.test.tsx | 17 ++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 8edc680a9..f3efd6236 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -1,5 +1,5 @@ import type { ErrorInfo, ReactNode } from 'react'; -import { Component } from 'react'; +import { Component, createContext, useContext } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -44,6 +44,34 @@ class MarkdownErrorBoundary extends Component< } } +const ListDepthContext = createContext(0); + +function MarkdownUnorderedList({ children }: { children?: ReactNode }) { + const depth = useContext(ListDepthContext); + return ( + +
      0 ? 'pl-4' : 'pl-0' + }`} + > + {children} +
    +
    + ); +} + +function MarkdownOrderedList({ children }: { children?: ReactNode }) { + const depth = useContext(ListDepthContext); + return ( + +
      + {children} +
    +
    + ); +} + const markdownComponents: Components = { // TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。 a: ({ children }) => children, @@ -60,14 +88,8 @@ const markdownComponents: Components = { p: ({ children }) => (

    {children}

    ), - ul: ({ children }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      - {children} -
    - ), + ul: MarkdownUnorderedList, + ol: MarkdownOrderedList, li: ({ children }) =>
  • - {children}
  • , blockquote: ({ children }) => (
    diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index d37929a32..f3778c4ab 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -21,6 +21,23 @@ describe('ChatMarkdownMessage', () => { ); }); + it('为嵌套无序列表保留逐层缩进', () => { + const { container } = render( + , + ); + + const lists = container.querySelectorAll('ul'); + expect(lists).toHaveLength(3); + expect(lists[0]?.className).toContain('pl-0'); + expect(lists[1]?.className).toContain('pl-4'); + expect(lists[2]?.className).toContain('pl-4'); + }); + it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { const { container } = render( Date: Fri, 4 Sep 2026 13:18:26 +0800 Subject: [PATCH 11/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=89=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=B6=88=E6=81=AF=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分有序与无序列表标记,避免有序项显示短横线 让列表项段落与文本保持同行 补充有序列表嵌套渲染回归测试 --- .../components/ChatMarkdownMessage/index.tsx | 55 ++++++++++++++----- .../tests/ChatMarkdownMessage.test.tsx | 15 +++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index f3efd6236..39926feee 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -45,18 +45,22 @@ class MarkdownErrorBoundary extends Component< } const ListDepthContext = createContext(0); +const ListKindContext = createContext<'unordered' | 'ordered' | null>(null); +const ListItemContext = createContext(false); function MarkdownUnorderedList({ children }: { children?: ReactNode }) { const depth = useContext(ListDepthContext); return ( -
      0 ? 'pl-4' : 'pl-0' - }`} - > - {children} -
    + +
      0 ? 'pl-4' : 'pl-0' + }`} + > + {children} +
    +
    ); } @@ -65,13 +69,38 @@ function MarkdownOrderedList({ children }: { children?: ReactNode }) { const depth = useContext(ListDepthContext); return ( -
      - {children} -
    + +
      + {children} +
    +
    ); } +function MarkdownParagraph({ children }: { children?: ReactNode }) { + const inListItem = useContext(ListItemContext); + return ( +

    + {children} +

    + ); +} + +function MarkdownListItem({ children }: { children?: ReactNode }) { + const listKind = useContext(ListKindContext); + return ( + +
  • + {listKind === 'unordered' ? '- ' : null} + {children} +
  • +
    + ); +} + const markdownComponents: Components = { // TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。 a: ({ children }) => children, @@ -85,12 +114,10 @@ const markdownComponents: Components = { h3: ({ children }) => (

    {children}

    ), - p: ({ children }) => ( -

    {children}

    - ), + p: MarkdownParagraph, ul: MarkdownUnorderedList, ol: MarkdownOrderedList, - li: ({ children }) =>
  • - {children}
  • , + li: MarkdownListItem, blockquote: ({ children }) => (
    {children} diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index f3778c4ab..43f4a9bde 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -38,6 +38,21 @@ describe('ChatMarkdownMessage', () => { expect(lists[2]?.className).toContain('pl-4'); }); + it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => { + const { container } = render( + , + ); + + const items = container.querySelectorAll('ol > li'); + expect(items[0]?.textContent?.trim()).toBe('第一件事'); + expect(items[1]?.textContent?.trim()).toContain('第二件事'); + expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline'); + expect(items[0]?.textContent).not.toContain('-'); + }); + it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { const { container } = render( Date: Fri, 4 Sep 2026 13:21:58 +0800 Subject: [PATCH 12/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E4=B8=8E=E5=86=85=E5=AE=B9=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免消息保留空白规则把列表项内容推到下一行 补充有序列表同行显示断言 --- .../src/components/ChatMarkdownMessage/index.tsx | 2 +- apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 39926feee..02a9f3d8a 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -93,7 +93,7 @@ function MarkdownListItem({ children }: { children?: ReactNode }) { const listKind = useContext(ListKindContext); return ( -
  • +
  • {listKind === 'unordered' ? '- ' : null} {children}
  • diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index 43f4a9bde..30eac0d09 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -50,6 +50,7 @@ describe('ChatMarkdownMessage', () => { expect(items[0]?.textContent?.trim()).toBe('第一件事'); expect(items[1]?.textContent?.trim()).toContain('第二件事'); expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline'); + expect(items[0]?.className).toContain('whitespace-normal'); expect(items[0]?.textContent).not.toContain('-'); }); -- 2.52.0 From 1ce50ab031fa2545dc6167f6418b13fec1f79791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 13:25:56 +0800 Subject: [PATCH 13/24] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=B1=82=E7=BA=A7=E5=AD=97=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 Tailwind 重要性修饰覆盖全局标题样式 恢复 H1 到 H3 的清晰字号层级 --- .../components/ChatMarkdownMessage/index.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 02a9f3d8a..3578d5801 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -106,13 +106,24 @@ const markdownComponents: Components = { a: ({ children }) => children, img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'), h1: ({ children }) => ( -

    {children}

    +

    {children}

    ), h2: ({ children }) => ( -

    {children}

    +

    {children}

    ), h3: ({ children }) => ( -

    {children}

    +

    {children}

    + ), + h4: ({ children }) => ( +

    {children}

    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    + {children} +
    ), p: MarkdownParagraph, ul: MarkdownUnorderedList, -- 2.52.0 From 8c2a4fea64e039b80ab62201a89646e87a003af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 4 Sep 2026 13:26:07 +0800 Subject: [PATCH 14/24] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=B1=82=E7=BA=A7=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证 H4 到 H6 的字号与字重样式 --- .../tests/ChatMarkdownMessage.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index 30eac0d09..82aec9c8e 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -38,6 +38,21 @@ describe('ChatMarkdownMessage', () => { expect(lists[2]?.className).toContain('pl-4'); }); + it('为四到六级标题提供递进的字号与字重', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h4')?.className).toContain('text-sm'); + expect(container.querySelector('h4')?.className).toContain('font-semibold'); + expect(container.querySelector('h5')?.className).toContain('font-medium'); + expect(container.querySelector('h6')?.className).toContain('text-xs'); + expect(container.querySelector('h6')?.className).toContain('tracking-wide'); + }); + it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => { const { container } = render( Date: Fri, 4 Sep 2026 13:37:14 +0800 Subject: [PATCH 15/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F=20Ma?= =?UTF-8?q?rkdown=20=E9=94=99=E8=AF=AF=E8=BE=B9=E7=95=8C=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 累计文本变化时清除错误边界状态并重新尝试 Markdown 渲染。 新增首次渲染失败后随文本更新恢复的回归测试。 --- .../components/ChatMarkdownMessage/index.tsx | 11 +++++- .../tests/ChatMarkdownMessage.test.tsx | 39 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 3578d5801..ab7df6394 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -18,7 +18,7 @@ type MarkdownErrorBoundaryState = { hasError: boolean; }; -class MarkdownErrorBoundary extends Component< +export class MarkdownErrorBoundary extends Component< MarkdownErrorBoundaryProps, MarkdownErrorBoundaryState > { @@ -32,6 +32,15 @@ class MarkdownErrorBoundary extends Component< // Keep the original message visible without logging its potentially sensitive content. } + componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) { + if ( + this.state.hasError && + prevProps.fallbackText !== this.props.fallbackText + ) { + this.setState({ hasError: false }); + } + } + render() { if (this.state.hasError) { return ( diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx index 82aec9c8e..149b4d25c 100644 --- a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -1,9 +1,19 @@ // @vitest-environment jsdom import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { ChatMarkdownMessage } from '../src/components/ChatMarkdownMessage'; +import { + ChatMarkdownMessage, + MarkdownErrorBoundary, +} from '../src/components/ChatMarkdownMessage'; + +function FailingChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error('transient render failure'); + } + return markdown recovered; +} describe('ChatMarkdownMessage', () => { it('渲染 assistant 的 GFM 内容与代码块', () => { @@ -95,6 +105,31 @@ describe('ChatMarkdownMessage', () => { expect(code?.className).not.toContain('rounded'); }); + it('累计文本变化后可从 Markdown 错误回退中恢复', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + const { container, rerender } = render( + + + , + ); + + expect(container.textContent).toBe('partial'); + + rerender( + + + , + ); + + expect(container.textContent).toBe('markdown recovered'); + } finally { + consoleError.mockRestore(); + } + }); + it('保留流式 assistant 文本并标记 streaming 状态', () => { const { container } = render( Date: Fri, 4 Sep 2026 15:28:40 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BA=AF=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=81=E5=BC=8F=E5=9B=9E=E5=A4=8D=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复 standalone supervisor 聊天的流式消息自动跟随 保留用户上移后的滚动位置 统一使用底部阈值判断是否继续跟随 --- .../SupervisorChatOnlyView.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 1153ddf33..f5b59ab38 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -5,8 +5,10 @@ import type { FormEventHandler, RefObject, SetStateAction, + UIEvent, UIEventHandler, } from 'react'; +import { useLayoutEffect, useRef } from 'react'; import type { AgentRuntimeState, @@ -33,6 +35,8 @@ type RuntimeControlProps = ComponentProps< typeof ProjectSupervisorRuntimeControls >; +const CHAT_SCROLL_BOTTOM_THRESHOLD = 24; + type SupervisorChatOnlyViewProps = { chatAgentBusy: boolean; chatInput: string; @@ -95,6 +99,7 @@ export function SupervisorChatOnlyView({ visibleMessages, workspaceStatus, }: SupervisorChatOnlyViewProps) { + const shouldFollowLatestRef = useRef(true); const running = Boolean( chatAgentBusy || (runtime && !isAgentRuntimeTerminalState(runtime)), ); @@ -107,6 +112,34 @@ export function SupervisorChatOnlyView({ : runtime ? projectSupervisorChatRuntimeStatus(runtime) : projectWorkspaceStatusForDisplay(workspaceStatus); + useLayoutEffect(() => { + if (!running) { + return; + } + const messageList = messagesRef.current; + if (!messageList) { + return; + } + if (shouldFollowLatestRef.current) { + messageList.scrollTop = messageList.scrollHeight; + } + }, [ + messagesRef, + running, + runtime?.updatedAt, + transientReply, + visibleMessages.length, + ]); + function handleScroll(event: UIEvent) { + const messageList = event.currentTarget; + const distanceFromBottom = + messageList.scrollHeight - + messageList.scrollTop - + messageList.clientHeight; + shouldFollowLatestRef.current = + distanceFromBottom <= CHAT_SCROLL_BOTTOM_THRESHOLD; + onScroll(event); + } const conversationLabel = directCodex ? '陶泥儿项目对话' : '项目总控 Agent 纯聊天'; @@ -139,7 +172,7 @@ export function SupervisorChatOnlyView({ ref={messagesRef} className="message-list supervisor-chat-only-message-list" aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'} - onScroll={onScroll} + onScroll={handleScroll} > {hiddenConversationCount > 0 ? (