初步实现正常聊天
This commit is contained in:
parent
bc69a9964e
commit
1fafb3a2bf
9
package-lock.json
generated
9
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "vscode-ai-chat-plugin",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.14.1",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1016,10 +1017,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
|
||||
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
|
||||
"dev": true,
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@ -11219,7 +11219,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
|
@ -83,6 +83,7 @@
|
||||
"yo": "^4.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^22.14.1",
|
||||
"ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
|
108
src/components/ChatPanel.vue
Normal file
108
src/components/ChatPanel.vue
Normal file
@ -0,0 +1,108 @@
|
||||
try {
|
||||
// 设置加载状态
|
||||
chatState.loading = true;
|
||||
log('设置loading状态为true,准备发送到WebSocket');
|
||||
|
||||
// 立即创建一个空的AI响应消息占位符,显示loading状态
|
||||
const placeholderMessage: ChatMessage = {
|
||||
id: currentGenerationId,
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
createAt: new Date()
|
||||
};
|
||||
chatState.messages.push(placeholderMessage);
|
||||
log('添加了AI响应占位消息', placeholderMessage);
|
||||
|
||||
// 再次滚动确保占位符可见
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
log('添加占位符后再次滚动到底部');
|
||||
});
|
||||
|
||||
// 设置自动超时处理
|
||||
let loadingTimeout = setTimeout(() => {
|
||||
if (chatState.loading) {
|
||||
log('响应超时,自动重置loading状态');
|
||||
chatState.loading = false;
|
||||
|
||||
// 更新占位消息为超时提示
|
||||
const timeoutIndex = chatState.messages.findIndex((m: ChatMessage) => m.id === currentGenerationId);
|
||||
if (timeoutIndex !== -1) {
|
||||
chatState.messages[timeoutIndex].content = '响应超时,请稍后再试或检查服务器状态';
|
||||
log('更新占位消息为超时提示');
|
||||
} else {
|
||||
// 如果找不到占位消息,添加新的超时提示
|
||||
const timeoutMessage: ChatMessage = {
|
||||
id: `timeout-${Date.now()}`,
|
||||
content: '响应超时,请稍后再试或检查服务器状态',
|
||||
role: 'assistant',
|
||||
createAt: new Date()
|
||||
};
|
||||
chatState.messages.push(timeoutMessage);
|
||||
log('添加了超时提示消息');
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// 按照时间排序消息,确保按照正确的顺序处理
|
||||
const sortedMessages = [...chatState.messages].sort((a: ChatMessage, b: ChatMessage) =>
|
||||
a.createAt.getTime() - b.createAt.getTime()
|
||||
);
|
||||
|
||||
// 找出最新的助手消息
|
||||
const assistantMessages = sortedMessages.filter((m: ChatMessage) => m.role === 'assistant');
|
||||
const latestAssistantMessage = assistantMessages.length > 0
|
||||
? assistantMessages[assistantMessages.length - 1]
|
||||
: null;
|
||||
|
||||
log('最新的助手消息:', latestAssistantMessage?.id);
|
||||
|
||||
// 用于跟踪已添加的用户消息内容
|
||||
const userMessageContents = new Set();
|
||||
|
||||
// 处理每条消息
|
||||
sortedMessages.forEach((message: ChatMessage) => {
|
||||
const isAIMessage = message.role === 'assistant';
|
||||
const isLatestAIMessage = isAIMessage && latestAssistantMessage && message.id === latestAssistantMessage.id;
|
||||
|
||||
// 如果是完全空消息,跳过
|
||||
if (message.content.trim() === '' && !isLatestAIMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 专门针对用户消息进行内容去重
|
||||
if (message.role === 'user') {
|
||||
// 如果这个用户消息内容已经被添加过,跳过
|
||||
if (userMessageContents.has(message.content)) {
|
||||
log('跳过重复的用户消息:', message.content.substring(0, 20));
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到已处理集合
|
||||
userMessageContents.add(message.content);
|
||||
|
||||
// 用户消息使用内容作为key,避免重复
|
||||
const userKey = `user-${message.content}`;
|
||||
|
||||
const bubbleMsg = {
|
||||
key: message.id,
|
||||
role: message.role as 'user' | 'assistant',
|
||||
content: message.content || '',
|
||||
loading: false,
|
||||
timestamp: message.createAt ? message.createAt.toISOString() : new Date().toISOString()
|
||||
};
|
||||
|
||||
uniqueMessagesMap.set(userKey, bubbleMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送消息时出错:', error);
|
||||
}
|
@ -49,25 +49,33 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
this._saveSettings(message.settings);
|
||||
break;
|
||||
case 'log':
|
||||
// 根据消息来源分类日志
|
||||
if (message.message) {
|
||||
// ChatPanel组件日志处理
|
||||
if (message.message.includes('[ChatPanel]')) {
|
||||
this._logger(`📱 ChatPanel日志: ${message.message}`);
|
||||
}
|
||||
// 为SettingsModal相关日志添加特殊标记
|
||||
if (message.message && message.message.includes('SettingsModal')) {
|
||||
this._logger(`【SettingsModal日志】: ${message.message}`);
|
||||
else if (message.message.includes('SettingsModal')) {
|
||||
this._logger(`⚙️ SettingsModal日志: ${message.message}`);
|
||||
}
|
||||
// 为WebSocket连接相关日志添加更详细的输出
|
||||
else if (message.message && (
|
||||
message.message.includes('WebSocket') ||
|
||||
else if (message.message.includes('WebSocket') ||
|
||||
message.message.includes('ChatStore') ||
|
||||
message.message.includes('initWebSocketService')
|
||||
)) {
|
||||
this._logger(`WebSocket日志: ${message.message}`);
|
||||
) {
|
||||
this._logger(`🔌 WebSocket日志: ${message.message}`);
|
||||
}
|
||||
// 为重要日志添加特殊标记
|
||||
else if (message.message && message.message.includes('【重要】')) {
|
||||
else if (message.message.includes('【重要】')) {
|
||||
this._logger(`⚠️ ${message.message}`);
|
||||
}
|
||||
else {
|
||||
this._logger(`WebView日志: ${message.message}`);
|
||||
}
|
||||
} else {
|
||||
this._logger(`WebView日志: ${JSON.stringify(message)}`);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
this._logger(`WebView错误: ${message.message}`);
|
||||
@ -357,7 +365,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
<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'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src ws://localhost:8080; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<title>AI Chat</title>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
|
487
src/store/chat.ts
Normal file
487
src/store/chat.ts
Normal file
@ -0,0 +1,487 @@
|
||||
// @ts-ignore - 在构建时忽略模块查找错误
|
||||
import { defineStore } from 'pinia';
|
||||
// @ts-ignore - 在构建时忽略模块查找错误
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
|
||||
// 使用正确的超时类型声明
|
||||
type TimeoutType = ReturnType<typeof setTimeout>;
|
||||
|
||||
// 定义消息类型
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
messageId?: string;
|
||||
model?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// WebSocket事件类型
|
||||
export enum WebSocketEvent {
|
||||
CONNECTED = 'connected',
|
||||
DISCONNECTED = 'disconnected',
|
||||
MESSAGE = 'message',
|
||||
ERROR = 'error',
|
||||
MODEL_LIST = 'model_list'
|
||||
}
|
||||
|
||||
// 定义VSCode API接口
|
||||
let vscode: any = undefined;
|
||||
|
||||
// @ts-ignore
|
||||
try {
|
||||
// @ts-ignore
|
||||
vscode = acquireVsCodeApi();
|
||||
} catch (error) {
|
||||
// 在开发环境下,可能没有VSCode API
|
||||
console.warn('VSCode API不可用,可能是在浏览器开发环境中运行');
|
||||
}
|
||||
|
||||
// WebSocket服务类 - 浏览器环境下实现
|
||||
export class WebSocketService {
|
||||
private socket: WebSocket | null = null;
|
||||
private requestMap = new Map<string, (response: any) => void>();
|
||||
private nextRequestId = 1;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: TimeoutType | null = null;
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = 5;
|
||||
private readonly RECONNECT_DELAY = 2000;
|
||||
private isReconnecting = false;
|
||||
private pingInterval: TimeoutType | null = null;
|
||||
|
||||
public isConnected = false;
|
||||
private eventListeners: Map<string, Array<(data?: any) => void>> = new Map();
|
||||
|
||||
constructor(private apiUrl: string, private apiKey: string, private logger: (message: string) => void = console.log) {}
|
||||
|
||||
/**
|
||||
* 连接到WebSocket服务器
|
||||
*/
|
||||
public connect(): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.logger("[WebSocketService] 已连接");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger(`[WebSocketService] 尝试连接到: ${this.apiUrl}`);
|
||||
|
||||
// 检查URL格式
|
||||
let wsUrl = this.apiUrl;
|
||||
|
||||
// 确保URL以/ws结尾,但避免重复
|
||||
if (!wsUrl.endsWith('/ws')) {
|
||||
if (wsUrl.endsWith('/')) {
|
||||
wsUrl += 'ws';
|
||||
} else {
|
||||
wsUrl += '/ws';
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有API密钥,如果有则添加为查询参数
|
||||
if (this.apiKey) {
|
||||
const separator = wsUrl.includes('?') ? '&' : '?';
|
||||
wsUrl += `${separator}api_key=${this.apiKey}`;
|
||||
}
|
||||
|
||||
this.logger(`[WebSocketService] 最终WebSocket URL: ${wsUrl}`);
|
||||
this.socket = new WebSocket(wsUrl);
|
||||
this.setupEventHandlers();
|
||||
} catch (error) {
|
||||
this.logger(`[WebSocketService] 连接失败: ${error}`);
|
||||
this.emit(WebSocketEvent.ERROR, `连接失败: ${error}`);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
public disconnect(): void {
|
||||
this.clearTimers();
|
||||
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (err) {
|
||||
this.logger(`关闭WebSocket时出错: ${err}`);
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
this.emit(WebSocketEvent.DISCONNECTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听事件
|
||||
*/
|
||||
public on(event: WebSocketEvent, callback: (data?: any) => void): void {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, []);
|
||||
}
|
||||
|
||||
this.eventListeners.get(event)?.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听
|
||||
*/
|
||||
public off(event: WebSocketEvent, callback: (data?: any) => void): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
const index = listeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit(event: WebSocketEvent, data?: any): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息到websocket
|
||||
* @param message 用户消息
|
||||
* @param opts 发送选项
|
||||
* @returns 返回一个Promise,解析为响应内容
|
||||
*/
|
||||
sendChatMessage(message: string, opts: SendMessageOptions = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.logger('[WebSocketService] WebSocket not connected, cannot send message');
|
||||
return reject('WebSocket未连接');
|
||||
}
|
||||
|
||||
const requestId = Date.now().toString();
|
||||
|
||||
// 创建符合API文档格式的请求
|
||||
const request = {
|
||||
cmd: 'exec_chat',
|
||||
request_id: requestId,
|
||||
msg: message,
|
||||
model: opts.model || '',
|
||||
stream: false // 目前不支持流式响应
|
||||
};
|
||||
|
||||
this.logger(`[WebSocketService] Sending message: ${JSON.stringify(request)}`);
|
||||
|
||||
// 注册请求处理函数
|
||||
this.registerRequestHandler(requestId, (response) => {
|
||||
if (response.error) {
|
||||
this.logger(`[WebSocketService] Chat error: ${response.error}`);
|
||||
reject(response.error);
|
||||
} else if (response.reply !== undefined) {
|
||||
// 发送AI回复到消息列表
|
||||
this.emit(WebSocketEvent.MESSAGE, {
|
||||
id: `ai-${Date.now()}`,
|
||||
content: response.reply,
|
||||
role: 'assistant',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
resolve(response.reply);
|
||||
} else {
|
||||
this.logger('[WebSocketService] Invalid chat response format');
|
||||
reject('无效的聊天响应格式');
|
||||
}
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
this.socket.send(JSON.stringify(request));
|
||||
|
||||
// 发送用户消息到消息列表
|
||||
this.emit(WebSocketEvent.MESSAGE, {
|
||||
id: opts.messageId || `user-${Date.now()}`,
|
||||
content: message,
|
||||
role: 'user',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型列表
|
||||
* @returns 返回包含模型列表的Promise
|
||||
*/
|
||||
getModelList(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
this.logger('[WebSocketService] WebSocket not connected, cannot get model list');
|
||||
return reject('WebSocket未连接');
|
||||
}
|
||||
|
||||
const requestId = Date.now().toString();
|
||||
|
||||
// 创建符合API文档格式的请求
|
||||
const request = {
|
||||
cmd: 'list_model',
|
||||
request_id: requestId
|
||||
};
|
||||
|
||||
this.logger(`[WebSocketService] Requesting model list: ${JSON.stringify(request)}`);
|
||||
|
||||
// 注册请求处理函数
|
||||
this.registerRequestHandler(requestId, (response) => {
|
||||
if (response.error) {
|
||||
this.logger(`[WebSocketService] Model list error: ${response.error}`);
|
||||
reject(response.error);
|
||||
} else if (response.models && Array.isArray(response.models)) {
|
||||
this.logger(`[WebSocketService] Got model list: ${response.models.join(', ')}`);
|
||||
resolve(response.models);
|
||||
} else {
|
||||
this.logger('[WebSocketService] Invalid model list response format');
|
||||
reject('无效的模型列表响应格式');
|
||||
}
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
this.socket.send(JSON.stringify(request));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.isReconnecting || this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.logger(`达到最大重连尝试次数 (${this.MAX_RECONNECT_ATTEMPTS}),停止重连`);
|
||||
this.emit(WebSocketEvent.ERROR, `连接失败:已尝试 ${this.MAX_RECONNECT_ATTEMPTS} 次重连,请检查网络或服务器状态后手动重连`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReconnecting = true;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.isReconnecting = false;
|
||||
this.connect();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有定时器
|
||||
*/
|
||||
private clearTimers(): void {
|
||||
if (this.pingInterval !== null) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置WebSocket事件处理器
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.socket) {
|
||||
this.logger('[WebSocketService] 无法设置事件处理器: socket为null');
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.logger('[WebSocketService] WebSocket连接已建立成功');
|
||||
this.isConnected = true;
|
||||
this.emit(WebSocketEvent.CONNECTED);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
this.logger(`[WebSocketService] 收到消息: ${event.data}`);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// 检查是否有request_id
|
||||
if (data.request_id) {
|
||||
// 查找这个请求是否有注册的回调处理函数
|
||||
const handler = this.requestMap.get(data.request_id);
|
||||
if (handler) {
|
||||
handler(data);
|
||||
this.requestMap.delete(data.request_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有request_id或没有找到对应的处理函数,才作为普通消息广播
|
||||
// 防止重复处理消息
|
||||
this.logger('[WebSocketService] 收到未处理的消息,作为通用消息广播');
|
||||
this.emit(WebSocketEvent.MESSAGE, data);
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
this.logger('[WebSocketService] WebSocket错误');
|
||||
this.emit(WebSocketEvent.ERROR, '连接错误');
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.logger('[WebSocketService] WebSocket连接关闭');
|
||||
this.isConnected = false;
|
||||
this.clearTimers();
|
||||
this.emit(WebSocketEvent.DISCONNECTED);
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册请求处理函数
|
||||
* @param requestId 请求ID
|
||||
* @param handler 回调函数
|
||||
*/
|
||||
private registerRequestHandler(requestId: string, handler: (response: any) => void): void {
|
||||
this.requestMap.set(requestId, handler);
|
||||
|
||||
// 设置超时处理,避免请求没有响应导致内存泄漏
|
||||
setTimeout(() => {
|
||||
if (this.requestMap.has(requestId)) {
|
||||
this.logger(`[WebSocketService] 请求 ${requestId} 超时,移除处理函数`);
|
||||
this.requestMap.delete(requestId);
|
||||
}
|
||||
}, 30000); // 30秒超时
|
||||
}
|
||||
}
|
||||
|
||||
// 创建聊天状态管理
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
// WebSocket服务实例
|
||||
const wsService = ref<WebSocketService | null>(null);
|
||||
|
||||
// 聊天状态
|
||||
const chatState = reactive({
|
||||
messages: [] as ChatMessage[],
|
||||
loading: false,
|
||||
connectionStatus: 'disconnected' as 'connecting' | 'connected' | 'disconnected' | 'error',
|
||||
errorMessage: '',
|
||||
availableModels: [] as string[],
|
||||
currentModel: '',
|
||||
loadingModels: false
|
||||
});
|
||||
|
||||
// 初始化WebSocket服务
|
||||
function initWebSocketService(apiUrl: string, apiKey: string) {
|
||||
if (wsService.value) {
|
||||
wsService.value.disconnect();
|
||||
}
|
||||
wsService.value = new WebSocketService(apiUrl, apiKey);
|
||||
|
||||
wsService.value.on(WebSocketEvent.CONNECTED, () => {
|
||||
chatState.connectionStatus = 'connected';
|
||||
chatState.errorMessage = '';
|
||||
|
||||
// 当连接建立后,自动获取模型列表
|
||||
refreshModels();
|
||||
});
|
||||
|
||||
wsService.value.on(WebSocketEvent.DISCONNECTED, () => {
|
||||
chatState.connectionStatus = 'disconnected';
|
||||
});
|
||||
|
||||
wsService.value.on(WebSocketEvent.ERROR, (error: string) => {
|
||||
chatState.errorMessage = error;
|
||||
chatState.connectionStatus = 'error';
|
||||
});
|
||||
|
||||
wsService.value.on(WebSocketEvent.MESSAGE, (message: ChatMessage) => {
|
||||
chatState.messages.push(message);
|
||||
chatState.loading = false;
|
||||
});
|
||||
|
||||
chatState.connectionStatus = 'connecting';
|
||||
wsService.value.connect();
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage(content: string): Promise<string> {
|
||||
if (!wsService.value) {
|
||||
throw new Error('WebSocket服务未初始化');
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
chatState.loading = true;
|
||||
|
||||
try {
|
||||
// 不需要手动添加消息到聊天历史,WebSocketService会通过事件发送
|
||||
const response = await wsService.value.sendChatMessage(content, { model: chatState.currentModel });
|
||||
return response;
|
||||
} catch (error) {
|
||||
chatState.errorMessage = `发送消息失败: ${error}`;
|
||||
throw error;
|
||||
} finally {
|
||||
chatState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新模型列表
|
||||
async function refreshModels() {
|
||||
if (!wsService.value) {
|
||||
chatState.errorMessage = '无法获取模型列表:WebSocket服务未初始化';
|
||||
return;
|
||||
}
|
||||
|
||||
chatState.loadingModels = true;
|
||||
|
||||
try {
|
||||
const models = await wsService.value.getModelList();
|
||||
chatState.availableModels = models;
|
||||
|
||||
// 如果当前模型不在可用列表中,并且有可用模型,则设置为第一个可用模型
|
||||
if (models.length > 0 && !models.includes(chatState.currentModel)) {
|
||||
chatState.currentModel = models[0];
|
||||
}
|
||||
} catch (error) {
|
||||
chatState.errorMessage = `获取模型列表失败: ${error}`;
|
||||
} finally {
|
||||
chatState.loadingModels = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前模型
|
||||
function setCurrentModel(model: string) {
|
||||
chatState.currentModel = model;
|
||||
}
|
||||
|
||||
// 清空消息
|
||||
function clearMessages() {
|
||||
chatState.messages = [];
|
||||
}
|
||||
|
||||
// 手动重连
|
||||
function reconnect() {
|
||||
if (wsService.value) {
|
||||
wsService.value.disconnect();
|
||||
chatState.connectionStatus = 'connecting';
|
||||
wsService.value.connect();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chatState,
|
||||
initWebSocketService,
|
||||
sendMessage,
|
||||
refreshModels,
|
||||
setCurrentModel,
|
||||
clearMessages,
|
||||
reconnect
|
||||
};
|
||||
});
|
21
webview/package-lock.json
generated
21
webview/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"ant-design-x-vue": "^1.0.7",
|
||||
"axios": "^1.6.0",
|
||||
@ -18,6 +19,7 @@
|
||||
"marked": "^15.0.7",
|
||||
"pinia": "^2.1.0",
|
||||
"utf-8-validate": "^6.0.5",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
@ -952,6 +954,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@ -2452,6 +2460,19 @@
|
||||
"node": ">=6.14.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.5.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.11.tgz",
|
||||
|
@ -12,6 +12,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"ant-design-x-vue": "^1.0.7",
|
||||
"axios": "^1.6.0",
|
||||
@ -20,6 +21,7 @@
|
||||
"marked": "^15.0.7",
|
||||
"pinia": "^2.1.0",
|
||||
"utf-8-validate": "^6.0.5",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,137 +0,0 @@
|
||||
<template>
|
||||
<div :class="$style.inputArea">
|
||||
<div :class="$style.textareaContainer">
|
||||
<textarea
|
||||
:class="$style.textarea"
|
||||
placeholder="发送消息给AI助手..."
|
||||
v-model="inputText"
|
||||
@keydown.enter.prevent="handleSend"
|
||||
ref="textareaRef"
|
||||
></textarea>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<div :class="$style.button">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 3.5C6.067 3.5 4.5 5.067 4.5 7C4.5 8.933 6.067 10.5 8 10.5C9.933 10.5 11.5 8.933 11.5 7C11.5 5.067 9.933 3.5 8 3.5ZM3.5 7C3.5 4.51472 5.51472 2.5 8 2.5C10.4853 2.5 12.5 4.51472 12.5 7C12.5 9.48528 10.4853 11.5 8 11.5C5.51472 11.5 3.5 9.48528 3.5 7Z" fill="currentColor"/>
|
||||
<path d="M7.75 5C7.75 4.72386 7.97386 4.5 8.25 4.5H8.5C8.77614 4.5 9 4.72386 9 5C9 5.27614 8.77614 5.5 8.5 5.5H8.25C7.97386 5.5 7.75 5.27614 7.75 5Z" fill="currentColor"/>
|
||||
<path d="M7.25 7C7.25 6.72386 7.47386 6.5 7.75 6.5H8.25C8.52614 6.5 8.75 6.72386 8.75 7V8.25C8.75 8.52614 8.52614 8.75 8.25 8.75C7.97386 8.75 7.75 8.52614 7.75 8.25V7.5H7.75C7.47386 7.5 7.25 7.27614 7.25 7Z" fill="currentColor"/>
|
||||
<path d="M13.5 10.5C13.5 10.2239 13.7239 10 14 10C14.2761 10 14.5 10.2239 14.5 10.5V12C14.5 13.3807 13.3807 14.5 12 14.5H4C2.61929 14.5 1.5 13.3807 1.5 12V10.5C1.5 10.2239 1.72386 10 2 10C2.27614 10 2.5 10.2239 2.5 10.5V12C2.5 12.8284 3.17157 13.5 4 13.5H12C12.8284 13.5 13.5 12.8284 13.5 12V10.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<button :class="$style.sendButton" @click="handleSend" :disabled="!inputText.trim()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.6377 7.50843C14.8789 7.62568 15 7.88465 15 8.14286C15 8.40106 14.8789 8.66003 14.6377 8.77729L2.7558 14.7302C2.52264 14.8435 2.24207 14.841 2.01156 14.7239C1.78105 14.6067 1.63933 14.3889 1.64 14.1538L1.65522 1.88393C1.65589 1.64878 1.79856 1.43155 2.02963 1.31517C2.26069 1.19878 2.5415 1.19736 2.7737 1.3115L14.6377 7.50843ZM2.64063 2.48423L2.62752 13.5174L13.4075 8.14286L2.64063 2.48423Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const inputText = ref('');
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'send', value: string): void
|
||||
}>();
|
||||
|
||||
const handleSend = () => {
|
||||
if (inputText.value.trim()) {
|
||||
emit('send', inputText.value);
|
||||
inputText.value = '';
|
||||
// 重置文本框高度
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
@use "sass:color";
|
||||
|
||||
.inputArea {
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
background-color: var(--vscode-editor-background);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.textareaContainer {
|
||||
flex: 1;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vscode-input-background);
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
padding: 10px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--vscode-description-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-description-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-list-hover-background);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-button-hover-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--vscode-disabled-foreground);
|
||||
color: var(--vscode-description-foreground);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div :class="[$style.messageItem, message.role === 'user' ? $style.userMessage : '']">
|
||||
<div :class="$style.avatar">
|
||||
<svg v-if="message.role === 'user'" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 8C9.65685 8 11 6.65685 11 5C11 3.34315 9.65685 2 8 2C6.34315 2 5 3.34315 5 5C5 6.65685 6.34315 8 8 8Z" fill="currentColor"/>
|
||||
<path d="M8 9C5.79086 9 4 10.7909 4 13V14H12V13C12 10.7909 10.2091 9 8 9Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1ZM8 13.8C4.79847 13.8 2.2 11.2015 2.2 8C2.2 4.79847 4.79847 2.2 8 2.2C11.2015 2.2 13.8 4.79847 13.8 8C13.8 11.2015 11.2015 13.8 8 13.8Z" fill="currentColor"/>
|
||||
<path d="M5.5 6.5C5.5 5.67157 6.17157 5 7 5H9C9.82843 5 10.5 5.67157 10.5 6.5V7.5C10.5 8.32843 9.82843 9 9 9H7C6.17157 9 5.5 8.32843 5.5 7.5V6.5Z" fill="currentColor"/>
|
||||
<path d="M4 10.5C4 10.2239 4.22386 10 4.5 10H11.5C11.7761 10 12 10.2239 12 10.5C12 10.7761 11.7761 11 11.5 11H4.5C4.22386 11 4 10.7761 4 10.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
{{ message.content }}
|
||||
<div :class="$style.actions">
|
||||
<div :class="$style.actionButton">复制</div>
|
||||
<div :class="$style.actionButton">插入代码</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
message: Message;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.messageItem {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
background-color: $user-message-background;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $avatar-background;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: $dark-foreground-secondary;
|
||||
|
||||
&:hover {
|
||||
background-color: $hover-background;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<div :class="$style.messageList" ref="containerRef">
|
||||
<MessageItem
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import MessageItem from './MessageItem.vue';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollTop = containerRef.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
watch(() => props.messages.length, () => {
|
||||
// 消息列表变化时,滚动到底部
|
||||
setTimeout(scrollToBottom, 50); // 使用setTimeout确保DOM已更新
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.messageList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
@ -410,6 +410,23 @@ onMounted(() => {
|
||||
throw new Error('chatStore.initWebSocketService不是函数');
|
||||
}
|
||||
|
||||
// 防止重复初始化
|
||||
const configFingerprint = `${formState.apiHost}:${formState.apiKey}`;
|
||||
|
||||
// 从chatStore获取lastInitializedConfig
|
||||
// 通过useChatStore()可以访问到存储在Pinia中的状态
|
||||
const chatStateRef = chatStore.$state?.chatState || {};
|
||||
const lastConfig = chatStateRef.lastInitializedConfig || '';
|
||||
|
||||
if (lastConfig === configFingerprint) {
|
||||
console.log('跳过重复的WebSocket初始化,配置完全相同');
|
||||
if (vscode) {
|
||||
vscode.postMessage({
|
||||
type: 'log',
|
||||
message: '跳过重复的WebSocket初始化,配置完全相同'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 调用WebSocket初始化
|
||||
console.log('调用chatStore.initWebSocketService开始');
|
||||
chatStore.initWebSocketService(formState.apiHost, formState.apiKey);
|
||||
@ -421,6 +438,20 @@ onMounted(() => {
|
||||
});
|
||||
}
|
||||
|
||||
// 尝试更新lastInitializedConfig
|
||||
try {
|
||||
// 需要使用$patch方法更新Pinia状态
|
||||
chatStore.$patch((state) => {
|
||||
if (state.chatState) {
|
||||
state.chatState.lastInitializedConfig = configFingerprint;
|
||||
}
|
||||
});
|
||||
console.log('已更新lastInitializedConfig:', configFingerprint);
|
||||
} catch (err) {
|
||||
console.error('更新lastInitializedConfig失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 延时手动触发重连
|
||||
setTimeout(() => {
|
||||
try {
|
||||
|
@ -23,11 +23,38 @@ let vscodeGlobal = window.vscodeApi; // 直接使用window.vscodeApi,它已经
|
||||
// 记录API状态
|
||||
if (vscodeGlobal) {
|
||||
console.log('main.ts: VSCode API已由扩展初始化,直接使用');
|
||||
|
||||
// 发送初始化成功消息到VSCode
|
||||
vscodeGlobal.postMessage({
|
||||
type: 'log',
|
||||
message: '[主程序] VSCode API初始化成功,WebView已加载完成'
|
||||
});
|
||||
|
||||
window.__VSCODE_API_INITIALIZED__ = true;
|
||||
} else {
|
||||
console.warn('main.ts: VSCode API未在window中找到');
|
||||
window.__VSCODE_API_INITIALIZED__ = false;
|
||||
|
||||
// 尝试获取VSCode API
|
||||
try {
|
||||
if (typeof acquireVsCodeApi === 'function') {
|
||||
window.vscodeApi = acquireVsCodeApi();
|
||||
console.log('main.ts: 成功通过acquireVsCodeApi()获取VSCode API');
|
||||
|
||||
// 发送到VSCode
|
||||
window.vscodeApi.postMessage({
|
||||
type: 'log',
|
||||
message: '[主程序] 通过acquireVsCodeApi()成功获取VSCode API'
|
||||
});
|
||||
|
||||
window.__VSCODE_API_INITIALIZED__ = true;
|
||||
} else {
|
||||
console.error('main.ts: acquireVsCodeApi不是函数');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('main.ts: 获取VSCode API失败', err);
|
||||
}
|
||||
|
||||
// 处理情况:如果在dev环境中,提供一个虚拟的VSCode API
|
||||
// 警告:这只用于开发,不应该在生产环境使用
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@ -65,9 +92,23 @@ if (vscodeGlobal) {
|
||||
}
|
||||
}
|
||||
|
||||
// 每5秒检查一次VSCode API状态(调试用)
|
||||
setInterval(() => {
|
||||
console.log('VSCode API状态:', window.vscodeApi ? '可用' : '不可用');
|
||||
// 每5秒检查一次VSCode API状态并通知VSCode(仅前3次)
|
||||
let checkCount = 0;
|
||||
const apiCheckInterval = setInterval(() => {
|
||||
const isAvailable = !!window.vscodeApi;
|
||||
console.log('VSCode API状态:', isAvailable ? '可用' : '不可用');
|
||||
|
||||
if (isAvailable && window.vscodeApi) {
|
||||
window.vscodeApi.postMessage({
|
||||
type: 'log',
|
||||
message: `[主程序] VSCode API状态检查 #${checkCount+1}: 可用`
|
||||
});
|
||||
}
|
||||
|
||||
checkCount++;
|
||||
if (checkCount >= 3) {
|
||||
clearInterval(apiCheckInterval);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 创建Vue应用
|
||||
|
1
webview/src/patch.js
Normal file
1
webview/src/patch.js
Normal file
@ -0,0 +1 @@
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user