/** * SaaS Relay Gateway Client * * A lightweight GatewayClient-compatible adapter for browser-only mode. * Routes agent listing through SaaS agent-templates, Converts * chatStream() to OpenAI SSE streaming via SaaS relay. * * Used in connectionStore when running in a browser (non-Tauri) with * SaaS relay connection mode. */ import type { GatewayClient } from './gateway-client'; import { saasClient } from './saas-client'; import type { AgentTemplateAvailable } from './saas-types'; import { createLogger } from './logger'; const log = createLogger('SaaSRelayGateway'); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface CloneInfo { id: string; name: string; role?: string; nickname?: string; emoji?: string; personality?: string; scenarios?: string[]; model?: string; status?: string; templateId?: string; } // --------------------------------------------------------------------------- // Implementation // --------------------------------------------------------------------------- /** * Create a GatewayClient-compatible object that routes through SaaS APIs. * Only the methods needed by the stores are implemented; others return * sensible defaults. */ export function createSaaSRelayGatewayClient( _saasUrl: string, getModel: () => string, ): GatewayClient { // saasUrl preserved for future direct API routing (currently routed through saasClient singleton) void _saasUrl; // Local in-memory agent registry const agents = new Map(); let defaultAgentId: string | null = null; // ----------------------------------------------------------------------- // Helper: list agents as clones // ----------------------------------------------------------------------- async function listClones(): Promise<{ clones: CloneInfo[] }> { try { const templates: AgentTemplateAvailable[] = await saasClient.fetchAvailableTemplates(); const clones: CloneInfo[] = templates.map((t) => { const id = t.id || `agent-${t.name}`; const clone: CloneInfo = { id, name: t.name, role: t.description || t.category, emoji: t.emoji, personality: t.category, scenarios: [], model: getModel(), status: 'active', templateId: t.id, }; agents.set(id, clone); return clone; }); // Set first as default if (clones.length > 0 && !defaultAgentId) { defaultAgentId = clones[0].id; } return { clones }; } catch (err) { log.warn('Failed to list templates', err); return { clones: [] }; } } // ----------------------------------------------------------------------- // Helper: OpenAI SSE streaming via SaaS relay // ----------------------------------------------------------------------- async function chatStream( message: string, callbacks: { onDelta: (delta: string) => void; onThinkingDelta?: (delta: string) => void; onTool?: (tool: string, input: string, output: string) => void; onHand?: (name: string, status: string, result?: unknown) => void; onComplete: (inputTokens?: number, outputTokens?: number) => void; onError: (error: string) => void; }, opts?: { sessionKey?: string; agentId?: string; thinking_enabled?: boolean; reasoning_effort?: string; plan_mode?: boolean; subagent_enabled?: boolean; }, ): Promise<{ runId: string }> { const runId = `run_${Date.now()}`; try { const body: Record = { model: getModel(), messages: [{ role: 'user', content: message }], stream: true, }; // P3-06: Pass sessionKey/agentId to relay for session continuity if (opts?.sessionKey) body['session_key'] = opts.sessionKey; if (opts?.agentId) body['agent_id'] = opts.agentId; if (opts?.thinking_enabled) body['thinking_enabled'] = true; if (opts?.reasoning_effort) body['reasoning_effort'] = opts.reasoning_effort; if (opts?.plan_mode) body['plan_mode'] = true; if (opts?.subagent_enabled) body['subagent_enabled'] = true; const response = await saasClient.chatCompletion(body); if (!response.ok) { const errText = await response.text().catch(() => ''); callbacks.onError(`Relay error: ${response.status} ${errText}`); callbacks.onComplete(); return { runId }; } // Parse SSE stream const reader = response.body?.getReader(); if (!reader) { callbacks.onError('No response body'); callbacks.onComplete(); return { runId }; } const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; // keep incomplete last line for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); // Handle SSE error events from relay (e.g. stream_timeout) if (parsed.error) { const errMsg = parsed.message || parsed.error || 'Unknown stream error'; log.warn('SSE stream error:', errMsg); callbacks.onError(errMsg); callbacks.onComplete(); return { runId }; } const choices = parsed.choices?.[0]; if (!choices) continue; const delta = choices.delta; // Handle thinking/reasoning content if (delta?.reasoning_content) { callbacks.onThinkingDelta?.(delta.reasoning_content); } // Handle regular content if (delta?.content) { callbacks.onDelta(delta.content); } // Check for completion if (choices.finish_reason) { const usage = parsed.usage; callbacks.onComplete( usage?.prompt_tokens, usage?.completion_tokens, ); return { runId }; } } catch { // Skip malformed SSE lines } } } // Stream ended without explicit finish_reason callbacks.onComplete(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); callbacks.onError(msg); callbacks.onComplete(); } return { runId }; } // ----------------------------------------------------------------------- // Build the client object with GatewayClient-compatible shape // ----------------------------------------------------------------------- return { // --- Connection --- connect: async () => { log.debug('SaaS relay client connect'); }, disconnect: async () => {}, getState: () => 'connected' as const, onStateChange: undefined, onLog: undefined, // --- Agents (Clones) --- listClones, createClone: async (opts: Record) => { const id = `agent-${Date.now()}`; const clone: CloneInfo = { id, name: (opts.name as string) || 'New Agent', role: opts.role as string, nickname: opts.nickname as string, emoji: opts.emoji as string, model: getModel(), status: 'active', }; agents.set(id, clone); if (!defaultAgentId) defaultAgentId = id; return { clone }; }, updateClone: async (id: string, updates: Record) => { const existing = agents.get(id); if (existing) agents.set(id, { ...existing, ...updates }); return { clone: agents.get(id) }; }, deleteClone: async (id: string) => { agents.delete(id); if (defaultAgentId === id) defaultAgentId = null; }, getDefaultAgentId: () => defaultAgentId, setDefaultAgentId: (id: string) => { defaultAgentId = id; }, // --- Chat --- chatStream, // --- Hands --- listHands: async () => ({ hands: [] }), getHand: async () => null, triggerHand: async () => ({ runId: `hand_${Date.now()}`, status: 'completed' }), // --- Skills --- listSkills: async () => ({ skills: [] }), getSkill: async () => null, createSkill: async () => null, updateSkill: async () => null, deleteSkill: async () => {}, // --- Config --- getQuickConfig: async () => ({}), saveQuickConfig: async () => {}, getWorkspaceInfo: async () => null, // --- Health --- health: async () => ({ status: 'ok', mode: 'saas-relay' }), status: async () => ({ version: 'saas-relay', mode: 'browser' }), // --- Usage --- getUsageStats: async () => null, getSessionStats: async () => null, // --- REST helpers (not used in browser mode) --- restGet: async () => { throw new Error('REST not available in browser mode'); }, restPost: async () => { throw new Error('REST not available in browser mode'); }, restPut: async () => { throw new Error('REST not available in browser mode'); }, restDelete: async () => { throw new Error('REST not available in browser mode'); }, restPatch: async () => { throw new Error('REST not available in browser mode'); }, } as unknown as GatewayClient; }