实战:AI 代码助手插件
把配置管理、SecretStorage、Node 侧网络请求、Webview 消息通信、编辑器 Diff 等能力综合起来,实现一个 AI 代码助手插件:在侧边栏与模型聊天、对选中代码一键优化并查看 Diff。
目标
- 聊天面板:Webview 消息列表 + 输入框 + 流式输出渲染
- 流式响应:Node 侧按 SSE 增量接收内容,边生成边推送
- 选区优化:对选中代码调用模型,生成优化版本并用 Diff 对比
- 密钥安全:API Key 存入 SecretStorage,不落盘明文
- 命令入口:命令面板触发聊天与优化功能
项目结构
ai-assistant/
├── package.json
├── tsconfig.json
├── src/
│ ├── extension.ts # 激活入口:命令、配置、视图注册
│ ├── aiService.ts # OpenAI API 封装:非流式 + 流式
│ ├── chatView.ts # Webview 聊天面板
│ └── suggest.ts # 选区优化 + Diff 展示
└── media/
└── chat.js # Webview 端脚本package.json
{
"name": "ai-code-assistant",
"displayName": "AI Code Assistant",
"description": "AI 代码助手:聊天问答与选区代码优化",
"version": "0.1.0",
"publisher": "mypublisher",
"engines": { "vscode": "^1.84.0" },
"categories": ["Other"],
"main": "./out/extension.js",
"activationEvents": [
"onCommand:aiAssistant.chat",
"onCommand:aiAssistant.suggest"
],
"contributes": {
"commands": [
{ "command": "aiAssistant.chat", "title": "AI 助手: 打开聊天" },
{ "command": "aiAssistant.suggest", "title": "AI 助手: 优化选中代码" },
{ "command": "aiAssistant.setKey", "title": "AI 助手: 设置 API Key" },
{ "command": "aiAssistant.clearKey", "title": "AI 助手: 清除 API Key" }
],
"menus": {
"editor/context": [
{
"command": "aiAssistant.suggest",
"when": "editorHasSelection",
"group": "1_modification"
}
],
"view/title": [
{
"command": "aiAssistant.chat",
"when": "view == aiAssistantView",
"group": "navigation"
}
]
},
"viewsContainers": {
"activitybar": [
{ "id": "aiAssistant", "title": "AI 助手", "icon": "media/icon.svg" }
]
},
"views": {
"aiAssistant": [
{ "id": "aiAssistantView", "name": "AI 助手", "type": "webview" }
]
},
"configuration": {
"title": "AI 助手",
"properties": {
"aiAssistant.model": {
"type": "string",
"default": "gpt-4o-mini",
"description": "使用的模型名称"
},
"aiAssistant.baseUrl": {
"type": "string",
"default": "https://api.openai.com/v1",
"description": "兼容 OpenAI 协议的接口地址"
},
"aiAssistant.maxTokens": {
"type": "number",
"default": 2048,
"description": "单次回复的最大 token 数"
}
}
}
},
"scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" },
"devDependencies": {
"@types/vscode": "^1.84.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}三个要点:type: "webview" 的视图容器让聊天界面常驻侧边栏;editor/context 菜单在选中代码时出现「优化选中代码」;API Key 不进配置项,交给 SecretStorage。
extension.ts:激活与命令注册
import * as vscode from 'vscode';
import { AiService } from './aiService';
import { ChatViewProvider } from './chatView';
import { suggestCode } from './suggest';
export function activate(context: vscode.ExtensionContext) {
// 全局唯一的 AI 服务实例,负责读配置与发请求
const ai = new AiService(context);
// ---------- Webview 聊天视图 ----------
const chatProvider = new ChatViewProvider(context.extensionUri, ai);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(
'aiAssistantView',
chatProvider,
{ webviewOptions: { retainContextWhenHidden: true } }
)
);
// ---------- 命令:打开聊天 ----------
context.subscriptions.push(
vscode.commands.registerCommand('aiAssistant.chat', () => {
// 聚焦侧边栏里的聊天视图
vscode.commands.executeCommand('aiAssistantView.focus');
})
);
// ---------- 命令:优化选中代码 ----------
context.subscriptions.push(
vscode.commands.registerCommand('aiAssistant.suggest', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty) {
vscode.window.showWarningMessage('请先选中要优化的代码');
return;
}
const code = editor.document.getText(editor.selection);
await suggestCode(ai, editor, code);
})
);
// ---------- 命令:管理 API Key ----------
context.subscriptions.push(
vscode.commands.registerCommand('aiAssistant.setKey', async () => {
// 输入框中用 password 掩码,防止输入过程被旁观
const key = await vscode.window.showInputBox({
prompt: '输入 OpenAI API Key',
password: true,
ignoreFocusOut: true
});
if (!key) return;
await context.secrets.store('aiAssistant.apiKey', key.trim());
vscode.window.showInformationMessage('API Key 已保存');
})
);
context.subscriptions.push(
vscode.commands.registerCommand('aiAssistant.clearKey', () => {
context.secrets.delete('aiAssistant.apiKey');
vscode.window.showInformationMessage('API Key 已清除');
})
);
}
export function deactivate() {}context.secrets 把密钥交给系统级安全存储(Windows 凭据管理器 / macOS Keychain / Linux secret service),任何情况下都不要把密钥写进 globalState 或配置文件。
aiService.ts:OpenAI API 封装
Node 18+ 自带全局 fetch,无需额外依赖。流式模式下接口返回 SSE(Server-Sent Events)格式,逐行解析 data: 前缀的 JSON:
import * as vscode from 'vscode';
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export class AiService {
constructor(private readonly context: vscode.ExtensionContext) {}
// 读取配置与密钥;未设置密钥时抛错引导用户
private async prepareRequest(): Promise<{
url: string;
headers: Record<string, string>;
}> {
const cfg = vscode.workspace.getConfiguration('aiAssistant');
const baseUrl = cfg.get<string>('baseUrl') ?? 'https://api.openai.com/v1';
const key = await this.context.secrets.get('aiAssistant.apiKey');
if (!key) {
throw new Error('未设置 API Key,请先执行命令:AI 助手: 设置 API Key');
}
return {
url: `${baseUrl}/chat/completions`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`
}
};
}
/**
* 流式对话:边生成边回调 onToken。
* 兼容任何实现 OpenAI Chat Completions 协议的端点(OpenAI / Azure / 本地服务)。
*/
async *streamChat(
messages: ChatMessage[]
): AsyncGenerator<string, void, unknown> {
const { url, headers } = await this.prepareRequest();
const cfg = vscode.workspace.getConfiguration('aiAssistant');
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
model: cfg.get<string>('model'),
messages,
max_tokens: cfg.get<number>('maxTokens'),
stream: true // 开启流式
})
});
if (!res.ok) {
throw new Error(`请求失败 HTTP ${res.status}: ${await res.text()}`);
}
if (!res.body) {
throw new Error('响应没有内容流');
}
// 逐块读取并切分 SSE 行
const reader = res.body.getReader();
const decoder = new TextDecoder();
// 缓冲上一块未拆完的半行,避免 JSON 被截断
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE 事件以空行分隔,按行扫描
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 最后一段可能不完整,留到下一块
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') return; // 流结束标记
try {
const json = JSON.parse(data);
// 增量内容在 choices[0].delta.content
const delta = json.choices?.[0]?.delta?.content ?? '';
if (delta) yield delta;
} catch {
// 忽略半截 JSON(理论上已被缓冲逻辑避免)
}
}
}
}
/** 非流式一次调用(用于优化建议等不需要逐字渲染的场景) */
async chat(messages: ChatMessage[]): Promise<string> {
const { url, headers } = await this.prepareRequest();
const cfg = vscode.workspace.getConfiguration('aiAssistant');
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
model: cfg.get<string>('model'),
messages,
max_tokens: cfg.get<number>('maxTokens'),
stream: false
})
});
if (!res.ok) {
throw new Error(`请求失败 HTTP ${res.status}: ${await res.text()}`);
}
const json = await res.json();
return json.choices?.[0]?.message?.content ?? '';
}
}async generator 是流式传递的优雅载体:上层拿到生成器后 for await 逐段消费,AI 服务的职责与界面渲染彻底解耦。
chatView.ts:Webview 聊天面板
视图提供者负责生成 HTML、接收页面消息、把流式 token 逐段转发给页面:
import * as vscode from 'vscode';
import { AiService } from './aiService';
export class ChatViewProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView;
// 消息历史挂在面板对象上,面板刷新后丢失(可接受)
private history: Array<{ role: 'user' | 'assistant'; content: string }> = [];
constructor(
private readonly extensionUri: vscode.Uri,
private readonly ai: AiService
) {}
resolveWebviewView(view: vscode.WebviewView): void {
this.view = view;
view.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'media')]
};
view.webview.html = this.getHtml();
// 接收页面消息:{ type: 'send', text } 或 { type: 'ready' }
view.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'send') {
await this.sendMessage(msg.text);
}
});
}
private async sendMessage(text: string): Promise<void> {
const view = this.view;
if (!view) return;
// 1. 立即渲染用户消息
this.history.push({ role: 'user', content: text });
view.webview.postMessage({ type: 'user', text });
// 2. 组装给模型的上下文(含系统提示)
const messages = [
{ role: 'system' as const,
content: '你是一个嵌入式 VSCode 插件里的代码助手,回答简洁,优先给出可运行代码。' },
...this.history
];
// 3. 流式输出:逐段追加到页面
let answer = '';
view.webview.postMessage({ type: 'assistantStart' });
try {
for await (const token of this.ai.streamChat(messages)) {
answer += token;
view.webview.postMessage({ type: 'token', text: token });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
view.webview.postMessage({ type: 'error', text: message });
return;
}
view.webview.postMessage({ type: 'assistantEnd' });
this.history.push({ role: 'assistant', content: answer });
}
private getHtml(): string {
// nonce 配合 CSP,避免内联脚本被策略拦截
const nonce = getNonce();
const scriptUri = this.view!.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'media', 'chat.js')
);
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
style-src ${this.view!.webview.cspSource};
script-src 'nonce-${nonce}';">
</head>
<body>
<div id="messages"></div>
<div id="composer">
<textarea id="input" rows="3" placeholder="输入问题,Ctrl+Enter 发送"></textarea>
<button id="send">发送</button>
</div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
}
function getNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let text = '';
for (let i = 0; i < 32; i++) {
text += chars.charAt(Math.floor(Math.random() * chars.length));
}
return text;
}media/chat.js:流式渲染脚本
页面侧维护 DOM:用户消息整段插入,助手消息先创建容器再逐 token 追加,实现打字机效果:
// @ts-check
const vscode = acquireVsCodeApi();
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
// 正在生成的助手消息容器(全局引用,供流式追加)
let streamingEl = null;
function addMessage(role, text) {
const div = document.createElement('div');
div.className = `msg ${role}`;
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', (e) => {
// Ctrl+Enter 发送
if (e.ctrlKey && e.key === 'Enter') send();
});
function send() {
const text = inputEl.value.trim();
if (!text || streamingEl) return; // 生成过程中禁止再发
inputEl.value = '';
vscode.postMessage({ type: 'send', text });
}
window.addEventListener('message', (event) => {
const msg = event.data;
switch (msg.type) {
case 'user':
addMessage('user', msg.text);
break;
case 'assistantStart':
streamingEl = addMessage('assistant', '');
break;
case 'token':
// 逐段追加,保持光标在底部
if (streamingEl) {
streamingEl.textContent += msg.text;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
break;
case 'assistantEnd':
streamingEl = null;
break;
case 'error':
addMessage('error', `请求失败:${msg.text}`);
streamingEl = null;
break;
}
});suggest.ts:选区优化与 Diff 展示
把选中代码发给模型,拿到优化结果后用 vscode.diff 内置视图对比,用户确认后再一键应用:
import * as vscode from 'vscode';
import { AiService } from './aiService';
const SUGGEST_PROMPT = (code: string) => `请优化下面这段代码,直接输出优化后的完整代码,不要解释,不要用 Markdown 代码块包裹:
${code}`;
export async function suggestCode(
ai: AiService,
editor: vscode.TextEditor,
code: string
): Promise<void> {
// 1. 进度提示,避免用户以为卡死
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification,
title: 'AI 正在优化代码…', cancellable: true },
async (_progress, token) => {
// 支持取消:中断 fetch 需要传递 signal,此处简化演示
const result = await ai.chat([
{ role: 'user', content: SUGGEST_PROMPT(code) }
]);
if (token.isCancellationRequested) return;
if (!result) {
vscode.window.showWarningMessage('模型未返回内容');
return;
}
await showDiff(editor, code, result);
}
);
}
async function showDiff(
editor: vscode.TextEditor,
original: string,
optimized: string
): Promise<void> {
const doc = editor.document;
const selection = editor.selection;
// 2. 用 in-memory scheme 构造两个临时文档
// 原代码 = 当前文件本身,优化代码 = 虚拟文档
const originalUri = doc.uri;
const optimizedUri = vscode.Uri.parse(
`untitled:AI优化建议-${Date.now()}.${doc.languageId}`
);
const optDoc = await vscode.workspace.openTextDocument(optimizedUri);
const edit = new vscode.WorkspaceEdit();
edit.insert(optimizedUri, new vscode.Position(0, 0), optimized);
await vscode.workspace.applyEdit(edit);
// 3. 打开内置 Diff 视图,对比原始文件与优化版本
await vscode.commands.executeCommand(
'vscode.diff',
originalUri,
optimizedUri,
'AI 优化建议'
);
// 4. 提供「应用修改」按钮:把优化结果写回选区
const action = await vscode.window.showInformationMessage(
'选择如何处理优化结果',
'应用到选中区域', '放弃'
);
if (action === '应用到选中区域') {
await editor.edit((builder) => builder.replace(selection, optimized));
// 关闭临时的优化文档
await vscode.commands.executeCommand(
'workbench.action.closeActiveEditor'
);
}
}关键点:优化结果放进 untitled: 虚拟文档而非直接改文件,用户先看 Diff 再决定是否应用,避免 AI 输出破坏原代码。
运行与验证
按 F5 启动调试:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 执行「AI 助手: 设置 API Key」 | 输入密钥后提示已保存,不落盘 |
| 2 | 点击侧边栏 AI 助手图标 | 聊天面板打开 |
| 3 | 输入问题,Ctrl+Enter 发送 | 助手消息逐字流式渲染 |
| 4 | 在编辑器中选中代码 | 右键出现「AI 助手: 优化选中代码」 |
| 5 | 执行优化命令 | 打开 Diff 视图,左侧原文右侧优化结果 |
| 6 | 点击「应用到选中区域」 | 选区被替换为优化代码 |
| 7 | 执行「AI 助手: 清除 API Key」 | 密钥被删除,再请求时提示设置 |
安全与健壮性清单
| 项目 | 做法 |
|---|---|
| API Key | 只存 context.secrets,日志与配置中禁止出现 |
| 请求超时 | 用 AbortController + setTimeout 兜底,避免挂起 |
| 错误提示 | 网络/鉴权错误统一转成可读的中文提示 |
| CSP | Webview 用 nonce + cspSource,禁止外链脚本 |
| 长回复 | 限制 maxTokens,防止界面被超大响应拖垮 |
| 多语言 | 提示词与 UI 文案集中管理,便于后续国际化 |
本实战把 AI 能力完整接入编辑器:流式协议解析是通用技能(任何兼容 OpenAI 协议的服务都适用),Webview 打字机渲染与 Diff 确认流程则是「编辑器内 AI 功能」的标准形态。