修复 DirectProject 对话状态条时机与被挤扁、Markdown 代码块换行与围栏解析 #511

Merged
suzmii merged 4 commits from fix/recover-100ms into master 2026-09-24 17:36:39 +08:00
11 changed files with 195 additions and 28 deletions
@@ -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 = {
</blockquote>
),
pre: ({ children }) => (
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 break-words whitespace-pre-wrap first:mt-0">
<CodeBlockContext.Provider value={true}>
{children}
</CodeBlockContext.Provider>
@@ -260,7 +299,7 @@ const markdownComponents: Components = {
return isBlock ? (
<code
{...props}
className={`agc-markdown-code font-mono whitespace-pre ${className ?? ''}`}
className={`agc-markdown-code font-mono break-words whitespace-pre-wrap ${className ?? ''}`}
>
{children}
</code>
@@ -338,7 +377,10 @@ function ChatMarkdownMessageImpl({
streaming ? streamingMarkdownComponents : markdownComponents
}
>
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
{/* 顺序有讲究:先拆粘住的围栏,空行压缩的 ` ```…``` ` 配对才认得出真正的代码块。 */}
{preserveBlankLines
? normalizeMarkdownFences(text)
: normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
</ReactMarkdown>
</MarkdownErrorBoundary>
);
+15 -11
View File
@@ -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 时压到 33px240px
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
@@ -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}
@@ -15,7 +15,7 @@ export function DirectProjectConversation({
turns,
messagesRef,
historyHasMore,
nativeRunning,
turnInFlight,
activeTurnStartedAt,
onLoadEarlierHistory,
onScroll,
@@ -24,10 +24,12 @@ export function DirectProjectConversation({
messagesRef: RefObject<HTMLDivElement | null>;
historyHasMore: boolean;
/**
* 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片
* 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
* 这一轮在飞吗:`DirectProjectTurnStatus.displayBusy`(本地命令在飞 ∪ 原生已确认在跑)
*
* 只认原生 `turnRunning` 会让卡片在「命令已发出、`turn.started` 未到」的空窗里不出现——
* 模型首 token 之前那段(实测约十秒)界面就没有任何「正在处理」的交代。
*/
nativeRunning: boolean;
turnInFlight: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
@@ -53,7 +55,7 @@ export function DirectProjectConversation({
<DirectProjectTurn key={turn.key} turn={turn} />
))}
</div>
{nativeRunning ? (
{turnInFlight ? (
<AgentMessageContent
as="section"
tone="process"
@@ -13,11 +13,13 @@ import type {
* 一次讲清楚,组件只读这一个对象:
*
* - `nativeRunning`**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started`
* 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。
* 已到、`turn.completed` 未到)。
* - `commandInFlight`**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke →
* 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。
* - `displayBusy`header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再
* 接受新的发送。
* - `displayBusy`header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集:
* 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning``turn.started`
* 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」
* 的交代(2026-09-24 口令)。
* - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
*
* 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
@@ -159,7 +159,7 @@ describe('ChatMarkdownMessage', () => {
).toBeNull();
});
it('无语言标记的多行围栏代码仍使用代码块样式', () => {
it('无语言标记的多行围栏代码仍使用代码块样式,且允许自动换行', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
@@ -168,8 +168,94 @@ describe('ChatMarkdownMessage', () => {
);
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(
<ChatMarkdownMessage
role="assistant"
text={'实现细节(game.js):```js\nconst answer = 42;\n```\n'}
/>,
);
// 开场围栏不拆开的话,整行会退化成「一段正文里跟着 ```js」,
// 代码块根本不成立、后面的正文又会被当成代码。
expect(container.querySelector('pre code')?.textContent).toBe(
'const answer = 42;\n',
);
expect(container.querySelector('pre')?.textContent).not.toContain(
'实现细节',
);
});
it('收场围栏粘在代码行末尾时不再把后续正文吞进代码块', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={'```js\nconst a = 1;\n}); }```\n\n后面还是正文\n'}
/>,
);
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(
<ChatMarkdownMessage
role="assistant"
text={'```js\nconst s = "```";\n```\n\n行内 `code` 保持原样\n'}
/>,
);
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(
<ChatMarkdownMessage role="assistant" text={'文本 ```x```\n'} />,
);
expect(container.querySelector('p code')?.textContent).toBe('x');
expect(container.querySelectorAll('pre')).toHaveLength(0);
});
it('引用块与列表项开头的围栏是合法结构,不做拆分', () => {
const { container } = render(
<ChatMarkdownMessage
role="assistant"
text={
'> ```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 字面量', () => {
@@ -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('回复');
@@ -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 () => {
@@ -27,7 +27,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
nativeRunning
turnInFlight
activeTurnStartedAt={STARTED_AT}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
@@ -61,7 +61,7 @@ test('没有运行中的回合时不订阅时钟', () => {
turns={[]}
messagesRef={createRef<HTMLDivElement>()}
historyHasMore={false}
nativeRunning={false}
turnInFlight={false}
activeTurnStartedAt={0}
onLoadEarlierHistory={() => undefined}
onScroll={() => undefined}
@@ -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 skipped1874 条里 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 仍在使用。
@@ -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 秒,只有这一处不动。