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 543351e25..bc7c55761 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -37,6 +37,45 @@ function normalizeMarkdownBlankLines(text: string) { .join(''); } +/** + * 把粘在正文行里的围栏拆到独立行。 + * + * 模型经常把 ``` 直接粘在上一行末尾(`…实现细节(game.js):```js`、`… }````),而 + * CommonMark 只认整行的围栏(最多 3 个空格缩进):粘着的 ``` 退化成正文,于是正文被当成 + * 代码渲染,或者代码块一直不闭合、把后面所有内容一起吞进代码块。 + * + * 判据收得很窄:围栏前面必须是非空白字符,且围栏到行尾只允许剩语言标识(可空)。 + * 这样整行围栏、缩进围栏、行内代码(单个反引号)都不受影响,代码里出现的 ``` 只要后面还有 + * 别的字符(`const s = "```";`)也不会被拆开。 + * + * 引用块与列表项开头的围栏(`> ```js`、`- ```js`)是**合法结构**,不是粘住的:整行跳过, + * 拆开只会把它们从引用块 / 列表项里挪出来。同一行出现第二段围栏串(行内代码 + * ``文本 ```x``` ``)时同样跳过——末尾那截不是收场围栏。 + */ +const GLUED_FENCE_LINE = + /([^\s`~])[ \t]*(`{3,}|~{3,})([A-Za-z0-9+#._-]*)[ \t]*$/; +const BLOCK_MARKER_LINE = /^\s*(?:[-*+]|\d+[.)]|>)\s/; +const FENCE_RUN_TOKEN = /(`{3,}|~{3,})/g; + +function normalizeMarkdownFences(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => { + if (BLOCK_MARKER_LINE.test(line)) { + return line; + } + // 行里还有第二条围栏串时不动:那是行内代码(`文本 ```x``` `),末尾那截不是收场围栏。 + // 拆开它会凭空多出一个开场围栏,把后面的正文全变成代码。 + const runs = line.match(FENCE_RUN_TOKEN); + if (runs && runs.length > 1) { + return line; + } + return line.replace(GLUED_FENCE_LINE, '$1\n$2$3'); + }) + .join('\n'); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -249,7 +288,7 @@ const markdownComponents: Components = { ), pre: ({ children }) => ( -
+
{children}
@@ -260,7 +299,7 @@ const markdownComponents: Components = {
return isBlock ? (
{children}
@@ -338,7 +377,10 @@ function ChatMarkdownMessageImpl({
streaming ? streamingMarkdownComponents : markdownComponents
}
>
- {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
+ {/* 顺序有讲究:先拆粘住的围栏,空行压缩的 ` ```…``` ` 配对才认得出真正的代码块。 */}
+ {preserveBlankLines
+ ? normalizeMarkdownFences(text)
+ : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
);
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index 368ddf987..4b4ed2ebc 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -12282,6 +12282,21 @@ button.design-workspace-tree__entry:hover,
flex: 1 1 auto;
}
+/* 过程卡(「陶泥儿正在处理」)渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面,
+ 不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、
+ 输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距
+ 同样由这里给。
+ `flex: 0 0 auto` 是这条链上的承重项:卡片带 `overflow: hidden`,flex 项的自动最小尺寸
+ 因此归零,窗口变矮时它会先被挤扁、文字被裁掉(实测高度 300px 时压到 33px、240px 时
+ 24px),而这一列里只有消息列表该被压缩。 */
+.game-workbench-chat
+ .project-chat-surface.is-direct-codex
+ .project-chat-conversation
+ > .project-chat-process-card {
+ flex: 0 0 auto;
+ margin: 12px 16px 8px;
+}
+
/* ============================================================
工具调用折叠块(2026-09):一回合一个块,Codex 风格
============================================================
@@ -12576,17 +12591,6 @@ button.design-workspace-tree__entry:hover,
justify-content: flex-end;
}
-/* 过程卡(「陶泥儿正在处理」)现在渲染在消息列表**外**、紧贴输入盒上方(固定在输入框上面,
- 不随消息滚走)。它不再继承消息列表的左右 16px 内缩,所以要自己补齐,才能与消息内容、
- 输入盒两侧对齐;离开列表后列表的 padding-bottom 也不再作用于它,与最后一条消息的间距
- 同样由这里给。 */
-.game-workbench-chat
- .project-chat-surface.is-direct-codex
- .project-chat-conversation
- > .project-chat-process-card {
- margin: 12px 16px 8px;
-}
-
/* 工具调用折叠块块头改成两行:第一行图标 + 汇总(超长省略),第二行状态 + 用时,箭头右侧跨两行。
窄面板里一行塞不下(汇总会被截断、"进行中"还会折成两行),拆成两行后每段都有自己的宽度。 */
.game-workbench-chat
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
index 2787dab0c..a2c70915b 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx
@@ -171,8 +171,12 @@ export function DirectProjectChatView({
turnBusy,
turns: directTurns,
});
+ // 状态条的起点只认**未结束**的最新一轮:`awaiting-start`(本地已发出、宿主还没确认)
+ // 也有用户发送时间,只读 `running` 会让卡片在模型首 token 之前根本不出现。
+ // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮,`awaiting-start` 也只判最新一轮。
+ const latestTurn = directTurns.at(-1) ?? null;
const activeTurnStartedAt =
- directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0;
+ latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
const statusText =
runtimeNotice ||
statusNotice ||
@@ -248,7 +252,7 @@ export function DirectProjectChatView({
turns={directTurns}
messagesRef={messagesRef}
historyHasMore={historyHasMore}
- nativeRunning={turnStatus.nativeRunning}
+ turnInFlight={turnStatus.displayBusy}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void loadEarlierHistory()}
onScroll={handleScroll}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
index 4ab6f8e03..90dae2d40 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx
@@ -15,7 +15,7 @@ export function DirectProjectConversation({
turns,
messagesRef,
historyHasMore,
- nativeRunning,
+ turnInFlight,
activeTurnStartedAt,
onLoadEarlierHistory,
onScroll,
@@ -24,10 +24,12 @@ export function DirectProjectConversation({
messagesRef: RefObject;
historyHasMore: boolean;
/**
- * 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。
- * 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
+ * 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)。
+ *
+ * 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现——
+ * 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。
*/
- nativeRunning: boolean;
+ turnInFlight: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler;
@@ -53,7 +55,7 @@ export function DirectProjectConversation({
))}
- {nativeRunning ? (
+ {turnInFlight ? (
{
).toBeNull();
});
- it('无语言标记的多行围栏代码仍使用代码块样式', () => {
+ it('无语言标记的多行围栏代码仍使用代码块样式,且允许自动换行', () => {
const { container } = render(
{
);
const code = container.querySelector('pre code');
- expect(code?.className).toContain('whitespace-pre');
+ const pre = container.querySelector('pre');
expect(code?.className).not.toContain('rounded');
+ // 代码块必须自动换行:窄面板(280–420px)里长行会把消息拉宽并顶出横向滚动条,
+ // `pre` 与块内 `code` 各自都写过 `white-space`,两处都要给。
+ expect(code?.className).toContain('whitespace-pre-wrap');
+ expect(code?.className).toContain('break-words');
+ expect(pre?.className).toContain('whitespace-pre-wrap');
+ expect(pre?.className).toContain('break-words');
+ });
+
+ it('围栏粘在正文行末尾时仍开在正确位置', () => {
+ const { container } = render(
+ ,
+ );
+
+ // 开场围栏不拆开的话,整行会退化成「一段正文里跟着 ```js」,
+ // 代码块根本不成立、后面的正文又会被当成代码。
+ expect(container.querySelector('pre code')?.textContent).toBe(
+ 'const answer = 42;\n',
+ );
+ expect(container.querySelector('pre')?.textContent).not.toContain(
+ '实现细节',
+ );
+ });
+
+ it('收场围栏粘在代码行末尾时不再把后续正文吞进代码块', () => {
+ const { container } = render(
+ ,
+ );
+
+ const code = container.querySelector('pre code');
+ expect(code?.textContent).toBe('const a = 1;\n}); }\n');
+ expect(code?.textContent).not.toContain('```');
+ expect(container.querySelectorAll('pre')).toHaveLength(1);
+ // 围栏之后的段落回到正文,而不是继续当代码渲染。
+ expect(container.querySelector('pre')?.textContent).not.toContain(
+ '后面还是正文',
+ );
+ expect(container.textContent).toContain('后面还是正文');
+ });
+
+ it('不拆开代码里出现的 ``` 与行内代码', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('pre code')?.textContent).toBe(
+ 'const s = "```";\n',
+ );
+ expect(container.querySelectorAll('pre')).toHaveLength(1);
+ expect(container.textContent).toContain('行内 code 保持原样');
+ });
+
+ it('行内代码里的三段反引号保持原样,不凭空造出围栏', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('p code')?.textContent).toBe('x');
+ expect(container.querySelectorAll('pre')).toHaveLength(0);
+ });
+
+ it('引用块与列表项开头的围栏是合法结构,不做拆分', () => {
+ const { container } = render(
+ ```js\n> const quoted = 1;\n> ```\n\n- ```js\n const listed = 2;\n ```\n'
+ }
+ />,
+ );
+
+ // 拆开这两行只会把围栏从引用块 / 列表项里挪出来。
+ expect(
+ container.querySelector('blockquote pre code')?.textContent,
+ ).toContain('const quoted = 1;');
+ expect(container.querySelector('li pre code')?.textContent).toContain(
+ 'const listed = 2;',
+ );
});
it('保留行内代码中的 HTML 字面量', () => {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
index 205bd0fca..52b132d5e 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
@@ -549,6 +549,12 @@ export function registerChatComposerControlTests() {
});
expect(within(surface).queryByText(/本轮结束于/)).toBeNull();
expect(within(surface).queryByTestId('turn-usage')).toBeNull();
+ // 卡片从「本地命令在飞」起就得出现:只认原生 turn.started 的话,模型首 token 之前
+ // 那段(实测约十秒)界面完全不说"正在处理"。
+ expect(
+ within(surface).getAllByText('陶泥儿正在处理').length,
+ ).toBeGreaterThan(0);
+ expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull();
await act(async () => {
pending[0]?.resolve('回复');
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 1d5ac535f..c12e7f92b 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
@@ -6373,8 +6373,11 @@ export function registerProjectWorkbenchFoundationTests() {
expect(userMessageRule).toContain('color: var(--platform-text-base);');
// 过程卡渲染在消息列表的**兄弟**位置(列表外,紧贴输入盒上方),拿不到列表的
// `padding: 14px 16px 0`,左右内缩与上下间距只能自己给:左右必须与列表的 16px 对齐。
+ // 同一列里它是定高条目:窗口变矮时只有消息列表可以被压缩,卡片带 `overflow: hidden`
+ // 时自动最小尺寸归零,少了 `flex: 0 0 auto` 就会被挤扁、文字被裁掉。
expect(processCardRules.length).toBe(1);
expect(processCardRules[0]).toContain('margin: 12px 16px 8px;');
+ expect(processCardRules[0]).toContain('flex: 0 0 auto;');
});
it('enables the run presentation and renders registered images in the resource viewer', async () => {
diff --git a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
index 396c75f62..99ed6ae34 100644
--- a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
+++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx
@@ -27,7 +27,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
turns={[]}
messagesRef={createRef()}
historyHasMore={false}
- nativeRunning
+ turnInFlight
activeTurnStartedAt={STARTED_AT}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
@@ -61,7 +61,7 @@ test('没有运行中的回合时不订阅时钟', () => {
turns={[]}
messagesRef={createRef()}
historyHasMore={false}
- nativeRunning={false}
+ turnInFlight={false}
activeTurnStartedAt={0}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index b7aef73f4..b63bfbef2 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -1,5 +1,15 @@
# 决策记录
+## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错
+
+- 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。
+- 决策(卡片口径翻转,**更正** 2026-09-22「卡片口径取保守」):卡片与已耗时起点改读 `displayBusy`(本地命令在飞 ∪ 原生已确认在跑)与「最新一个**未结束**回合的用户发送时间」。理由:`turn.started` 要等宿主应答返回才发出,只认原生真相会让首 token 之前那段没有交代;窗口期这一轮确实已经交给宿主(本地命令在飞),文案不虚报「宿主已在跑」之外的东西。
+- 决策(那条预言的处置):2026-09-22 那条写「若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,应把投影压成 `unfinished: boolean`」。本次改完后投影三态**仍有**消费者——`DirectProjectTurn` 用 `state !== 'finished'` 做否定式判断、`state === 'running'` 挑流式正文,状态条也用 `state !== 'finished'` 定起点——所以不动 `DirectChatTurnState`,也不压缩成布尔。
+- 决策(状态条几何):卡片在 `.project-chat-conversation` 这条定高 flex 列里必须 `flex: 0 0 auto`。它带 `overflow: hidden`,按 flex 规范该项的自动最小尺寸归零,是这条链上唯一还能被压缩的项;压缩只能由消息列表吸收。同一选择器只保留一条规则(几何 + 不可压缩),不留两份。
+- 决策(对话 Markdown 对模型输出的容错):解析前先 `normalizeMarkdownFences` 再压缩空行;代码块 `pre` 与块内 `code` 各自都给 `whitespace-pre-wrap` + `break-words`。细则与判据见 `pitfalls.md` 同日两条。
+- 影响面:`apps/ai-game-creator-shell/src/{styles.css,components/ChatMarkdownMessage/index.tsx,view/project-development/chat/{DirectProjectChatView.tsx,components/DirectProjectConversation/DirectProjectConversation.tsx,controller/useDirectProjectTurnStatus.ts}}`;用例 `tests/{ChatMarkdownMessage.test.tsx,directProjectProcessStatus.test.tsx,appSurface/{chat-composer.suite.ts,project-development.suite.ts}}`。
+- 验证:`npx vitest run apps/ai-game-creator-shell/tests` 186 passed / 1 skipped(1874 条里 1860 passed / 14 skipped);真实 Chromium 夹具复核读秒 100ms、窗口 900→220px 高度下卡片恒为 36px 不被裁、代码块换行与两类粘住围栏;变异验证(读秒改回 1000ms、删 `flex: 0 0 auto`、卡片退回 `nativeRunning`、停掉围栏归一化、换行类名退回)逐条变红。
+
## 策划 V1/V2 退役的现行边界
- 旧策划 V1 和 Runtime V2 均已删除,当前策划入口统一使用独立 Design Agent。V1 被 V2 接替只描述历史过程,不表示 V2 仍在使用。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index b281c5734..de4f438cb 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,6 +2,14 @@
> 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
+## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
+
+- **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
+- **原因**:CommonMark 只认**整行**的围栏(最多 3 个空格缩进)。模型经常把 ``` 直接粘在上一行末尾,那个 ``` 退化成行内文本:开场围栏不成立(后面的正文被当成代码)、收场围栏不生效(代码块不闭合,吞掉剩余内容)。`ChatMarkdownMessage` 原先只做空行压缩,没有这一步归一化。
+- **处理(现行口径)**:`normalizeMarkdownFences` 在解析前把「围栏前是非空白字符、围栏到行尾只剩语言标识(可空)」的行拆成两行。判据刻意收窄:整行 / 缩进围栏、行内代码(单个反引号)、代码里出现的 ``` (`const s = "```";`)、引用块 / 列表项开头的合法围栏(`> ```js`、`- ```js`)以及同一行里出现第二段围栏串的行内代码(``文本 ```x``` ``)都不动——后三者拆开只会把围栏从引用块 / 列表项里挪出来,或者凭空造出一个开场围栏。归一化对 `preserveBlankLines`(文件预览)同样生效。
+- **同时**:块级 `pre` 与块内 `code` 都要给 `whitespace-pre-wrap` + `break-words`(两处各写过 `white-space`,只改一处不换行),否则窄面板里长行会把消息拉宽、顶出横向滚动条。
+- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx`(去掉归一化或换行类名即红);真实 Chromium 夹具里长代码行 `horizontalOverflow: false`、粘住的开场 / 收场围栏都渲染成正确结构。
+
## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
- **现象**:AGC DirectProject 对话区底部那条「陶泥儿正在处理 / 已耗时 12.4秒」的状态条,小数位一秒才动一格,看着像读数卡住;同一屏里工具卡片的耗时与资源生成侧栏的读秒都在正常走 0.1 秒,只有这一处不动。