mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
refactor(vscode-ide-companion): 重构 WebViewProvider 组件
This commit is contained in:
File diff suppressed because it is too large
Load Diff
31
packages/vscode-ide-companion/src/utils/webviewUtils.ts
Normal file
31
packages/vscode-ide-companion/src/utils/webviewUtils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从完整路径中提取文件名
|
||||
* @param fsPath 文件的完整路径
|
||||
* @returns 文件名(不含路径)
|
||||
*/
|
||||
export function getFileName(fsPath: string): string {
|
||||
// 使用 path.basename 的逻辑:找到最后一个路径分隔符后的部分
|
||||
const lastSlash = Math.max(fsPath.lastIndexOf('/'), fsPath.lastIndexOf('\\'));
|
||||
return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义函数,防止 XSS 攻击
|
||||
* 将特殊字符转换为 HTML 实体
|
||||
* @param text 需要转义的文本
|
||||
* @returns 转义后的文本
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
163
packages/vscode-ide-companion/src/webview/CliInstaller.ts
Normal file
163
packages/vscode-ide-companion/src/webview/CliInstaller.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { CliDetector } from '../utils/cliDetector.js';
|
||||
|
||||
/**
|
||||
* CLI 检测和安装处理器
|
||||
* 负责 Qwen CLI 的检测、安装和提示功能
|
||||
*/
|
||||
export class CliInstaller {
|
||||
/**
|
||||
* 检查 CLI 安装状态并发送结果到 WebView
|
||||
* @param sendToWebView 发送消息到 WebView 的回调函数
|
||||
*/
|
||||
static async checkInstallation(
|
||||
sendToWebView: (message: unknown) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await CliDetector.detectQwenCli();
|
||||
|
||||
sendToWebView({
|
||||
type: 'cliDetectionResult',
|
||||
data: {
|
||||
isInstalled: result.isInstalled,
|
||||
cliPath: result.cliPath,
|
||||
version: result.version,
|
||||
error: result.error,
|
||||
installInstructions: result.isInstalled
|
||||
? undefined
|
||||
: CliDetector.getInstallationInstructions(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.isInstalled) {
|
||||
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
|
||||
} else {
|
||||
console.log(
|
||||
'[CliInstaller] Qwen CLI detected:',
|
||||
result.cliPath,
|
||||
result.version,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CliInstaller] CLI detection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示用户安装 CLI
|
||||
* 显示警告消息,提供安装选项
|
||||
*/
|
||||
static async promptInstallation(): Promise<void> {
|
||||
const selection = await vscode.window.showWarningMessage(
|
||||
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
|
||||
'Install Now',
|
||||
'View Documentation',
|
||||
'Remind Me Later',
|
||||
);
|
||||
|
||||
if (selection === 'Install Now') {
|
||||
await this.install();
|
||||
} else if (selection === 'View Documentation') {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 Qwen CLI
|
||||
* 通过 npm 安装全局 CLI 包
|
||||
*/
|
||||
static async install(): Promise<void> {
|
||||
try {
|
||||
// Show progress notification
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Installing Qwen Code CLI',
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
progress.report({
|
||||
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
|
||||
});
|
||||
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(
|
||||
'npm install -g @qwen-code/qwen-code@latest',
|
||||
{ timeout: 120000 }, // 2 minutes timeout
|
||||
);
|
||||
|
||||
console.log('[CliInstaller] Installation output:', stdout);
|
||||
if (stderr) {
|
||||
console.warn('[CliInstaller] Installation stderr:', stderr);
|
||||
}
|
||||
|
||||
// Clear cache and recheck
|
||||
CliDetector.clearCache();
|
||||
const detection = await CliDetector.detectQwenCli();
|
||||
|
||||
if (detection.isInstalled) {
|
||||
vscode.window
|
||||
.showInformationMessage(
|
||||
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
|
||||
'Reload Window',
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === 'Reload Window') {
|
||||
vscode.commands.executeCommand(
|
||||
'workbench.action.reloadWindow',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Installation completed but CLI still not detected',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error('[CliInstaller] Installation failed:', errorMessage);
|
||||
|
||||
vscode.window
|
||||
.showErrorMessage(
|
||||
`Failed to install Qwen Code CLI: ${errorMessage}`,
|
||||
'Try Manual Installation',
|
||||
'View Documentation',
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === 'Try Manual Installation') {
|
||||
const terminal = vscode.window.createTerminal(
|
||||
'Qwen Code Installation',
|
||||
);
|
||||
terminal.show();
|
||||
terminal.sendText(
|
||||
'npm install -g @qwen-code/qwen-code@latest',
|
||||
);
|
||||
} else if (selection === 'View Documentation') {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse(
|
||||
'https://github.com/QwenLM/qwen-code#installation',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[CliInstaller] Install CLI error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
153
packages/vscode-ide-companion/src/webview/FileOperations.ts
Normal file
153
packages/vscode-ide-companion/src/webview/FileOperations.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { getFileName } from '../utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* 文件操作处理器
|
||||
* 负责处理文件打开和 diff 查看功能
|
||||
*/
|
||||
export class FileOperations {
|
||||
/**
|
||||
* 打开文件并可选跳转到指定行
|
||||
* @param filePath 文件路径,可以包含行号(格式:path/to/file.ts:123)
|
||||
*/
|
||||
static async openFile(filePath?: string): Promise<void> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
console.warn('[FileOperations] No file path provided');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[FileOperations] Opening file:', filePath);
|
||||
|
||||
// Parse file path and line number (format: path/to/file.ts:123)
|
||||
const match = filePath.match(/^(.+?)(?::(\d+))?$/);
|
||||
if (!match) {
|
||||
console.warn('[FileOperations] Invalid file path format:', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, lineStr] = match;
|
||||
const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers
|
||||
|
||||
// Convert to absolute path if relative
|
||||
let absolutePath = path;
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
// Relative path - resolve against workspace
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the document
|
||||
const uri = vscode.Uri.file(absolutePath);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const editor = await vscode.window.showTextDocument(document, {
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
// Navigate to line if specified
|
||||
if (lineStr) {
|
||||
const position = new vscode.Position(lineNumber, 0);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
editor.revealRange(
|
||||
new vscode.Range(position, position),
|
||||
vscode.TextEditorRevealType.InCenter,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[FileOperations] File opened successfully:', absolutePath);
|
||||
} catch (error) {
|
||||
console.error('[FileOperations] Failed to open file:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 diff 视图比较文件变更
|
||||
* @param data Diff 数据,包含文件路径、旧内容和新内容
|
||||
*/
|
||||
static async openDiff(data?: {
|
||||
path?: string;
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!data || !data.path) {
|
||||
console.warn('[FileOperations] No file path provided for diff');
|
||||
return;
|
||||
}
|
||||
|
||||
const { path, oldText = '', newText = '' } = data;
|
||||
console.log('[FileOperations] Opening diff for:', path);
|
||||
|
||||
// Convert to absolute path if relative
|
||||
let absolutePath = path;
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the file name for display
|
||||
const fileName = getFileName(absolutePath);
|
||||
|
||||
// Create URIs for old and new content
|
||||
// Use untitled scheme for old content (before changes)
|
||||
const oldUri = vscode.Uri.parse(`untitled:${absolutePath}.old`).with({
|
||||
scheme: 'untitled',
|
||||
});
|
||||
|
||||
// Use the actual file URI for new content
|
||||
const newUri = vscode.Uri.file(absolutePath);
|
||||
|
||||
// Create a TextDocument for the old content using an in-memory document
|
||||
const _oldDocument = await vscode.workspace.openTextDocument(
|
||||
oldUri.with({ scheme: 'untitled' }),
|
||||
);
|
||||
|
||||
// Write old content to the document
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(
|
||||
oldUri.with({ scheme: 'untitled' }),
|
||||
new vscode.Position(0, 0),
|
||||
oldText,
|
||||
);
|
||||
await vscode.workspace.applyEdit(edit);
|
||||
|
||||
// Check if new file exists, if not create it with new content
|
||||
try {
|
||||
await vscode.workspace.fs.stat(newUri);
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(newUri, encoder.encode(newText));
|
||||
}
|
||||
|
||||
// Open diff view
|
||||
await vscode.commands.executeCommand(
|
||||
'vscode.diff',
|
||||
oldUri.with({ scheme: 'untitled' }),
|
||||
newUri,
|
||||
`${fileName} (Before ↔ After)`,
|
||||
{
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
console.log('[FileOperations] Diff opened successfully');
|
||||
} catch (error) {
|
||||
console.error('[FileOperations] Failed to open diff:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
507
packages/vscode-ide-companion/src/webview/MessageHandler.ts
Normal file
507
packages/vscode-ide-companion/src/webview/MessageHandler.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { QwenAgentManager } from '../agents/qwenAgentManager.js';
|
||||
import { type ChatMessage } from '../agents/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../storage/conversationStore.js';
|
||||
import { FileOperations } from './FileOperations.js';
|
||||
import { CliInstaller } from './CliInstaller.js';
|
||||
import { CliDetector } from '../utils/cliDetector.js';
|
||||
import { getFileName } from '../utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* WebView 消息处理器
|
||||
* 负责处理所有来自 WebView 的消息请求
|
||||
*/
|
||||
export class MessageHandler {
|
||||
// 跟踪当前流式内容,用于保存
|
||||
private currentStreamContent = '';
|
||||
// 权限请求处理器(临时存储)
|
||||
private permissionHandler?: (msg: {
|
||||
type: string;
|
||||
data: { optionId: string };
|
||||
}) => void;
|
||||
|
||||
constructor(
|
||||
private agentManager: QwenAgentManager,
|
||||
private conversationStore: ConversationStore,
|
||||
private currentConversationId: string | null,
|
||||
private sendToWebView: (message: unknown) => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取当前对话 ID
|
||||
*/
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.currentConversationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前对话 ID
|
||||
*/
|
||||
setCurrentConversationId(id: string | null): void {
|
||||
this.currentConversationId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前流式内容
|
||||
*/
|
||||
getCurrentStreamContent(): string {
|
||||
return this.currentStreamContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加流式内容
|
||||
*/
|
||||
appendStreamContent(chunk: string): void {
|
||||
this.currentStreamContent += chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置流式内容
|
||||
*/
|
||||
resetStreamContent(): void {
|
||||
this.currentStreamContent = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置权限处理器
|
||||
*/
|
||||
setPermissionHandler(
|
||||
handler: (msg: { type: string; data: { optionId: string } }) => void,
|
||||
): void {
|
||||
this.permissionHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除权限处理器
|
||||
*/
|
||||
clearPermissionHandler(): void {
|
||||
this.permissionHandler = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由 WebView 消息到对应的处理函数
|
||||
*/
|
||||
async route(message: { type: string; data?: unknown }): Promise<void> {
|
||||
console.log('[MessageHandler] Received message from webview:', message);
|
||||
|
||||
// Type guard for safe access to data properties
|
||||
const data = message.data as Record<string, unknown> | undefined;
|
||||
|
||||
switch (message.type) {
|
||||
case 'sendMessage':
|
||||
await this.handleSendMessage((data?.text as string) || '');
|
||||
break;
|
||||
|
||||
case 'permissionResponse':
|
||||
// Forward to permission handler
|
||||
if (this.permissionHandler) {
|
||||
this.permissionHandler(
|
||||
message as { type: string; data: { optionId: string } },
|
||||
);
|
||||
this.clearPermissionHandler();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'loadConversation':
|
||||
await this.handleLoadConversation((data?.id as string) || '');
|
||||
break;
|
||||
|
||||
case 'newConversation':
|
||||
await this.handleNewConversation();
|
||||
break;
|
||||
|
||||
case 'newQwenSession':
|
||||
await this.handleNewQwenSession();
|
||||
break;
|
||||
|
||||
case 'deleteConversation':
|
||||
await this.handleDeleteConversation((data?.id as string) || '');
|
||||
break;
|
||||
|
||||
case 'getQwenSessions':
|
||||
await this.handleGetQwenSessions();
|
||||
break;
|
||||
|
||||
case 'getActiveEditor': {
|
||||
// 发送当前激活编辑器的文件名给 WebView
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const fileName = editor?.document.uri.fsPath
|
||||
? getFileName(editor.document.uri.fsPath)
|
||||
: null;
|
||||
this.sendToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
data: { fileName },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'switchQwenSession':
|
||||
await this.handleSwitchQwenSession((data?.sessionId as string) || '');
|
||||
break;
|
||||
|
||||
case 'recheckCli':
|
||||
// Clear cache and recheck CLI installation
|
||||
CliDetector.clearCache();
|
||||
await CliInstaller.checkInstallation(this.sendToWebView);
|
||||
break;
|
||||
|
||||
case 'cancelPrompt':
|
||||
await this.handleCancelPrompt();
|
||||
break;
|
||||
|
||||
case 'openFile':
|
||||
await FileOperations.openFile(data?.path as string | undefined);
|
||||
break;
|
||||
|
||||
case 'openDiff':
|
||||
await FileOperations.openDiff(
|
||||
data as {
|
||||
path?: string;
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[MessageHandler] Unknown message type:', message.type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送消息请求
|
||||
*/
|
||||
private async handleSendMessage(text: string): Promise<void> {
|
||||
console.log('[MessageHandler] handleSendMessage called with:', text);
|
||||
|
||||
// Ensure we have an active conversation - create one if needed
|
||||
if (!this.currentConversationId) {
|
||||
console.log('[MessageHandler] No active conversation, creating one...');
|
||||
try {
|
||||
const newConv = await this.conversationStore.createConversation();
|
||||
this.currentConversationId = newConv.id;
|
||||
this.sendToWebView({
|
||||
type: 'conversationLoaded',
|
||||
data: newConv,
|
||||
});
|
||||
console.log(
|
||||
'[MessageHandler] Created conversation:',
|
||||
this.currentConversationId,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to create conversation: ${error}`;
|
||||
console.error('[MessageHandler]', errorMsg);
|
||||
vscode.window.showErrorMessage(errorMsg);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Double check after creation attempt
|
||||
if (!this.currentConversationId) {
|
||||
const errorMsg =
|
||||
'Failed to create conversation. Please restart the extension.';
|
||||
console.error('[MessageHandler]', errorMsg);
|
||||
vscode.window.showErrorMessage(errorMsg);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save user message
|
||||
const userMessage: ChatMessage = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await this.conversationStore.addMessage(
|
||||
this.currentConversationId,
|
||||
userMessage,
|
||||
);
|
||||
console.log('[MessageHandler] User message saved to store');
|
||||
|
||||
// Send to WebView
|
||||
this.sendToWebView({
|
||||
type: 'message',
|
||||
data: userMessage,
|
||||
});
|
||||
console.log('[MessageHandler] User message sent to webview');
|
||||
|
||||
// Check if agent is connected
|
||||
if (!this.agentManager.isConnected) {
|
||||
console.warn(
|
||||
'[MessageHandler] Agent is not connected, skipping AI response',
|
||||
);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: {
|
||||
message:
|
||||
'Agent is not connected. Enable Qwen in settings or configure API key.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send to agent
|
||||
try {
|
||||
// Reset stream content
|
||||
this.resetStreamContent();
|
||||
|
||||
// Create placeholder for assistant message
|
||||
this.sendToWebView({
|
||||
type: 'streamStart',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
console.log('[MessageHandler] Stream start sent');
|
||||
|
||||
console.log('[MessageHandler] Sending to agent manager...');
|
||||
await this.agentManager.sendMessage(text);
|
||||
console.log('[MessageHandler] Agent manager send complete');
|
||||
|
||||
// Stream is complete - save assistant message
|
||||
if (this.currentStreamContent && this.currentConversationId) {
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: this.currentStreamContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await this.conversationStore.addMessage(
|
||||
this.currentConversationId,
|
||||
assistantMessage,
|
||||
);
|
||||
console.log('[MessageHandler] Assistant message saved to store');
|
||||
}
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'streamEnd',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
console.log('[MessageHandler] Stream end sent');
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Error sending message:', error);
|
||||
vscode.window.showErrorMessage(`Error sending message: ${error}`);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: String(error) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理加载对话请求
|
||||
*/
|
||||
private async handleLoadConversation(id: string): Promise<void> {
|
||||
const conversation = await this.conversationStore.getConversation(id);
|
||||
if (conversation) {
|
||||
this.currentConversationId = id;
|
||||
this.sendToWebView({
|
||||
type: 'conversationLoaded',
|
||||
data: conversation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新建对话请求
|
||||
*/
|
||||
private async handleNewConversation(): Promise<void> {
|
||||
const newConv = await this.conversationStore.createConversation();
|
||||
this.currentConversationId = newConv.id;
|
||||
this.sendToWebView({
|
||||
type: 'conversationLoaded',
|
||||
data: newConv,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理删除对话请求
|
||||
*/
|
||||
private async handleDeleteConversation(id: string): Promise<void> {
|
||||
await this.conversationStore.deleteConversation(id);
|
||||
this.sendToWebView({
|
||||
type: 'conversationDeleted',
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理获取 Qwen 会话列表请求
|
||||
*/
|
||||
private async handleGetQwenSessions(): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Getting Qwen sessions...');
|
||||
const sessions = await this.agentManager.getSessionList();
|
||||
console.log('[MessageHandler] Retrieved sessions:', sessions.length);
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionList',
|
||||
data: { sessions },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to get Qwen sessions:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to get sessions: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新建 Qwen 会话请求
|
||||
*/
|
||||
private async handleNewQwenSession(): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Creating new Qwen session...');
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
await this.agentManager.createNewSession(workingDir);
|
||||
|
||||
// Clear current conversation UI
|
||||
this.sendToWebView({
|
||||
type: 'conversationCleared',
|
||||
data: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to create new session:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to create new session: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理切换 Qwen 会话请求
|
||||
*/
|
||||
private async handleSwitchQwenSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Switching to Qwen session:', sessionId);
|
||||
|
||||
// Get session messages from local files
|
||||
const messages = await this.agentManager.getSessionMessages(sessionId);
|
||||
console.log(
|
||||
'[MessageHandler] Loaded messages from session:',
|
||||
messages.length,
|
||||
);
|
||||
|
||||
// Get session details for the header
|
||||
let sessionDetails = null;
|
||||
try {
|
||||
const allSessions = await this.agentManager.getSessionList();
|
||||
sessionDetails = allSessions.find(
|
||||
(s: { id?: string; sessionId?: string }) =>
|
||||
s.id === sessionId || s.sessionId === sessionId,
|
||||
);
|
||||
} catch (err) {
|
||||
console.log('[MessageHandler] Could not get session details:', err);
|
||||
}
|
||||
|
||||
// Try to load session via ACP first, fallback to creating new session
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
try {
|
||||
console.log('[MessageHandler] Testing session/load via ACP...');
|
||||
const loadResponse =
|
||||
await this.agentManager.loadSessionViaAcp(sessionId);
|
||||
console.log('[MessageHandler] session/load succeeded:', loadResponse);
|
||||
|
||||
// If load succeeded, use the loaded session
|
||||
this.currentConversationId = sessionId;
|
||||
console.log(
|
||||
'[MessageHandler] Set currentConversationId to loaded session:',
|
||||
sessionId,
|
||||
);
|
||||
} catch (_loadError) {
|
||||
console.log(
|
||||
'[MessageHandler] session/load not supported, creating new session',
|
||||
);
|
||||
|
||||
// Fallback: CLI doesn't support loading old sessions
|
||||
// So we create a NEW ACP session for continuation
|
||||
try {
|
||||
const newAcpSessionId =
|
||||
await this.agentManager.createNewSession(workingDir);
|
||||
console.log(
|
||||
'[MessageHandler] Created new ACP session for conversation:',
|
||||
newAcpSessionId,
|
||||
);
|
||||
|
||||
// Use the NEW ACP session ID for sending messages to CLI
|
||||
this.currentConversationId = newAcpSessionId;
|
||||
console.log(
|
||||
'[MessageHandler] Set currentConversationId (ACP) to:',
|
||||
newAcpSessionId,
|
||||
);
|
||||
} catch (createError) {
|
||||
console.error(
|
||||
'[MessageHandler] Failed to create new ACP session:',
|
||||
createError,
|
||||
);
|
||||
vscode.window.showWarningMessage(
|
||||
'Could not switch to session. Created new session instead.',
|
||||
);
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
|
||||
// Send messages and session details to WebView
|
||||
// The historical messages are display-only, not sent to CLI
|
||||
this.sendToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId, messages, session: sessionDetails },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to switch session:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to switch session: ${error}` },
|
||||
});
|
||||
vscode.window.showErrorMessage(`Failed to switch session: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理取消提示请求
|
||||
* 取消当前 AI 响应生成
|
||||
*/
|
||||
private async handleCancelPrompt(): Promise<void> {
|
||||
try {
|
||||
console.log('[MessageHandler] Cancel prompt requested');
|
||||
|
||||
if (!this.agentManager.isConnected) {
|
||||
console.warn('[MessageHandler] Agent not connected, cannot cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentManager.cancelCurrentPrompt();
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'promptCancelled',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
|
||||
console.log('[MessageHandler] Prompt cancelled successfully');
|
||||
} catch (error) {
|
||||
console.error('[MessageHandler] Failed to cancel prompt:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to cancel: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
177
packages/vscode-ide-companion/src/webview/PanelManager.ts
Normal file
177
packages/vscode-ide-companion/src/webview/PanelManager.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Panel 和 Tab 管理器
|
||||
* 负责管理 WebView Panel 的创建、显示和 Tab 跟踪
|
||||
*/
|
||||
export class PanelManager {
|
||||
private panel: vscode.WebviewPanel | null = null;
|
||||
private panelTab: vscode.Tab | null = null;
|
||||
|
||||
constructor(
|
||||
private extensionUri: vscode.Uri,
|
||||
private onPanelDispose: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取当前的 Panel
|
||||
*/
|
||||
getPanel(): vscode.WebviewPanel | null {
|
||||
return this.panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Panel(用于恢复)
|
||||
*/
|
||||
setPanel(panel: vscode.WebviewPanel): void {
|
||||
this.panel = panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 WebView Panel
|
||||
* @returns 是否是新创建的 Panel
|
||||
*/
|
||||
createPanel(): boolean {
|
||||
if (this.panel) {
|
||||
return false; // Panel already exists
|
||||
}
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code Chat',
|
||||
{
|
||||
viewColumn: vscode.ViewColumn.Beside, // Open on right side of active editor
|
||||
preserveFocus: true, // Don't steal focus from editor
|
||||
},
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Set panel icon to Qwen logo
|
||||
this.panel.iconPath = vscode.Uri.joinPath(
|
||||
this.extensionUri,
|
||||
'assets',
|
||||
'icon.png',
|
||||
);
|
||||
|
||||
return true; // New panel created
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动锁定编辑器组(仅在新创建 Panel 时调用)
|
||||
*/
|
||||
async autoLockEditorGroup(): Promise<void> {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PanelManager] Auto-locking editor group for Qwen Code chat');
|
||||
try {
|
||||
// Reveal panel without preserving focus to make it the active group
|
||||
this.revealPanel(false);
|
||||
|
||||
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
|
||||
console.log('[PanelManager] Editor group locked successfully');
|
||||
} catch (error) {
|
||||
console.warn('[PanelManager] Failed to lock editor group:', error);
|
||||
// Non-fatal error, continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Panel(如果存在则 reveal,否则什么都不做)
|
||||
* @param preserveFocus 是否保持焦点
|
||||
*/
|
||||
revealPanel(preserveFocus: boolean = true): void {
|
||||
if (this.panel) {
|
||||
this.panel.reveal(vscode.ViewColumn.Beside, preserveFocus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获与 WebView Panel 对应的 Tab
|
||||
* 用于跟踪和管理 Tab 状态
|
||||
*/
|
||||
captureTab(): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer slightly so the tab model is updated after create/reveal
|
||||
setTimeout(() => {
|
||||
const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs);
|
||||
const match = allTabs.find((t) => {
|
||||
// Type guard for webview tab input
|
||||
const input: unknown = (t as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
const isWebview = isWebviewInput(input);
|
||||
const sameViewType = isWebview && input.viewType === 'qwenCode.chat';
|
||||
const sameLabel = t.label === this.panel!.title;
|
||||
return !!(sameViewType || sameLabel);
|
||||
});
|
||||
this.panelTab = match ?? null;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Panel 的 dispose 事件处理器
|
||||
* @param disposables 用于存储 Disposable 的数组
|
||||
*/
|
||||
registerDisposeHandler(disposables: vscode.Disposable[]): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.onDidDispose(
|
||||
() => {
|
||||
this.panel = null;
|
||||
this.panelTab = null;
|
||||
this.onPanelDispose();
|
||||
},
|
||||
null,
|
||||
disposables,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册视图状态变化事件处理器
|
||||
* @param disposables 用于存储 Disposable 的数组
|
||||
*/
|
||||
registerViewStateChangeHandler(disposables: vscode.Disposable[]): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.onDidChangeViewState(
|
||||
() => {
|
||||
if (this.panel && this.panel.visible) {
|
||||
this.captureTab();
|
||||
}
|
||||
},
|
||||
null,
|
||||
disposables,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁 Panel
|
||||
*/
|
||||
dispose(): void {
|
||||
this.panel?.dispose();
|
||||
this.panel = null;
|
||||
this.panelTab = null;
|
||||
}
|
||||
}
|
||||
50
packages/vscode-ide-companion/src/webview/WebViewContent.ts
Normal file
50
packages/vscode-ide-companion/src/webview/WebViewContent.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { escapeHtml } from '../utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* WebView HTML 内容生成器
|
||||
* 负责生成 WebView 的 HTML 内容
|
||||
*/
|
||||
export class WebViewContent {
|
||||
/**
|
||||
* 生成 WebView 的 HTML 内容
|
||||
* @param panel WebView Panel
|
||||
* @param extensionUri 扩展的 URI
|
||||
* @returns HTML 字符串
|
||||
*/
|
||||
static generate(
|
||||
panel: vscode.WebviewPanel,
|
||||
extensionUri: vscode.Uri,
|
||||
): string {
|
||||
const scriptUri = panel.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'),
|
||||
);
|
||||
|
||||
// Convert extension URI for webview access - this allows frontend to construct resource paths
|
||||
const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri);
|
||||
|
||||
// 对 URI 进行 HTML 转义以防止潜在的注入攻击
|
||||
const safeExtensionUri = escapeHtml(extensionUriForWebview.toString());
|
||||
const safeScriptUri = escapeHtml(scriptUri.toString());
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
|
||||
<title>Qwen Code Chat</title>
|
||||
</head>
|
||||
<body data-extension-uri="${safeExtensionUri}">
|
||||
<div id="root"></div>
|
||||
<script src="${safeScriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,36 @@ function getExtensionUri(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URL 是否为安全的 VS Code webview 资源 URL
|
||||
* 防止 XSS 攻击
|
||||
*
|
||||
* @param url - 待验证的 URL
|
||||
* @returns 是否为安全的 URL
|
||||
*/
|
||||
function isValidWebviewUrl(url: string): boolean {
|
||||
try {
|
||||
// VS Code webview 资源 URL 的合法协议
|
||||
const allowedProtocols = [
|
||||
'vscode-webview-resource:',
|
||||
'https-vscode-webview-resource:',
|
||||
'vscode-file:',
|
||||
'https:',
|
||||
];
|
||||
|
||||
// 检查是否以合法协议开头
|
||||
return allowedProtocols.some((protocol) => url.startsWith(protocol));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a resource URL for webview access
|
||||
* Similar to the pattern used in other VSCode extensions
|
||||
*
|
||||
* @param relativePath - Relative path from extension root (e.g., 'assets/icon.png')
|
||||
* @returns Full webview-accessible URL
|
||||
* @returns Full webview-accessible URL (empty string if validation fails)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -52,6 +76,15 @@ export function generateResourceUrl(relativePath: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 验证 extensionUri 是否为安全的 URL
|
||||
if (!isValidWebviewUrl(extensionUri)) {
|
||||
console.error(
|
||||
'[resourceUrl] Invalid extension URI - possible security risk:',
|
||||
extensionUri,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove leading slash if present
|
||||
const cleanPath = relativePath.startsWith('/')
|
||||
? relativePath.slice(1)
|
||||
@@ -62,7 +95,15 @@ export function generateResourceUrl(relativePath: string): string {
|
||||
? extensionUri
|
||||
: `${extensionUri}/`;
|
||||
|
||||
return `${baseUri}${cleanPath}`;
|
||||
const fullUrl = `${baseUri}${cleanPath}`;
|
||||
|
||||
// 验证最终生成的 URL 是否安全
|
||||
if (!isValidWebviewUrl(fullUrl)) {
|
||||
console.error('[resourceUrl] Generated URL failed validation:', fullUrl);
|
||||
return '';
|
||||
}
|
||||
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user