feat(vscode-ide-companion): import chat chat customEditor to vscode extension folder

This commit is contained in:
yiliang114
2025-11-17 18:53:00 +08:00
parent 0eeffc6875
commit dc40995e70
17 changed files with 2428 additions and 4 deletions

View File

@@ -110,6 +110,7 @@ export default tseslint.config(
{ {
allow: [ allow: [
'react-dom/test-utils', 'react-dom/test-utils',
'react-dom/client',
'memfs/lib/volume.js', 'memfs/lib/volume.js',
'yargs/**', 'yargs/**',
'msw/node', 'msw/node',

66
package-lock.json generated
View File

@@ -3690,6 +3690,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode-terminal": { "node_modules/@types/qrcode-terminal": {
"version": "0.12.2", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
@@ -16296,12 +16303,16 @@
"@modelcontextprotocol/sdk": "^1.15.1", "@modelcontextprotocol/sdk": "^1.15.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "20.x", "@types/node": "20.x",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/vscode": "^1.99.0", "@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
@@ -16316,6 +16327,27 @@
"vscode": "^1.99.0" "vscode": "^1.99.0"
} }
}, },
"packages/vscode-ide-companion/node_modules/@types/react": {
"version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"packages/vscode-ide-companion/node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"packages/vscode-ide-companion/node_modules/@types/vscode": { "packages/vscode-ide-companion/node_modules/@types/vscode": {
"version": "1.99.0", "version": "1.99.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz",
@@ -16409,6 +16441,40 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"packages/vscode-ide-companion/node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"packages/vscode-ide-companion/node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"packages/vscode-ide-companion/node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"packages/vscode-ide-companion/node_modules/send": { "packages/vscode-ide-companion/node_modules/send": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",

View File

@@ -2317,3 +2317,84 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
react@19.1.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
react-dom@19.1.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
scheduler@0.26.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -31,8 +31,30 @@ const esbuildProblemMatcherPlugin = {
}, },
}; };
/**
* @type {import('esbuild').Plugin}
*/
const cssInjectPlugin = {
name: 'css-inject',
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
const fs = await import('fs');
const css = await fs.promises.readFile(args.path, 'utf8');
return {
contents: `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(css)};
document.head.appendChild(style);
`,
loader: 'js',
};
});
},
};
async function main() { async function main() {
const ctx = await esbuild.context({ // Build extension
const extensionCtx = await esbuild.context({
entryPoints: ['src/extension.ts'], entryPoints: ['src/extension.ts'],
bundle: true, bundle: true,
format: 'cjs', format: 'cjs',
@@ -55,11 +77,29 @@ async function main() {
], ],
loader: { '.node': 'file' }, loader: { '.node': 'file' },
}); });
// Build webview
const webviewCtx = await esbuild.context({
entryPoints: ['src/webview/index.tsx'],
bundle: true,
format: 'iife',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'browser',
outfile: 'dist/webview.js',
logLevel: 'silent',
plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin],
define: {
'process.env.NODE_ENV': production ? '"production"' : '"development"',
},
});
if (watch) { if (watch) {
await ctx.watch(); await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
} else { } else {
await ctx.rebuild(); await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
await ctx.dispose(); await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
} }
} }

View File

@@ -54,8 +54,48 @@
{ {
"command": "qwen-code.showNotices", "command": "qwen-code.showNotices",
"title": "Qwen Code: View Third-Party Notices" "title": "Qwen Code: View Third-Party Notices"
},
{
"command": "qwenCode.openChat",
"title": "Qwen Code: Open Chat",
"icon": "$(comment-discussion)"
} }
], ],
"configuration": {
"title": "Qwen Code",
"properties": {
"qwenCode.qwen.enabled": {
"type": "boolean",
"default": true,
"description": "Enable Qwen agent integration"
},
"qwenCode.qwen.cliPath": {
"type": "string",
"default": "qwen",
"description": "Path to Qwen CLI executable"
},
"qwenCode.qwen.openaiApiKey": {
"type": "string",
"default": "",
"description": "OpenAI API key for Qwen (optional, if not using Code Assist)"
},
"qwenCode.qwen.openaiBaseUrl": {
"type": "string",
"default": "",
"description": "OpenAI base URL for custom endpoints (optional)"
},
"qwenCode.qwen.model": {
"type": "string",
"default": "",
"description": "Model to use (optional)"
},
"qwenCode.qwen.proxy": {
"type": "string",
"default": "",
"description": "Proxy for Qwen client (format: schema://user:password@host:port, e.g., http://127.0.0.1:7890)"
}
}
},
"menus": { "menus": {
"commandPalette": [ "commandPalette": [
{ {
@@ -77,6 +117,10 @@
"command": "qwen.diff.cancel", "command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible", "when": "qwen.diff.isVisible",
"group": "navigation" "group": "navigation"
},
{
"command": "qwenCode.openChat",
"group": "navigation"
} }
] ]
}, },
@@ -90,6 +134,11 @@
"command": "qwen.diff.accept", "command": "qwen.diff.accept",
"key": "cmd+s", "key": "cmd+s",
"when": "qwen.diff.isVisible" "when": "qwen.diff.isVisible"
},
{
"command": "qwenCode.openChat",
"key": "ctrl+shift+a",
"mac": "cmd+shift+a"
} }
] ]
}, },
@@ -116,6 +165,8 @@
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "20.x", "@types/node": "20.x",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/vscode": "^1.99.0", "@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
@@ -130,6 +181,8 @@
"@modelcontextprotocol/sdk": "^1.15.1", "@modelcontextprotocol/sdk": "^1.15.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.25.76" "zod": "^3.25.76"
} }
} }

View File

@@ -0,0 +1,452 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import {
QwenAgentManager,
type ChatMessage,
} from './agents/QwenAgentManager.js';
import { ConversationStore } from './storage/ConversationStore.js';
import type { AcpPermissionRequest } from './shared/acpTypes.js';
export class WebViewProvider {
private panel: vscode.WebviewPanel | null = null;
private agentManager: QwenAgentManager;
private conversationStore: ConversationStore;
private currentConversationId: string | null = null;
private disposables: vscode.Disposable[] = [];
private agentInitialized = false; // Track if agent has been initialized
constructor(
private context: vscode.ExtensionContext,
private extensionUri: vscode.Uri,
) {
this.agentManager = new QwenAgentManager();
this.conversationStore = new ConversationStore(context);
// Setup agent callbacks
this.agentManager.onStreamChunk((chunk: string) => {
this.sendMessageToWebView({
type: 'streamChunk',
data: { chunk },
});
});
this.agentManager.onPermissionRequest(
async (request: AcpPermissionRequest) => {
// Send permission request to WebView
this.sendMessageToWebView({
type: 'permissionRequest',
data: request,
});
// Wait for user response
return new Promise((resolve) => {
const handler = (message: {
type: string;
data: { optionId: string };
}) => {
if (message.type === 'permissionResponse') {
resolve(message.data.optionId);
}
};
// Store handler temporarily (in real implementation, use proper event system)
(this as { permissionHandler?: typeof handler }).permissionHandler =
handler;
});
},
);
}
async show(): Promise<void> {
if (this.panel) {
this.panel.reveal();
return;
}
this.panel = vscode.window.createWebviewPanel(
'qwenCode.chat',
'Qwen Code Chat',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'dist')],
},
);
this.panel.webview.html = this.getWebviewContent();
// Handle messages from WebView
this.panel.webview.onDidReceiveMessage(
async (message) => {
await this.handleWebViewMessage(message);
},
null,
this.disposables,
);
this.panel.onDidDispose(
() => {
this.panel = null;
// Don't disconnect agent - keep it alive for next time
this.disposables.forEach((d) => d.dispose());
},
null,
this.disposables,
);
// Initialize agent connection only once
if (!this.agentInitialized) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
console.log(
'[WebViewProvider] Starting initialization, workingDir:',
workingDir,
);
const config = vscode.workspace.getConfiguration('qwenCode');
const qwenEnabled = config.get<boolean>('qwen.enabled', true);
if (qwenEnabled) {
try {
console.log('[WebViewProvider] Connecting to agent...');
await this.agentManager.connect(workingDir);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// 显示成功通知
vscode.window.showInformationMessage(
'✅ Qwen Code connected successfully!',
);
} catch (error) {
console.error('[WebViewProvider] Agent connection error:', error);
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
);
}
} else {
console.log('[WebViewProvider] Qwen agent is disabled in settings');
}
} else {
console.log(
'[WebViewProvider] Agent already initialized, reusing existing connection',
);
}
// Load or create conversation (always do this, even if agent fails)
try {
console.log('[WebViewProvider] Loading conversations...');
const conversations = await this.conversationStore.getAllConversations();
console.log(
'[WebViewProvider] Found conversations:',
conversations.length,
);
if (conversations.length > 0) {
const lastConv = conversations[conversations.length - 1];
this.currentConversationId = lastConv.id;
console.log(
'[WebViewProvider] Loaded existing conversation:',
this.currentConversationId,
);
this.sendMessageToWebView({
type: 'conversationLoaded',
data: lastConv,
});
} else {
console.log('[WebViewProvider] Creating new conversation...');
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
console.log(
'[WebViewProvider] Created new conversation:',
this.currentConversationId,
);
this.sendMessageToWebView({
type: 'conversationLoaded',
data: newConv,
});
}
console.log('[WebViewProvider] Initialization complete');
} catch (convError) {
console.error(
'[WebViewProvider] Failed to create conversation:',
convError,
);
vscode.window.showErrorMessage(
`Failed to initialize conversation: ${convError}`,
);
}
}
private async handleWebViewMessage(message: {
type: string;
data?: { text?: string; id?: string; sessionId?: string };
}): Promise<void> {
console.log('[WebViewProvider] Received message from webview:', message);
const self = this as {
permissionHandler?: (msg: {
type: string;
data: { optionId: string };
}) => void;
};
switch (message.type) {
case 'sendMessage':
await this.handleSendMessage(message.data?.text || '');
break;
case 'permissionResponse':
// Forward to permission handler
if (self.permissionHandler) {
self.permissionHandler(
message as { type: string; data: { optionId: string } },
);
delete self.permissionHandler;
}
break;
case 'loadConversation':
await this.handleLoadConversation(message.data?.id || '');
break;
case 'newConversation':
await this.handleNewConversation();
break;
case 'newQwenSession':
await this.handleNewQwenSession();
break;
case 'deleteConversation':
await this.handleDeleteConversation(message.data?.id || '');
break;
case 'getQwenSessions':
await this.handleGetQwenSessions();
break;
case 'switchQwenSession':
await this.handleSwitchQwenSession(message.data?.sessionId || '');
break;
default:
console.warn('[WebViewProvider] Unknown message type:', message.type);
break;
}
}
private async handleSendMessage(text: string): Promise<void> {
console.log('[WebViewProvider] handleSendMessage called with:', text);
if (!this.currentConversationId) {
console.error('[WebViewProvider] No current conversation ID');
return;
}
// Save user message
const userMessage: ChatMessage = {
role: 'user',
content: text,
timestamp: Date.now(),
};
await this.conversationStore.addMessage(
this.currentConversationId,
userMessage,
);
console.log('[WebViewProvider] User message saved to store');
// Send to WebView
this.sendMessageToWebView({
type: 'message',
data: userMessage,
});
console.log('[WebViewProvider] User message sent to webview');
// Check if agent is connected
if (!this.agentManager.isConnected) {
console.warn(
'[WebViewProvider] Agent is not connected, skipping AI response',
);
this.sendMessageToWebView({
type: 'error',
data: {
message:
'Agent is not connected. Enable Qwen in settings or configure API key.',
},
});
return;
}
// Send to agent
try {
// Create placeholder for assistant message
this.sendMessageToWebView({
type: 'streamStart',
data: { timestamp: Date.now() },
});
console.log('[WebViewProvider] Stream start sent');
console.log('[WebViewProvider] Sending to agent manager...');
await this.agentManager.sendMessage(text);
console.log('[WebViewProvider] Agent manager send complete');
// Stream is complete
this.sendMessageToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
});
console.log('[WebViewProvider] Stream end sent');
} catch (error) {
console.error('[WebViewProvider] Error sending message:', error);
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendMessageToWebView({
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.sendMessageToWebView({
type: 'conversationLoaded',
data: conversation,
});
}
}
private async handleNewConversation(): Promise<void> {
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
this.sendMessageToWebView({
type: 'conversationLoaded',
data: newConv,
});
}
private async handleDeleteConversation(id: string): Promise<void> {
await this.conversationStore.deleteConversation(id);
this.sendMessageToWebView({
type: 'conversationDeleted',
data: { id },
});
}
private async handleGetQwenSessions(): Promise<void> {
try {
console.log('[WebViewProvider] Getting Qwen sessions...');
const sessions = await this.agentManager.getSessionList();
console.log('[WebViewProvider] Retrieved sessions:', sessions.length);
this.sendMessageToWebView({
type: 'qwenSessionList',
data: { sessions },
});
} catch (error) {
console.error('[WebViewProvider] Failed to get Qwen sessions:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to get sessions: ${error}` },
});
}
}
private async handleNewQwenSession(): Promise<void> {
try {
console.log('[WebViewProvider] 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.sendMessageToWebView({
type: 'conversationCleared',
data: {},
});
vscode.window.showInformationMessage('✅ New Qwen session created!');
} catch (error) {
console.error('[WebViewProvider] Failed to create new session:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to create new session: ${error}` },
});
}
}
private async handleSwitchQwenSession(sessionId: string): Promise<void> {
try {
console.log('[WebViewProvider] Switching to Qwen session:', sessionId);
// Get session messages from local files
const messages = await this.agentManager.getSessionMessages(sessionId);
console.log(
'[WebViewProvider] Loaded messages from session:',
messages.length,
);
// Try to switch session in ACP (may fail if not supported)
try {
await this.agentManager.switchToSession(sessionId);
} catch (_switchError) {
console.log(
'[WebViewProvider] session/switch not supported, but loaded messages anyway',
);
}
// Send messages to WebView
this.sendMessageToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
vscode.window.showInformationMessage(
`Loaded Qwen session with ${messages.length} messages`,
);
} catch (error) {
console.error('[WebViewProvider] Failed to switch session:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to switch session: ${error}` },
});
}
}
private sendMessageToWebView(message: unknown): void {
this.panel?.webview.postMessage(message);
}
private getWebviewContent(): string {
const scriptUri = this.panel!.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview.js'),
);
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'; script-src ${this.panel!.webview.cspSource}; style-src ${this.panel!.webview.cspSource} 'unsafe-inline';">
<title>Qwen Code Chat</title>
</head>
<body>
<div id="root"></div>
<script src="${scriptUri}"></script>
</body>
</html>`;
}
dispose(): void {
this.panel?.dispose();
this.agentManager.disconnect();
this.disposables.forEach((d) => d.dispose());
}
}

View File

@@ -0,0 +1,442 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { JSONRPC_VERSION } from '../shared/acpTypes.js';
import type {
AcpBackend,
AcpMessage,
AcpNotification,
AcpPermissionRequest,
AcpRequest,
AcpResponse,
AcpSessionUpdate,
} from '../shared/acpTypes.js';
import type { ChildProcess, SpawnOptions } from 'child_process';
import { spawn } from 'child_process';
interface PendingRequest<T = unknown> {
resolve: (value: T) => void;
reject: (error: Error) => void;
timeoutId?: NodeJS.Timeout;
method: string;
}
export class AcpConnection {
private child: ChildProcess | null = null;
private pendingRequests = new Map<number, PendingRequest<unknown>>();
private nextRequestId = 0;
private sessionId: string | null = null;
private isInitialized = false;
private backend: AcpBackend | null = null;
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}> = () => Promise.resolve({ optionId: 'allow' });
onEndTurn: () => void = () => {};
async connect(
backend: AcpBackend,
cliPath: string,
workingDir: string = process.cwd(),
extraArgs: string[] = [],
): Promise<void> {
if (this.child) {
this.disconnect();
}
this.backend = backend;
const isWindows = process.platform === 'win32';
const env = { ...process.env };
// If proxy is configured in extraArgs, also set it as environment variables
// This ensures token refresh requests also use the proxy
const proxyArg = extraArgs.find(
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
);
if (proxyArg) {
const proxyIndex = extraArgs.indexOf('--proxy');
const proxyUrl = extraArgs[proxyIndex + 1];
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
// Set standard proxy env vars
env.HTTP_PROXY = proxyUrl;
env.HTTPS_PROXY = proxyUrl;
env.http_proxy = proxyUrl;
env.https_proxy = proxyUrl;
// For Node.js fetch (undici), we need to use NODE_OPTIONS with a custom agent
// Or use the global-agent package, but for now we'll rely on the --proxy flag
// and hope the CLI handles it properly for all requests
// Alternative: disable TLS verification for proxy (not recommended for production)
// env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
let spawnCommand: string;
let spawnArgs: string[];
if (cliPath.startsWith('npx ')) {
const parts = cliPath.split(' ');
spawnCommand = isWindows ? 'npx.cmd' : 'npx';
spawnArgs = [...parts.slice(1), '--experimental-acp', ...extraArgs];
} else {
spawnCommand = cliPath;
spawnArgs = ['--experimental-acp', ...extraArgs];
}
console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' '));
const options: SpawnOptions = {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env,
shell: isWindows,
};
this.child = spawn(spawnCommand, spawnArgs, options);
await this.setupChildProcessHandlers(backend);
}
private async setupChildProcessHandlers(backend: string): Promise<void> {
let spawnError: Error | null = null;
this.child!.stderr?.on('data', (data) => {
const message = data.toString();
// Many CLIs output informational messages to stderr, so use console.log instead of console.error
// Only treat it as error if it contains actual error keywords
if (
message.toLowerCase().includes('error') &&
!message.includes('Loaded cached')
) {
console.error(`[ACP ${backend}]:`, message);
} else {
console.log(`[ACP ${backend}]:`, message);
}
});
this.child!.on('error', (error) => {
spawnError = error;
});
this.child!.on('exit', (code, signal) => {
console.error(
`[ACP ${backend}] Process exited with code: ${code}, signal: ${signal}`,
);
});
// Wait for process to start
await new Promise((resolve) => setTimeout(resolve, 1000));
if (spawnError) {
throw spawnError;
}
if (!this.child || this.child.killed) {
throw new Error(`${backend} ACP process failed to start`);
}
// Handle messages from ACP server
let buffer = '';
this.child.stdout?.on('data', (data) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
const message = JSON.parse(line) as AcpMessage;
this.handleMessage(message);
} catch (_error) {
// Ignore non-JSON lines
}
}
}
});
// Initialize protocol
await this.initialize();
}
private sendRequest<T = unknown>(
method: string,
params?: Record<string, unknown>,
): Promise<T> {
const id = this.nextRequestId++;
const message: AcpRequest = {
jsonrpc: JSONRPC_VERSION,
id,
method,
...(params && { params }),
};
return new Promise((resolve, reject) => {
const timeoutDuration = method === 'session/prompt' ? 120000 : 60000;
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
const pendingRequest: PendingRequest<T> = {
resolve: (value: T) => {
clearTimeout(timeoutId);
resolve(value);
},
reject: (error: Error) => {
clearTimeout(timeoutId);
reject(error);
},
timeoutId,
method,
};
this.pendingRequests.set(id, pendingRequest as PendingRequest<unknown>);
this.sendMessage(message);
});
}
private sendMessage(message: AcpRequest | AcpNotification): void {
if (this.child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
this.child.stdin.write(jsonString + lineEnding);
}
}
private sendResponseMessage(response: AcpResponse): void {
if (this.child?.stdin) {
const jsonString = JSON.stringify(response);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
this.child.stdin.write(jsonString + lineEnding);
}
}
private handleMessage(message: AcpMessage): void {
try {
if ('method' in message) {
// Request or notification
this.handleIncomingRequest(message).catch(() => {});
} else if (
'id' in message &&
typeof message.id === 'number' &&
this.pendingRequests.has(message.id)
) {
// Response
const pendingRequest = this.pendingRequests.get(message.id)!;
const { resolve, reject, method } = pendingRequest;
this.pendingRequests.delete(message.id);
if ('result' in message) {
console.log(
`[ACP] Response for ${method}:`,
JSON.stringify(message.result).substring(0, 200),
);
if (
message.result &&
typeof message.result === 'object' &&
'stopReason' in message.result &&
message.result.stopReason === 'end_turn'
) {
this.onEndTurn();
}
resolve(message.result);
} else if ('error' in message) {
const errorCode = message.error?.code || 'unknown';
const errorMsg = message.error?.message || 'Unknown ACP error';
const errorData = message.error?.data
? JSON.stringify(message.error.data)
: '';
console.error(`[ACP] Error response for ${method}:`, {
code: errorCode,
message: errorMsg,
data: errorData,
});
reject(
new Error(
`${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`,
),
);
}
}
} catch (error) {
console.error('[ACP] Error handling message:', error);
}
}
private async handleIncomingRequest(
message: AcpRequest | AcpNotification,
): Promise<void> {
const { method, params } = message;
try {
let result = null;
switch (method) {
case 'session/update':
this.onSessionUpdate(params as AcpSessionUpdate);
break;
case 'session/request_permission':
result = await this.handlePermissionRequest(
params as AcpPermissionRequest,
);
break;
default:
break;
}
if ('id' in message && typeof message.id === 'number') {
this.sendResponseMessage({
jsonrpc: JSONRPC_VERSION,
id: message.id,
result,
});
}
} catch (error) {
if ('id' in message && typeof message.id === 'number') {
this.sendResponseMessage({
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : String(error),
},
});
}
}
}
private async handlePermissionRequest(params: AcpPermissionRequest): Promise<{
outcome: { outcome: string; optionId: string };
}> {
try {
const response = await this.onPermissionRequest(params);
const optionId = response.optionId;
const outcome = optionId.includes('reject') ? 'rejected' : 'selected';
return {
outcome: {
outcome,
optionId,
},
};
} catch (_error) {
return {
outcome: {
outcome: 'rejected',
optionId: 'reject_once',
},
};
}
}
private async initialize(): Promise<AcpResponse> {
const initializeParams = {
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
};
console.log('[ACP] Sending initialize request...');
const response = await this.sendRequest<AcpResponse>(
'initialize',
initializeParams,
);
this.isInitialized = true;
console.log('[ACP] Initialize successful');
return response;
}
async authenticate(methodId?: string): Promise<AcpResponse> {
// New version requires methodId to be provided
const authMethodId = methodId || 'default';
console.log(
'[ACP] Sending authenticate request with methodId:',
authMethodId,
);
const response = await this.sendRequest<AcpResponse>('authenticate', {
methodId: authMethodId,
});
console.log('[ACP] Authenticate successful');
return response;
}
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
console.log('[ACP] Sending session/new request with cwd:', cwd);
const response = await this.sendRequest<
AcpResponse & { sessionId?: string }
>('session/new', {
cwd,
mcpServers: [],
});
this.sessionId = response.sessionId || null;
console.log('[ACP] Session created with ID:', this.sessionId);
return response;
}
async sendPrompt(prompt: string): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
return await this.sendRequest('session/prompt', {
sessionId: this.sessionId,
prompt: [{ type: 'text', text: prompt }],
});
}
async listSessions(): Promise<AcpResponse> {
console.log('[ACP] Requesting session list...');
try {
const response = await this.sendRequest<AcpResponse>('session/list', {});
console.log(
'[ACP] Session list response:',
JSON.stringify(response).substring(0, 200),
);
return response;
} catch (error) {
console.error('[ACP] Failed to get session list:', error);
throw error;
}
}
async switchSession(sessionId: string): Promise<AcpResponse> {
console.log('[ACP] Switching to session:', sessionId);
this.sessionId = sessionId;
const response = await this.sendRequest<AcpResponse>('session/switch', {
sessionId,
});
console.log('[ACP] Session switched successfully');
return response;
}
disconnect(): void {
if (this.child) {
this.child.kill();
this.child = null;
}
this.pendingRequests.clear();
this.sessionId = null;
this.isInitialized = false;
this.backend = null;
}
get isConnected(): boolean {
return this.child !== null && !this.child.killed;
}
get hasActiveSession(): boolean {
return this.sessionId !== null;
}
}

View File

@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { AcpConnection } from '../acp/AcpConnection.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
} from '../shared/acpTypes.js';
import {
QwenSessionReader,
type QwenSession,
} from '../services/QwenSessionReader.js';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
export class QwenAgentManager {
private connection: AcpConnection;
private sessionReader: QwenSessionReader;
private onMessageCallback?: (message: ChatMessage) => void;
private onStreamChunkCallback?: (chunk: string) => void;
private onPermissionRequestCallback?: (
request: AcpPermissionRequest,
) => Promise<string>;
private currentWorkingDir: string = process.cwd();
constructor() {
this.connection = new AcpConnection();
this.sessionReader = new QwenSessionReader();
// Setup session update handler
this.connection.onSessionUpdate = (data: AcpSessionUpdate) => {
this.handleSessionUpdate(data);
};
// Setup permission request handler
this.connection.onPermissionRequest = async (
data: AcpPermissionRequest,
) => {
if (this.onPermissionRequestCallback) {
const optionId = await this.onPermissionRequestCallback(data);
return { optionId };
}
return { optionId: 'allow_once' };
};
// Setup end turn handler
this.connection.onEndTurn = () => {
// Notify UI that response is complete
};
}
async connect(workingDir: string): Promise<void> {
this.currentWorkingDir = workingDir;
const config = vscode.workspace.getConfiguration('qwenCode');
const cliPath = config.get<string>('qwen.cliPath', 'qwen');
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const openaiBaseUrl = config.get<string>('qwen.openaiBaseUrl', '');
const model = config.get<string>('qwen.model', '');
const proxy = config.get<string>('qwen.proxy', '');
// Build additional CLI arguments
const extraArgs: string[] = [];
if (openaiApiKey) {
extraArgs.push('--openai-api-key', openaiApiKey);
}
if (openaiBaseUrl) {
extraArgs.push('--openai-base-url', openaiBaseUrl);
}
if (model) {
extraArgs.push('--model', model);
}
if (proxy) {
extraArgs.push('--proxy', proxy);
console.log('[QwenAgentManager] Using proxy:', proxy);
}
await this.connection.connect('qwen', cliPath, workingDir, extraArgs);
// Determine auth method based on configuration
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
// Since session/list is not supported, try to get sessions from local files
console.log('[QwenAgentManager] Reading local session files...');
try {
const sessions = await this.sessionReader.getAllSessions(workingDir);
if (sessions.length > 0) {
// Use the most recent session
console.log(
'[QwenAgentManager] Found existing sessions:',
sessions.length,
);
const lastSession = sessions[0]; // Already sorted by lastUpdated
// Try to switch to it (this may fail if not supported)
try {
await this.connection.switchSession(lastSession.sessionId);
console.log(
'[QwenAgentManager] Restored session:',
lastSession.sessionId,
);
} catch (_switchError) {
console.log(
'[QwenAgentManager] session/switch not supported, creating new session',
);
await this.connection.authenticate(authMethod);
await this.connection.newSession(workingDir);
}
} else {
// No sessions, authenticate and create a new one
console.log(
'[QwenAgentManager] No existing sessions, creating new session',
);
await this.connection.authenticate(authMethod);
await this.connection.newSession(workingDir);
}
} catch (error) {
// If reading local sessions fails, fall back to creating new session
const errorMessage =
error instanceof Error ? error.message : String(error);
console.log(
'[QwenAgentManager] Failed to read local sessions, creating new session:',
errorMessage,
);
await this.connection.authenticate(authMethod);
await this.connection.newSession(workingDir);
}
}
async sendMessage(message: string): Promise<void> {
await this.connection.sendPrompt(message);
}
async getSessionList(): Promise<Array<Record<string, unknown>>> {
try {
// Read from local session files instead of ACP protocol
// Get all sessions from all projects
const sessions = await this.sessionReader.getAllSessions(undefined, true);
console.log(
'[QwenAgentManager] Session list from files (all projects):',
sessions.length,
);
// Transform to UI-friendly format
return sessions.map(
(session: QwenSession): Record<string, unknown> => ({
id: session.sessionId,
sessionId: session.sessionId,
title: this.sessionReader.getSessionTitle(session),
name: this.sessionReader.getSessionTitle(session),
startTime: session.startTime,
lastUpdated: session.lastUpdated,
messageCount: session.messages.length,
projectHash: session.projectHash,
}),
);
} catch (error) {
console.error('[QwenAgentManager] Failed to get session list:', error);
return [];
}
}
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try {
const session = await this.sessionReader.getSession(
sessionId,
this.currentWorkingDir,
);
if (!session) {
return [];
}
// Convert Qwen messages to ChatMessage format
return session.messages.map(
(msg: { type: string; content: string; timestamp: string }) => ({
role:
msg.type === 'user' ? ('user' as const) : ('assistant' as const),
content: msg.content,
timestamp: new Date(msg.timestamp).getTime(),
}),
);
} catch (error) {
console.error(
'[QwenAgentManager] Failed to get session messages:',
error,
);
return [];
}
}
async createNewSession(workingDir: string): Promise<void> {
console.log('[QwenAgentManager] Creating new session...');
await this.connection.newSession(workingDir);
}
async switchToSession(sessionId: string): Promise<void> {
await this.connection.switchSession(sessionId);
}
private handleSessionUpdate(data: AcpSessionUpdate): void {
const update = data.update;
if (update.sessionUpdate === 'agent_message_chunk') {
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
} else if (update.sessionUpdate === 'tool_call') {
// Handle tool call updates
const toolCall = update as { title?: string; status?: string };
const title = toolCall.title || 'Tool Call';
const status = toolCall.status || 'pending';
if (this.onStreamChunkCallback) {
this.onStreamChunkCallback(`\n🔧 ${title} [${status}]\n`);
}
}
}
onMessage(callback: (message: ChatMessage) => void): void {
this.onMessageCallback = callback;
}
onStreamChunk(callback: (chunk: string) => void): void {
this.onStreamChunkCallback = callback;
}
onPermissionRequest(
callback: (request: AcpPermissionRequest) => Promise<string>,
): void {
this.onPermissionRequestCallback = callback;
}
disconnect(): void {
this.connection.disconnect();
}
get isConnected(): boolean {
return this.connection.isConnected;
}
}

View File

@@ -14,6 +14,7 @@ import {
IDE_DEFINITIONS, IDE_DEFINITIONS,
type IdeInfo, type IdeInfo,
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { WebViewProvider } from './WebViewProvider.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -31,6 +32,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet<IdeInfo['name']> = new Set([
let ideServer: IDEServer; let ideServer: IDEServer;
let logger: vscode.OutputChannel; let logger: vscode.OutputChannel;
let webViewProvider: WebViewProvider;
let log: (message: string) => void = () => {}; let log: (message: string) => void = () => {};
@@ -110,6 +112,9 @@ export async function activate(context: vscode.ExtensionContext) {
const diffContentProvider = new DiffContentProvider(); const diffContentProvider = new DiffContentProvider();
const diffManager = new DiffManager(log, diffContentProvider); const diffManager = new DiffManager(log, diffContentProvider);
// Initialize WebView Provider
webViewProvider = new WebViewProvider(context, context.extensionUri);
context.subscriptions.push( context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => { vscode.workspace.onDidCloseTextDocument((doc) => {
if (doc.uri.scheme === DIFF_SCHEME) { if (doc.uri.scheme === DIFF_SCHEME) {
@@ -132,6 +137,9 @@ export async function activate(context: vscode.ExtensionContext) {
diffManager.cancelDiff(docUri); diffManager.cancelDiff(docUri);
} }
}), }),
vscode.commands.registerCommand('qwenCode.openChat', () => {
webViewProvider.show();
}),
); );
ideServer = new IDEServer(log, diffManager); ideServer = new IDEServer(log, diffManager);
@@ -204,6 +212,9 @@ export async function deactivate(): Promise<void> {
if (ideServer) { if (ideServer) {
await ideServer.stop(); await ideServer.stop();
} }
if (webViewProvider) {
webViewProvider.dispose();
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
log(`Failed to stop IDE server during deactivation: ${message}`); log(`Failed to stop IDE server during deactivation: ${message}`);

View File

@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
interface QwenMessage {
id: string;
timestamp: string;
type: 'user' | 'qwen';
content: string;
thoughts?: unknown[];
tokens?: {
input: number;
output: number;
cached: number;
thoughts: number;
tool: number;
total: number;
};
model?: string;
}
export interface QwenSession {
sessionId: string;
projectHash: string;
startTime: string;
lastUpdated: string;
messages: QwenMessage[];
filePath?: string;
}
export class QwenSessionReader {
private qwenDir: string;
constructor() {
this.qwenDir = path.join(os.homedir(), '.qwen');
}
/**
* 获取所有会话列表(可选:仅当前项目或所有项目)
*/
async getAllSessions(
workingDir?: string,
allProjects: boolean = false,
): Promise<QwenSession[]> {
try {
const sessions: QwenSession[] = [];
if (!allProjects && workingDir) {
// 仅当前项目
const projectHash = await this.getProjectHash(workingDir);
const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats');
const projectSessions = await this.readSessionsFromDir(chatsDir);
sessions.push(...projectSessions);
} else {
// 所有项目
const tmpDir = path.join(this.qwenDir, 'tmp');
if (!fs.existsSync(tmpDir)) {
console.log('[QwenSessionReader] Tmp directory not found:', tmpDir);
return [];
}
const projectDirs = fs.readdirSync(tmpDir);
for (const projectHash of projectDirs) {
const chatsDir = path.join(tmpDir, projectHash, 'chats');
const projectSessions = await this.readSessionsFromDir(chatsDir);
sessions.push(...projectSessions);
}
}
// 按最后更新时间排序
sessions.sort(
(a, b) =>
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(),
);
return sessions;
} catch (error) {
console.error('[QwenSessionReader] Failed to get sessions:', error);
return [];
}
}
/**
* 从指定目录读取所有会话
*/
private async readSessionsFromDir(chatsDir: string): Promise<QwenSession[]> {
const sessions: QwenSession[] = [];
if (!fs.existsSync(chatsDir)) {
return sessions;
}
const files = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('session-') && f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(chatsDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const session = JSON.parse(content) as QwenSession;
session.filePath = filePath;
sessions.push(session);
} catch (error) {
console.error(
'[QwenSessionReader] Failed to read session file:',
filePath,
error,
);
}
}
return sessions;
}
/**
* 获取特定会话的详情
*/
async getSession(
sessionId: string,
_workingDir?: string,
): Promise<QwenSession | null> {
// First try to find in all projects
const sessions = await this.getAllSessions(undefined, true);
return sessions.find((s) => s.sessionId === sessionId) || null;
}
/**
* 计算项目 hash需要与 Qwen CLI 一致)
* Qwen CLI 使用项目路径的 SHA256 hash
*/
private async getProjectHash(workingDir: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(workingDir).digest('hex');
}
/**
* 获取会话的标题(基于第一条用户消息)
*/
getSessionTitle(session: QwenSession): string {
const firstUserMessage = session.messages.find((m) => m.type === 'user');
if (firstUserMessage) {
// 截取前50个字符作为标题
return (
firstUserMessage.content.substring(0, 50) +
(firstUserMessage.content.length > 50 ? '...' : '')
);
}
return 'Untitled Session';
}
/**
* 删除会话文件
*/
async deleteSession(
sessionId: string,
_workingDir: string,
): Promise<boolean> {
try {
const session = await this.getSession(sessionId, _workingDir);
if (session && session.filePath) {
fs.unlinkSync(session.filePath);
return true;
}
return false;
} catch (error) {
console.error('[QwenSessionReader] Failed to delete session:', error);
return false;
}
}
}

View File

@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// ACP JSON-RPC Protocol Types
export const JSONRPC_VERSION = '2.0' as const;
export type AcpBackend = 'qwen' | 'claude' | 'gemini' | 'codex';
export interface AcpRequest {
jsonrpc: typeof JSONRPC_VERSION;
id: number;
method: string;
params?: unknown;
}
export interface AcpResponse {
jsonrpc: typeof JSONRPC_VERSION;
id: number;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export interface AcpNotification {
jsonrpc: typeof JSONRPC_VERSION;
method: string;
params?: unknown;
}
// Base interface for all session updates
export interface BaseSessionUpdate {
sessionId: string;
}
// Agent message chunk update
export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_message_chunk';
content: {
type: 'text' | 'image';
text?: string;
data?: string;
mimeType?: string;
uri?: string;
};
};
}
// Tool call update
export interface ToolCallUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'tool_call';
toolCallId: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
title: string;
kind: 'read' | 'edit' | 'execute';
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: 'text';
text: string;
};
path?: string;
oldText?: string | null;
newText?: string;
}>;
};
}
// Union type for all session updates
export type AcpSessionUpdate = AgentMessageChunkUpdate | ToolCallUpdate;
// Permission request
export interface AcpPermissionRequest {
sessionId: string;
options: Array<{
optionId: string;
name: string;
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
}>;
toolCall: {
toolCallId: string;
rawInput?: {
command?: string;
description?: string;
[key: string]: unknown;
};
title?: string;
kind?: string;
};
}
export type AcpMessage =
| AcpRequest
| AcpNotification
| AcpResponse
| AcpSessionUpdate;

View File

@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type * as vscode from 'vscode';
import type { ChatMessage } from '../agents/QwenAgentManager.js';
export interface Conversation {
id: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
}
export class ConversationStore {
private context: vscode.ExtensionContext;
private currentConversationId: string | null = null;
constructor(context: vscode.ExtensionContext) {
this.context = context;
}
async createConversation(title: string = 'New Chat'): Promise<Conversation> {
const conversation: Conversation = {
id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
title,
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
const conversations = await this.getAllConversations();
conversations.push(conversation);
await this.context.globalState.update('conversations', conversations);
this.currentConversationId = conversation.id;
return conversation;
}
async getAllConversations(): Promise<Conversation[]> {
return this.context.globalState.get<Conversation[]>('conversations', []);
}
async getConversation(id: string): Promise<Conversation | null> {
const conversations = await this.getAllConversations();
return conversations.find((c) => c.id === id) || null;
}
async addMessage(
conversationId: string,
message: ChatMessage,
): Promise<void> {
const conversations = await this.getAllConversations();
const conversation = conversations.find((c) => c.id === conversationId);
if (conversation) {
conversation.messages.push(message);
conversation.updatedAt = Date.now();
await this.context.globalState.update('conversations', conversations);
}
}
async deleteConversation(id: string): Promise<void> {
const conversations = await this.getAllConversations();
const filtered = conversations.filter((c) => c.id !== id);
await this.context.globalState.update('conversations', filtered);
if (this.currentConversationId === id) {
this.currentConversationId = null;
}
}
getCurrentConversationId(): string | null {
return this.currentConversationId;
}
setCurrentConversationId(id: string): void {
this.currentConversationId = id;
}
}

View File

@@ -0,0 +1,340 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
:root {
--vscode-font-family: var(--vscode-font-family);
--vscode-editor-background: var(--vscode-editor-background);
--vscode-editor-foreground: var(--vscode-editor-foreground);
--vscode-input-background: var(--vscode-input-background);
--vscode-input-foreground: var(--vscode-input-foreground);
--vscode-button-background: var(--vscode-button-background);
--vscode-button-foreground: var(--vscode-button-foreground);
--vscode-button-hoverBackground: var(--vscode-button-hoverBackground);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--vscode-font-family);
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
overflow: hidden;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 80%;
padding: 12px 16px;
border-radius: 8px;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
align-self: flex-end;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.message.assistant {
align-self: flex-start;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.message.streaming {
position: relative;
}
.streaming-indicator {
position: absolute;
right: 12px;
bottom: 12px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
}
.message-timestamp {
font-size: 11px;
opacity: 0.6;
align-self: flex-end;
}
.input-form {
display: flex;
gap: 8px;
padding: 16px;
background-color: var(--vscode-editor-background);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.input-field {
flex: 1;
padding: 10px 12px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 14px;
font-family: var(--vscode-font-family);
outline: none;
}
.input-field:focus {
border-color: var(--vscode-button-background);
}
.input-field:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-button {
padding: 10px 20px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.send-button:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Scrollbar styling */
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Session selector styles */
.chat-header {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
background-color: var(--vscode-editor-background);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.session-button {
padding: 6px 12px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s;
}
.session-button:hover {
background-color: var(--vscode-button-hoverBackground);
}
.session-selector-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-in;
}
.session-selector {
background-color: var(--vscode-editor-background);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
width: 80%;
max-width: 500px;
max-height: 70vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.session-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.session-selector-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.session-selector-header button {
background: none;
border: none;
color: var(--vscode-editor-foreground);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.session-selector-header button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.session-selector-actions {
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.new-session-button {
width: 100%;
padding: 8px 16px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.new-session-button:hover {
background-color: var(--vscode-button-hoverBackground);
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.no-sessions {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.5);
}
.session-item {
padding: 12px 16px;
margin-bottom: 8px;
background-color: var(--vscode-input-background);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.session-item:hover {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--vscode-button-background);
}
.session-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
}
.session-time {
color: var(--vscode-descriptionForeground);
}
.session-count {
color: var(--vscode-descriptionForeground);
}
.session-id {
font-size: 12px;
opacity: 0.6;
font-family: monospace;
}

View File

@@ -0,0 +1,276 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useRef } from 'react';
import { useVSCode } from './hooks/useVSCode.js';
import type { ChatMessage } from '../agents/QwenAgentManager.js';
import type { Conversation } from '../storage/ConversationStore.js';
export const App: React.FC = () => {
const vscode = useVSCode();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [currentStreamContent, setCurrentStreamContent] = useState('');
const [qwenSessions, setQwenSessions] = useState<
Array<Record<string, unknown>>
>([]);
const [showSessionSelector, setShowSessionSelector] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const handlePermissionRequest = React.useCallback(
(request: {
options: Array<{ name: string; kind: string; optionId: string }>;
toolCall: { title?: string };
}) => {
const optionNames = request.options.map((opt) => opt.name).join(', ');
const confirmed = window.confirm(
`Tool permission request:\n${request.toolCall.title || 'Tool Call'}\n\nOptions: ${optionNames}\n\nAllow?`,
);
const selectedOption = confirmed
? request.options.find((opt) => opt.kind === 'allow_once')
: request.options.find((opt) => opt.kind === 'reject_once');
vscode.postMessage({
type: 'permissionResponse',
data: { optionId: selectedOption?.optionId || 'reject_once' },
});
},
[vscode],
);
useEffect(() => {
// Listen for messages from extension
const handleMessage = (event: MessageEvent) => {
const message = event.data;
switch (message.type) {
case 'conversationLoaded': {
const conversation = message.data as Conversation;
setMessages(conversation.messages);
break;
}
case 'message': {
const newMessage = message.data as ChatMessage;
setMessages((prev) => [...prev, newMessage]);
break;
}
case 'streamStart':
setIsStreaming(true);
setCurrentStreamContent('');
break;
case 'streamChunk':
setCurrentStreamContent((prev) => prev + message.data.chunk);
break;
case 'streamEnd':
// Finalize the streamed message
if (currentStreamContent) {
const assistantMessage: ChatMessage = {
role: 'assistant',
content: currentStreamContent,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, assistantMessage]);
}
setIsStreaming(false);
setCurrentStreamContent('');
break;
case 'error':
console.error('Error from extension:', message.data.message);
setIsStreaming(false);
break;
case 'permissionRequest':
// Show permission dialog
handlePermissionRequest(message.data);
break;
case 'qwenSessionList':
setQwenSessions(message.data.sessions || []);
break;
case 'qwenSessionSwitched':
setShowSessionSelector(false);
// Load messages from the session
if (message.data.messages) {
setMessages(message.data.messages);
} else {
setMessages([]);
}
setCurrentStreamContent('');
break;
case 'conversationCleared':
setMessages([]);
setCurrentStreamContent('');
break;
default:
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [currentStreamContent, handlePermissionRequest]);
useEffect(() => {
// Auto-scroll to bottom when messages change
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, currentStreamContent]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!inputText.trim() || isStreaming) {
console.log('Submit blocked:', { inputText, isStreaming });
return;
}
console.log('Sending message:', inputText);
vscode.postMessage({
type: 'sendMessage',
data: { text: inputText },
});
setInputText('');
};
const handleLoadQwenSessions = () => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
setShowSessionSelector(true);
};
const handleNewQwenSession = () => {
vscode.postMessage({ type: 'newQwenSession', data: {} });
setShowSessionSelector(false);
// Clear messages in UI
setMessages([]);
setCurrentStreamContent('');
};
const handleSwitchSession = (sessionId: string) => {
vscode.postMessage({
type: 'switchQwenSession',
data: { sessionId },
});
};
return (
<div className="chat-container">
{showSessionSelector && (
<div className="session-selector-overlay">
<div className="session-selector">
<div className="session-selector-header">
<h3>Qwen Sessions</h3>
<button onClick={() => setShowSessionSelector(false)}></button>
</div>
<div className="session-selector-actions">
<button
className="new-session-button"
onClick={handleNewQwenSession}
>
New Session
</button>
</div>
<div className="session-list">
{qwenSessions.length === 0 ? (
<p className="no-sessions">No sessions available</p>
) : (
qwenSessions.map((session) => {
const sessionId =
(session.id as string) ||
(session.sessionId as string) ||
'';
const title =
(session.title as string) ||
(session.name as string) ||
'Untitled Session';
const lastUpdated =
(session.lastUpdated as string) ||
(session.startTime as string) ||
'';
const messageCount = (session.messageCount as number) || 0;
return (
<div
key={sessionId}
className="session-item"
onClick={() => handleSwitchSession(sessionId)}
>
<div className="session-title">{title}</div>
<div className="session-meta">
<span className="session-time">
{new Date(lastUpdated).toLocaleString()}
</span>
<span className="session-count">
{messageCount} messages
</span>
</div>
<div className="session-id">
{sessionId.substring(0, 8)}...
</div>
</div>
);
})
)}
</div>
</div>
</div>
)}
<div className="chat-header">
<button className="session-button" onClick={handleLoadQwenSessions}>
📋 Sessions
</button>
</div>
<div className="messages-container">
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">{currentStreamContent}</div>
<div className="streaming-indicator"></div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-form" onSubmit={handleSubmit}>
<input
type="text"
className="input-field"
placeholder="Type your message..."
value={inputText}
onChange={(e) => setInputText((e.target as HTMLInputElement).value)}
disabled={isStreaming}
/>
<button
type="submit"
className="send-button"
disabled={isStreaming || !inputText.trim()}
>
Send
</button>
</form>
</div>
);
};

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
export interface VSCodeAPI {
postMessage: (message: unknown) => void;
getState: () => unknown;
setState: (state: unknown) => void;
}
declare const acquireVsCodeApi: () => VSCodeAPI;
export function useVSCode(): VSCodeAPI {
return useMemo(() => {
if (typeof acquireVsCodeApi !== 'undefined') {
return acquireVsCodeApi();
}
// Fallback for development/testing
return {
postMessage: (message: unknown) => {
console.log('Mock postMessage:', message);
},
getState: () => ({}),
setState: (state: unknown) => {
console.log('Mock setState:', state);
},
};
}, []);
}

View File

@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import ReactDOM from 'react-dom/client';
import { App } from './App.js';
import './App.css';
const container = document.getElementById('root');
if (container) {
const root = ReactDOM.createRoot(container);
root.render(<App />);
}

View File

@@ -4,6 +4,7 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"target": "ES2022", "target": "ES2022",
"lib": ["ES2022", "dom"], "lib": ["ES2022", "dom"],
"jsx": "react-jsx",
"sourceMap": true, "sourceMap": true,
"strict": true /* enable all strict type-checking options */ "strict": true /* enable all strict type-checking options */
/* Additional Checks */ /* Additional Checks */