import * as d3 from 'd3'; import type { SimpleEventHandler } from '../../shared/core/SimpleEventHandler'; import { GLTR_Mode, GLTR_Text_Box } from '../../shared/vis/GLTR_Text_Box'; import { CHAT_SURPRISAL_COLOR_MAP_MAX } from '../../shared/cross/SurprisalColorConfig'; import { buildCompletionDisplayResult } from './buildCompletionDisplayResult'; import type { ChatDisplaySegment } from './chatSegments'; /** 展示用:前块为 output 且末尾无 \\n 时,去掉 input 前导 \\n(块边界已承担该换行,避免双空行)。 */ function inputSegmentTextForDisplay( text: string, prev: ChatDisplaySegment | undefined ): string { if ( prev?.kind === 'output' && text.startsWith('\n') && !prev.text.endsWith('\n') ) { return text.slice(1); } return text; } const GLTR_OPTIONS = { gltrMode: GLTR_Mode.fract_p, enableRenderAnimation: false, enableMinimap: false, overlayTokenRenderStyle: 'classic' as const, overlayIgnoreGlobalInfoDensityDisable: true, surprisalColorMax: CHAT_SURPRISAL_COLOR_MAP_MAX, }; export class ChatTurnsView { private readonly container: d3.Selection; private readonly eventHandler: SimpleEventHandler; private gltrBoxes: GLTR_Text_Box[] = []; private lastSegments: ChatDisplaySegment[] = []; /** 最近点击的 output 段索引;-1 表示默认最后一轮 */ private activeOutputIndex = -1; constructor( container: HTMLElement, eventHandler: SimpleEventHandler ) { this.container = d3.select(container); this.eventHandler = eventHandler; } clear(): void { for (const box of this.gltrBoxes) { box.destroy?.(); } this.gltrBoxes = []; this.activeOutputIndex = -1; this.container.selectAll('*').remove(); } rerender(): void { if (this.lastSegments.length > 0) { this.render(this.lastSegments); } } private resolvedOutputIndex(): number { if (this.activeOutputIndex >= 0 && this.activeOutputIndex < this.gltrBoxes.length) { return this.activeOutputIndex; } return Math.max(0, this.gltrBoxes.length - 1); } getActiveAnalyzeResult() { const box = this.gltrBoxes[this.resolvedOutputIndex()]; return box?.getCurrentAnalyzeResult() ?? null; } getPromptPrefixForSidebar(): string { const outIdx = this.resolvedOutputIndex(); let outputCount = 0; let legacyPrefix = ''; for (const seg of this.lastSegments) { if (seg.kind === 'output') { if (outputCount === outIdx) { return seg.promptUsed ?? legacyPrefix; } outputCount++; } legacyPrefix += seg.text; } return ''; } getFullTextForCopy(): string { return this.lastSegments.map((s) => s.text).join(''); } render(segments: ChatDisplaySegment[]): void { this.lastSegments = segments; const prevActive = this.activeOutputIndex; this.clear(); let outputIndex = 0; let prev: ChatDisplaySegment | undefined; for (const seg of segments) { if (seg.kind === 'input') { const block = this.container .append('div') .attr('class', 'chat-segment chat-segment-input'); block .append('pre') .attr( 'class', seg.pending ? 'chat-segment-input-text tool-calling-pending-text' : 'chat-segment-input-text', ) .text(inputSegmentTextForDisplay(seg.text, prev)); } else { const block = this.container .append('div') .attr('class', 'chat-segment chat-segment-output'); const outHost = block.append('div').attr('class', 'chat-segment-output-host'); const box = new GLTR_Text_Box(outHost, this.eventHandler); box.updateOptions(GLTR_OPTIONS, true); const display = buildCompletionDisplayResult( seg.text, seg.modelName, seg.response.info_radar?.bpe_strings ?? null ); box.update(display); const capturedIndex = outputIndex; block.node()?.addEventListener( 'click', () => { this.activeOutputIndex = capturedIndex; }, true ); if (capturedIndex === prevActive) { this.activeOutputIndex = capturedIndex; } this.gltrBoxes.push(box); outputIndex++; } prev = seg; } } }