news 2026/9/29 22:45:42

使用 Vue 开发 VS Code 插件前端页面(下):Webview 配置与 TaoToken 接入实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
使用 Vue 开发 VS Code 插件前端页面(下):Webview 配置与 TaoToken 接入实战

1. Webview 里跑 Vue,卡住的多半不是 Vue 本身

如果你已经按上篇把插件子项目和 Vue 前端子项目放在同一个仓库里,两边都能各自跑起来,那么真正让人抓头的阶段才刚开始:Vue 页面在浏览器里跑得好好的,一塞进 VS Code 侧边栏就白屏;index.html里的/assets/index-xxx.js加载 404;acquireVsCodeApi在开发环境报未定义;插件和页面互相postMessage却谁也收不到。这些问题跟 Vue 语法没关系,全部出在 Webview 的资源加载规则和消息通道上。

这篇就接着上篇的工程结构往下做,目标是把三件事落地:第一,让 Vue 构建产物能被 Webview 正确加载并挂载;第二,把插件与页面的双向消息通道打通,约定好消息格式;第三,在插件侧接一条统一的 API 通道,用 TaoToken 的 Key 和 Base URL 发起一次真实请求,验证整条链路是通的。适合已经写过最简 VS Code 插件、会用 Vite 打包 Vue、但对 Webview 的 URI 机制和 CSP 限制还不熟的人。下面所有代码都可以直接复制进你自己的项目改路径使用。

2. 先把 TaoToken 的 Key 和 API 通道准备好

Webview 页面本身只是壳,真正要验证的是「页面点一下按钮,插件侧带着 Key 去请求模型,再把结果回传渲染」。所以先把外部 API 通道准备好,避免后面调页面时把两类问题混在一起排查。

TaoToken 在这里的角色是一个统一的模型 API 入口:你拿到一个 Key,配一个 Base URL,就能用 OpenAI 兼容的方式调用多种模型,不用为每个模型单独维护一套鉴权和地址。对插件这种要长期迭代的工具来说,把 Key 收在插件侧、页面只负责发指令,是比较省心的做法。

操作路径很直接:打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册后进入控制台,在 API Keys 页面创建一个 Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。创建时给它起个能认出来的名字,比如vscode-webview-demo,方便以后按项目吊销。

注意:Key 只在创建时完整显示一次,复制后先存到本地临时文件,别直接写进会提交到 Git 的源码里。后面我们会把它放进 VS Code 的配置项,而不是硬编码。

API 的 Base URL 用 https://taotoken.net/api ,这个地址不加任何查询参数,直接作为 OpenAI 兼容客户端的baseURL使用。接口文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,需要确认模型名或请求字段时去这里查。

如果你后面打算把这个插件做成长期用的编码助手,或者要接 Agent 类的多轮调用,可以顺带看下 Coding Plan 页面 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,它更适合高频、长周期的编码场景。本篇先聚焦单次请求跑通。

3. Webview 资源加载:把 Vue 构建产物正确挂上去

3.1 先确认构建产物的路径约定

上篇里前端子项目用 Vite 构建,产物默认落在dist。为了让插件能稳定找到它,建议在vite.config.ts里把build.outDir固定成一个明确目录,比如dist/gui-webviewview,并且把base设成'./',让产物里的资源引用是相对路径:

// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], base: './', build: { outDir: 'dist/gui-webviewview', emptyOutDir: true } })

base: './'这一步很关键。默认的'/'会生成/assets/index-xxx.js这种绝对路径,Webview 拿到后无法解析,直接 404 白屏。改成相对路径后,产物里是./assets/index-xxx.js,我们再在插件侧把它替换成 Webview 能识别的 URI。

3.2 用 asWebviewUri 重写资源路径

Webview 出于安全考虑,不允许直接加载file://资源,必须走webview.asWebviewUri()转换。所以插件侧读取index.html后,要把里面所有href和src的相对路径替换掉。下面是SidebarViewProvider.ts的完整写法:

// src/views/SidebarViewProvider.ts import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import { RequestHandler } from '../communication/RequestHandler'; import { MessageSender } from '../communication/MessageSender'; export class SidebarViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'extension-example.sidebar'; constructor(private readonly _extensionUri: vscode.Uri) {} public resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ) { webviewView.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] }; webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); // 把 view 实例交给发送器和处理器,后续回消息要用 MessageSender.view = webviewView; RequestHandler.view = webviewView; // 监听前端发来的消息 webviewView.webview.onDidReceiveMessage( (message) => RequestHandler.handleRequest(message), undefined, [] ); } private _getHtmlForWebview(webview: vscode.Webview): string { const guiPath = vscode.Uri.joinPath(this._extensionUri, 'dist', 'gui-webviewview'); const indexPath = vscode.Uri.joinPath(guiPath, 'index.html'); let indexHtml = fs.readFileSync(indexPath.fsPath, 'utf-8'); const matchLinks = /(href|src)="([^"]*)"/g; const toUri = (_: string, prefix: 'href' | 'src', link: string) => { if (link === '#' || link.startsWith('http')) { return `${prefix}="${link}"`; } const _path = path.join(guiPath.fsPath, link.replace(/^\.\//, '')); const uri = vscode.Uri.file(_path); return `${prefix}="${webview.asWebviewUri(uri)}"`; }; indexHtml = indexHtml.replace(matchLinks, toUri); return indexHtml; } }

这里比上篇多做了两件事:一是过滤掉http开头的外链,避免把 CDN 地址也当本地文件处理;二是把./前缀去掉再path.join,否则拼出来的路径会带一层多余的./,在部分平台上解析异常。

3.3 注册视图并保留上下文

extension.ts里注册视图时,建议加上retainContextWhenHidden,否则用户切到别的侧边栏再切回来,Vue 组件状态会被清空,输入框里的内容全没了:

// src/extension.ts import * as vscode from 'vscode'; import { SidebarViewProvider } from './views/SidebarViewProvider'; export function activate(context: vscode.ExtensionContext) { const provider = new SidebarViewProvider(context.extensionUri); context.subscriptions.push( vscode.window.registerWebviewViewProvider( SidebarViewProvider.viewType, provider, { webviewOptions: { retainContextWhenHidden: true } } ) ); } export function deactivate() {}

对应的package.json里要有视图声明,图标放一个本地 svg 即可:

{ "contributes": { "viewsContainers": { "activitybar": [ { "id": "extension-example", "title": "示例插件", "icon": "assets/logo.svg" } ] }, "views": { "extension-example": [ { "id": "extension-example.sidebar", "name": "Vue 页面", "type": "webview" } ] } } }

改完前端记得先pnpm build,再按 F5 启动插件宿主窗口,侧边栏出现 Vue 页面就说明资源加载这关过了。

4. 消息通道与统一 API 接入的可复制配置

4.1 前端侧:把 acquireVsCodeApi 包成 store

acquireVsCodeApi只能在 Webview 环境调用一次,且开发环境(pnpm dev直接开浏览器)里不存在,所以要判空。用 Pinia 包一层,组件里就不用到处判断:

// src/stores/vscode.ts import { defineStore } from 'pinia'; declare const acquireVsCodeApi: () => { postMessage: (data: any) => any }; export const useVsCodeApiStore = defineStore('vsCodeApi', () => { const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined; return { vscode }; });

发送和接收拆成两个 store,约定每条消息都是对象且必带command字段:

// src/stores/sender.ts import { defineStore } from 'pinia'; import { useVsCodeApiStore } from './vscode'; export const useSenderStore = defineStore('sender', () => { const vscode = useVsCodeApiStore().vscode; function initReady() { vscode?.postMessage({ command: 'init.ready' }); } function askModel(prompt: string) { vscode?.postMessage({ command: 'model.ask', prompt }); } return { initReady, askModel }; });
// src/stores/listener.ts import { defineStore } from 'pinia'; import { ref } from 'vue'; export const useListenerStore = defineStore('listener', () => { const receive = ref(''); window.addEventListener('message', (event) => { const message = event.data; switch (message.command) { case 'extension.message': receive.value = message.data; break; case 'model.answer': receive.value = message.data; break; default: receive.value = `未识别消息:\n${JSON.stringify(message)}`; } }); return { receive }; });

4.2 插件侧:请求处理器与 Key 读取

插件侧新建communication/RequestHandler.ts,把model.ask分支接到真实请求上。Key 从 VS Code 配置读,不写死在代码里:

// src/communication/RequestHandler.ts import * as vscode from 'vscode'; import { MessageSender } from './MessageSender'; export class RequestHandler { public static view: vscode.WebviewView | undefined; public static async handleRequest(message: any) { switch (message.command) { case 'init.ready': MessageSender.respondInit(); break; case 'model.ask': await RequestHandler.askModel(message.prompt); break; } } private static async askModel(prompt: string) { const config = vscode.workspace.getConfiguration('extensionExample'); const apiKey = config.get<string>('apiKey'); const baseUrl = config.get<string>('baseUrl') ?? 'https://taotoken.net/api'; const model = config.get<string>('model') ?? 'gpt-4o-mini'; if (!apiKey) { MessageSender.respondModel('未配置 API Key,请在设置中填写 extensionExample.apiKey'); return; } try { const res = await fetch(`${baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }) }); if (!res.ok) { const text = await res.text(); MessageSender.respondModel(`请求失败 ${res.status}:${text}`); return; } const data: any = await res.json(); const answer = data?.choices?.[0]?.message?.content ?? '返回结构里没有找到内容'; MessageSender.respondModel(answer); } catch (err: any) { MessageSender.respondModel(`请求异常:${err?.message ?? String(err)}`); } } }

MessageSender.ts负责把结果推回页面:

// src/communication/MessageSender.ts import * as vscode from 'vscode'; export class MessageSender { public static view: vscode.WebviewView | undefined; public static respondInit() { MessageSender.view?.webview.postMessage({ command: 'extension.message', data: '插件已收到前端初始化完成' }); } public static respondModel(data: string) { MessageSender.view?.webview.postMessage({ command: 'model.answer', data }); } }

4.3 settings.json 片段

把 Key 和地址放进用户或工作区设置,插件通过getConfiguration读取。工作区.vscode/settings.json里这样写:

{ "extensionExample.apiKey": "sk-你的TaoToken密钥", "extensionExample.baseUrl": "https://taotoken.net/api", "extensionExample.model": "gpt-4o-mini" }

同时在插件package.json里声明这三个配置项,用户才能在设置界面看到并修改:

{ "contributes": { "configuration": { "title": "示例插件", "properties": { "extensionExample.apiKey": { "type": "string", "default": "", "description": "TaoToken API Key" }, "extensionExample.baseUrl": { "type": "string", "default": "https://taotoken.net/api", "description": "API Base URL" }, "extensionExample.model": { "type": "string", "default": "gpt-4o-mini", "description": "默认模型名" } } } } }

注意:.vscode/settings.json如果会提交到仓库,别把真实 Key 写进去。更稳妥的做法是让用户在自己的用户级 settings 里配,或者用 SecretStorage 存 Key,这里为了演示链路先走配置项。

5. 验证请求是否走通:三个检查动作

5.1 页面侧:确认消息发出去了

在App.vue挂载时发一次init.ready,再放一个输入框和按钮触发model.ask:

<template> <div class="container"> <textarea v-model="prompt" placeholder="输入要问模型的内容"></textarea> <button @click="ask">发送请求</button> <textarea disabled v-model="receive" placeholder="模型返回结果"></textarea> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { storeToRefs } from 'pinia'; import { useSenderStore } from '@/stores/sender'; import { useListenerStore } from '@/stores/listener'; const prompt = ref(''); const { receive } = storeToRefs(useListenerStore()); onMounted(() => useSenderStore().initReady()); function ask() { if (!prompt.value.trim()) return; useSenderStore().askModel(prompt.value); } </script>

启动插件后,第二个文本框应该先出现「插件已收到前端初始化完成」,说明消息通道是通的。

5.2 插件侧:确认请求真的发出去了

在askModel的fetch前后各加一行日志,打开「帮助 → 切换开发人员工具 → 扩展宿主」看输出:

console.log('[askModel] baseUrl=', baseUrl, 'model=', model); const res = await fetch(`${baseUrl}/v1/chat/completions`, { /* ... */ }); console.log('[askModel] status=', res.status);

如果日志里status=200,说明 Key 和地址都对;如果是401,多半是 Key 复制时带了空格或已失效;404通常是baseUrl多写或少写了/v1。

5.3 端到端:页面里看到模型返回

在输入框写一句「用一句话解释什么是 Webview」,点发送。正常流程是:页面postMessage→ 插件onDidReceiveMessage→fetch请求 TaoToken → 拿到choices[0].message.content→postMessage回页面 → 第二个文本框显示结果。整条链路跑通,说明 Webview 资源加载、消息通道、API 接入三块都对了。

如果你只是想先确认模型侧是否正常,可以打开模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 手动发一条消息对比返回,排除是插件代码还是通道配置的问题。

6. 本篇常见错误排查

白屏,控制台报Failed to load resource或net::ERR_FILE_NOT_FOUND。九成是vite.config.ts里base没改成'./',产物里还是绝对路径。改完重新pnpm build,再确认_getHtmlForWebview里的guiPath和实际产物目录一致。

acquireVsCodeApi is not defined。这个报错只在浏览器里跑pnpm dev时出现,属于预期行为,store 里已经判空处理。如果是在 Webview 里报这个错,检查webview.options.enableScripts是否为true。

消息发出去了但插件收不到。先确认onDidReceiveMessage是在resolveWebviewView里注册的,而不是在activate里;再确认前端postMessage的对象里有command字段,RequestHandler的switch是按command匹配的。

fetch报TypeError: fetch failed。插件宿主运行在 Node 环境,VS Code 1.80+ 内置了fetch,低版本需要自己引入node-fetch或升级 VS Code。另外检查baseUrl结尾不要带/,代码里拼的是${baseUrl}/v1/chat/completions。

改了前端页面但侧边栏没变化。Webview 加载的是构建产物,不是源码。每次改完 Vue 代码都要重新pnpm build,再重启插件宿主窗口(不是重载窗口,是关掉 Extension Development Host 重新 F5)。

Key 配了但一直 401。去 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 确认 Key 状态是否正常,重新复制一次,注意别把首尾空白带进 settings.json。请求字段和模型名以接口文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 为准。

7. 接下来怎么把这套结构用起来

到这一步,你手上应该有一个能加载 Vue 页面、能双向通信、能带着 Key 请求模型的插件骨架。后面要扩展,基本都在这三个点上加:页面里加组件和 store,RequestHandler里加command分支,配置里加新的extensionExample.*项。消息格式一旦约定好,前后端各改各的,不容易互相踩。

如果只是偶尔手动问一句,现在这套就够了;如果打算把它做成常驻的编码助手,频繁调用、多轮上下文、Agent 式任务编排,建议去看下 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,按长期编码场景来配更合适。需要新建或轮换 Key 时,控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 和 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 是常去的地方。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 22:44:37

STM32开发资源与实战指南:从平台选择到工程落地

我先用一段从业者视角的话开头&#xff1a;很多刚接触 STM32 的朋友&#xff0c;最先问的不是“怎么学”&#xff0c;而是“去哪找靠谱的参考方案”。国内资源其实非常丰富&#xff0c;但分散在社区、论坛、视频站和开源仓库里&#xff0c;搜索质量参差不齐&#xff0c;想少走弯…

作者头像 李华
网站建设 2026/9/29 22:44:37

2026亨得利钟表线下服务中心怎么找?表友实地走访后的几点提醒

在钟表养护圈层里&#xff0c;绝大多数表主的核心困扰&#xff0c;并非维修价格高低、养护流程繁琐&#xff0c;而是难以精准定位正规可信赖的亨得利钟表服务中心。2026年网络信息繁杂冗余&#xff0c;各类同名维修店铺、非授权维保机构信息混杂在搜索引擎、地图平台中&#xf…

作者头像 李华
网站建设 2026/9/29 22:43:43

2026 朱雀 AI 检测降AI率全攻略:职场/自媒体必备的3个超好用的工具

上周用大模型偷懒写了份小红书文案稿发给总监&#xff0c;结果直接被打回重写&#xff0c;老板一眼就看出是机器写的。当时我还挺不服气&#xff0c;自己跑去测了一下朱雀查AI率&#xff0c;结果看到百分之七十的AI生成概率直接傻眼了&#xff0c;太让人头疼了。 现在很多平台和…

作者头像 李华
网站建设 2026/9/29 22:42:31

Spring 把发版窗口从两周压成一天:AI 找漏洞的速度,快过修复排期

9 月 21 日&#xff0c;Spring 官方博客发了一篇标题很克制的公告——《Releasing Spring for Modern Challenges》&#xff0c;作者 Michael Minella。内容却一点都不克制&#xff1a;Spring 整个产品组合的发版方式被改掉了。原来是一个两周长的发版窗口&#xff0c;各项目错…

作者头像 李华