Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-split-chatpane-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Extract pure turn-rendering helpers out of the chat pane into their own module.
147 changes: 12 additions & 135 deletions apps/kimi-web/src/components/ChatPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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^.
Expand Down
139 changes: 139 additions & 0 deletions apps/kimi-web/src/components/chatTurnRendering.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Loading
Loading