refactor: 重构消息区域模块化架构

- 将 messageArea.ts 拆分为多个独立模块
   - 新增 messageRenderer.ts:消息渲染逻辑
   - 新增 messageStyles.ts:样式定义
   - 新增 questionHandler.ts:问题处理
   - 新增 segmentRenderer.ts:分段渲染
   - 新增 textFormatter.ts:文本格式化
   - 新增 toolHelpers.ts:工具辅助函数
This commit is contained in:
Roe-xin
2026-03-12 15:46:18 +08:00
parent 2a280aaa93
commit c138406217
7 changed files with 1536 additions and 1737 deletions

View File

@ -0,0 +1,274 @@
/**
* 分段消息渲染脚本模块
* 功能:实时更新分段消息、工具调用展示
* 依赖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();
}
`;
}