首页 / 资讯中心 / 文章详情

这个300万下载量的VSCode插件竟是这样开发的:TaoToken视角拆解PDF customEditors与webview

这个300万下载量的VSCode插件竟是这样开发的:TaoToken视角拆解PDF customEditors与webview ★ FEATURED ARTICLE
1. 从一次 PDF 预览踩坑说起customEditors 与 webview 到底怎么配合VSCode 插件里做 PDF 预览核心就两件事用customEditors把*.pdf从默认文本编辑器手里抢过来再用webview把渲染层塞进去。听起来简单但真正动手你会发现坑集中在三个地方——iframe被安全策略拦、pdf.js 的 origin 校验报file origin does not match viewers、以及localResourceRoots没配对导致资源 401。这篇就把这条链路拆开顺带说清楚 AI 能力该接在哪一层。先说清楚这套东西是什么、能做什么、适合谁。customEditors是 VSCode 提供的扩展点允许你为特定文件类型注册一个完全自定义的读写编辑器取代默认的文本编辑器。webview则是插件里嵌入网页内容的容器可以加载 HTML、跑脚本、和插件主进程双向通信。把两者拼起来你就能在 VSCode 标签页里直接渲染 PDF而不是看到一堆乱码二进制。适合谁适合已经会创建插件项目、想给插件加非文本文件预览能力的开发者也适合想把 AI 摘要、AI 问答嵌进阅读流程的人。我试过的第一个版本非常朴素resolveCustomEditor里直接写个iframesrc指向 pdf.js 的viewer.html。结果页面一片空白。原因是 VSCode webview 默认不允许嵌套外部iframe安全策略直接把它掐了。于是换思路——不嵌iframe而是把viewer.html的内容读出来直接赋给webviewPanel.webview.html。这一步能显示界面了但新的问题来了pdf.js 靠查询参数?filexxx拿文件地址而我们是直接塞 HTML 字符串没有 URL 可以挂参数。翻 pdf.js 源码会发现它取文件地址的逻辑是file params.get(file) ?? AppOptions.get(defaultUrl)。有人会想那我改成从全局变量读不就行了改完确实能拿到地址但紧接着就撞上 origin 校验file origin does not match viewers。因为 webview 的页面 origin 是vscode-webview://...而 PDF 文件是https://...或本地file://两者天然不一致。去掉校验能跑但改动太大、后续升级 pdf.js 还得重新 patch不划算。真正的突破口在 pdf.js 的fileinputchange事件处理里。它监听文件输入变化后调用PDFViewerApplication.open({ url: URL.createObjectURL(file), originalUrl: file.name })注意这个open走的是 blob URL不触发 origin 校验。那我们只要在 pdf.js 初始化完成后主动调一次open把 webview 能访问的 PDF 资源 URI 传进去就行。这就是整套方案的关键不修改 pdf.js 源码只在注入的脚本里等initializedPromise完成后调用open。这里就引出 AI 能力该接在哪。PDF 渲染是纯前端展示层AI 辅助摘要、问答、翻译属于对文档内容做二次加工它不该塞进 pdf.js 内部而应该放在插件主进程或 webview 的消息层webview 负责把当前页文本、选中内容通过postMessage发给插件插件再调用统一的大模型 API 通道拿结果回传给 webview 渲染。这样渲染和 AI 解耦pdf.js 升级不影响 AI 逻辑AI 换模型也不影响渲染。下一节先把 TaoToken 这条统一通道的前置准备好再回到配置细节。2. TaoToken 前置准备统一 Key 与 API 通道在插件里的接入位置在插件里接 AI最烦的不是写调用代码而是 Key 管理。硬编码进源码会泄露让每个用户自己填又体验差多模型切换还要维护一堆 endpoint。TaoToken 在这里的角色是统一 Key 统一 API 通道你拿一个 Key通过一个 Base URL 就能访问多种模型插件侧只需要维护一份配置。官网入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 根地址是 https://taotoken.net/api 。先说清楚接入位置。插件里 AI 调用应该发生在扩展主进程Node 环境而不是 webview 里。原因有三第一webview 是沙箱环境直接发外部请求容易被 CSP 限制第二Key 不该出现在 webview 的 HTML/JS 里否则用户 F12 就能看到第三主进程能拿到vscode.workspace.getConfiguration读取用户配置也能用context.secrets安全存储。所以标准做法是webview 通过postMessage把要处理的文本发给主进程主进程调 TaoToken API再把结果postMessage回 webview。Key 的存放建议用context.secrets这是 VSCode 提供的加密存储比塞进settings.json安全。用户第一次使用时通过命令面板触发一个设置 API Key的命令把 Key 存进 secrets。Base URL 和 Model ID 可以放settings.json因为它们不敏感而且用户可能想切换模型。这样三件套就齐了Base URL 固定为https://taotoken.net/apiKey 走 secretsModel ID 走配置。如果你用的是 Claude Code 这类工具做辅助开发或者想用 Coding Plan 跑长任务配置逻辑是一样的Base URL 指向 TaoToken 的 API 根地址Key 用你申请的那把Model ID 填你要用的模型标识。这三件套缺一不可尤其是 Model ID很多人只填了 Base URL 和 Key结果请求报模型不存在。下面给一份可直接复制的配置片段路径和字段名按 VSCode 插件惯例来。{ taotoken.baseUrl: https://taotoken.net/api, taotoken.modelId: claude-sonnet-4-5, taotoken.maxTokens: 2048 }上面这段放进插件的package.json的contributes.configuration里用户就能在设置界面看到这三个选项。Key 不进这里走 secrets。读取时这样写const config vscode.workspace.getConfiguration(taotoken); const baseUrl config.getstring(taotoken.baseUrl) ?? https://taotoken.net/api; const modelId config.getstring(taotoken.modelId) ?? claude-sonnet-4-5; const apiKey await context.secrets.get(taotoken.apiKey); if (!apiKey) { vscode.window.showWarningMessage(请先设置 TaoToken API Key); return; }注意context.secrets.get是异步的别漏了await。另外 Base URL 末尾不要带/拼接路径时统一用${baseUrl}/v1/messages这种形式避免出现双斜杠。Model ID 的具体取值以你账号里可用的为准这里只是示例占位。前置准备好之后下一节进入 customEditors 的完整可复制配置。3. 可复制配置customEditors 注册 webview 消息通信 pdf.js 注入这一节是全文最核心的部分目标是把package.json的贡献点、CustomEditorProvider的实现、webview 的 HTML 注入、以及消息通信全部串起来每段都能直接抄。先看package.json里的customEditors声明。viewType是自定义编辑器的唯一标识selector用filenamePattern匹配*.pdf。这段决定了 VSCode 在打开 PDF 时会不会把你的编辑器作为候选。{ contributes: { customEditors: [ { viewType: taotoken.pdfEditor, displayName: PDF Viewer (TaoToken), selector: [ { filenamePattern: *.pdf } ], priority: default } ], commands: [ { command: taotoken.setApiKey, title: TaoToken: 设置 API Key } ] } }priority设为default表示默认用它打开用户仍可通过打开方式切换回文本编辑器。接着实现CustomEditorProvider。这里用PartialCustomEditorProvider只实现必要方法openCustomDocument返回一个带uri和dispose的对象resolveCustomEditor负责注入 HTML。import * as vscode from vscode; import * as path from path; import { readFileSync } from fs; class PdfEditorProvider implements vscode.CustomEditorProvider { constructor(private readonly context: vscode.ExtensionContext) {} openCustomDocument( uri: vscode.Uri, _openContext: vscode.CustomDocumentOpenContext, _token: vscode.CancellationToken ): vscode.CustomDocument { return { uri, dispose: () {} }; } resolveCustomEditor( document: vscode.CustomDocument, webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): void { const base vscode.Uri.joinPath( this.context.extensionUri, dist, web, pdf, web ); webviewPanel.webview.options { enableScripts: true, localResourceRoots: [ vscode.Uri.file(path.dirname(document.uri.fsPath)), this.context.extensionUri ] }; const viewerHtml readFileSync( path.join(base.fsPath, viewer.html), utf8 ); const baseUri webviewPanel.webview.asWebviewUri(base).toString(); const pdfUri webviewPanel.webview.asWebviewUri(document.uri).toString(); webviewPanel.webview.html viewerHtml.replace( head, head base href${baseUri} script window.addEventListener(load, function () { PDFViewerApplication.initializedPromise.then(function () { setTimeout(function () { PDFViewerApplication.open({ url: ${pdfUri} }); }, 0); }); }); /script stylebody { padding: 0; }/style ); } saveCustomDocument(): Thenablevoid { return Promise.resolve(); } saveCustomDocumentAs(): Thenablevoid { return Promise.resolve(); } revertCustomDocument(): Thenablevoid { return Promise.resolve(); } backupCustomDocument(): Thenablevscode.CustomDocumentBackup { return Promise.resolve({ id: , delete: () {} }); } }注册提供程序时viewType必须和package.json里完全一致const provider new PdfEditorProvider(context); context.subscriptions.push( vscode.window.registerCustomEditorProvider(taotoken.pdfEditor, provider, { webviewOptions: { retainContextWhenHidden: true } }) );retainContextWhenHidden: true让 webview 在标签切换时保留状态避免每次切回来都重新加载 PDF。接下来是消息通信。webview 侧监听message事件主进程侧用webviewPanel.webview.onDidReceiveMessage接收。AI 请求的典型流程是webview 发{ type: ai-summarize, text: ... }主进程调 TaoToken API回发{ type: ai-result, content: ... }。webviewPanel.webview.onDidReceiveMessage(async (msg) { if (msg.type ai-summarize) { const config vscode.workspace.getConfiguration(taotoken); const baseUrl config.getstring(taotoken.baseUrl) ?? https://taotoken.net/api; const modelId config.getstring(taotoken.modelId) ?? claude-sonnet-4-5; const apiKey await this.context.secrets.get(taotoken.apiKey); if (!apiKey) { webviewPanel.webview.postMessage({ type: ai-error, message: 未设置 API Key }); return; } try { const resp await fetch(${baseUrl}/v1/messages, { method: POST, headers: { Content-Type: application/json, x-api-key: apiKey, anthropic-version: 2023-06-01 }, body: JSON.stringify({ model: modelId, max_tokens: 1024, messages: [{ role: user, content: 请总结以下内容\n${msg.text} }] }) }); const data await resp.json(); webviewPanel.webview.postMessage({ type: ai-result, content: data.content?.[0]?.text ?? }); } catch (e) { webviewPanel.webview.postMessage({ type: ai-error, message: String(e) }); } } });注意请求头用的是x-api-key和anthropic-version这是 Anthropic 兼容格式。如果你用的模型走 OpenAI 兼容格式改成Authorization: Bearer ${apiKey}和/v1/chat/completions即可。webview 侧接收结果window.addEventListener(message, (event) { const msg event.data; if (msg.type ai-result) { document.getElementById(ai-panel).textContent msg.content; } });到这里渲染链路和 AI 链路都通了。下一节做本地验证。4. 本地验证从 F5 调试到成功渲染 PDF 并跑通 AI 请求验证分两步先确认 PDF 能渲染再确认 AI 请求能返回。第一步在插件项目根目录按 F5 启动扩展开发宿主会弹出一个新的 VSCode 窗口。在这个窗口里打开任意一个.pdf文件如果customEditors注册正确标签页标题会显示PDF Viewer (TaoToken)内容区应该出现 pdf.js 的工具栏和页面。如果页面空白先看开发者工具。命令面板执行Developer: Open Webview Developer Tools切到 Console 看报错。最常见的两个一是Failed to load resource: 401说明localResourceRoots没包含 pdf.js 所在目录二是file origin does not match viewers说明open调用没走 blob 或 URI 没转成 webview 可访问格式。确认asWebviewUri用对了base目录也加进了localResourceRoots。第二步验证 AI。先在命令面板执行TaoToken: 设置 API Key把 Key 存进 secrets。然后在 webview 里触发一次摘要请求可以在 pdf.js 工具栏加个按钮或直接在 Console 里执行acquireVsCodeApi().postMessage({ type: ai-summarize, text: 测试文本 })。主进程收到后调 API正常情况 webview 会收到ai-result。验证请求是否成功最直接的办法是在主进程的fetch前后打日志。成功时resp.status是 200data.content[0].text有内容。如果返回 401检查 Key 是否存对、请求头字段名是否正确。如果返回 404检查 Base URL 拼接路径https://taotoken.net/api后面接/v1/messages不要多斜杠也不要少。如果返回模型不存在检查 Model ID 是否是你账号可用的。一个容易忽略的点fetch在 Node 18 才原生可用如果你的插件声明了较低的engines.vscode可能需要引入node-fetch。另外 webview 的 CSP 可能拦截外部请求但我们的请求发生在主进程不受 webview CSP 影响这也是把 AI 调用放主进程的好处之一。验证通过后你可以进一步把 AI 能力做成选中文本右键摘要或整页翻译。这些都是在消息层加type分支的事渲染层完全不用动。下一节集中排错。5. 常见报错排查401、local proxy failed、reading choices、OAuth这一节按真实报错逐条对照。第一条401 Unauthorized。在 PDF 渲染场景里401 有两种来源一是localResourceRoots没配对webview 加载 pdf.js 资源被拒控制台报 401二是 AI 请求的 Key 无效或没带。区分方法看报错位置webview Console 里的 401 是资源加载主进程日志里的 401 是 API 鉴权。资源 401 就补localResourceRootsAPI 401 就检查x-api-key或Authorization头。第二条local proxy failed。这个通常出现在你配置了本地代理或环境变量指向了不可达地址时。插件里如果用了http.proxy设置或HTTPS_PROXY环境变量而代理没启动请求就会失败。排查办法是临时清空相关环境变量或确认代理地址可达。注意这里说的是本地网络配置问题不涉及任何绕过网络限制的操作纯粹是开发环境排查。第三条reading choices或Cannot read properties of undefined (reading choices)。这是 OpenAI 兼容格式的典型报错说明你按data.choices[0].message.content取值但返回结构不是这个。如果你用的是 Anthropic 兼容格式返回是data.content[0].text取choices自然是 undefined。解决办法是对照你实际调用的接口格式取值别混用。可以在取值前先console.log(JSON.stringify(data))看真实结构。第四条OAuth相关报错。如果你用 Claude Code 或某些 CLI 工具接入可能会遇到 OAuth token 过期或未登录的提示。这类工具通常有自己的登录态管理和插件里的 API Key 是两套体系。插件里走的是 Key 鉴权不涉及 OAuth 流程。如果你在配置 Claude Code 时遇到 OAuth 问题检查它的配置文件如~/.claude/settings.json或项目级配置里的 Base URL 和 Key 是否正确。再补一个高频问题webview里postMessage发了但主进程收不到。检查onDidReceiveMessage是否在resolveCustomEditor里注册且webview.options.enableScripts为true。还有acquireVsCodeApi()只能调用一次重复调用会报错把它存成全局变量复用。排查时建议按渲染层 → 通信层 → API 层顺序定位先确认 PDF 能显示再确认消息能收发最后确认 API 能返回。这样不会在多层之间来回猜。6. 把 AI 能力接进阅读流程从 PDF 预览到智能辅助的下一步渲染跑通、消息通了、API 验证过了接下来就是把 AI 真正用起来。几个实用的接入点选中文本后右键AI 解释把选中内容通过postMessage发给主进程调模型返回解释整页内容提取后做摘要适合长文档快速浏览跨页问答把用户问题和当前页文本一起发给模型。这些都不需要改 pdf.js只在 webview 加 UI、在主进程加消息分支。如果你要长期做编码类或 Agent 类任务可以考虑用 Coding Plan 来跑配置三件套还是那套Base URL 填https://taotoken.net/apiKey 用你的Model ID 按需选。模型对话入口可以用来快速验证某个模型是否可用接入文档里有各语言的调用示例API Keys 页面管理你的 Key。这几个入口按需取用即可。最后说个实操细节pdf.js 的viewer.html里有很多相对路径资源base href必须指向 webview 可访问的 URI否则 CSS、字体、worker 全部加载失败。localResourceRoots里除了 pdf.js 目录还要加上 PDF 文件所在目录否则asWebviewUri(document.uri)生成的地址也会被拒。这两处配好基本就不会再遇到资源 401 了。
阅读完成 · 觉得有帮助?
咨询建站