1. 项目缘起为什么要在本地折腾一个“离线版”的对话界面作为一名长期在本地环境搞开发的程序员我经常遇到一个尴尬的场景想快速验证一段代码逻辑或者想用自然语言描述一个功能让AI帮我生成代码片段但手头只有VSCode和本地部署的大模型。打开浏览器登录某个在线平台再把代码片段复制粘贴过去这个过程虽然不算复杂但总觉得打断了编码的“心流”。更别提有时候网络环境不稳定或者涉及一些内部代码片段直接扔到公网服务上总让人心里不踏实。于是一个想法就冒出来了能不能在VSCode这个我最熟悉的编辑器里直接开一个聊天窗口让它跟我本地的AI模型对话这个窗口最好能记住我们聊过的内容持久化还能让我直接把当前正在编辑的文件甚至截图直接丢给AI分析。这不就是一个完美的“离线编程助手”工作台吗市面上虽然有一些VSCode插件支持对接OpenAI的API但它们大多依赖网络且功能相对固定。我的需求更“极客”一点完全离线、对接我自己部署的本地大模型比如通过Ollama、LM Studio或者直接调用本地API、操作要足够轻量和快速。经过一番摸索和折腾我成功实现了一个基于VSCode Webview的单文件对话界面。它不依赖任何复杂的后端服务所有逻辑都在一个.html文件里通过VSCode的扩展API与本地模型通信并利用浏览器的localStorage和IndexedDB实现了对话历史和文件内容的持久化。这篇文章我就来手把手拆解这个项目的实现过程。你会发现核心代码量并不大但其中涉及的前端与编辑器集成、本地API调用、数据持久化等技巧对于想深入VSCode扩展开发或构建本地AI工具链的朋友会是一次非常扎实的实战。2. 核心架构设计一个文件如何承载完整应用在开始写代码之前我们先来厘清整个系统的架构。我们的目标是在VSCode中创建一个Webview面板这个面板的界面和逻辑全部由一个单独的HTML文件驱动。这个设计哲学是“单一入口功能内聚”非常适合小型工具类插件。2.1 技术栈选型与理由为什么选择纯前端HTML/JS/CSS VSCode API的方案极致轻量与快速启动不需要启动额外的Node.js服务器进程。Webview本身就是基于Chromium的浏览器环境我们的HTML文件被加载后应用即刻就绪。天然的离线能力所有资源HTML, JS, CSS都打包在插件中或由本地提供无需网络请求即可运行界面逻辑。便捷的编辑器集成通过VSCode扩展API我们可以轻松获取当前活动编辑器的文件内容、路径信息这是实现在线编程助手的核心能力。数据持久化方案成熟现代浏览器环境Webview就是提供了localStorage简单键值对和IndexedDB结构化数据库两种成熟的客户端存储方案足以应对对话历史、文件缓存等需求。整个数据流如下图所示此处用文字描述用户在Webview界面中输入问题或上传文件。Webview前端通过acquireVsCodeApi()获取的API对象向VSCode扩展宿主发送消息。扩展宿主运行在Node.js环境接收到消息后执行相应操作。例如当收到“发送消息”的指令时宿主会通过HTTP或WebSocket请求调用本地大模型服务如http://localhost:11434/api/generate。本地大模型服务返回流式或非流式的响应。扩展宿主将响应数据包装成消息发送回Webview前端。Webview前端更新界面显示AI回复并同时将本轮对话存入IndexedDB。2.2 项目文件结构规划虽然核心是一个HTML文件但为了清晰我们通常会组织一个简单的目录结构my-offline-ai-assistant/ ├── package.json // VSCode扩展清单 ├── extension.js // 扩展主入口文件负责注册命令和创建Webview ├── media/ │ └── ai-chat.html // 我们的核心单文件应用 └── .vscodeignore // 发布忽略文件ai-chat.html是这个项目的灵魂它内部通过script和style标签内联了所有的JavaScript逻辑和CSS样式真正做到一个文件包含所有。下面我们就进入这个文件的内部。3. 单文件HTML应用 (ai-chat.html) 的骨架搭建我们先构建一个最基本的聊天界面。为了可读性我会分块解释但请记住它们最终都在同一个HTML文件中。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title离线AI助手/title style /* 基础样式重置与布局 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, sans-serif; background-color: var(--vscode-editor-background); color: var(--vscode-editor-foreground); height: 100vh; display: flex; flex-direction: column; padding: 10px; } #chat-container { flex: 1; overflow-y: auto; padding: 15px; border: 1px solid var(--vscode-panel-border); border-radius: 6px; margin-bottom: 15px; background-color: var(--vscode-editorWidget-background); } .message { margin-bottom: 20px; line-height: 1.6; } .user-message { text-align: right; } .user-message .bubble { background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); border-radius: 18px 18px 4px 18px; } .ai-message .bubble { background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); border-radius: 18px 18px 18px 4px; } .bubble { display: inline-block; max-width: 80%; padding: 12px 16px; word-wrap: break-word; white-space: pre-wrap; /* 保留换行符 */ } /* 输入区域样式 */ #input-area { display: flex; gap: 10px; border-top: 1px solid var(--vscode-panel-border); padding-top: 15px; } #message-input { flex: 1; padding: 12px; border: 1px solid var(--vscode-input-border); background: var(--vscode-input-background); color: var(--vscode-input-foreground); border-radius: 4px; resize: none; font-family: inherit; } #send-btn, #upload-btn { padding: 12px 24px; background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 4px; cursor: pointer; } #send-btn:hover, #upload-btn:hover { background-color: var(--vscode-button-hoverBackground); } #send-btn:disabled { opacity: 0.6; cursor: not-allowed; } /style /head body div idchat-container !-- 聊天消息将通过JS动态插入到这里 -- div classmessage ai-message div classbubble你好我是你的离线AI助手。你可以直接向我提问也可以上传文件或图片让我分析。/div /div /div div idinput-area textarea idmessage-input placeholder输入你的问题... (ShiftEnter换行Enter发送) rows3/textarea button idupload-btn title上传文件或图片/button button idsend-btn发送/button /div !-- 隐藏的文件上传输入框 -- input typefile idfile-input styledisplay: none; multiple accept.txt,.js,.py,.java,.c,.cpp,.md,.json,.png,.jpg,.jpeg,.gif script // 主要的JavaScript逻辑将在这里编写 (function() { // 获取VSCode API实例 const vscode acquireVsCodeApi(); // DOM元素引用 const chatContainer document.getElementById(chat-container); const messageInput document.getElementById(message-input); const sendButton document.getElementById(send-btn); const uploadButton document.getElementById(upload-btn); const fileInput document.getElementById(file-input); // 初始化函数 function init() { setupEventListeners(); loadChatHistory(); // 后续实现 } function setupEventListeners() { // 发送按钮点击事件 sendButton.addEventListener(click, sendMessage); // 输入框回车发送事件 messageInput.addEventListener(keydown, (e) { if (e.key Enter !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // 上传按钮点击事件 uploadButton.addEventListener(click, () fileInput.click()); // 文件选择变化事件 fileInput.addEventListener(change, handleFileUpload); // 监听来自扩展宿主extension.js的消息 window.addEventListener(message, handleExtensionMessage); } function sendMessage() { const text messageInput.value.trim(); if (!text) return; // 在界面中立即显示用户消息优化体验 appendMessage(user, text); messageInput.value ; sendButton.disabled true; // 向扩展宿主发送消息请求调用AI vscode.postMessage({ command: sendMessage, text: text }); } function appendMessage(sender, content) { const messageDiv document.createElement(div); messageDiv.className message ${sender}-message; const bubbleDiv document.createElement(div); bubbleDiv.className bubble; // 简单处理换行和基本HTML转义防止XSS bubbleDiv.textContent content; messageDiv.appendChild(bubbleDiv); chatContainer.appendChild(messageDiv); // 滚动到底部 chatContainer.scrollTop chatContainer.scrollHeight; } function handleExtensionMessage(event) { const message event.data; switch (message.command) { case aiResponse: // 收到AI回复启用发送按钮并显示回复 sendButton.disabled false; appendMessage(ai, message.text); // 触发保存历史记录后续实现 saveMessageToHistory(user, /* 需要从上下文中获取稍后解决 */); saveMessageToHistory(ai, message.text); break; case aiResponseChunk: // 处理流式响应如果模型支持 // 这里可以实现打字机效果 break; case error: sendButton.disabled false; appendMessage(ai, 错误${message.text}); break; } } function handleFileUpload(event) { const files event.target.files; if (!files.length) return; // 这里我们先将文件信息发送给扩展宿主处理 // 扩展宿主可以读取文件内容或处理图片上传逻辑 for (let file of files) { vscode.postMessage({ command: uploadFile, file: { name: file.name, type: file.type, size: file.size // 注意Webview不能直接发送File对象需要转换 // 实际处理见下文 } }); } // 清空input以便再次选择同一文件 fileInput.value ; } // 启动 init(); })(); /script /body /html关键点解析VSCode主题集成CSS中使用了var(--vscode-*)变量这使得我们的Webview能够自动适应VSCode的深色/浅色主题用户体验更原生。消息通信桥梁acquireVsCodeApi()是Webview与扩展宿主通信的钥匙。postMessage用于发送消息window.addEventListener(message, ...)用于接收消息。即时反馈在sendMessage函数中我们先将用户消息显示在界面上然后禁用发送按钮再通知后端。这符合聊天应用的直觉避免了用户因网络或处理延迟而产生“没发送成功”的错觉。安全性使用textContent而非innerHTML来显示消息内容防止潜在的XSS攻击。即使消息来自“可信”的本地AI这也是一个好习惯。至此一个能发送和接收文本消息的界面骨架就完成了。但它是“失忆”的且无法处理文件。接下来我们攻克持久化和文件上传这两个核心难题。4. 实现对话持久化选用IndexedDB而非LocalStorage持久化意味着关闭VSCode再打开之前的聊天记录还在。localStorage简单易用但容量有限通常5MB且仅支持字符串。对于可能包含长代码片段和图片Base64编码的聊天记录IndexedDB是更专业的选择。4.1 设计聊天记录的数据结构我们在ai-chat.html的script标签内增加一个数据库管理模块。// 在 init() 函数之前或之后定义数据库相关函数 const DB_NAME AIChatDB; const DB_VERSION 1; const STORE_NAME conversations; let db null; function initDatabase() { return new Promise((resolve, reject) { const request indexedDB.open(DB_NAME, DB_VERSION); request.onerror (event) { console.error(IndexedDB打开失败:, event.target.error); reject(event.target.error); }; request.onsuccess (event) { db event.target.result; console.log(IndexedDB连接成功); resolve(db); }; request.onupgradeneeded (event) { const database event.target.result; // 如果对象存储空间不存在则创建 if (!database.objectStoreNames.contains(STORE_NAME)) { const store database.createObjectStore(STORE_NAME, { keyPath: id, autoIncrement: true }); // 创建索引方便按会话或时间查询 store.createIndex(timestamp, timestamp, { unique: false }); store.createIndex(sessionId, sessionId, { unique: false }); console.log(对象存储空间创建成功); } }; }); } // 消息对象结构 function createMessage(sender, content, type text, sessionId default) { return { sender: sender, // user 或 ai content: content, // 文本内容或文件信息 type: type, // text, file, image timestamp: new Date().toISOString(), sessionId: sessionId // 用于支持多会话这里先用默认会话 }; } // 保存消息到数据库 function saveMessageToHistory(messageObj) { if (!db) { console.warn(数据库未就绪消息未保存); return Promise.reject(DB not ready); } return new Promise((resolve, reject) { const transaction db.transaction([STORE_NAME], readwrite); const store transaction.objectStore(STORE_NAME); const request store.add(messageObj); request.onsuccess () { console.log(消息已保存到历史记录); resolve(); }; request.onerror (event) { console.error(保存消息失败:, event.target.error); reject(event.target.error); }; }); } // 从数据库加载当前会话的历史消息 function loadChatHistory(sessionId default, limit 50) { if (!db) return Promise.resolve([]); return new Promise((resolve, reject) { const transaction db.transaction([STORE_NAME], readonly); const store transaction.objectStore(STORE_NAME); const index store.index(sessionId); const range IDBKeyRange.only(sessionId); const request index.openCursor(range, prev); // 反向游标获取最新的 const messages []; request.onsuccess (event) { const cursor event.target.result; if (cursor messages.length limit) { messages.unshift(cursor.value); // 因为反向遍历用unshift保持时间顺序 cursor.continue(); } else { resolve(messages); } }; request.onerror (event) { reject(event.target.error); }; }); }实操心得与避坑点注意IndexedDB操作是异步的。在init()函数中我们需要等待数据库初始化完成后再加载历史记录。可以修改init函数为async函数或者使用Promise.then。async function init() { setupEventListeners(); try { await initDatabase(); const history await loadChatHistory(); // 将历史记录渲染到界面 history.forEach(msg { // 注意加载历史时我们可能不需要再触发保存 appendMessage(msg.sender, msg.content, false); // 假设appendMessage支持一个不触发保存的参数 }); console.log(加载了 ${history.length} 条历史消息); } catch (error) { console.error(初始化数据库或加载历史失败:, error); appendMessage(ai, 历史记录加载失败将从新会话开始。); } }另一个关键点在handleExtensionMessage中收到AI回复后我们需要保存完整的一轮对话用户问题AI回答。但此时用户的问题文本在前端可能已经“丢失”了因为输入框已清空。因此更好的做法是在sendMessage函数中在postMessage之前就先将用户消息对象保存到数据库并暂存其ID或内容。当收到AI回复时再将两者关联保存。这里为了简化我们可以在前端维护一个简单的“当前会话”数组或者将用户消息内容随请求一起发送给后端让后端在返回响应时一并带回。我们采用后一种简化方案修改消息发送逻辑。修改extension.js下一节会详述和前端sendMessage函数确保用户消息内容能关联保存。5. 文件与图片上传前端处理与内容传递文件上传涉及两个步骤1. 前端读取文件内容2. 将内容传递给扩展宿主再由宿主发送给AI模型。5.1 前端读取文件并编码我们不能直接将File对象通过postMessage发送需要将其转换为文本或Base64字符串。修改handleFileUpload函数async function handleFileUpload(event) { const files Array.from(event.target.files); if (!files.length) return; for (const file of files) { // 根据文件类型决定处理方式 if (file.type.startsWith(image/)) { // 处理图片转换为Base64 const base64 await readFileAsDataURL(file); // 在聊天界面显示一个预览或提示 appendMessage(user, [上传了图片: ${file.name}], image); // 将Base64数据发送给扩展宿主 vscode.postMessage({ command: uploadImage, data: { name: file.name, type: file.type, base64: base64.split(,)[1], // 去掉 data:image/png;base64, 前缀 preview: base64 // 带前缀的完整Base64可用于前端预览注意大图性能 } }); // 保存文件信息到历史可选Base64数据很大 saveMessageToHistory(createMessage(user, [图片: ${file.name}], image)); } else if (file.type text/plain || file.name.match(/\.(js|py|java|c|cpp|md|json|txt)$/i)) { // 处理文本文件读取为文本 const text await readFileAsText(file); appendMessage(user, [上传了文件: ${file.name}]\n\\\\n${text}\n\\\, file); vscode.postMessage({ command: uploadFile, data: { name: file.name, type: file.type, content: text } }); saveMessageToHistory(createMessage(user, [文件: ${file.name}]\n${text}, file)); } else { // 其他类型文件可能只发送文件名或提示不支持 appendMessage(user, [上传了文件: ${file.name}] (类型: ${file.type} 暂不支持内容解析)); vscode.postMessage({ command: uploadFile, data: { name: file.name, type: file.type, content: null } }); } } fileInput.value ; } // 工具函数读取文件为DataURL (Base64) function readFileAsDataURL(file) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload (e) resolve(e.target.result); reader.onerror (e) reject(reader.error); reader.readAsDataURL(file); }); } // 工具函数读取文件为文本 function readFileAsText(file) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload (e) resolve(e.target.result); reader.onerror (e) reject(reader.error); reader.readAsText(file); }); }注意事项Base64体积膨胀图片转换为Base64后数据量会增大约33%。对于大图片需要考虑性能问题。一种优化方案是前端只发送一个图片引用如路径或哈希由扩展宿主直接读取本地文件。但这要求图片文件位于VSCode工作区内或已知路径限制了灵活性。对于“上传”场景Base64是通用解决方案。文本文件格式化在显示时我们用Markdown代码块包裹文件内容这样在聊天界面中显示会更清晰。发送给AI时可以附加一段说明如“这是文件xxx.js的内容”。5.2 扩展宿主 (extension.js) 的实现现在我们需要实现VSCode扩展的主逻辑它负责创建Webview并处理来自Webview的消息调用本地大模型API。// extension.js const vscode require(vscode); const path require(path); const fs require(fs); const axios require(axios); // 需要安装: npm install axios // 本地大模型配置示例为Ollama const LOCAL_LLM_API http://localhost:11434/api/generate; const MODEL_NAME qwen2.5:7b; // 替换成你的模型名 /** * param {vscode.ExtensionContext} context */ function activate(context) { console.log(离线AI助手扩展已激活); // 注册命令用于打开聊天面板 let disposable vscode.commands.registerCommand(offline-ai-assistant.openChat, function () { // 创建Webview面板 const panel vscode.window.createWebviewPanel( aiChat, // 内部标识 离线AI助手, // 面板标题 vscode.ViewColumn.Two, // 在第二栏打开 { enableScripts: true, // 启用JS retainContextWhenHidden: true, // 隐藏时保持状态重要避免重载 localResourceRoots: [ vscode.Uri.joinPath(context.extensionUri, media) ] } ); // 获取HTML文件路径并设置Webview内容 const htmlPath vscode.Uri.joinPath(context.extensionUri, media, ai-chat.html); fs.readFile(htmlPath.fsPath, utf-8, (err, htmlContent) { if (err) { panel.webview.html htmlbody错误无法加载界面文件/body/html; return; } // 可以在这里对HTML内容进行简单的变量替换如果需要 panel.webview.html htmlContent; }); // 处理来自Webview的消息 panel.webview.onDidReceiveMessage( async message { switch (message.command) { case sendMessage: // 处理用户文本消息 handleUserMessage(panel, message.text); break; case uploadFile: // 处理上传的文件文本内容 handleFileUpload(panel, message.data); break; case uploadImage: // 处理上传的图片Base64 handleImageUpload(panel, message.data); break; } }, undefined, context.subscriptions ); }); context.subscriptions.push(disposable); } async function handleUserMessage(panel, userText) { // 可选获取当前活动编辑器的文件内容作为上下文 const activeEditor vscode.window.activeTextEditor; let fileContext ; if (activeEditor) { const document activeEditor.document; fileContext 当前文件${path.basename(document.fileName)}\n文件内容\n\\\\n${document.getText()}\n\\\; } // 构建最终发送给AI的提示词 const fullPrompt fileContext ? ${fileContext}\n\n基于以上代码我的问题是${userText} : userText; try { // 调用本地大模型API (以Ollama为例) const response await axios.post(LOCAL_LLM_API, { model: MODEL_NAME, prompt: fullPrompt, stream: false, // 先使用非流式简化处理 options: { temperature: 0.7, // 其他模型参数... } }, { timeout: 120000 // 2分钟超时 }); const aiResponse response.data.response; // 将AI回复发送回Webview panel.webview.postMessage({ command: aiResponse, text: aiResponse }); } catch (error) { console.error(调用AI模型失败:, error); let errorMsg 请求AI模型时出错。; if (error.code ECONNREFUSED) { errorMsg 无法连接到本地AI服务。请确保Ollama或其他本地模型服务已启动。; } else if (error.response) { errorMsg AI服务返回错误: ${error.response.status} - ${error.response.data.error || 未知}; } panel.webview.postMessage({ command: error, text: errorMsg }); } } function handleFileUpload(panel, fileData) { // 这里接收到的是前端已经读取的文本内容 // 我们可以直接将其作为上下文或者存储起来等待用户提问 // 简单处理直接通知用户已接收文件并将内容暂存实际项目可能需要更复杂的状态管理 const notification 已接收文件: ${fileData.name}; panel.webview.postMessage({ command: aiResponse, text: notification }); // 在实际应用中你可能需要将fileData.content与某个会话或上下文关联 } function handleImageUpload(panel, imageData) { // 对于图片Base64数据很大我们可能不希望直接打印在聊天框。 // 可以通知用户已接收图片并在后续调用支持视觉的模型时将base64数据嵌入提示词。 const notification 已接收图片: ${imageData.name} (${Math.round(imageData.base64.length / 1024)} KB); panel.webview.postMessage({ command: aiResponse, text: notification }); // 注意将大型Base64数据通过postMessage传递可能会遇到大小限制。 // 更稳健的做法是让宿主保存到临时文件然后传递文件路径给后续的AI请求。 } function deactivate() {} module.exports { activate, deactivate };关键实现细节与避坑指南retainContextWhenHidden: true这个选项至关重要。它使得Webview在标签页隐藏时不会被销毁和重建从而保持了JavaScript状态包括IndexedDB连接和变量。如果设为false每次切换标签页聊天记录都会因为页面重载而“消失”虽然数据还在DB里但需要重新加载体验中断。本地API调用这里使用axios库进行HTTP请求。你需要确保本地大模型服务如Ollama正在运行且API地址和模型名称配置正确。Ollama的默认API地址是http://localhost:11434/api/generate。错误处理网络错误、连接拒绝、模型不存在等情况都需要捕获并给用户友好的提示。流式响应为了更好的体验许多本地模型支持流式响应stream: true。这需要更复杂的前后端配合使用axios的流式响应或EventSource来逐块接收和显示文本实现打字机效果。这可以作为进阶优化点。文件上下文集成handleUserMessage函数中演示了如何获取当前活动编辑器的代码内容并将其作为上下文附加到用户问题前。这是一个非常强大的功能让AI能“看到”你正在编写的代码。图片处理当前的handleImageUpload只是做了通知。要真正让AI分析图片你需要使用支持视觉的多模态模型如LLaVA并将Base64数据或图片路径按照该模型API的要求进行组装。这通常涉及更复杂的提示词工程和API调用格式。6. 功能增强与实战优化基础功能完成后我们可以从实用性和健壮性角度进行一系列优化。6.1 支持流式响应与打字机效果非流式响应需要等待模型完全生成完毕才能显示对于长文本等待感明显。修改extension.js中的handleUserMessage函数以支持流式响应const { Readable } require(stream); const axios require(axios); async function handleUserMessage(panel, userText) { // ... 构建fullPrompt ... try { const response await axios({ method: post, url: LOCAL_LLM_API, data: { model: MODEL_NAME, prompt: fullPrompt, stream: true, // 启用流式 options: { temperature: 0.7 } }, responseType: stream // 关键指定响应类型为流 }); const stream response.data; let fullResponse ; stream.on(data, (chunk) { try { // Ollama的流式响应每行是一个JSON对象 const lines chunk.toString().split(\n).filter(line line.trim() ! ); for (const line of lines) { const parsed JSON.parse(line); if (parsed.response) { fullResponse parsed.response; // 逐块发送回Webview panel.webview.postMessage({ command: aiResponseChunk, text: parsed.response }); } if (parsed.done) { // 流结束发送完成信号可选 panel.webview.postMessage({ command: aiResponseDone, fullText: fullResponse }); } } } catch (e) { console.error(解析流数据出错:, e); } }); stream.on(end, () { console.log(流式响应结束); }); stream.on(error, (err) { console.error(流式响应错误:, err); panel.webview.postMessage({ command: error, text: AI响应流中断 }); }); } catch (error) { // ... 错误处理 ... } }同时在前端ai-chat.html中修改handleExtensionMessage函数以支持逐块显示let currentAiMessageBubble null; let aiResponseBuffer ; function handleExtensionMessage(event) { const message event.data; switch (message.command) { case aiResponse: sendButton.disabled false; appendMessage(ai, message.text); // ... 保存历史 ... break; case aiResponseChunk: // 流式响应块 if (!currentAiMessageBubble) { // 创建新的AI消息气泡 const messageDiv document.createElement(div); messageDiv.className message ai-message; currentAiMessageBubble document.createElement(div); currentAiMessageBubble.className bubble; messageDiv.appendChild(currentAiMessageBubble); chatContainer.appendChild(messageDiv); } aiResponseBuffer message.text; currentAiMessageBubble.textContent aiResponseBuffer; chatContainer.scrollTop chatContainer.scrollHeight; break; case aiResponseDone: // 流式响应结束保存完整消息到历史 saveMessageToHistory(createMessage(ai, aiResponseBuffer)); currentAiMessageBubble null; aiResponseBuffer ; sendButton.disabled false; break; // ... 其他case ... } }6.2 模型配置与切换硬编码模型配置不够灵活。我们可以增加一个设置界面或使用VSCode的配置API。在package.json中定义配置{ contributes: { configuration: { title: 离线AI助手, properties: { offlineAiAssistant.modelEndpoint: { type: string, default: http://localhost:11434/api/generate, description: 本地大模型API端点地址 }, offlineAiAssistant.modelName: { type: string, default: qwen2.5:7b, description: 要使用的模型名称 }, offlineAiAssistant.enableFileContext: { type: boolean, default: true, description: 是否自动将当前打开的文件内容作为上下文发送 } } } } }在extension.js中读取配置const config vscode.workspace.getConfiguration(offlineAiAssistant); const apiEndpoint config.get(modelEndpoint, LOCAL_LLM_API); const modelName config.get(modelName, MODEL_NAME); const enableFileContext config.get(enableFileContext, true);6.3 清理历史记录与多会话管理随着使用IndexedDB中会积累大量数据。我们需要提供清理功能。可以在Webview界面添加一个“清空历史”按钮点击后调用clearHistory()函数function clearHistory(sessionId default) { if (!db) return Promise.reject(DB not ready); return new Promise((resolve, reject) { const transaction db.transaction([STORE_NAME], readwrite); const store transaction.objectStore(STORE_NAME); const index store.index(sessionId); const range IDBKeyRange.only(sessionId); const request index.openCursor(range); request.onsuccess (event) { const cursor event.target.result; if (cursor) { cursor.delete(); cursor.continue(); } else { resolve(); } }; request.onerror (event) reject(event.target.error); }); }对于多会话管理可以维护一个currentSessionId变量在创建新聊天时生成新的ID如时间戳并将此ID用于所有消息的存储和查询。这允许用户同时进行多个独立的对话线程。7. 打包、发布与使用心得7.1 项目打包与安装安装依赖在项目根目录运行npm install axios如果你用了axios。编译打包对于纯JavaScript的VSCode扩展可以直接打包。使用命令vsce package需要先安装vsce工具npm install -g vscode/vsce。这会生成一个.vsix文件。本地安装在VSCode中通过“扩展”视图顶部的“...”菜单选择“从VSIX安装...”然后选择生成的.vsix文件。7.2 使用流程与心得启动本地模型服务确保你的本地大模型服务如Ollama已启动并加载了所需模型。你可以通过命令行ollama run qwen2.5:7b测试服务是否正常。在VSCode中打开聊天面板按下CtrlShiftP(或CmdShiftPon Mac)输入命令Offline AI Assistant: Open Chat并执行。开始对话在右侧打开的聊天面板中直接输入问题。你可以直接提问关于编程、概念解释等。利用文件上下文先打开一个代码文件再提问AI会自动将该文件内容作为背景。上传文件点击回形针图标选择代码文件AI会在回复中引用文件内容。上传图片选择图片文件如果模型支持视觉可以询问图片内容。踩坑实录与经验总结Webview生命周期最初没有设置retainContextWhenHidden: true每次切换标签页聊天记录就没了排查了半天才发现是Webview被销毁重建了。API超时本地模型处理复杂问题可能很慢axios默认超时时间较短务必在请求中设置合理的timeout如120000毫秒。Base64传输性能尝试上传一张3MB的图片导致消息传递卡顿甚至失败。postMessage对数据大小有限制。对于大图片最终方案是让扩展宿主将Base64保存为临时文件只将文件路径传给后续的AI处理逻辑前端仅保留一个缩略图或引用。流式响应解析不同模型的流式响应格式可能不同如Ollama是每行JSON有些可能是SSE格式。需要根据你实际使用的模型API文档进行调整。模型能力边界本地7B参数模型在代码生成和简单推理上表现不错但对于非常复杂的问题或需要大量知识的任务可能会胡言乱语。理解你所用模型的能力范围很重要不要期望它解决所有问题。实现这个“离线VSCode对话界面”的过程是一次对VSCode扩展开发、前端数据持久化、本地API集成以及简单应用架构的完整练习。它虽然小巧但五脏俱全并且实实在在地提升了我本地开发的效率。希望这个详细的实现指南和其中分享的踩坑经验能帮助你打造属于自己的、完全受控的离线AI编程伴侣。