refactor(web): extract pure turn-rendering helpers from ChatPane (#1001)

* refactor(web): extract pure turn-rendering helpers from ChatPane

* chore: add changeset for chat pane helper extraction

* test(kimi-web): cover chat turn-rendering helpers

Pure-logic tests for the helpers extracted from ChatPane, focused on
assistantRenderBlocks (tool-stack grouping, interrupt/media break, single
tool) plus the formatting/boundary helpers. Doubles as a safety net
confirming the extraction preserved behavior.
This commit is contained in:
qer 2026-06-23 15:20:00 +08:00 committed by GitHub
parent e15edfd017
commit ea1b33b674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 335 additions and 135 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Extract pure turn-rendering helpers out of the chat pane into their own module.

View file

@ -2,7 +2,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ChatTurn, ApprovalBlock, FilePreviewRequest, ToolMedia, TurnBlock } from '../types';
import type { ChatTurn, ApprovalBlock, FilePreviewRequest, ToolMedia } from '../types';
import ToolCall from './ToolCall.vue';
import Markdown from './Markdown.vue';
import ThinkingBlock from './ThinkingBlock.vue';
@ -11,6 +11,17 @@ import AgentCard from './AgentCard.vue';
import AgentGroup from './AgentGroup.vue';
import MoonSpinner from './MoonSpinner.vue';
import { formatMessageTime } from '../lib/formatMessageTime';
import {
assistantRenderBlocks,
formatDuration,
formatTokens,
renderBlockKey,
toolStackKey,
toolStackPosition,
turnBlocks,
turnFinalText,
turnToMarkdown,
} from './chatTurnRendering';
const { t } = useI18n();
@ -211,20 +222,6 @@ function canEditTurn(turn: ChatTurn): boolean {
);
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = ((ms % 60_000) / 1000).toFixed(1);
return `${m}m${s}s`;
}
/** Divider label: "Context compacted"/"auto-compacted" + optional token stats. */
function compactionDividerLabel(turn: ChatTurn): string {
const c = turn.compaction;
@ -286,32 +283,6 @@ function confirmEditMessage(turn: ChatTurn): void {
const copiedConversation = ref(false);
let copiedConversationTimer: ReturnType<typeof setTimeout> | null = null;
function turnFinalText(turn: ChatTurn): string {
return turnBlocks(turn)
.flatMap((blk) => (blk.kind === 'text' && blk.text ? [blk.text] : []))
.join('\n\n');
}
/** Convert a single turn to Markdown. */
function turnToMarkdown(turn: ChatTurn): string {
const parts: string[] = [];
for (const blk of turnBlocks(turn)) {
if (blk.kind === 'thinking' && blk.thinking) {
parts.push(`> **Thinking**\n> ${blk.thinking.split('\n').join('\n> ')}`);
} else if (blk.kind === 'text' && blk.text) {
parts.push(blk.text);
} else if (blk.kind === 'tool' && blk.tool.output && blk.tool.output.length > 0) {
const output = blk.tool.output.join('\n');
parts.push(`\`\`\`\n[${blk.tool.name}]\n${output}\n\`\`\``);
} else if (blk.kind === 'agent') {
parts.push(`**Agent** ${blk.member.name} (${blk.member.phase})`);
} else if (blk.kind === 'agentGroup') {
parts.push(`**Agents**\n\n${blk.members.map((member) => `- ${member.name}: ${member.phase}`).join('\n')}`);
}
}
return parts.join('\n\n');
}
/** Convert the entire conversation to Markdown and copy to clipboard. */
function copyConversation(): void {
if (props.turns.length === 0) return;
@ -401,105 +372,11 @@ function copyAssistantRun(index: number): void {
}).catch(() => {/* ignore */});
}
// Ordered render blocks for an assistant turn. messagesToTurns supplies `blocks`
// (thinking + text + tool cards in call order); fall back to deriving them from
// the aggregate fields for any turn built without blocks (e.g. unit tests).
function turnBlocks(turn: ChatTurn): TurnBlock[] {
if (turn.blocks) return turn.blocks;
const blocks: TurnBlock[] = [];
if (turn.thinking) blocks.push({ kind: 'thinking', thinking: turn.thinking });
if (turn.text) blocks.push({ kind: 'text', text: turn.text });
for (const tool of turn.tools ?? []) blocks.push({ kind: 'tool', tool });
return blocks;
}
type ToolStackPosition = 'single' | 'first' | 'middle' | 'last';
type ToolStackItem = {
tool: Extract<TurnBlock, { kind: 'tool' }>['tool'];
sourceIndex: number;
};
type AssistantRenderBlock =
| { kind: 'thinking'; thinking: string; sourceIndex: number }
| { kind: 'text'; text: string; sourceIndex: number }
| { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number }
| { kind: 'tool-stack'; tools: ToolStackItem[] }
| { kind: 'agent'; member: Extract<TurnBlock, { kind: 'agent' }>['member']; sourceIndex: number }
| { kind: 'agentGroup'; members: Extract<TurnBlock, { kind: 'agentGroup' }>['members']; sourceIndex: number };
function rendersToolCard(block: Extract<TurnBlock, { kind: 'tool' }>): boolean {
return !(block.tool.status === 'ok' && block.tool.media);
}
function toolStackPosition(index: number, count: number): ToolStackPosition {
if (count <= 1) return 'single';
if (index === 0) return 'first';
if (index === count - 1) return 'last';
return 'middle';
}
function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] {
const blocks = turnBlocks(turn);
const rendered: AssistantRenderBlock[] = [];
let toolRun: ToolStackItem[] = [];
const flushToolRun = () => {
if (toolRun.length === 1) {
const [item] = toolRun;
if (item) rendered.push({ kind: 'tool', tool: item.tool, sourceIndex: item.sourceIndex });
} else if (toolRun.length > 1) {
rendered.push({ kind: 'tool-stack', tools: toolRun });
}
toolRun = [];
};
blocks.forEach((block, sourceIndex) => {
if (block.kind === 'tool') {
if (rendersToolCard(block)) {
toolRun.push({ tool: block.tool, sourceIndex });
return;
}
flushToolRun();
rendered.push({ kind: 'tool', tool: block.tool, sourceIndex });
return;
}
flushToolRun();
if (block.kind === 'thinking') {
rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex });
} else if (block.kind === 'text') {
rendered.push({ kind: 'text', text: block.text, sourceIndex });
} else if (block.kind === 'agent') {
rendered.push({ kind: 'agent', member: block.member, sourceIndex });
} else {
rendered.push({ kind: 'agentGroup', members: block.members, sourceIndex });
}
});
flushToolRun();
return rendered;
}
function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): boolean {
if (turn.id !== streamingTurnId.value) return false;
return block.sourceIndex === turnBlocks(turn).length - 1;
}
function toolStackKey(item: ToolStackItem): string {
return item.tool.id || `tool-${item.sourceIndex}`;
}
function renderBlockKey(block: AssistantRenderBlock, index: number): string {
if (block.kind === 'tool-stack') {
return `tool-stack-${block.tools[0]?.sourceIndex ?? index}`;
}
if (block.kind === 'tool') return toolStackKey({ tool: block.tool, sourceIndex: block.sourceIndex });
if (block.kind === 'agent') return `agent-${block.member.id}-${block.sourceIndex}`;
if (block.kind === 'agentGroup') return `agent-group-${block.members[0]?.id ?? block.sourceIndex}`;
return `${block.kind}-${block.sourceIndex}`;
}
// NOTE: the turn-summary line (" N ") was removed in f9417af. If it
// comes back, rebuild it from turnBlocks() with i18n strings the old
// implementation lives in git history at f9417af^.

View file

@ -0,0 +1,139 @@
// apps/kimi-web/src/components/chatTurnRendering.ts
// Pure turn-rendering helpers: pure functions of their arguments (no Vue
// reactivity, no component state). Shared by ChatPane.vue's template and its
// stateful copy/edit helpers.
import type { ChatTurn, TurnBlock } from '../types';
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = ((ms % 60_000) / 1000).toFixed(1);
return `${m}m${s}s`;
}
// Ordered render blocks for an assistant turn. messagesToTurns supplies `blocks`
// (thinking + text + tool cards in call order); fall back to deriving them from
// the aggregate fields for any turn built without blocks (e.g. unit tests).
export function turnBlocks(turn: ChatTurn): TurnBlock[] {
if (turn.blocks) return turn.blocks;
const blocks: TurnBlock[] = [];
if (turn.thinking) blocks.push({ kind: 'thinking', thinking: turn.thinking });
if (turn.text) blocks.push({ kind: 'text', text: turn.text });
for (const tool of turn.tools ?? []) blocks.push({ kind: 'tool', tool });
return blocks;
}
export type ToolStackPosition = 'single' | 'first' | 'middle' | 'last';
export type ToolStackItem = {
tool: Extract<TurnBlock, { kind: 'tool' }>['tool'];
sourceIndex: number;
};
export type AssistantRenderBlock =
| { kind: 'thinking'; thinking: string; sourceIndex: number }
| { kind: 'text'; text: string; sourceIndex: number }
| { kind: 'tool'; tool: ToolStackItem['tool']; sourceIndex: number }
| { kind: 'tool-stack'; tools: ToolStackItem[] }
| { kind: 'agent'; member: Extract<TurnBlock, { kind: 'agent' }>['member']; sourceIndex: number }
| { kind: 'agentGroup'; members: Extract<TurnBlock, { kind: 'agentGroup' }>['members']; sourceIndex: number };
export function rendersToolCard(block: Extract<TurnBlock, { kind: 'tool' }>): boolean {
return !(block.tool.status === 'ok' && block.tool.media);
}
export function toolStackPosition(index: number, count: number): ToolStackPosition {
if (count <= 1) return 'single';
if (index === 0) return 'first';
if (index === count - 1) return 'last';
return 'middle';
}
export function assistantRenderBlocks(turn: ChatTurn): AssistantRenderBlock[] {
const blocks = turnBlocks(turn);
const rendered: AssistantRenderBlock[] = [];
let toolRun: ToolStackItem[] = [];
const flushToolRun = () => {
if (toolRun.length === 1) {
const [item] = toolRun;
if (item) rendered.push({ kind: 'tool', tool: item.tool, sourceIndex: item.sourceIndex });
} else if (toolRun.length > 1) {
rendered.push({ kind: 'tool-stack', tools: toolRun });
}
toolRun = [];
};
blocks.forEach((block, sourceIndex) => {
if (block.kind === 'tool') {
if (rendersToolCard(block)) {
toolRun.push({ tool: block.tool, sourceIndex });
return;
}
flushToolRun();
rendered.push({ kind: 'tool', tool: block.tool, sourceIndex });
return;
}
flushToolRun();
if (block.kind === 'thinking') {
rendered.push({ kind: 'thinking', thinking: block.thinking, sourceIndex });
} else if (block.kind === 'text') {
rendered.push({ kind: 'text', text: block.text, sourceIndex });
} else if (block.kind === 'agent') {
rendered.push({ kind: 'agent', member: block.member, sourceIndex });
} else {
rendered.push({ kind: 'agentGroup', members: block.members, sourceIndex });
}
});
flushToolRun();
return rendered;
}
export function turnFinalText(turn: ChatTurn): string {
return turnBlocks(turn)
.flatMap((blk) => (blk.kind === 'text' && blk.text ? [blk.text] : []))
.join('\n\n');
}
/** Convert a single turn to Markdown. */
export function turnToMarkdown(turn: ChatTurn): string {
const parts: string[] = [];
for (const blk of turnBlocks(turn)) {
if (blk.kind === 'thinking' && blk.thinking) {
parts.push(`> **Thinking**\n> ${blk.thinking.split('\n').join('\n> ')}`);
} else if (blk.kind === 'text' && blk.text) {
parts.push(blk.text);
} else if (blk.kind === 'tool' && blk.tool.output && blk.tool.output.length > 0) {
const output = blk.tool.output.join('\n');
parts.push(`\`\`\`\n[${blk.tool.name}]\n${output}\n\`\`\``);
} else if (blk.kind === 'agent') {
parts.push(`**Agent** ${blk.member.name} (${blk.member.phase})`);
} else if (blk.kind === 'agentGroup') {
parts.push(`**Agents**\n\n${blk.members.map((member) => `- ${member.name}: ${member.phase}`).join('\n')}`);
}
}
return parts.join('\n\n');
}
export function toolStackKey(item: ToolStackItem): string {
return item.tool.id || `tool-${item.sourceIndex}`;
}
export function renderBlockKey(block: AssistantRenderBlock, index: number): string {
if (block.kind === 'tool-stack') {
return `tool-stack-${block.tools[0]?.sourceIndex ?? index}`;
}
if (block.kind === 'tool') return toolStackKey({ tool: block.tool, sourceIndex: block.sourceIndex });
if (block.kind === 'agent') return `agent-${block.member.id}-${block.sourceIndex}`;
if (block.kind === 'agentGroup') return `agent-group-${block.members[0]?.id ?? block.sourceIndex}`;
return `${block.kind}-${block.sourceIndex}`;
}

View file

@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import type { ChatTurn, ToolCall, TurnBlock } from '../src/types';
import {
assistantRenderBlocks,
formatDuration,
formatTokens,
rendersToolCard,
renderBlockKey,
toolStackPosition,
turnBlocks,
turnFinalText,
turnToMarkdown,
} from '../src/components/chatTurnRendering';
function tool(id: string, over: Partial<ToolCall> = {}): ToolCall {
return { id, name: 'read', arg: `· ${id}.ts`, status: 'ok', ...over };
}
function toolBlock(id: string, over: Partial<ToolCall> = {}): Extract<TurnBlock, { kind: 'tool' }> {
return { kind: 'tool', tool: tool(id, over) };
}
function assistantTurn(blocks: TurnBlock[], over: Partial<ChatTurn> = {}): ChatTurn {
return { id: 't1', role: 'assistant', no: 1, text: '', blocks, ...over };
}
describe('formatTokens', () => {
it('keeps small counts verbatim and abbreviates at the k / M thresholds', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(999)).toBe('999');
expect(formatTokens(1000)).toBe('1.0k');
expect(formatTokens(1500)).toBe('1.5k');
expect(formatTokens(1_000_000)).toBe('1.0M');
expect(formatTokens(2_500_000)).toBe('2.5M');
});
});
describe('formatDuration', () => {
it('switches units at the 1s and 1m boundaries', () => {
expect(formatDuration(999)).toBe('999ms');
expect(formatDuration(1000)).toBe('1.0s');
expect(formatDuration(59_999)).toBe('60.0s');
expect(formatDuration(60_000)).toBe('1m0.0s');
expect(formatDuration(90_500)).toBe('1m30.5s');
});
});
describe('turnBlocks', () => {
it('returns the ordered blocks as-is when present', () => {
const blocks: TurnBlock[] = [{ kind: 'text', text: 'hi' }];
expect(turnBlocks(assistantTurn(blocks))).toBe(blocks);
});
it('falls back to thinking -> text -> tools order when blocks are absent', () => {
const turn: ChatTurn = {
id: 't1',
role: 'assistant',
no: 1,
text: 'answer',
thinking: 'plan',
tools: [tool('a')],
};
expect(turnBlocks(turn)).toEqual([
{ kind: 'thinking', thinking: 'plan' },
{ kind: 'text', text: 'answer' },
{ kind: 'tool', tool: tool('a') },
]);
});
});
describe('rendersToolCard', () => {
it('hides the card only for a successful tool that carries inline media', () => {
expect(rendersToolCard(toolBlock('a'))).toBe(true);
expect(rendersToolCard(toolBlock('r', { status: 'running' }))).toBe(true);
expect(
rendersToolCard(toolBlock('m', { status: 'ok', media: { kind: 'image', url: 'x' } })),
).toBe(false);
// media but errored -> still rendered as a card
expect(
rendersToolCard(toolBlock('e', { status: 'error', media: { kind: 'image', url: 'x' } })),
).toBe(true);
});
});
describe('toolStackPosition', () => {
it('marks a lone tool single and otherwise reports first/middle/last', () => {
expect(toolStackPosition(0, 1)).toBe('single');
expect(toolStackPosition(0, 0)).toBe('single');
expect(toolStackPosition(0, 3)).toBe('first');
expect(toolStackPosition(1, 3)).toBe('middle');
expect(toolStackPosition(2, 3)).toBe('last');
});
});
describe('assistantRenderBlocks', () => {
it('groups consecutive renderable tools into one tool-stack', () => {
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a'), toolBlock('b')]));
expect(rendered).toHaveLength(1);
expect(rendered[0]).toMatchObject({ kind: 'tool-stack' });
if (rendered[0]?.kind === 'tool-stack') {
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
expect(rendered[0].tools.map((t) => t.sourceIndex)).toEqual([0, 1]);
}
});
it('renders a lone tool as a standalone tool, not a stack', () => {
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a')]));
expect(rendered).toEqual([{ kind: 'tool', tool: tool('a'), sourceIndex: 0 }]);
});
it('breaks the stack when a non-tool block interrupts the run', () => {
const rendered = assistantRenderBlocks(
assistantTurn([toolBlock('a'), { kind: 'text', text: 'x' }, toolBlock('b')]),
);
expect(rendered.map((b) => b.kind)).toEqual(['tool', 'text', 'tool']);
});
it('breaks the stack when a media tool (no card) interrupts the run', () => {
const rendered = assistantRenderBlocks(
assistantTurn([
toolBlock('a'),
toolBlock('b'),
toolBlock('c', { status: 'ok', media: { kind: 'image', url: 'x' } }),
]),
);
expect(rendered.map((b) => b.kind)).toEqual(['tool-stack', 'tool']);
if (rendered[0]?.kind === 'tool-stack') {
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
}
});
it('preserves thinking/text order with their source indexes', () => {
const rendered = assistantRenderBlocks(
assistantTurn([
{ kind: 'thinking', thinking: 'plan' },
{ kind: 'text', text: 'answer' },
]),
);
expect(rendered).toEqual([
{ kind: 'thinking', thinking: 'plan', sourceIndex: 0 },
{ kind: 'text', text: 'answer', sourceIndex: 1 },
]);
});
});
describe('turnFinalText', () => {
it('joins only the text blocks, dropping thinking and tools', () => {
const turn = assistantTurn([
{ kind: 'thinking', thinking: 'plan' },
{ kind: 'text', text: 'first' },
toolBlock('a'),
{ kind: 'text', text: 'second' },
]);
expect(turnFinalText(turn)).toBe('first\n\nsecond');
});
});
describe('turnToMarkdown', () => {
it('renders thinking as a quote, text verbatim, and tool output as a fenced block', () => {
const turn = assistantTurn([
{ kind: 'thinking', thinking: 'line1\nline2' },
{ kind: 'text', text: 'hello' },
toolBlock('a', { name: 'bash', output: ['out1', 'out2'] }),
]);
expect(turnToMarkdown(turn)).toBe(
['> **Thinking**\n> line1\n> line2', 'hello', '```\n[bash]\nout1\nout2\n```'].join('\n\n'),
);
});
});
describe('renderBlockKey', () => {
it('derives stable keys per block kind', () => {
expect(renderBlockKey({ kind: 'text', text: 'x', sourceIndex: 2 }, 0)).toBe('text-2');
expect(renderBlockKey({ kind: 'tool', tool: tool('a'), sourceIndex: 3 }, 0)).toBe('a');
expect(
renderBlockKey({ kind: 'tool-stack', tools: [{ tool: tool('a'), sourceIndex: 5 }] }, 0),
).toBe('tool-stack-5');
});
});