import type { SetStateAction } from "react"; import { apiFetchSession, apiFetchSessionHistory, apiBind, apiSendEvent, apiSendControl, apiInterrupt, getUuid, } from "../api/client"; import { generateMessageUuid } from "./utils"; import type { SessionEvent, EventPayload } from "../types"; import type { ThreadEntry, ToolCallData, ToolCallStatus, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, UserMessageImage, PendingPermission, } from "./types"; // SSE Event Bus — 复用自 rcs-transport.ts,仅保留连接管理 type SSEEventHandler = (event: SessionEvent) => void; class SSEBus { private listeners: Set = new Set(); private eventSource: EventSource | null = null; onEvent(handler: SSEEventHandler): () => void { this.listeners.add(handler); return () => this.listeners.delete(handler); } connect(sessionId: string): void { this.disconnect(); const uuid = getUuid(); const url = `/web/sessions/${sessionId}/events?uuid=${encodeURIComponent(uuid)}`; const es = new EventSource(url); this.eventSource = es; es.addEventListener("message", (e: MessageEvent) => { try { const data = JSON.parse(e.data) as SessionEvent; for (const handler of this.listeners) { handler(data); } } catch { // ignore parse errors } }); } disconnect(): void { if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } } } // 全局 SSE bus 实例 export const sseBus = new SSEBus(); // ============================================================================= // RCS Chat Adapter — 将 SSE 事件转为 ThreadEntry // ============================================================================= function mapToolStatus(status: string): ToolCallStatus { if (status === "completed") return "complete"; if (status === "failed") return "error"; return "running"; } function extractEventText(payload: EventPayload): string { if (typeof payload.content === "string") return payload.content; if (payload.message && typeof payload.message === "object") { const msg = payload.message as Record; if (typeof msg.content === "string") return msg.content; if (Array.isArray(msg.content)) { return (msg.content as Array>) .filter((b) => b.type === "text" && typeof b.text === "string") .map((b) => b.text as string) .join(""); } } return ""; } function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) { return i; } } return -1; } export class RCSChatAdapter { private sessionId: string; private setEntries: React.Dispatch>; private unsub: (() => void) | null = null; private onStatusChange?: (status: string) => void; private onError?: (error: string) => void; private onPermissionRequest?: (permission: PendingPermission) => void; constructor( sessionId: string, setEntries: React.Dispatch>, options?: { onStatusChange?: (status: string) => void; onError?: (error: string) => void; onPermissionRequest?: (permission: PendingPermission) => void; }, ) { this.sessionId = sessionId; this.setEntries = setEntries; this.onStatusChange = options?.onStatusChange; this.onError = options?.onError; this.onPermissionRequest = options?.onPermissionRequest; } /** 初始化:绑定会话、加载历史、连接 SSE */ async init(): Promise { try { await apiBind(this.sessionId); } catch { // may already be bound } await this.loadHistory(); this.connectSSE(); } /** 加载历史事件并转为 ThreadEntry */ async loadHistory(): Promise { const { events } = await apiFetchSessionHistory(this.sessionId); if (!events || events.length === 0) return; const historyEntries: ThreadEntry[] = []; let currentAssistant: AssistantMessageEntry | null = null; const flushAssistant = () => { if (currentAssistant) { historyEntries.push(currentAssistant); currentAssistant = null; } }; for (const event of events) { const payload = event.payload || ({} as EventPayload); if (event.type === "user") { if (event.direction === "outbound") continue; // skip echoed user messages flushAssistant(); const text = extractEventText(payload); if (text) { historyEntries.push({ type: "user_message", id: event.id || `hist-user-${historyEntries.length}`, content: text, }); } } else if (event.type === "assistant") { flushAssistant(); const text = extractEventText(payload); const toolParts: ThreadEntry[] = []; const msg = payload.message as Record | undefined; if (msg && typeof msg === "object" && Array.isArray(msg.content)) { for (const block of msg.content as Array>) { if (block.type === "tool_use") { toolParts.push({ type: "tool_call", toolCall: { id: (block.id as string) || `hist-tool-${historyEntries.length}`, title: (block.name as string) || "tool", status: "complete", rawInput: (block.input as Record) || {}, }, }); } } } if (text || toolParts.length > 0) { currentAssistant = { type: "assistant_message", id: event.id || `hist-asst-${historyEntries.length}`, chunks: text ? [{ type: "message", text }] : [], }; historyEntries.push(currentAssistant); // Push tool calls after assistant message for (const tp of toolParts) { historyEntries.push(tp); } currentAssistant = null; // Tool calls are separate entries } } else if (event.type === "tool_use") { const p = payload as Record; const tc: ToolCallEntry = { type: "tool_call", toolCall: { id: (p.tool_call_id as string) || `hist-tool-${historyEntries.length}`, title: (p.tool_name as string) || "tool", status: "complete", rawInput: (p.tool_input as Record) || {}, }, }; historyEntries.push(tc); } else if (event.type === "tool_result") { const p = payload as Record; // Find last tool call and update with output const idx = findToolCallIndex(historyEntries, (p.tool_call_id as string) || ""); if (idx >= 0) { const entry = historyEntries[idx] as ToolCallEntry; historyEntries[idx] = { type: "tool_call", toolCall: { ...entry.toolCall, rawOutput: { output: p.content || p.output || "" }, }, }; } } } flushAssistant(); this.setEntries(historyEntries); } /** 连接 SSE 事件流 */ connectSSE(): void { sseBus.connect(this.sessionId); this.unsub = sseBus.onEvent((event) => this.handleEvent(event)); } /** 断开 SSE */ disconnect(): void { if (this.unsub) { this.unsub(); this.unsub = null; } sseBus.disconnect(); } /** 处理 SSE 事件 */ handleEvent(event: SessionEvent): void { const type = event.type; const payload = event.payload || ({} as EventPayload); // Skip bridge init noise const serialized = JSON.stringify(event); if (/Remote Control connecting/i.test(serialized)) return; switch (type) { // ---- 助手消息 ---- case "assistant": { const content = typeof payload.content === "string" ? payload.content : ""; this.setEntries((prev) => { const lastEntry = prev[prev.length - 1]; // If last entry is AssistantMessage, append to it if (lastEntry?.type === "assistant_message") { const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1]; if (lastChunk?.type === "message") { return [ ...prev.slice(0, -1), { ...lastEntry, chunks: [...lastEntry.chunks.slice(0, -1), { type: "message", text: lastChunk.text + content }] }, ]; } return [ ...prev.slice(0, -1), { ...lastEntry, chunks: [...lastEntry.chunks, { type: "message", text: content }] }, ]; } // Create new AssistantMessage if (content && content.trim()) { const newEntry: AssistantMessageEntry = { type: "assistant_message", id: `assistant-${Date.now()}`, chunks: [{ type: "message", text: content }], }; return [...prev, newEntry]; } return prev; }); // Check for embedded tool_use blocks const msg = payload.message as Record | undefined; if (msg && typeof msg === "object" && Array.isArray(msg.content)) { const toolBlocks = (msg.content as Array>).filter((b) => b.type === "tool_use"); for (const block of toolBlocks) { const toolCallId = (block.id as string) || `call-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const toolData: ToolCallData = { id: toolCallId, title: (block.name as string) || "tool", status: "running", rawInput: (block.input as Record) || {}, }; this.setEntries((prev) => [...prev, { type: "tool_call", toolCall: toolData }]); } } break; } // ---- 工具调用 ---- case "tool_use": { const p = payload as Record; const toolCallId = (p.tool_call_id as string) || `call-${Date.now()}`; const toolData: ToolCallData = { id: toolCallId, title: (p.tool_name as string) || "tool", status: "running", rawInput: (p.tool_input as Record) || {}, }; this.setEntries((prev) => [...prev, { type: "tool_call", toolCall: toolData }]); break; } // ---- 工具结果 ---- case "tool_result": { const p = payload as Record; const callId = (p.tool_call_id as string) || ""; this.setEntries((prev) => { const idx = findToolCallIndex(prev, callId); if (idx < 0) return prev; const entry = prev[idx] as ToolCallEntry; return prev.map((e, i) => i === idx ? { type: "tool_call", toolCall: { ...entry.toolCall, status: "complete" as ToolCallStatus, rawOutput: { output: p.content || p.output || "" } } } : e, ); }); break; } // ---- 权限请求 ---- case "control_request": case "permission_request": { const req = payload.request as Record | undefined; if (req && req.subtype === "can_use_tool") { const requestId = payload.request_id || ""; const toolName = (req.tool_name as string) || "unknown"; const toolInput = (req.input || req.tool_input || {}) as Record; const description = (req.description as string) || ""; // Update tool call status this.setEntries((prev) => { // Find matching tool call const idx = [...prev].reverse().findIndex((e) => e.type === "tool_call"); if (idx >= 0) { const realIdx = prev.length - 1 - idx; const entry = prev[realIdx] as ToolCallEntry; if (entry.toolCall.status === "running") { return prev.map((e, i) => i === realIdx ? { type: "tool_call", toolCall: { ...entry.toolCall, status: "waiting_for_confirmation" as ToolCallStatus, permissionRequest: { requestId, options: [] } } } : e, ); } } return prev; }); // Notify parent this.onPermissionRequest?.({ requestId, toolName, toolInput, description, }); } break; } // ---- 会话状态 ---- case "session_status": { if (typeof payload.status === "string") { this.onStatusChange?.(payload.status); } break; } // ---- 错误 ---- case "error": { const errorMsg = String(payload.message || payload.content || "Unknown error"); this.onError?.(errorMsg); break; } // ---- 忽略的事件类型 ---- case "partial_assistant": case "result": case "result_success": case "control_response": case "permission_response": case "system": case "task_state": case "automation_state": case "status": break; } } /** 发送用户消息 */ async sendMessage(text: string, images?: UserMessageImage[]): Promise { if (!text.trim() && (!images || images.length === 0)) return; // Add user message to entries const userEntry: UserMessageEntry = { type: "user_message", id: `user-${Date.now()}`, content: text, images: images && images.length > 0 ? images : undefined, }; this.setEntries((prev) => [...prev, userEntry]); // Send to backend await apiSendEvent(this.sessionId, { type: "user", uuid: generateMessageUuid(), content: text, message: { content: text }, }); } /** 响应权限请求 */ async respondPermission(requestId: string, approved: boolean, extra?: Record): Promise { await apiSendControl(this.sessionId, { type: "permission_response", approved, request_id: requestId, ...extra, }); // Update tool call status this.setEntries((prev) => prev.map((entry) => { if (entry.type !== "tool_call") return entry; if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry; return { type: "tool_call", toolCall: { ...entry.toolCall, status: approved ? "running" : ("rejected" as ToolCallStatus), permissionRequest: undefined, }, }; }), ); } /** 中断当前操作 */ async interrupt(): Promise { // Mark running tools as canceled this.setEntries((prev) => prev.map((entry) => { if (entry.type !== "tool_call") return entry; if (entry.toolCall.status !== "running" && entry.toolCall.status !== "waiting_for_confirmation") return entry; return { type: "tool_call", toolCall: { ...entry.toolCall, status: "canceled" as ToolCallStatus, permissionRequest: undefined }, }; }), ); await apiInterrupt(this.sessionId); } }