feat: 实现 WebView 流式消息显示和状态管理

- 添加流式消息分段显示功能
  - 支持 AI 消息的实时流式渲染
  - 实现消息块(MessageChunk)的增量更新
  - 使用 marked 库进行 Markdown 渲染

- 新增加载状态指示器
  - 显示 AI 思考中的动画效果
  - 支持加载状态的显示和隐藏

- 实现工具执行状态展示
  - 显示工具调用的实时状态(执行中/成功/失败)
  - 展示工具名称、参数和执行结果
  - 提供折叠/展开功能查看详细信息

- 添加用户问题交互 UI
  - 支持 AI 向用户提问的界面展示
  - 显示问题内容和等待用户响应的提示
  - 集成答案提交和对话中止功能

- 优化消息渲染性能
  - 使用 DocumentFragment 批量更新 DOM
  - 避免频繁的页面重排和重绘
This commit is contained in:
XiaoFeng
2025-12-16 19:09:35 +08:00
parent 703912bb5f
commit c61e29a41f

View File

@ -538,6 +538,148 @@ export function getWebviewContent(iconUri?: string): string {
transform: translateX(-50%) translateY(0); 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;
}
</style> </style>
</head> </head>
<body> <body>
@ -947,12 +1089,44 @@ export function getWebviewContent(iconUri?: string): string {
} }
}); });
// 流式消息相关状态
let currentStreamingMessage = null;
let loadingIndicator = null;
window.addEventListener('message', event => { window.addEventListener('message', event => {
const message = event.data; const message = event.data;
console.log('[WebView] 收到消息:', message.command, message);
switch (message.command) { switch (message.command) {
case 'receiveMessage': case 'receiveMessage':
// 完成流式消息或普通消息
if (currentStreamingMessage) {
finalizeStreamingMessage(message.text);
} else {
addMessage(message.text, 'bot'); 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; break;
case 'fileContent': case 'fileContent':
displayFileContent(message.content, message.filePath); 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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
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 = \`
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
<span class="loading-text">\${text}</span>
\`;
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 = \`
<span class="tool-icon">\${statusIcons[status]}</span>
<span class="tool-name">\${toolName}</span>
<span class="tool-status-text">\${statusTexts[status]}</span>
\${detail ? \`<div class="tool-detail">\${detail}</div>\` : ''}
\`;
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 = \`
<div class="question-text">\${question}</div>
<div class="question-options">
\${options.map((opt, i) => \`
<button class="question-option" data-ask-id="\${askId}" data-option="\${opt}">
\${opt}
</button>
\`).join('')}
<div class="custom-input-container">
<input type="text" class="custom-input" placeholder="或输入自定义回答..." />
<button class="custom-submit" data-ask-id="\${askId}">发送</button>
</div>
</div>
\`;
// 绑定选项点击事件
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) => { filePathInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {