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..1a52578f0 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,36 @@ function normalizeMarkdownBlankLines(text: string) { .join(''); } +/** + * 把粘在正文行里的围栏拆到独立行。 + * + * 模型经常把 ``` 直接粘在上一行末尾(`…实现细节(game.js):```js`、`… }````),而 + * CommonMark 只认整行的围栏(最多 3 个空格缩进):粘着的 ``` 退化成正文,于是正文被当成 + * 代码渲染,或者代码块一直不闭合、把后面所有内容一起吞进代码块。 + * + * 判据收得很窄:围栏前面必须是非空白字符,且围栏到行尾只允许剩语言标识(可空)。 + * 这样整行围栏、缩进围栏、行内代码(单个反引号)都不受影响,代码里出现的 ``` 只要后面还有 + * 别的字符(`const s = "```";`)也不会被拆开。 + * + * 引用块与列表项开头的围栏(`> ```js`、`- ```js`)是**合法结构**,不是粘住的:整行跳过, + * 拆开只会把它们从引用块 / 列表项里挪出来。 + */ +const GLUED_FENCE_LINE = + /([^\s`~])[ \t]*(`{3,}|~{3,})([A-Za-z0-9+#._-]*)[ \t]*$/; +const BLOCK_MARKER_LINE = /^\s*(?:[-*+]|\d+[.)]|>)\s/; + +function normalizeMarkdownFences(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => + BLOCK_MARKER_LINE.test(line) + ? line + : line.replace(GLUED_FENCE_LINE, '$1\n$2$3'), + ) + .join('\n'); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -249,7 +279,7 @@ const markdownComponents: Components = { ), pre: ({ children }) => ( -
+
{children}
@@ -260,7 +290,7 @@ const markdownComponents: Components = {
return isBlock ? (
{children}
@@ -338,7 +368,9 @@ function ChatMarkdownMessageImpl({
streaming ? streamingMarkdownComponents : markdownComponents
}
>
- {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
+ {preserveBlankLines
+ ? normalizeMarkdownFences(text)
+ : normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
);
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(
+ ```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/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}