feat: 重构对话功能

This commit is contained in:
DEV_DSW
2026-04-14 17:02:20 +08:00
parent b3f07c4cfe
commit c61e41049f
53 changed files with 5200 additions and 1982 deletions

View File

@@ -1,330 +0,0 @@
/**
* 简化的 WebSocket 管理器Web 版)
* 专门负责 WebSocket 连接和消息传输,不包含打字机逻辑
*/
import { IdUtils, CallbackUtils, MessageUtils, TimerUtils } from './index'
/* =======================
* 类型定义
* ======================= */
export interface WebSocketCallbacks {
onConnect?: (event?: Event) => void
onDisconnect?: (event?: CloseEvent) => void
onError?: (error: any) => void
onMessage?: (message: any) => void
getConversationId?: () => string
getAgentId?: () => string
}
export interface WebSocketManagerOptions extends WebSocketCallbacks {
wsUrl?: string
protocols?: string[]
reconnectInterval?: number
maxReconnectAttempts?: number
heartbeatInterval?: number
messageId?: string
baseDelay?: number
retries?: number
maxDelay?: number
tryReconnect?: boolean
}
export interface QueuedMessage {
[key: string]: any
retryCount?: number
timestamp?: number
}
export interface WebSocketStats {
messagesReceived: number
messagesSent: number
messagesDropped: number
reconnectCount: number
connectionStartTime: number | null
lastMessageTime: number | null
}
/* =======================
* WebSocketManager
* ======================= */
export class WebSocketManager {
private wsUrl = ''
private protocols: string[] = []
private reconnectInterval = 3000
private maxReconnectAttempts = 5
private heartbeatInterval = 30000
private ws: WebSocket | null = null
private reconnectAttempts = 0
private isConnecting = false
private connectionState = false
private heartbeatTimer: number | null = null
private reconnectTimer: number | null = null
private callbacks: Required<WebSocketCallbacks>
private messageQueue: QueuedMessage[] = []
private stats: WebSocketStats = {
messagesReceived: 0,
messagesSent: 0,
messagesDropped: 0,
reconnectCount: 0,
connectionStartTime: null,
lastMessageTime: null,
}
constructor(options: WebSocketManagerOptions = {}) {
this.wsUrl = options.wsUrl ?? ''
this.protocols = options.protocols ?? []
this.reconnectInterval = options.reconnectInterval ?? 3000
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5
this.heartbeatInterval = options.heartbeatInterval ?? 30000
this.callbacks = {
onConnect: options.onConnect ?? (() => { }),
onDisconnect: options.onDisconnect ?? (() => { }),
onError: options.onError ?? (() => { }),
onMessage: options.onMessage ?? (() => { }),
getConversationId: options.getConversationId ?? (() => ''),
getAgentId: options.getAgentId ?? (() => ''),
}
}
/* =======================
* 内部工具
* ======================= */
private safeCall(name: keyof WebSocketCallbacks, ...args: any[]): void {
CallbackUtils.safeCall(this.callbacks, name as string, ...args)
}
/* =======================
* 连接管理
* ======================= */
async init(wsUrl?: string): Promise<void> {
if (wsUrl) this.wsUrl = wsUrl
if (!this.wsUrl) throw new Error('WebSocket URL is required')
await this.connect()
}
// 改进方案让connect()真正等待连接
async connect(): Promise<void> {
console.log('[WebSocket] connect() called, isConnecting:', this.isConnecting, 'connectionState:', this.connectionState)
if (this.isConnecting || this.connectionState) {
console.log('[WebSocket] Already connecting or connected, returning early')
return
}
this.isConnecting = true
console.log('[WebSocket] Starting connection...')
return new Promise((resolve, reject) => {
try {
console.log('[WebSocket] About to create new WebSocket with URL:', this.wsUrl)
this.ws = new WebSocket(this.wsUrl, this.protocols)
console.log('[WebSocket] WebSocket object created, readyState:', this.ws?.readyState)
// 包装handleOpen以resolve Promise
this.ws.onopen = (event: Event) => {
console.log('[WebSocket] onopen event fired')
this.handleOpen(event)
resolve() // ← 真正的连接成功
}
this.ws.onmessage = this.handleMessage
this.ws.onclose = (event: CloseEvent) => {
console.log('[WebSocket] onclose event fired, code:', event.code, 'reason:', event.reason)
this.handleClose(event)
}
this.ws.onerror = (error: Event) => {
console.log('[WebSocket] onerror event fired', error)
this.handleError(error)
reject(error) // ← Promise拒绝
}
} catch (error) {
this.isConnecting = false
this.safeCall('onError', error)
this.scheduleReconnect()
reject(error)
}
})
}
private handleOpen = (event: Event): void => {
this.isConnecting = false
this.connectionState = true
this.reconnectAttempts = 0
this.stats.connectionStartTime = Date.now()
this.startHeartbeat()
this.safeCall('onConnect', event)
this.processQueue()
}
private handleMessage = (event: MessageEvent): void => {
const raw = event.data
if (MessageUtils.isPongMessage(raw)) return
const data =
typeof raw === 'string'
? MessageUtils.safeParseJSON(raw)
: raw
if (!data) return
this.stats.messagesReceived++
this.stats.lastMessageTime = Date.now()
this.safeCall('onMessage', data)
}
private handleClose = (event: CloseEvent): void => {
this.connectionState = false
this.isConnecting = false
this.stopHeartbeat()
this.safeCall('onDisconnect', event)
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
private handleError = (error: Event): void => {
this.connectionState = false
this.isConnecting = false
this.safeCall('onError', {
type: 'WEBSOCKET_ERROR',
error,
})
if (!this.reconnectTimer) {
this.scheduleReconnect()
}
}
/* =======================
* 消息发送
* ======================= */
sendMessage(message: QueuedMessage): boolean {
const data = {
...message,
timestamp: Date.now(),
retryCount: message.retryCount ?? 0,
}
if (!this.isConnected()) {
this.messageQueue.push(data)
this.connect()
return false
}
try {
this.ws!.send(JSON.stringify(data))
this.stats.messagesSent++
return true
} catch (error) {
this.messageQueue.push(data)
this.safeCall('onError', error)
return false
}
}
private processQueue(): void {
while (this.messageQueue.length) {
const msg = this.messageQueue.shift()!
if ((msg.retryCount ?? 0) >= 3) {
this.stats.messagesDropped++
continue
}
msg.retryCount = (msg.retryCount ?? 0) + 1
this.sendMessage(msg)
}
}
/* =======================
* 心跳 & 重连
* ======================= */
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (!this.isConnected()) return
this.sendMessage({
messageType: '3',
messageContent: 'heartbeat',
messageId: IdUtils.generateMessageId(),
conversationId: this.callbacks.getConversationId(),
agentId: this.callbacks.getAgentId(),
})
}, this.heartbeatInterval)
}
private stopHeartbeat(): void {
this.heartbeatTimer = TimerUtils.clearTimer(this.heartbeatTimer, 'interval')
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
this.reconnectAttempts++
this.stats.reconnectCount++
const delay = Math.min(
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
30000
)
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
/* =======================
* 对外 API
* ======================= */
isConnected(): boolean {
return (
this.connectionState &&
!!this.ws &&
this.ws.readyState === WebSocket.OPEN
)
}
getStats() {
return {
...this.stats,
queueLength: this.messageQueue.length,
isConnected: this.isConnected(),
}
}
close(): void {
this.stopHeartbeat()
TimerUtils.clearTimer(this.reconnectTimer)
this.reconnectTimer = null
this.ws?.close(1000)
this.ws = null
this.connectionState = false
this.isConnecting = false
this.messageQueue = []
}
destroy(): void {
this.close()
}
}
export default WebSocketManager

View File

@@ -70,6 +70,10 @@ export enum IPC_EVENTS {
SCRIPT_RECORD_STOP = 'script:record-stop',
SCRIPT_CODEGEN = 'script:codegen',
// Gateway (对齐 ClawX)
GATEWAY_RPC = 'gateway:rpc',
GATEWAY_EVENT = 'gateway:event',
// 更新
UPDATE_CHECK = 'update:check',
UPDATE_DOWNLOAD = 'update:download',

15
src/lib/gateway-client.ts Normal file
View File

@@ -0,0 +1,15 @@
import { IPC_EVENTS } from '@lib/constants';
import type { GatewayEvent } from '@electron/gateway/types';
export async function gatewayRpc<T = any>(method: string, params?: any): Promise<T> {
if (!window.api?.invoke) {
throw new Error('IPC not available');
}
return window.api.invoke(IPC_EVENTS.GATEWAY_RPC, method, params);
}
export function onGatewayEvent(
callback: (event: GatewayEvent) => void
): () => void {
return window.api.on(IPC_EVENTS.GATEWAY_EVENT, callback as (event: any) => void);
}

View File

@@ -1,15 +1,21 @@
import { IPC_EVENTS } from '@lib/constants';
import { Session } from '@utils/storage';
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const method = init?.method || 'GET';
const token = Session.get('token');
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
try {
// Attempt to call via IPC if window.api exists
if ((window as any).api && (window as any).api.invoke) {
const response = await (window as any).api.invoke('hostapi:fetch', {
path,
method,
headers: init?.headers || {},
headers: {
...authHeaders,
...(init?.headers || {}),
},
body: init?.body ?? null,
});

View File

@@ -6,6 +6,7 @@ export const PROVIDER_TYPES = [
'ark',
'moonshot',
'siliconflow',
'deepseek',
'minimax-portal',
'minimax-portal-cn',
'modelstudio',
@@ -22,6 +23,7 @@ export const BUILTIN_PROVIDER_TYPES = [
'ark',
'moonshot',
'siliconflow',
'deepseek',
'minimax-portal',
'minimax-portal-cn',
'modelstudio',
@@ -161,6 +163,7 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
{ id: 'minimax-portal-cn', name: 'MiniMax (CN)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimaxi.com/' },
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5', docsUrl: 'https://platform.moonshot.cn/' },
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3', docsUrl: 'https://docs.siliconflow.cn/cn/userguide/introduction' },
{ id: 'deepseek', name: 'DeepSeek', icon: '🐋', placeholder: 'sk-...', model: 'DeepSeek', requiresApiKey: true, defaultBaseUrl: 'https://api.deepseek.com/v1', showModelId: true, modelIdPlaceholder: 'deepseek-chat', defaultModelId: 'deepseek-chat', apiKeyUrl: 'https://platform.deepseek.com/api_keys', docsUrl: 'https://api-docs.deepseek.com/' },
{ id: 'minimax-portal', name: 'MiniMax (Global)', icon: '☁️', placeholder: 'sk-...', model: 'MiniMax', requiresApiKey: false, isOAuth: true, supportsApiKey: true, defaultModelId: 'MiniMax-M2.7', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'MiniMax-M2.7', apiKeyUrl: 'https://platform.minimax.io' },
{ id: 'modelstudio', name: 'Model Studio', icon: '☁️', placeholder: 'sk-...', model: 'Qwen', requiresApiKey: true, defaultBaseUrl: 'https://coding.dashscope.aliyuncs.com/v1', showBaseUrl: true, defaultModelId: 'qwen3.5-plus', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'qwen3.5-plus', apiKeyUrl: 'https://bailian.console.aliyun.com/', hidden: true },
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx', docsUrl: 'https://www.volcengine.com/', codePlanPresetBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', codePlanPresetModelId: 'ark-code-latest', codePlanDocsUrl: 'https://www.volcengine.com/docs/82379/1928261?lang=zh' },