| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { serverMessageSchema, type ServerMessage } from '@moonshot-ai/kap-server/protocol'; |
|
|
| import type { WsLike, WsLikeCtor } from '../channel/wsLike'; |
|
|
| const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; |
|
|
| const KNOWN_MESSAGE_TYPES: ReadonlySet<string> = new Set([ |
| 'turn', |
| 'step', |
| 'user', |
| 'assistant', |
| 'assistant.delta', |
| 'thinking', |
| 'thinking.delta', |
| 'tool_call', |
| 'tool_call.delta', |
| 'tool.progress', |
| 'system', |
| 'interaction', |
| 'task', |
| 'todo', |
| 'session.state', |
| 'session', |
| 'workspace', |
| 'config', |
| 'config.warning', |
| 'model_catalog', |
| 'plugin', |
| 'capability', |
| 'hello', |
| 'ack', |
| 'error', |
| ]); |
|
|
| export interface ChatWsHandlers { |
| |
| onMessage: (message: ServerMessage) => void; |
| |
| onAck: (code: number, msg?: string) => void; |
| |
| onProtocolError: (code: number, msg: string) => void; |
| |
| onInvalidFrame?: (raw: unknown) => void; |
| |
| onReconnectScheduled?: (attempt: number) => void; |
| } |
|
|
| export interface ChatWsOptions { |
| |
| readonly url: string; |
| readonly token?: string; |
| readonly sessionId: string; |
| |
| readonly agentIds?: readonly string[]; |
| |
| readonly omit?: readonly string[]; |
| readonly handlers: ChatWsHandlers; |
| |
| readonly WebSocketImpl?: WsLikeCtor; |
| |
| readonly reconnectDelayMs?: number; |
| } |
|
|
| export class ChatWs { |
| private readonly wsUrl: string; |
| private readonly token?: string; |
| private readonly sessionId: string; |
| private readonly agentIds?: readonly string[]; |
| private readonly omit?: readonly string[]; |
| private readonly handlers: ChatWsHandlers; |
| private readonly WsCtor: WsLikeCtor; |
| private readonly reconnectDelayMs: number; |
|
|
| private ws: WsLike | undefined; |
| private manualClose = false; |
| private reconnectAttempt = 0; |
| private reconnectTimer: ReturnType<typeof setTimeout> | undefined; |
| private subscribeId = 0; |
|
|
| constructor(opts: ChatWsOptions) { |
| this.wsUrl = toWsV3Url(opts.url); |
| this.token = opts.token; |
| this.sessionId = opts.sessionId; |
| this.agentIds = opts.agentIds; |
| this.omit = opts.omit; |
| this.handlers = opts.handlers; |
| const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined); |
| if (ctor === undefined) { |
| throw new Error('no WebSocket implementation available; pass WebSocketImpl'); |
| } |
| this.WsCtor = ctor; |
| this.reconnectDelayMs = opts.reconnectDelayMs ?? 500; |
| this.connect(); |
| } |
|
|
| |
| close(): void { |
| this.manualClose = true; |
| if (this.reconnectTimer !== undefined) { |
| clearTimeout(this.reconnectTimer); |
| this.reconnectTimer = undefined; |
| } |
| const ws = this.ws; |
| this.ws = undefined; |
| ws?.close(); |
| } |
|
|
| |
| reconnect(delayMs = 0): void { |
| if (this.manualClose) return; |
| if (this.reconnectTimer !== undefined) { |
| clearTimeout(this.reconnectTimer); |
| this.reconnectTimer = undefined; |
| } |
| const ws = this.ws; |
| this.ws = undefined; |
| ws?.close(); |
| this.reconnectAttempt += 1; |
| this.handlers.onReconnectScheduled?.(this.reconnectAttempt); |
| this.reconnectTimer = setTimeout(() => { |
| this.reconnectTimer = undefined; |
| this.connect(); |
| }, delayMs); |
| this.reconnectTimer.unref?.(); |
| } |
|
|
| private connect(): void { |
| const protocols = |
| this.token !== undefined && this.token.length > 0 |
| ? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`] |
| : undefined; |
| let ws: WsLike; |
| try { |
| ws = new this.WsCtor(this.wsUrl, protocols); |
| } catch { |
| this.scheduleReconnect(); |
| return; |
| } |
| this.ws = ws; |
| ws.addEventListener('open', () => { |
| this.reconnectAttempt = 0; |
| }); |
| ws.addEventListener('message', (event: { data: unknown }) => { |
| this.onMessage(event.data); |
| }); |
| ws.addEventListener('close', () => { |
| if (this.ws !== ws) return; |
| this.ws = undefined; |
| if (!this.manualClose) this.scheduleReconnect(); |
| }); |
| ws.addEventListener('error', () => {}); |
| } |
|
|
| private onMessage(raw: unknown): void { |
| let frame: unknown; |
| try { |
| frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)); |
| } catch { |
| this.handlers.onInvalidFrame?.(raw); |
| return; |
| } |
| const parsed = serverMessageSchema.safeParse(frame); |
| if (!parsed.success) { |
| const type = (frame as { readonly type?: unknown } | null)?.type; |
| if (typeof type !== 'string' || KNOWN_MESSAGE_TYPES.has(type)) { |
| this.handlers.onInvalidFrame?.(frame); |
| } |
| return; |
| } |
| const message = parsed.data; |
| switch (message.type) { |
| case 'hello': { |
| this.subscribeId += 1; |
| this.send({ |
| type: 'subscribe', |
| id: this.subscribeId, |
| session_id: this.sessionId, |
| agent_ids: this.agentIds !== undefined && this.agentIds.length > 0 ? [...this.agentIds] : undefined, |
| omit: this.omit !== undefined && this.omit.length > 0 ? [...this.omit] : undefined, |
| }); |
| return; |
| } |
| case 'ack': { |
| if (message.id === this.subscribeId) { |
| this.handlers.onAck(message.code, message.msg); |
| } |
| return; |
| } |
| case 'error': { |
| this.handlers.onProtocolError(message.code, message.msg); |
| return; |
| } |
| default: { |
| this.handlers.onMessage(message); |
| return; |
| } |
| } |
| } |
|
|
| private scheduleReconnect(): void { |
| if (this.manualClose) return; |
| this.reconnectAttempt += 1; |
| this.handlers.onReconnectScheduled?.(this.reconnectAttempt); |
| const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); |
| this.reconnectTimer = setTimeout(() => { |
| this.reconnectTimer = undefined; |
| this.connect(); |
| }, delay); |
| this.reconnectTimer.unref?.(); |
| } |
|
|
| private send(frame: Record<string, unknown>): void { |
| const ws = this.ws; |
| if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return; |
| try { |
| ws.send(JSON.stringify(frame)); |
| } catch { |
| } |
| } |
| } |
|
|
| |
| function toWsV3Url(base: string): string { |
| const url = new URL(base); |
| if (url.protocol === 'http:') url.protocol = 'ws:'; |
| else if (url.protocol === 'https:') url.protocol = 'wss:'; |
| if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { |
| throw new Error(`unsupported URL scheme for WS transport: ${base}`); |
| } |
| if (!url.pathname.endsWith('/api/v3/ws')) { |
| url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v3/ws`; |
| } |
| url.search = ''; |
| url.hash = ''; |
| return url.toString(); |
| } |
|
|