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);
|
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':
|
||||||
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;
|
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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user