ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

使用 Vue 开发 VS Code 插件前端页面(下):Webview 配置与 TaoToken 接入实战 1. Webview 里跑 Vue卡住的多半不是 Vue 本身如果你已经按上篇把插件子项目和 Vue 前端子项目放在同一个仓库里两边都能各自跑起来那么真正让人抓头的阶段才刚开始Vue 页面在浏览器里跑得好好的一塞进 VS Code 侧边栏就白屏index.html里的/assets/index-xxx.js加载 404acquireVsCodeApi在开发环境报未定义插件和页面互相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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后进入控制台在 API Keys 页面创建一个 Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content Key 管理页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。创建时给它起个能认出来的名字比如vscode-webview-demo方便以后按项目吊销。注意Key 只在创建时完整显示一次复制后先存到本地临时文件别直接写进会提交到 Git 的源码里。后面我们会把它放进 VS Code 的配置项而不是硬编码。API 的 Base URL 用 https://taotoken.net/api 这个地址不加任何查询参数直接作为 OpenAI 兼容客户端的baseURL使用。接口文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 需要确认模型名或请求字段时去这里查。如果你后面打算把这个插件做成长期用的编码助手或者要接 Agent 类的多轮调用可以顺带看下 Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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 包成 storeacquireVsCodeApi只能在 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.getstring(apiKey); const baseUrl config.getstring(baseUrl) ?? https://taotoken.net/api; const model config.getstring(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.asktemplate div classcontainer textarea v-modelprompt placeholder输入要问模型的内容/textarea button clickask发送请求/button textarea disabled v-modelreceive placeholder模型返回结果/textarea /div /template script setup langts 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);如果日志里status200说明 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 确认 Key 状态是否正常重新复制一次注意别把首尾空白带进 settings.json。请求字段和模型名以接口文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 为准。7. 接下来怎么把这套结构用起来到这一步你手上应该有一个能加载 Vue 页面、能双向通信、能带着 Key 请求模型的插件骨架。后面要扩展基本都在这三个点上加页面里加组件和 storeRequestHandler里加command分支配置里加新的extensionExample.*项。消息格式一旦约定好前后端各改各的不容易互相踩。如果只是偶尔手动问一句现在这套就够了如果打算把它做成常驻的编码助手频繁调用、多轮上下文、Agent 式任务编排建议去看下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 按长期编码场景来配更合适。需要新建或轮换 Key 时控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 和 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 是常去的地方。
返回列表