- 将 messageArea.ts 拆分为多个独立模块 - 新增 messageRenderer.ts:消息渲染逻辑 - 新增 messageStyles.ts:样式定义 - 新增 questionHandler.ts:问题处理 - 新增 segmentRenderer.ts:分段渲染 - 新增 textFormatter.ts:文本格式化 - 新增 toolHelpers.ts:工具辅助函数
275 lines
15 KiB
TypeScript
275 lines
15 KiB
TypeScript
/**
|
||
* 分段消息渲染脚本模块
|
||
* 功能:实时更新分段消息、工具调用展示
|
||
* 依赖:toolHelpers, textFormatter, waveformPreviewContent
|
||
* 使用场景:webview 中的分段消息渲染
|
||
*/
|
||
|
||
export function getSegmentRendererScript(): string {
|
||
return `
|
||
function updateSegmentsRealtime(segments, isComplete) {
|
||
if (isComplete && (!segments || segments.length === 0)) {
|
||
currentSegmentedMessage = null;
|
||
return;
|
||
}
|
||
if (!segments || segments.length === 0) return;
|
||
|
||
if (!currentSegmentedMessage) {
|
||
if (currentStreamingMessage) {
|
||
currentStreamingMessage.remove();
|
||
currentStreamingMessage = null;
|
||
}
|
||
const toolStatuses = messagesEl.querySelectorAll('.tool-status');
|
||
toolStatuses.forEach(el => el.remove());
|
||
const lastSegmented = messagesEl.querySelector('.segmented-message:last-child');
|
||
if (lastSegmented && !lastSegmented.querySelector('.message-actions')) {
|
||
currentSegmentedMessage = lastSegmented;
|
||
} else {
|
||
currentSegmentedMessage = document.createElement('div');
|
||
currentSegmentedMessage.className = 'message bot-message segmented-message';
|
||
messagesEl.appendChild(currentSegmentedMessage);
|
||
}
|
||
renderedSegmentCount = 0;
|
||
}
|
||
|
||
if (currentSegmentedMessage) {
|
||
const toolHeaders = currentSegmentedMessage.querySelectorAll('.tool-segment-header[data-collapsible="true"]');
|
||
toolHeaders.forEach((header, idx) => {
|
||
const isCollapsed = header.classList.contains('collapsed');
|
||
toolCollapseStates.set(idx, isCollapsed);
|
||
});
|
||
}
|
||
|
||
currentSegmentedMessage.innerHTML = '';
|
||
|
||
const mergedSegments = [];
|
||
let i = 0;
|
||
while (i < segments.length) {
|
||
const segment = segments[i];
|
||
if (segment.type === 'tool') {
|
||
let count = 1;
|
||
while (i + count < segments.length &&
|
||
segments[i + count].type === 'tool' &&
|
||
segments[i + count].toolName === segment.toolName) {
|
||
count++;
|
||
}
|
||
mergedSegments.push({ ...segment, toolCount: count });
|
||
i += count;
|
||
} else {
|
||
mergedSegments.push(segment);
|
||
i++;
|
||
}
|
||
}
|
||
|
||
let toolIndex = 0;
|
||
mergedSegments.forEach((segment, index) => {
|
||
const segmentDiv = document.createElement('div');
|
||
segmentDiv.className = 'message-segment segment-' + segment.type;
|
||
|
||
if (segment.type === 'text' && segment.content) {
|
||
segmentDiv.className += ' segment-text';
|
||
segmentDiv.innerHTML = formatText(segment.content);
|
||
} else if (segment.type === 'tool') {
|
||
if (segment.toolName === 'spawnExplorer') return;
|
||
segmentDiv.className += ' low-profile';
|
||
const toolResult = segment.toolResult || '';
|
||
const toolCount = segment.toolCount || 1;
|
||
const countSuffix = toolCount > 1 ? \` x\${toolCount}\` : '';
|
||
const toolDescription = segment.toolDescription || '';
|
||
const shouldCollapse = toolResult && toolResult.length > 60;
|
||
const savedState = toolCollapseStates.get(toolIndex);
|
||
const isCollapsed = savedState !== undefined ? savedState : shouldCollapse;
|
||
const currentToolIndex = toolIndex;
|
||
toolIndex++;
|
||
|
||
segmentDiv.innerHTML = \`
|
||
<div class="tool-segment-header\${isCollapsed ? ' collapsed' : ''}" data-collapsible="\${shouldCollapse}" data-tool-index="\${currentToolIndex}">
|
||
\${shouldCollapse ? \`<span class="tool-collapse-icon">\${collapseIconSvg}</span>\` : getToolIcon(segment.toolName)}
|
||
<span class="tool-segment-name">\${getToolDisplayName(segment.toolName) || '工具'}\${countSuffix}</span>
|
||
\${toolResult && !shouldCollapse ? \`<span class="tool-segment-result">\${toolResult}</span>\` : ''}
|
||
</div>
|
||
\${shouldCollapse ? \`<div class="tool-segment-content\${isCollapsed ? ' collapsed' : ''}" style="max-height:\${isCollapsed ? '0' : 'none'}"><span class="tool-segment-result" style="display:block;white-space:pre-wrap;max-width:100%;margin-top:8px;margin-left:18px;">\${toolResult}</span></div>\` : ''}
|
||
\${toolDescription ? \`<p class="tool-segment-description">\${toolDescription}</p>\` : ''}
|
||
\`;
|
||
|
||
if (segment.toolName === 'simulation' && segment.toolStatus === 'success') {
|
||
if (typeof createWaveformPreview === 'function') {
|
||
const vcdPaths = parseMultiVcdPaths(segment.toolResult);
|
||
if (vcdPaths.length > 0) {
|
||
vcdPaths.forEach(vcdInfo => {
|
||
const waveformPreview = createWaveformPreview(vcdInfo.path, vcdInfo.name);
|
||
segmentDiv.appendChild(waveformPreview);
|
||
});
|
||
} else {
|
||
let vcdPath = segment.vcdFilePath;
|
||
if (!vcdPath && segment.toolResult) {
|
||
const match = String(segment.toolResult).match(/(?:路径\\s*[::]\\s*|已生成[::]\\s*)(.+\\.vcd)/);
|
||
if (match && match[1]) {
|
||
vcdPath = match[1].trim();
|
||
}
|
||
}
|
||
if (vcdPath) {
|
||
const fileName = segment.fileName || vcdPath.split(/[\\\\\\/]/).pop() || 'waveform.vcd';
|
||
const waveformPreview = createWaveformPreview(vcdPath, fileName);
|
||
segmentDiv.appendChild(waveformPreview);
|
||
}
|
||
}
|
||
} else {
|
||
console.warn('[VCD Preview] createWaveformPreview function not found');
|
||
}
|
||
}
|
||
|
||
if (shouldCollapse) {
|
||
setTimeout(() => {
|
||
const header = segmentDiv.querySelector('.tool-segment-header');
|
||
const content = segmentDiv.querySelector('.tool-segment-content');
|
||
if (header && content) {
|
||
header.addEventListener('click', function() {
|
||
const isCollapsed = header.classList.contains('collapsed');
|
||
const toolIdx = parseInt(header.getAttribute('data-tool-index') || '0');
|
||
if (isCollapsed) {
|
||
header.classList.remove('collapsed');
|
||
content.classList.remove('collapsed');
|
||
content.style.maxHeight = content.scrollHeight + 'px';
|
||
toolCollapseStates.set(toolIdx, false);
|
||
} else {
|
||
header.classList.add('collapsed');
|
||
content.classList.add('collapsed');
|
||
content.style.maxHeight = '0';
|
||
toolCollapseStates.set(toolIdx, true);
|
||
}
|
||
});
|
||
}
|
||
}, 0);
|
||
}
|
||
} else if (segment.type === 'question') {
|
||
segmentDiv.className += ' segment-question';
|
||
const questions = segment.questions || (segment.question ? [{
|
||
question: segment.question,
|
||
options: segment.options || [],
|
||
multiSelect: false
|
||
}] : []);
|
||
const isAnswered = answeredQuestions.has(segment.askId);
|
||
const savedAnswers = answeredQuestions.get(segment.askId) || {};
|
||
if (isAnswered) {
|
||
segmentDiv.classList.add('answered');
|
||
}
|
||
|
||
const questionsHtml = questions.map((q, qIndex) => {
|
||
const inputType = q.multiSelect ? 'checkbox' : 'radio';
|
||
const inputName = \`q\${qIndex}\`;
|
||
const selectedAnswers = savedAnswers[qIndex] || [];
|
||
let optionsHtml;
|
||
if (!q.options || q.options.length === 0) {
|
||
const savedText = selectedAnswers[0] || '';
|
||
optionsHtml = \`<textarea class="question-text-input" name="\${inputName}" placeholder="请输入您的答案..." style="width:100%;min-height:80px;padding:8px;border:1px solid var(--vscode-input-border);border-radius:4px;background:var(--vscode-input-background);color:var(--vscode-input-foreground);resize:vertical;" \${isAnswered ? 'disabled' : ''}>\${savedText}</textarea>\`;
|
||
} else {
|
||
optionsHtml = q.options.map(opt => {
|
||
const isSelected = selectedAnswers.includes(opt);
|
||
return \`<label class="question-option\${isSelected ? ' selected' : ''}" style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:5px 5px 5px 0;">
|
||
<input type="\${inputType}" name="\${inputName}" value="\${opt}" \${isSelected ? 'checked' : ''} \${isAnswered ? 'disabled' : ''}>
|
||
<span>\${opt}</span>
|
||
</label>\`;
|
||
}).join('');
|
||
}
|
||
return \`
|
||
<div class="question-item" data-question-index="\${qIndex}" style="margin-bottom:12px;">
|
||
<div class="question-text" style="margin-bottom:8px;">\${formatText(q.question)}</div>
|
||
<div class="question-options">\${optionsHtml}</div>
|
||
</div>
|
||
\`;
|
||
}).join('');
|
||
|
||
segmentDiv.innerHTML = \`
|
||
\${questionsHtml}
|
||
<button class="custom-submit" style="display:\${isAnswered ? 'none' : 'block'};margin-top:8px;padding:8px 16px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);border:none;border-radius:6px;cursor:pointer;">提交答案</button>
|
||
\`;
|
||
|
||
if (!isAnswered) {
|
||
setTimeout(() => {
|
||
const submitBtn = segmentDiv.querySelector('.custom-submit');
|
||
if (submitBtn) {
|
||
submitBtn.addEventListener('click', function() {
|
||
const answers = {};
|
||
questions.forEach((q, qIndex) => {
|
||
const textarea = segmentDiv.querySelector(\`textarea[name="q\${qIndex}"]\`);
|
||
if (textarea) {
|
||
const value = textarea.value.trim();
|
||
answers[qIndex] = value ? [value] : [];
|
||
} else {
|
||
const inputs = segmentDiv.querySelectorAll(\`input[name="q\${qIndex}"]:checked\`);
|
||
answers[qIndex] = Array.from(inputs).map(input => input.value);
|
||
}
|
||
});
|
||
handleMultiQuestionAnswer(segment.askId, answers, segmentDiv);
|
||
});
|
||
}
|
||
}, 0);
|
||
}
|
||
} else if (segment.type === 'agent') {
|
||
renderAgentCard(segment, segmentDiv);
|
||
} else if (segment.type === 'plan') {
|
||
renderPlanCardInSegment(segment, segmentDiv, answeredQuestions);
|
||
}
|
||
|
||
currentSegmentedMessage.appendChild(segmentDiv);
|
||
});
|
||
|
||
if (isComplete) {
|
||
console.log('[WebView] 对话完成,添加操作按钮');
|
||
const actionsDiv = document.createElement('div');
|
||
actionsDiv.className = 'message-actions';
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.className = 'action-btn';
|
||
copyBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M761.088 715.3152a38.7072 38.7072 0 0 1 0-77.4144 37.4272 37.4272 0 0 0 37.4272-37.4272V265.0112a37.4272 37.4272 0 0 0-37.4272-37.4272H425.6256a37.4272 37.4272 0 0 0-37.4272 37.4272 38.7072 38.7072 0 1 1-77.4144 0 115.0976 115.0976 0 0 1 114.8416-114.8416h335.4624a115.0976 115.0976 0 0 1 114.8416 114.8416v335.4624a115.0976 115.0976 0 0 1-114.8416 114.8416z" fill="currentColor"/><path d="M589.4656 883.0976H268.1856a121.1392 121.1392 0 0 1-121.2928-121.2928v-322.56a121.1392 121.1392 0 0 1 121.2928-121.344h321.28a121.1392 121.1392 0 0 1 121.2928 121.2928v322.56c1.28 67.1232-54.1696 121.344-121.2928 121.344zM268.1856 395.3152a43.52 43.52 0 0 0-43.8784 43.8784v322.56a43.52 43.52 0 0 0 43.8784 43.8784h321.28a43.52 43.52 0 0 0 43.8784-43.8784v-322.56a43.52 43.52 0 0 0-43.8784-43.8784z" fill="currentColor"/></svg><span class="action-tooltip">复制</span>\`;
|
||
copyBtn.onclick = () => {
|
||
const textContent = segments
|
||
.filter(s => s.type === 'text' && s.content)
|
||
.map(s => s.content)
|
||
.join('\\n');
|
||
copyMessage(textContent, copyBtn);
|
||
};
|
||
const likeBtn = document.createElement('button');
|
||
likeBtn.className = 'action-btn';
|
||
likeBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M923.5 411.2c-28.6-33.9-72.1-53-116.4-51.1h-68c6.4-31.6 10.1-63.9 11.2-96v-0.8c-0.5-60.9-18.7-112-51.2-144-22.6-22.2-50.8-33.7-81.5-33.3-38.3 0-69.1 11.5-91.7 34.2-26.5 26.5-39.9 66.8-39.8 119.6 0.1 40.1-19.4 83.4-52.1 115.9-32 31.8-71.7 49.3-111.8 49.3H295.6c-3 0-6 0.3-8.9 0.8v-1.2H140.8c-39.7 0-72.2 32.5-72.2 72.2v392.9c0 39.7 32.5 72.2 72.2 72.2h146.8v-0.6c2.9 0.4 5.9 0.7 8.9 0.7h464.7c33.3-0.8 65.6-13 91.1-34.4s43.1-51.1 49.6-83.8l52.3-289.1c9.4-43.4-2.1-89.6-30.7-123.5zM147.7 843.7v-344c0-9 7.3-16.3 16.3-16.3h70.4V860H164c-9 0-16.3-7.3-16.3-16.3z m726.4-324.9l-0.2 0.6-51.7 290.3c-6.7 29.1-32.3 50.2-62.2 51.3l-4.9 0.2-0.4 0.3h-440V486h7.3c61.4 0 121-25.7 168.1-72.4 48.6-48.2 76.5-111.7 76.5-174.2-0.1-31.5 4.9-51.8 15.3-62.2 7.4-7.4 18.7-10.8 35.8-10.8h0.2c9-0.1 17.4 3.6 24.9 11 16.3 16.2 25.7 47.3 25.8 85.4-1.2 41.8-7.9 83.3-19.9 123.4l-21.6 54.3h181.5c24.5-0.6 48.2 8.9 65.1 26.1 16.9 17.2 25.3 40.8 23 64.9z" fill="currentColor"/></svg><span class="action-tooltip">点赞</span>\`;
|
||
likeBtn.onclick = () => toggleLike(likeBtn);
|
||
const dislikeBtn = document.createElement('button');
|
||
dislikeBtn.className = 'action-btn';
|
||
dislikeBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M360 640c60.992 40.107 88 87.381 88 149.333 0 5.611 0.427 12.864 1.579 21.462a174.933 174.933 0 0 0 11.2 42.666c19.584 48.192 60.864 83.008 119.018 85.12 28.843 2.56 60.886-3.584 91.414-25.045 58.709-41.237 81.706-117.248 69.973-230.87h48.15V640v42.667c17.173 0 38.4-2.475 61.823-10.667 66.304-23.125 107.627-84.117 84.928-162.816l-53.674-254.55a213.333 213.333 0 0 0-208.747-169.3H207.019a85.333 85.333 0 0 0-85.078 78.783l-29.546 384A85.333 85.333 0 0 0 177.493 640H360z m-61.333-109.333v24H177.493l29.526-384h91.648v360z m85.333-360h289.664a128 128 0 0 1 125.227 101.589l54.442 258.07c21.334 67.007-64 67.007-64 67.007H640c64 277.334-54.613 256-54.613 256-52.054 0-52.054-64-52.054-64 0-92.8-43.264-167.082-129.77-222.805A42.667 42.667 0 0 1 384 530.667v-360z" fill="currentColor"/></svg><span class="action-tooltip">点踩</span>\`;
|
||
dislikeBtn.onclick = () => toggleDislike(dislikeBtn);
|
||
actionsDiv.appendChild(copyBtn);
|
||
actionsDiv.appendChild(likeBtn);
|
||
actionsDiv.appendChild(dislikeBtn);
|
||
currentSegmentedMessage.appendChild(actionsDiv);
|
||
currentSegmentedMessage = null;
|
||
}
|
||
|
||
smartScrollToBottom();
|
||
}
|
||
|
||
function renderSegments(segments) {
|
||
console.log('[WebView] renderSegments 被调用, segments:', segments);
|
||
if (!segments || segments.length === 0) {
|
||
console.log('[WebView] segments 为空,跳过渲染');
|
||
return;
|
||
}
|
||
if (currentStreamingMessage) {
|
||
console.log('[WebView] 移除流式消息');
|
||
currentStreamingMessage.remove();
|
||
currentStreamingMessage = null;
|
||
}
|
||
const toolStatuses = messagesEl.querySelectorAll('.tool-status');
|
||
console.log('[WebView] 找到工具状态消息数量:', toolStatuses.length);
|
||
toolStatuses.forEach(el => {
|
||
console.log('[WebView] 移除工具状态消息:', el.className);
|
||
el.remove();
|
||
});
|
||
const container = document.createElement('div');
|
||
container.className = 'message bot-message segmented-message';
|
||
updateSegmentsRealtime(segments, true);
|
||
messagesEl.appendChild(container);
|
||
smartScrollToBottom();
|
||
}
|
||
`;
|
||
}
|