diff --git a/eslint.config.js b/eslint.config.js index 7b4f502f..15446467 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -110,6 +110,7 @@ export default tseslint.config( { allow: [ 'react-dom/test-utils', + 'react-dom/client', 'memfs/lib/volume.js', 'yargs/**', 'msw/node', diff --git a/package-lock.json b/package-lock.json index 296fc29b..e0843ff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3690,6 +3690,13 @@ "dev": true, "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": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", @@ -16296,12 +16303,16 @@ "@modelcontextprotocol/sdk": "^1.15.1", "cors": "^2.8.5", "express": "^5.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "zod": "^3.25.76" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "20.x", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", @@ -16316,6 +16327,27 @@ "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": { "version": "1.99.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", @@ -16409,6 +16441,40 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 8866f163..bab877ba 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -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 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. + + diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 7de7c7ad..d1ec4169 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -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() { - const ctx = await esbuild.context({ + // Build extension + const extensionCtx = await esbuild.context({ entryPoints: ['src/extension.ts'], bundle: true, format: 'cjs', @@ -55,11 +77,29 @@ async function main() { ], 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) { - await ctx.watch(); + await Promise.all([extensionCtx.watch(), webviewCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]); } } diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index dd86c816..22fbda42 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -54,8 +54,48 @@ { "command": "qwen-code.showNotices", "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": { "commandPalette": [ { @@ -77,6 +117,10 @@ "command": "qwen.diff.cancel", "when": "qwen.diff.isVisible", "group": "navigation" + }, + { + "command": "qwenCode.openChat", + "group": "navigation" } ] }, @@ -90,6 +134,11 @@ "command": "qwen.diff.accept", "key": "cmd+s", "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/express": "^5.0.3", "@types/node": "20.x", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", @@ -130,6 +181,8 @@ "@modelcontextprotocol/sdk": "^1.15.1", "cors": "^2.8.5", "express": "^5.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "zod": "^3.25.76" } } diff --git a/packages/vscode-ide-companion/src/WebViewProvider.ts b/packages/vscode-ide-companion/src/WebViewProvider.ts new file mode 100644 index 00000000..7a16f803 --- /dev/null +++ b/packages/vscode-ide-companion/src/WebViewProvider.ts @@ -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 { + 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('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 { + 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 { + 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 { + const conversation = await this.conversationStore.getConversation(id); + if (conversation) { + this.currentConversationId = id; + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: conversation, + }); + } + } + + private async handleNewConversation(): Promise { + const newConv = await this.conversationStore.createConversation(); + this.currentConversationId = newConv.id; + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + } + + private async handleDeleteConversation(id: string): Promise { + await this.conversationStore.deleteConversation(id); + this.sendMessageToWebView({ + type: 'conversationDeleted', + data: { id }, + }); + } + + private async handleGetQwenSessions(): Promise { + 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 { + 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 { + 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 ` + + + + + + Qwen Code Chat + + +
+ + +`; + } + + dispose(): void { + this.panel?.dispose(); + this.agentManager.disconnect(); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/packages/vscode-ide-companion/src/acp/AcpConnection.ts b/packages/vscode-ide-companion/src/acp/AcpConnection.ts new file mode 100644 index 00000000..8b0d2593 --- /dev/null +++ b/packages/vscode-ide-companion/src/acp/AcpConnection.ts @@ -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 { + 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>(); + 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 { + 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 { + 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( + method: string, + params?: Record, + ): Promise { + 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 = { + resolve: (value: T) => { + clearTimeout(timeoutId); + resolve(value); + }, + reject: (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }, + timeoutId, + method, + }; + + this.pendingRequests.set(id, pendingRequest as PendingRequest); + 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 { + 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 { + const initializeParams = { + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }; + + console.log('[ACP] Sending initialize request...'); + const response = await this.sendRequest( + 'initialize', + initializeParams, + ); + this.isInitialized = true; + console.log('[ACP] Initialize successful'); + return response; + } + + async authenticate(methodId?: string): Promise { + // 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('authenticate', { + methodId: authMethodId, + }); + console.log('[ACP] Authenticate successful'); + return response; + } + + async newSession(cwd: string = process.cwd()): Promise { + 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 { + 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 { + console.log('[ACP] Requesting session list...'); + try { + const response = await this.sendRequest('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 { + console.log('[ACP] Switching to session:', sessionId); + this.sessionId = sessionId; + const response = await this.sendRequest('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; + } +} diff --git a/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts new file mode 100644 index 00000000..3f6b1dc1 --- /dev/null +++ b/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts @@ -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; + 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 { + this.currentWorkingDir = workingDir; + const config = vscode.workspace.getConfiguration('qwenCode'); + const cliPath = config.get('qwen.cliPath', 'qwen'); + const openaiApiKey = config.get('qwen.openaiApiKey', ''); + const openaiBaseUrl = config.get('qwen.openaiBaseUrl', ''); + const model = config.get('qwen.model', ''); + const proxy = config.get('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 { + await this.connection.sendPrompt(message); + } + + async getSessionList(): Promise>> { + 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 => ({ + 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 { + 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 { + console.log('[QwenAgentManager] Creating new session...'); + await this.connection.newSession(workingDir); + } + + async switchToSession(sessionId: string): Promise { + 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, + ): void { + this.onPermissionRequestCallback = callback; + } + + disconnect(): void { + this.connection.disconnect(); + } + + get isConnected(): boolean { + return this.connection.isConnected; + } +} diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 8e2344a9..eb0562d0 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -14,6 +14,7 @@ import { IDE_DEFINITIONS, type IdeInfo, } 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 INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; @@ -31,6 +32,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; +let webViewProvider: WebViewProvider; let log: (message: string) => void = () => {}; @@ -110,6 +112,9 @@ export async function activate(context: vscode.ExtensionContext) { const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager(log, diffContentProvider); + // Initialize WebView Provider + webViewProvider = new WebViewProvider(context, context.extensionUri); + context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { if (doc.uri.scheme === DIFF_SCHEME) { @@ -132,6 +137,9 @@ export async function activate(context: vscode.ExtensionContext) { diffManager.cancelDiff(docUri); } }), + vscode.commands.registerCommand('qwenCode.openChat', () => { + webViewProvider.show(); + }), ); ideServer = new IDEServer(log, diffManager); @@ -204,6 +212,9 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } + if (webViewProvider) { + webViewProvider.dispose(); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/services/QwenSessionReader.ts b/packages/vscode-ide-companion/src/services/QwenSessionReader.ts new file mode 100644 index 00000000..b9c7e84e --- /dev/null +++ b/packages/vscode-ide-companion/src/services/QwenSessionReader.ts @@ -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 { + 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 { + 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 { + // 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 { + 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 { + 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; + } + } +} diff --git a/packages/vscode-ide-companion/src/shared/acpTypes.ts b/packages/vscode-ide-companion/src/shared/acpTypes.ts new file mode 100644 index 00000000..3b05354a --- /dev/null +++ b/packages/vscode-ide-companion/src/shared/acpTypes.ts @@ -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; diff --git a/packages/vscode-ide-companion/src/storage/ConversationStore.ts b/packages/vscode-ide-companion/src/storage/ConversationStore.ts new file mode 100644 index 00000000..ab5d5225 --- /dev/null +++ b/packages/vscode-ide-companion/src/storage/ConversationStore.ts @@ -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 { + 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 { + return this.context.globalState.get('conversations', []); + } + + async getConversation(id: string): Promise { + const conversations = await this.getAllConversations(); + return conversations.find((c) => c.id === id) || null; + } + + async addMessage( + conversationId: string, + message: ChatMessage, + ): Promise { + 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 { + 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; + } +} diff --git a/packages/vscode-ide-companion/src/webview/App.css b/packages/vscode-ide-companion/src/webview/App.css new file mode 100644 index 00000000..1f12993c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.css @@ -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; +} + diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx new file mode 100644 index 00000000..e3e17d4b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -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([]); + const [inputText, setInputText] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [currentStreamContent, setCurrentStreamContent] = useState(''); + const [qwenSessions, setQwenSessions] = useState< + Array> + >([]); + const [showSessionSelector, setShowSessionSelector] = useState(false); + const messagesEndRef = useRef(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 ( +
+ {showSessionSelector && ( +
+
+
+

Qwen Sessions

+ +
+
+ +
+
+ {qwenSessions.length === 0 ? ( +

No sessions available

+ ) : ( + 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 ( +
handleSwitchSession(sessionId)} + > +
{title}
+
+ + {new Date(lastUpdated).toLocaleString()} + + + {messageCount} messages + +
+
+ {sessionId.substring(0, 8)}... +
+
+ ); + }) + )} +
+
+
+ )} + +
+ +
+ +
+ {messages.map((msg, index) => ( +
+
{msg.content}
+
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+ ))} + + {isStreaming && currentStreamContent && ( +
+
{currentStreamContent}
+
+
+ )} + +
+
+ +
+ setInputText((e.target as HTMLInputElement).value)} + disabled={isStreaming} + /> + +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts new file mode 100644 index 00000000..05756bda --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts @@ -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); + }, + }; + }, []); +} diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx new file mode 100644 index 00000000..b7c7a00c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -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(); +} diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 02a9b53f..4cf9c6d9 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -4,6 +4,7 @@ "moduleResolution": "NodeNext", "target": "ES2022", "lib": ["ES2022", "dom"], + "jsx": "react-jsx", "sourceMap": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */