From c61e29a41f8c942cb5d7fd8ac6583c1b00581052 Mon Sep 17 00:00:00 2001 From: XiaoFeng <117837368+Fzhiyu1@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:09:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20WebView=20?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E6=B6=88=E6=81=AF=E6=98=BE=E7=A4=BA=E5=92=8C?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加流式消息分段显示功能 - 支持 AI 消息的实时流式渲染 - 实现消息块(MessageChunk)的增量更新 - 使用 marked 库进行 Markdown 渲染 - 新增加载状态指示器 - 显示 AI 思考中的动画效果 - 支持加载状态的显示和隐藏 - 实现工具执行状态展示 - 显示工具调用的实时状态(执行中/成功/失败) - 展示工具名称、参数和执行结果 - 提供折叠/展开功能查看详细信息 - 添加用户问题交互 UI - 支持 AI 向用户提问的界面展示 - 显示问题内容和等待用户响应的提示 - 集成答案提交和对话中止功能 - 优化消息渲染性能 - 使用 DocumentFragment 批量更新 DOM - 避免频繁的页面重排和重绘 --- src/views/webviewContent.ts | 333 +++++++++++++++++++++++++++++++++++- 1 file changed, 332 insertions(+), 1 deletion(-) diff --git a/src/views/webviewContent.ts b/src/views/webviewContent.ts index 6551c7c..2b4fd18 100644 --- a/src/views/webviewContent.ts +++ b/src/views/webviewContent.ts @@ -538,6 +538,148 @@ export function getWebviewContent(iconUri?: string): string { transform: translateX(-50%) translateY(0); } } + + /* 流式消息样式 */ + .streaming .message-content { + border-right: 2px solid var(--vscode-focusBorder); + animation: blink 1s infinite; + } + @keyframes blink { + 0%, 50% { border-color: var(--vscode-focusBorder); } + 51%, 100% { border-color: transparent; } + } + + /* 加载指示器样式 */ + .loading-message { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + color: var(--vscode-descriptionForeground); + } + .loading-dots { + display: flex; + gap: 4px; + } + .loading-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vscode-focusBorder); + animation: loadingDot 1.4s infinite ease-in-out; + } + .loading-dots span:nth-child(1) { animation-delay: 0s; } + .loading-dots span:nth-child(2) { animation-delay: 0.2s; } + .loading-dots span:nth-child(3) { animation-delay: 0.4s; } + @keyframes loadingDot { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } + } + .loading-text { + font-size: 13px; + } + + /* 工具状态样式 */ + .tool-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin: 4px 0; + font-size: 12px; + border-radius: 6px; + background: var(--vscode-textBlockQuote-background); + } + .tool-status.tool-start { + border-left: 3px solid var(--vscode-charts-blue); + } + .tool-status.tool-complete { + border-left: 3px solid var(--vscode-charts-green); + } + .tool-status.tool-error { + border-left: 3px solid var(--vscode-charts-red); + } + .tool-icon { + font-size: 14px; + } + .tool-name { + font-weight: 500; + color: var(--vscode-foreground); + } + .tool-status-text { + color: var(--vscode-descriptionForeground); + } + .tool-detail { + margin-top: 4px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + max-height: 100px; + overflow-y: auto; + } + + /* 用户问题样式 */ + .question-message { + padding: 16px; + } + .question-text { + margin-bottom: 12px; + font-weight: 500; + } + .question-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .question-option { + padding: 8px 16px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: 1px solid var(--vscode-button-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + } + .question-option:hover { + background: var(--vscode-button-secondaryHoverBackground); + } + .question-option.selected { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + } + .question-message.answered .question-option:not(.selected) { + opacity: 0.5; + pointer-events: none; + } + .custom-input-container { + display: flex; + gap: 8px; + width: 100%; + margin-top: 8px; + } + .custom-input { + flex: 1; + padding: 8px 12px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + font-size: 13px; + } + .custom-submit { + padding: 8px 16px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 6px; + cursor: pointer; + } + .custom-submit:hover { + background: var(--vscode-button-hoverBackground); + } + .question-message.answered .custom-input-container { + display: none; + } @@ -947,12 +1089,44 @@ export function getWebviewContent(iconUri?: string): string { } }); + // 流式消息相关状态 + let currentStreamingMessage = null; + let loadingIndicator = null; + window.addEventListener('message', event => { const message = event.data; + console.log('[WebView] 收到消息:', message.command, message); switch (message.command) { case 'receiveMessage': - addMessage(message.text, 'bot'); + // 完成流式消息或普通消息 + if (currentStreamingMessage) { + finalizeStreamingMessage(message.text); + } else { + addMessage(message.text, 'bot'); + } + break; + case 'updateStreamingMessage': + // 流式更新消息 + updateOrCreateStreamingMessage(message.text); + break; + case 'showLoading': + showLoadingIndicator(message.text || '正在思考...'); + break; + case 'hideLoading': + hideLoadingIndicator(); + break; + case 'toolStart': + addToolStatus(message.toolName, 'start'); + break; + case 'toolComplete': + addToolStatus(message.toolName, 'complete', message.result); + break; + case 'toolError': + addToolStatus(message.toolName, 'error', message.error); + break; + case 'showQuestion': + showUserQuestion(message.askId, message.question, message.options); break; case 'fileContent': displayFileContent(message.content, message.filePath); @@ -972,6 +1146,163 @@ export function getWebviewContent(iconUri?: string): string { } }); + // 更新或创建流式消息 + function updateOrCreateStreamingMessage(text) { + hideLoadingIndicator(); + + if (!currentStreamingMessage) { + // 创建新的流式消息元素 + const div = document.createElement('div'); + div.className = 'message bot-message streaming'; + + const messageContent = document.createElement('div'); + messageContent.className = 'message-content'; + messageContent.textContent = text; + div.appendChild(messageContent); + + messagesContainer.appendChild(div); + currentStreamingMessage = div; + } else { + // 更新现有消息内容 + const messageContent = currentStreamingMessage.querySelector('.message-content'); + if (messageContent) { + messageContent.textContent = text; + } + } + + // 滚动到底部 + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // 完成流式消息 + function finalizeStreamingMessage(finalText) { + if (currentStreamingMessage) { + const messageContent = currentStreamingMessage.querySelector('.message-content'); + if (messageContent) { + messageContent.textContent = finalText; + } + currentStreamingMessage.classList.remove('streaming'); + + // 添加操作按钮 + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'message-actions'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'action-btn'; + copyBtn.innerHTML = ''; + copyBtn.onclick = () => copyMessage(finalText, copyBtn); + actionsDiv.appendChild(copyBtn); + + currentStreamingMessage.appendChild(actionsDiv); + currentStreamingMessage = null; + } + + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // 显示加载指示器 + function showLoadingIndicator(text) { + hideLoadingIndicator(); + + loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'message bot-message loading-message'; + loadingIndicator.innerHTML = \` +
+ +
+ \${text} + \`; + messagesContainer.appendChild(loadingIndicator); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // 隐藏加载指示器 + function hideLoadingIndicator() { + if (loadingIndicator) { + loadingIndicator.remove(); + loadingIndicator = null; + } + } + + // 添加工具状态消息 + function addToolStatus(toolName, status, detail) { + const statusIcons = { + start: '🔧', + complete: '✅', + error: '❌' + }; + const statusTexts = { + start: '正在执行', + complete: '执行完成', + error: '执行失败' + }; + + const div = document.createElement('div'); + div.className = \`message tool-status tool-\${status}\`; + div.innerHTML = \` + \${statusIcons[status]} + \${toolName} + \${statusTexts[status]} + \${detail ? \`
\${detail}
\` : ''} + \`; + messagesContainer.appendChild(div); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // 显示用户问题 + function showUserQuestion(askId, question, options) { + const div = document.createElement('div'); + div.className = 'message bot-message question-message'; + div.innerHTML = \` +
\${question}
+
+ \${options.map((opt, i) => \` + + \`).join('')} +
+ + +
+
+ \`; + + // 绑定选项点击事件 + div.querySelectorAll('.question-option').forEach(btn => { + btn.onclick = () => { + const selected = btn.dataset.option; + submitAnswer(askId, [selected]); + div.classList.add('answered'); + btn.classList.add('selected'); + }; + }); + + // 绑定自定义输入提交 + const customInput = div.querySelector('.custom-input'); + const customSubmit = div.querySelector('.custom-submit'); + customSubmit.onclick = () => { + const value = customInput.value.trim(); + if (value) { + submitAnswer(askId, null, value); + div.classList.add('answered'); + } + }; + + messagesContainer.appendChild(div); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // 提交用户回答 + function submitAnswer(askId, selected, customInput) { + vscode.postMessage({ + command: 'submitAnswer', + askId: askId, + selected: selected, + customInput: customInput + }); + } + // 支持回车键读取文件 filePathInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') {