From 42de1b4c395aa97c4bdf5205a6a85fe4b701c53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 20:15:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B1=95=E7=A4=BA=EF=BC=9A=E5=8F=AF=E9=94=9A?= =?UTF-8?q?=E5=AE=9A=E5=9D=97=E5=B8=A6=E4=B8=8A=E7=A8=B3=E5=AE=9A=E5=9D=97?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=20data-block-key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectProjectTurn 给正文块写 data-block-key={block.key},「执行过程」包装块与终态文案各给一个回合内唯一的字面量块身份。 ToolCallGroup 与 AgentReasoning 新增可选 blockKey 并透传到 data-block-key,turnKey 的注释改成「按回合 key + 块身份定位」。 组件用例断言每个可锚定块都有块身份且同回合内唯一,并用 running→finished 前后对照钉住「块序号会整体后移、块身份不变」。 Co-authored-by: Junie --- .../AgentReasoning/AgentReasoning.tsx | 11 ++- .../DirectProjectConversation.test.tsx | 83 +++++++++++++++++++ .../DirectProjectTurn.tsx | 20 ++++- .../ToolCallGroup/ToolCallGroup.tsx | 11 ++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx index 99d32d69c..7394c030a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/AgentReasoning/AgentReasoning.tsx @@ -19,15 +19,21 @@ function AgentReasoningImpl({ label = '思考过程', testId, turnKey, + blockKey, }: { text: string; label?: string; testId?: string; /** - * 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点按 - * 「回合 key + 块序号」定位这一块(见 `DirectProjectConversation/conversationScrollAnchor.ts`)。 + * 所属回合的 `DirectChatTurn.key`:写进 `data-turn-key`,供会话滚动的前插锚点定位这一块 + * (见 `DirectProjectConversation/conversationScrollAnchor.ts`)。 */ turnKey?: string; + /** + * 这一块的稳定块身份(`DirectChatBlock.key`):写进 `data-block-key`。锚点按「回合 key + + * 块身份」定位,不按块序号——回合收口时过程块会被折进 `
`,序号会整体后移。 + */ + blockKey?: string; }) { const [expanded, setExpanded] = useState(false); /* @@ -44,6 +50,7 @@ function AgentReasoningImpl({ aria-label={label} data-testid={testId} data-turn-key={turnKey} + data-block-key={blockKey} onToggle={(event) => setExpanded((event.currentTarget as HTMLDetailsElement).open) } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx index 2394089a5..a85c6a51a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DirectChatToolCard } from '../../conversation/directThreadChat'; import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; import { DirectProjectConversation } from './DirectProjectConversation'; @@ -15,6 +16,47 @@ const turn = (key: string): DirectChatTurn => ({ endedAt: 20, }); +/** 一个工具调用的持久化卡片:块身份来自条目 itemId,这里只要能渲染出来。 */ +const toolCall = (id: string): DirectChatToolCard => ({ + schemaVersion: 'agc-tool-call.v1', + id, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: 10, + updatedAt: 20, +}); + +/** + * 带执行过程的回合:`renderTurnProcess` 对运行中的回合把过程块平铺、对已结束的回合把它们折进一个 + * `
`,所以「收口前后同一个块的块身份是否跟着块走」只能拿这种回合验。 + */ +const processTurn = ( + state: DirectChatTurn['state'], + overrides: Partial = {}, +): DirectChatTurn => ({ + key: 't1', + users: [{ kind: 'user', key: 't1:u', text: '做一个跳跃动作', at: 10 }], + process: [ + { kind: 'tools', key: 't1:g1', calls: [toolCall('c1')] }, + { kind: 'tools', key: 't1:g2', calls: [toolCall('c2')] }, + ], + finals: [{ kind: 'assistant', key: 't1:f', text: '改好了', at: 20 }], + state, + startedAt: 10, + endedAt: state === 'finished' ? 20 : 0, + ...overrides, +}); + +/** 列表里所有块的块身份,按文档顺序;拿不到 `data-block-key` 的块是 null。 */ +function blockKeysOf(container: HTMLElement): (string | null)[] { + return Array.from(container.querySelectorAll('[data-turn-key]')).map( + (block) => block.getAttribute('data-block-key'), + ); +} + function conversationElement(overrides: { conversationKey?: string; turns?: DirectChatTurn[]; @@ -103,6 +145,47 @@ describe('前插锚点的标记', () => { view.container.querySelectorAll('[data-turn-key="t1"]').length, ).toBeGreaterThan(0); }); + + it('每个可锚定的块都带上 data-block-key,且同一回合内唯一', () => { + const view = renderConversation({ turns: [processTurn('finished')] }); + const keys = blockKeysOf(view.container); + + // 正文块用 DirectChatBlock.key,过程包装块与终态文案各有自己的字面量块身份。 + expect(keys).toContain('t1:u'); + expect(keys).toContain('t1:g1'); + expect(keys).toContain('t1:g2'); + expect(keys).toContain('t1:f'); + expect(keys).toContain('process'); + expect(keys).toContain('usage'); + // 缺块身份的块会被锚点静默跳过:宁可这里红,也不要在滚动时才发现。 + expect(keys.every((key) => key !== null && key.length > 0)).toBe(true); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('回合收口后块身份跟着块走:块序号变了,块身份不变', () => { + const view = renderConversation({ turns: [processTurn('running')] }); + // 运行中:过程块平铺,没有「执行过程」包装块。 + expect(blockKeysOf(view.container)).toEqual([ + 't1:u', + 't1:g1', + 't1:g2', + 't1:f', + ]); + + view.rerender(conversationElement({ turns: [processTurn('finished')] })); + + // 收口后过程被折进一个 `
`,同一个工具组的序号从 1 变成 3——旧锚点存的是序号, + // 会解析到隔壁块;块身份仍然只属于它自己。 + const keys = blockKeysOf(view.container); + expect(keys).toEqual([ + 't1:u', + 'process', + 't1:g1', + 't1:g2', + 't1:f', + 'usage', + ]); + }); }); describe('换会话的复位方式', () => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index dce1a4c56..047e5cf0f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -30,6 +30,12 @@ import { * - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`: * 只有宿主开始事件到了才能这么说。 */ +/** 「执行过程」包装块的块身份:只在同一回合内要求唯一,与条目块的 `${回合 key}:${条目 id}` 不会撞。 */ +const PROCESS_BLOCK_KEY = 'process'; + +/** 终态文案的块身份:同上,同一个回合里只出现一次。 */ +const USAGE_BLOCK_KEY = 'usage'; + export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { const streamingKey = turn.state === 'running' @@ -50,7 +56,14 @@ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { ); } -/** 运行中的回合把执行过程平铺,已结束的回合折叠进「执行过程」。 */ +/** + * 运行中的回合把执行过程平铺,已结束的回合折叠进「执行过程」。 + * + * 两种结构下每个块都带 `data-block-key`:会话滚动的前插锚点按「回合 key + 块身份」定位, + * 不按块序号——收口时过程块被折进 `
`,块序号会整体后移(见 + * `conversationScrollAnchor.ts` 与 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`)。 + * 「执行过程」包装块与终态文案没有 `DirectChatBlock`,各用一个回合内唯一的字面量块身份。 + */ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { if (turn.process.length === 0) return null; const blocks = turn.process.map((block) => @@ -62,6 +75,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { className="message-turn-process" data-testid="turn-process" data-turn-key={turn.key} + data-block-key={PROCESS_BLOCK_KEY} > 执行过程
{blocks}
@@ -83,6 +97,7 @@ function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { data-testid="turn-usage" data-turn-running="false" data-turn-key={turn.key} + data-block-key={USAGE_BLOCK_KEY} > {`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', { hour12: false, @@ -105,6 +120,7 @@ function renderBlock( active={turn.state === 'running'} className="message-tool-call" turnKey={turn.key} + blockKey={block.key} /> ); } @@ -115,6 +131,7 @@ function renderBlock( text={block.text} label="思考过程" turnKey={turn.key} + blockKey={block.key} /> ); } @@ -124,6 +141,7 @@ function renderBlock( key={block.key} className={`message message--${role}`} data-turn-key={turn.key} + data-block-key={block.key} > `,序号会整体后移。 + */ + blockKey?: string; }) { const [expanded, setExpanded] = useState(false); const bodyId = useId(); @@ -105,6 +111,7 @@ export function ToolCallGroup({ data-testid="agent-tool-call-group" aria-label="陶泥儿执行过程" data-turn-key={turnKey} + data-block-key={blockKey} data-status={status} data-has-failure={hasFailure ? 'true' : undefined} data-duration-ms={timing.durationMs ?? ''}