feat: 实现 WebView 流式消息显示和状态管理
- 添加流式消息分段显示功能 - 支持 AI 消息的实时流式渲染 - 实现消息块(MessageChunk)的增量更新 - 使用 marked 库进行 Markdown 渲染 - 新增加载状态指示器 - 显示 AI 思考中的动画效果 - 支持加载状态的显示和隐藏 - 实现工具执行状态展示 - 显示工具调用的实时状态(执行中/成功/失败) - 展示工具名称、参数和执行结果 - 提供折叠/展开功能查看详细信息 - 添加用户问题交互 UI - 支持 AI 向用户提问的界面展示 - 显示问题内容和等待用户响应的提示 - 集成答案提交和对话中止功能 - 优化消息渲染性能 - 使用 DocumentFragment 批量更新 DOM - 避免频繁的页面重排和重绘
This commit is contained in:
@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -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':
|
||||
// 完成流式消息或普通消息
|
||||
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 = '<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) => {
|
||||
if (event.key === 'Enter') {
|
||||
|
||||
Reference in New Issue
Block a user