修复 DirectProject 对话状态条时机与 Markdown 代码块渲染
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx 状态条改为读 DirectProjectTurnStatus.displayBusy(本地命令在飞 ∪ 原生在跑),计时起点放宽到最新一个未结束回合:原先只认原生 turnRunning,而 turn.started 要等宿主应答返回才发出,模型首 token 之前那约十秒界面完全没有「正在处理」的交代 - apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx 入参 nativeRunning 更名 turnInFlight 并同步注释 - apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 块级 pre 与 code 补 whitespace-pre-wrap + break-words:窄面板里长行不再把消息拉宽、不再顶出横向滚动条 - apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx 新增 normalizeMarkdownFences:把粘在正文行里的 ``` 拆到独立行(模型常写成「…实现细节(game.js):```js」「… }```」),CommonMark 只认整行围栏,粘着的围栏会让正文被当成代码、或代码块不闭合把后续内容一起吞掉;整行/缩进围栏、行内代码、代码里的 ``` 与引用块 / 列表项开头的合法围栏都不受影响 - apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx 新增粘住围栏三种场景与代码块换行契约用例 - apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts 补「turn.started 未到时卡片已出现且已计时」断言 - apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx 同步入参更名
This commit is contained in:
@@ -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 = {
|
||||
</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 +290,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 +368,9 @@ function ChatMarkdownMessageImpl({
|
||||
streaming ? streamingMarkdownComponents : markdownComponents
|
||||
}
|
||||
>
|
||||
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
|
||||
{preserveBlankLines
|
||||
? normalizeMarkdownFences(text)
|
||||
: normalizeMarkdownBlankLines(normalizeMarkdownFences(text))}
|
||||
</ReactMarkdown>
|
||||
</MarkdownErrorBoundary>
|
||||
);
|
||||
|
||||
+6
-2
@@ -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}
|
||||
|
||||
+7
-5
@@ -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"
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('ChatMarkdownMessage', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('无语言标记的多行围栏代码仍使用代码块样式', () => {
|
||||
it('无语言标记的多行围栏代码仍使用代码块样式,且允许自动换行', () => {
|
||||
const { container } = render(
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
@@ -168,8 +168,85 @@ 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={
|
||||
'> ```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('回复');
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user