mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(vscode-ide-companion): import chat chat customEditor to vscode extension folder
This commit is contained in:
@@ -110,6 +110,7 @@ export default tseslint.config(
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
|
||||
66
package-lock.json
generated
66
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
452
packages/vscode-ide-companion/src/WebViewProvider.ts
Normal file
452
packages/vscode-ide-companion/src/WebViewProvider.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
442
packages/vscode-ide-companion/src/acp/AcpConnection.ts
Normal file
442
packages/vscode-ide-companion/src/acp/AcpConnection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
248
packages/vscode-ide-companion/src/agents/QwenAgentManager.ts
Normal file
248
packages/vscode-ide-companion/src/agents/QwenAgentManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<IdeInfo['name']> = 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<void> {
|
||||
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}`);
|
||||
|
||||
177
packages/vscode-ide-companion/src/services/QwenSessionReader.ts
Normal file
177
packages/vscode-ide-companion/src/services/QwenSessionReader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
packages/vscode-ide-companion/src/shared/acpTypes.ts
Normal file
104
packages/vscode-ide-companion/src/shared/acpTypes.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
340
packages/vscode-ide-companion/src/webview/App.css
Normal file
340
packages/vscode-ide-companion/src/webview/App.css
Normal 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;
|
||||
}
|
||||
|
||||
276
packages/vscode-ide-companion/src/webview/App.tsx
Normal file
276
packages/vscode-ide-companion/src/webview/App.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts
Normal file
34
packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
15
packages/vscode-ide-companion/src/webview/index.tsx
Normal file
15
packages/vscode-ide-companion/src/webview/index.tsx
Normal 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 />);
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user