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

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
IDE_DEFINITIONS,
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}`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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