离线VSCode对接本地大模型:单文件对话界面实现(持久化+文件图片上传)

0 阅读8分钟

前言

最近在处理离线内网环境下的VSCode开发需求,场景很明确:没有外网,没法安装GitHub Copilot之类的在线AI插件;本地已经部署好了大模型API(无论是Ollama、vLLM还是私有化部署的模型服务),想在VSCode的开发工作流里直接做代码问答、本地文档分析,不想切换厚重的独立客户端,也不想折腾复杂的插件配置和编译。

找了一圈没找到适配纯离线、零依赖的轻量方案,索性用纯前端写了一个单HTML文件的对话界面。它不需要任何后端服务、没有外部CDN依赖、全程离线运行,支持多会话历史持久化、文本/代码文件上传、图片上传,直接在VSCode环境里就能打开使用。今天把完整的实现思路和可直接复用的源码分享出来,适合同样有离线本地模型使用需求的开发者。

需求与设计目标

做这个工具的时候,我围绕「VSCode离线开发场景」定了几个核心设计原则,所有功能都围绕这几点展开:

  1. 纯离线单文件:单个HTML文件,无任何外部依赖,不引用任何在线CDN资源,完美适配完全断网的内网开发环境
  2. VSCode零侵入:不需要安装任何VSCode插件,不修改编辑器配置,纯文件级使用,拷贝即用,不会污染基础版VSCode环境
  3. 标准API兼容:完全兼容OpenAI /v1/chat/completions 标准接口格式,无缝适配Ollama、vLLM、本地私有化模型等所有兼容方案
  4. 多会话持久化:会话历史存储在浏览器本地存储中,刷新页面、关闭浏览器甚至重启电脑,所有对话记录都不会丢失
  5. 开发场景适配:支持上传文本/代码文件、图片,自动拼接进提问内容,满足本地代码审阅、文档分析、报错截图解读等开发需求
  6. 轻量无残留:纯前端运行,不安装任何系统服务,不留下后台进程,删除文件即完全卸载

技术选型

为了满足「单文件、零依赖、离线可用」的核心要求,全程使用浏览器原生能力实现,没有引入任何前端框架或第三方库:

  • 原生HTML + CSS + JavaScript:零构建、零依赖,单文件即可分发运行,不需要Node.js等环境支持
  • LocalStorage:浏览器本地持久化存储会话数据,全程离线不联网,数据仅存在本地浏览器中
  • Fetch API:原生HTTP请求能力,调用本地模型接口,完美兼容标准OpenAI请求格式
  • FileReader API:前端直接读取本地文件内容、生成图片预览,不需要后端上传服务,全程本地处理

核心功能实现

1. 整体布局设计

采用类ChatGPT的经典左右分栏布局,左侧为会话管理侧边栏,右侧为聊天主区域,底部固定输入工具栏。通过Flex布局实现全高度自适应,聊天内容溢出时自动出现滚动条。

核心布局CSS实现:

body {
    display: flex;
    height: 100vh;
    overflow: hidden;
    background: #f2f3f5;
}
/* 左侧会话栏 */
.sidebar {
    width: 240px;
    background: #ffffff;
    border-right: 1px solid #e5e7eb;
    display: flex;
    flex-direction: column;
}
/* 右侧聊天主区域 */
.main {
    flex: 1;
    display: flex;
    flex-direction: column;
}
.chat-box {
    flex: 1;
    padding: 20px;
    overflow-y: auto;
}

2. 多会话管理逻辑

设计了独立的会话数据结构,每个会话包含唯一ID、会话标题、消息列表,不同会话的上下文完全隔离,互不干扰。核心实现三个基础方法:

  • createNewSession():创建全新会话,自动生成时间戳作为唯一ID
  • switchSession():切换会话时清空当前聊天区,批量渲染对应历史消息
  • renderChatList():重绘左侧会话列表,高亮当前激活的会话

核心数据结构与切换逻辑:

// 单条会话数据结构
{
    id: "1718000000000",  // 时间戳生成唯一ID
    title: "新会话",        // 会话标题,自动取第一条提问前12字
    messages: []            // 对话消息列表,存储角色+内容
}

// 会话切换核心逻辑
function switchSession(sessionId) {
    currentSessionId = sessionId;
    const session = chatSessions.find(s => s.id === sessionId);
    // 清空当前聊天区与待上传附件
    chatBoxEl.innerHTML = '';
    attachList = [];
    renderAttachPreview();
    // 批量渲染当前会话的历史消息
    session.messages.forEach(item => {
        addMsgToView(item.content, item.role);
    });
    renderChatList();
    saveToLocalStorage();
}

3. 本地持久化存储

这是解决「刷新页面数据丢失」的核心能力。我封装了读写两个工具方法,在所有数据变更节点自动触发保存,页面加载时自动读取恢复数据。

自动保存的时机覆盖:新建会话、切换会话、发送消息完成、修改会话标题。

持久化核心代码:

// 本地存储唯一键名,避免和其他站点数据冲突
const STORAGE_KEY = "local_ai_chat_data";

// 保存会话数据到本地存储
function saveToLocalStorage() {
    const saveData = {
        chatSessions: chatSessions,
        currentSessionId: currentSessionId
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(saveData));
}

// 页面加载时从本地存储读取数据
function loadFromLocalStorage() {
    const str = localStorage.getItem(STORAGE_KEY);
    if (!str) return;
    try {
        const data = JSON.parse(str);
        chatSessions = data.chatSessions || [];
        currentSessionId = data.currentSessionId;
    } catch (e) {
        console.log("历史数据读取失败,已自动重置为空");
    }
}

4. 文件与图片上传处理

通过FileReader API纯前端读取本地文件,完全不需要后端服务参与,所有文件内容都在本地处理:

  • 文本/代码文件:读取文件纯文本内容,自动按文件名拼接进提问prompt,支持多文件同时上传
  • 图片文件:读取为DataURL生成缩略图预览,在提问中标注图片文件名,适配多模态模型场景

同时做了附件预览区,支持单个删除已选附件,消息发送后自动清空附件列表。

5. 模型API对接

完全兼容标准OpenAI聊天补全接口,内置加载状态提示、错误捕获,接口调用失败时会在聊天界面给出明确的错误提示,方便排查问题。

核心请求逻辑:

const res = await fetch(API_URL, {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${API_KEY}`
    },
    body: JSON.stringify({
        model: MODEL_NAME,
        messages: session.messages,
        temperature: 0.7
    })
});

在 VSCode 中的使用方法

这个方案完全不需要安装任何VSCode插件,对无外网的基础版VSCode环境完美适配,使用步骤非常简单:

  1. 将下方完整源码保存为 local-chat.html 文件,放入你的VSCode工作区任意目录
  2. 在VSCode左侧资源管理器中找到该文件,右键选择「在默认浏览器中打开」
  3. 浏览器打开后即可正常使用,对话数据保存在当前浏览器本地存储中,和VSCode环境完全解耦
  4. 如果需要常驻在VSCode窗口内使用,可提前部署离线版的内置浏览器插件,直接在编辑器侧边栏打开该HTML文件即可

完整可运行源码

下面是完整的单文件代码,复制保存为 local-chat.html 即可直接使用。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>本地模型对话 - 持久化会话+文件图片上传</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: "Microsoft Yahei", sans-serif;
        }
        body {
            display: flex;
            height: 100vh;
            overflow: hidden;
            background: #f2f3f5;
        }
        /* 左侧会话栏 */
        .sidebar {
            width: 240px;
            background: #ffffff;
            border-right: 1px solid #e5e7eb;
            display: flex;
            flex-direction: column;
        }
        .sidebar-header {
            padding: 16px;
            border-bottom: 1px solid #e5e7eb;
        }
        #newChat {
            width: 100%;
            padding: 10px;
            background: #1677ff;
            color: #fff;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
        }
        #newChat:hover {
            background: #0958d9;
        }
        .chat-list {
            flex: 1;
            padding: 8px;
            overflow-y: auto;
        }
        .chat-item {
            padding: 10px 12px;
            margin: 4px 0;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .chat-item:hover {
            background: #f5f7fa;
        }
        .chat-item.active {
            background: #e8f3ff;
            color: #1677ff;
        }
        /* 右侧主区域 */
        .main {
            flex: 1;
            display: flex;
            flex-direction: column;
        }
        .chat-box {
            flex: 1;
            padding: 20px;
            overflow-y: auto;
        }
        .message {
            max-width: 75%;
            padding: 12px 16px;
            border-radius: 12px;
            line-height: 1.6;
            margin-bottom: 16px;
            white-space: pre-wrap;
        }
        .user-msg {
            background: #1677ff;
            color: #fff;
            margin-left: auto;
        }
        .bot-msg {
            background: #fff;
            border: 1px solid #e5e7eb;
            color: #333;
        }
        .thinking {
            color: #888;
            font-style: italic;
        }
        /* 附件预览区 */
        .attach-preview {
            padding: 0 20px 10px;
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
        }
        .attach-item {
            position: relative;
            width: 80px;
            height: 80px;
            border: 1px solid #ddd;
            border-radius: 6px;
            overflow: hidden;
            background: #f9f9f9;
        }
        .attach-item img {
            width: 100%;
            height: 100%;
            object-fit: cover;
        }
        .attach-item .file-name {
            font-size: 10px;
            padding: 2px;
            text-align: center;
            overflow: hidden;
        }
        .attach-item .close {
            position: absolute;
            top: 2px;
            right: 2px;
            width: 16px;
            height: 16px;
            background: #ccc;
            color: #fff;
            border-radius: 50%;
            text-align: center;
            line-height: 16px;
            font-size: 12px;
            cursor: pointer;
        }
        /* 底部输入+上传栏 */
        .input-area {
            padding: 16px;
            background: #fff;
            border-top: 1px solid #e5e7eb;
        }
        .tool-bar {
            display: flex;
            gap: 10px;
            margin-bottom: 10px;
        }
        .tool-btn {
            padding: 6px 12px;
            border: 1px solid #ddd;
            border-radius: 4px;
            background: #f8f8f8;
            cursor: pointer;
            font-size: 13px;
        }
        .tool-btn:hover {
            background: #eee;
        }
        .input-row {
            display: flex;
            gap: 10px;
        }
        #inputText {
            flex: 1;
            padding: 12px 16px;
            border: 1px solid #dcdcdc;
            border-radius: 8px;
            outline: none;
            font-size: 14px;
            min-height: 48px;
            resize: none;
        }
        #inputText:focus {
            border-color: #1677ff;
        }
        #sendBtn {
            padding: 0 20px;
            background: #1677ff;
            color: #fff;
            border: none;
            border-radius: 8px;
            cursor: pointer;
        }
        #sendBtn:disabled {
            background: #b3d1f8;
            cursor: not-allowed;
        }
        /* 隐藏原生文件输入 */
        input[type="file"] {
            display: none;
        }
    </style>
</head>
<body>
    <!-- 左侧会话列表 -->
    <div class="sidebar">
        <div class="sidebar-header">
            <button id="newChat">+ 新建会话</button>
        </div>
        <div class="chat-list" id="chatList"></div>
    </div>

    <!-- 右侧主界面 -->
    <div class="main">
        <div class="chat-box" id="chatBox"></div>
        <!-- 附件预览区 -->
        <div class="attach-preview" id="attachPreview"></div>

        <div class="input-area">
            <!-- 上传按钮栏 -->
            <div class="tool-bar">
                <label class="tool-btn" for="imgUpload">📷 上传图片</label>
                <input type="file" id="imgUpload" accept="image/*" multiple>

                <label class="tool-btn" for="fileUpload">📄 上传文件</label>
                <input type="file" id="fileUpload" multiple>
            </div>
            <!-- 输入发送栏 -->
            <div class="input-row">
                <textarea id="inputText" placeholder="输入问题,回车发送"></textarea>
                <button id="sendBtn">发送</button>
            </div>
        </div>
    </div>

    <script>
        // ========== 模型配置(修改为你本地接口) ==========
        const API_URL = "http://localhost:11434/v1/chat/completions";
        const MODEL_NAME = "qwen:7b";
        const API_KEY = "sk-local";
        // 本地存储键名
        const STORAGE_KEY = "local_ai_chat_data";
        // ================================================

        // 全局数据
        let chatSessions = [];
        let currentSessionId = null;
        let attachList = [];

        // DOM 元素
        const chatListEl = document.getElementById('chatList');
        const chatBoxEl = document.getElementById('chatBox');
        const attachPreviewEl = document.getElementById('attachPreview');
        const inputEl = document.getElementById('inputText');
        const sendBtn = document.getElementById('sendBtn');
        const newChatBtn = document.getElementById('newChat');
        const imgUpload = document.getElementById('imgUpload');
        const fileUpload = document.getElementById('fileUpload');

        // 页面加载:读取本地存储
        window.onload = function () {
            loadFromLocalStorage();
            initFirstChat();
            renderChatList();
            if(currentSessionId) switchSession(currentSessionId);
        };

        // 保存数据到本地存储
        function saveToLocalStorage() {
            const saveData = {
                chatSessions: chatSessions,
                currentSessionId: currentSessionId
            };
            localStorage.setItem(STORAGE_KEY, JSON.stringify(saveData));
        }

        // 从本地存储读取数据
        function loadFromLocalStorage() {
            const str = localStorage.getItem(STORAGE_KEY);
            if (!str) return;
            try {
                const data = JSON.parse(str);
                chatSessions = data.chatSessions || [];
                currentSessionId = data.currentSessionId;
            } catch (e) {
                console.log("读取历史会话失败,初始化空数据");
                chatSessions = [];
                currentSessionId = null;
            }
        }

        // 新建会话
        function createNewSession() {
            const sessionId = Date.now().toString();
            chatSessions.push({
                id: sessionId,
                title: "新会话",
                messages: []
            });
            switchSession(sessionId);
            renderChatList();
            saveToLocalStorage();
        }

        // 初始化首个会话
        function initFirstChat() {
            if (chatSessions.length === 0) createNewSession();
        }

        // 切换会话
        function switchSession(sessionId) {
            currentSessionId = sessionId;
            const session = chatSessions.find(s => s.id === sessionId);
            chatBoxEl.innerHTML = '';
            attachList = [];
            renderAttachPreview();
            session.messages.forEach(item => {
                addMsgToView(item.content, item.role);
            });
            renderChatList();
            saveToLocalStorage();
        }

        // 渲染会话列表
        function renderChatList() {
            chatListEl.innerHTML = '';
            chatSessions.forEach(session => {
                const item = document.createElement('div');
                item.className = `chat-item ${session.id === currentSessionId ? 'active' : ''}`;
                item.innerText = session.title;
                item.onclick = () => switchSession(session.id);
                chatListEl.appendChild(item);
            });
        }

        // 渲染附件预览
        function renderAttachPreview() {
            attachPreviewEl.innerHTML = '';
            attachList.forEach((item, idx) => {
                const div = document.createElement('div');
                div.className = 'attach-item';

                const close = document.createElement('span');
                close.className = 'close';
                close.innerText = '×';
                close.onclick = () => {
                    attachList.splice(idx, 1);
                    renderAttachPreview();
                };
                div.appendChild(close);

                if (item.type === 'image') {
                    const img = document.createElement('img');
                    img.src = item.dataUrl;
                    div.appendChild(img);
                } else {
                    const name = document.createElement('div');
                    name.className = 'file-name';
                    name.innerText = item.name;
                    div.appendChild(name);
                }
                attachPreviewEl.appendChild(div);
            });
        }

        // 选择图片
        function handleImageSelect(e) {
            const files = e.target.files;
            for (let file of files) {
                const reader = new FileReader();
                reader.onload = function (ev) {
                    attachList.push({
                        type: 'image',
                        name: file.name,
                        dataUrl: ev.target.result,
                        raw: file
                    });
                    renderAttachPreview();
                };
                reader.readAsDataURL(file);
            }
            imgUpload.value = '';
        }

        // 选择普通文件
        function handleFileSelect(e) {
            const files = e.target.files;
            for (let file of files) {
                const reader = new FileReader();
                reader.onload = function (ev) {
                    attachList.push({
                        type: 'file',
                        name: file.name,
                        content: ev.target.result,
                        raw: file
                    });
                    renderAttachPreview();
                };
                reader.readAsText(file);
            }
            fileUpload.value = '';
        }

        // 页面添加消息
        function addMsgToView(text, role) {
            const div = document.createElement('div');
            if (role === 'user') {
                div.className = 'message user-msg';
            } else if (role === 'assistant') {
                div.className = 'message bot-msg';
            } else {
                div.className = 'message bot-msg thinking';
            }
            div.innerText = text;
            chatBoxEl.appendChild(div);
            chatBoxEl.scrollTop = chatBoxEl.scrollHeight;
            return div;
        }

        // 发送消息 + 附件
        async function sendMessage() {
            const content = inputEl.value.trim();
            const session = chatSessions.find(s => s.id === currentSessionId);
            if (!content && attachList.length === 0) return;

            // 设置会话标题
            if (session.messages.length === 0) {
                session.title = content.substring(0, 12) + (content.length > 12 ? '...' : '');
                renderChatList();
            }

            // 拼接提问内容
            let fullPrompt = content + "\n\n";
            attachList.forEach(att => {
                if (att.type === 'file') {
                    fullPrompt += `【文件:${att.name}】\n${att.content}\n\n`;
                } else if (att.type === 'image') {
                    fullPrompt += `【已上传图片:${att.name}】\n`;
                }
            });

            // 存入会话
            const userMsg = { role: 'user', content: fullPrompt };
            session.messages.push(userMsg);
            addMsgToView(fullPrompt, 'user');

            // 清空输入和附件
            inputEl.value = '';
            attachList = [];
            renderAttachPreview();

            // 加载状态
            const loadingDom = addMsgToView("模型思考中...", "thinking");
            sendBtn.disabled = true;

            try {
                const res = await fetch(API_URL, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        "Authorization": `Bearer ${API_KEY}`
                    },
                    body: JSON.stringify({
                        model: MODEL_NAME,
                        messages: session.messages,
                        temperature: 0.7
                    })
                });
                const data = await res.json();
                const reply = data.choices?.[0]?.message?.content || "模型无返回";

                chatBoxEl.removeChild(loadingDom);
                const botMsg = { role: 'assistant', content: reply };
                session.messages.push(botMsg);
                addMsgToView(reply, 'assistant');
                // 每次对话后自动保存
                saveToLocalStorage();
            } catch (err) {
                chatBoxEl.removeChild(loadingDom);
                addMsgToView(`请求失败:${err.message}`, "thinking");
            }

            sendBtn.disabled = false;
        }

        // 事件绑定
        newChatBtn.addEventListener('click', createNewSession);
        sendBtn.addEventListener('click', sendMessage);
        inputEl.addEventListener('keydown', e => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                sendMessage();
            }
        });
        imgUpload.addEventListener('change', handleImageSelect);
        fileUpload.addEventListener('change', handleFileSelect);
    </script>
</body>
</html>

在这里插入图片描述

接口配置说明

打开HTML文件,找到顶部的配置区域,修改为你自己的本地模型信息即可:

const API_URL = "http://localhost:11434/v1/chat/completions"; // 你的模型API地址
const MODEL_NAME = "qwen:7b"; // 你的模型名称
const API_KEY = "sk-local"; // 本地无鉴权模型可随意填写

可扩展方向

这个版本是基础可用版,还有很多可以优化扩展的空间,大家可以根据自己的需求二次开发:

  1. 流式输出:对接SSE流式响应,实现打字机输出效果,大幅提升交互体验
  2. 多模型切换:配置多个模型参数,通过下拉菜单快速切换不同的本地模型
  3. 会话导出:支持导出Markdown、JSON格式的对话记录,方便归档和分享
  4. 完整多模态支持:如果你的模型支持图文输入,可将图片base64按接口格式传入请求体
  5. 会话管理增强:增加单个会话删除、重命名会话、清空所有历史的功能
  6. VSCode插件封装:可将该页面封装为极简VSCode插件,常驻侧边栏使用

写在最后

这个小工具最适合的场景就是离线内网开发环境、本地模型调试、隐私敏感的代码对话需求。纯前端单文件的形式足够轻量,拷贝到任何地方都能直接运行,也不用担心中间件、依赖和环境问题。

我自己日常在无外网的VSCode开发环境里,用它对接本地的代码大模型,上传代码文件直接提问,比在终端里用curl调用接口方便太多。如果你也有本地大模型的使用需求,不妨保存下来试试,有什么优化想法也欢迎一起交流。