// Prussian Bot — engine: MCP (Streamable HTTP) client, OpenAI-compatible // streaming chat completions, and the helpers that glue them together. // Plain logic, exposed on window for the React layer to drive. /* ---- shared: read an SSE response body line-by-line ---- */ async function parseSSE(res, onData) { const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf('\n')) >= 0) { let line = buf.slice(0, idx); buf = buf.slice(idx + 1); if (line.endsWith('\r')) line = line.slice(0, -1); if (line.startsWith('data:')) onData(line.slice(5).replace(/^ /, '')); // event:/id:/: comments are ignored — we key off the JSON payload } } buf = buf.trim(); if (buf.startsWith('data:')) onData(buf.slice(5).trim()); } async function safeText(res) { try { return await res.text(); } catch { return ''; } } /* Stable per-tab session id — OpenCode requires x-opencode-session to route requests efficiently (caching). Persisted for the lifetime of the tab. */ function opencodeSessionId() { try { let id = sessionStorage.getItem('opencode-session'); if (!id) { id = (crypto.randomUUID ? crypto.randomUUID() : 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)); sessionStorage.setItem('opencode-session', id); } return id; } catch { return 'sess-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); } } /* A typed error so the UI can recognise rate limits etc. */ class ApiError extends Error { constructor(source, status, message) { super(message || t('error.' + source) + ' ' + status); this.source = source; // 'llm' | 'mcp' this.status = status; // HTTP status or JSON-RPC code this.isRateLimit = status === 429 || status === -32099; } } /* ============================================================ MCP — Streamable HTTP transport ============================================================ */ class MCPClient { constructor(url) { this.url = (url || '').trim(); this.sessionId = null; this.protocolVersion = '2025-06-18'; this._id = 0; this.initialized = false; } _headers() { const h = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', }; if (this.sessionId) h['Mcp-Session-Id'] = this.sessionId; if (this.initialized) h['MCP-Protocol-Version'] = this.protocolVersion; return h; } async _send(method, params, isNotification) { const reqId = isNotification ? undefined : ++this._id; const payload = { jsonrpc: '2.0', method }; if (params !== undefined && params !== null) payload.params = params; if (!isNotification) payload.id = reqId; let res; try { res = await fetch(this.url, { method: 'POST', headers: this._headers(), body: JSON.stringify(payload), }); } catch (e) { throw new ApiError('mcp', 0, t('error.mcpUnreachable') + ' (' + (e.message || t('error.network')) + ')'); } const sid = res.headers.get('Mcp-Session-Id') || res.headers.get('mcp-session-id'); if (sid) this.sessionId = sid; if (!res.ok) { throw new ApiError('mcp', res.status, mcpHttpMessage(res.status, await safeText(res))); } if (isNotification || res.status === 202) return null; const ct = (res.headers.get('Content-Type') || '').toLowerCase(); if (ct.includes('text/event-stream')) { let result = null, errored = null; await parseSSE(res, (data) => { if (data === '[DONE]') return; let j; try { j = JSON.parse(data); } catch { return; } if (j.id === reqId) { if (j.error) errored = new ApiError('mcp', j.error.code || -1, j.error.message); else result = j.result; } }); if (errored) throw errored; return result; } const j = await res.json(); if (j.error) throw new ApiError('mcp', j.error.code || -1, j.error.message); return j.result; } async initialize() { const r = await this._send('initialize', { protocolVersion: this.protocolVersion, capabilities: {}, clientInfo: { name: 'Prussian Bot', version: '1.0' }, }); if (r && r.protocolVersion) this.protocolVersion = r.protocolVersion; this.initialized = true; try { await this._send('notifications/initialized', null, true); } catch (_) {} return r; } async listTools() { if (!this.initialized) await this.initialize(); const r = await this._send('tools/list', {}); return (r && r.tools) || []; } async callTool(name, args) { if (!this.initialized) await this.initialize(); return this._send('tools/call', { name, arguments: args || {} }); } async listResources() { if (!this.initialized) await this.initialize(); const r = await this._send('resources/list', {}); return (r && r.resources) || []; } async readResource(uri) { if (!this.initialized) await this.initialize(); const r = await this._send('resources/read', { uri }); if (!r || !r.contents) return ''; return r.contents.map((c) => c.text || '').join('\n').trim(); } } function mcpHttpMessage(status, body) { if (status === 429) return t('error.mcpRateLimit'); if (status === 404) return t('error.mcp404'); if (status === 401 || status === 403) return t('error.mcpAuth') + ' (' + status + ')'; const snip = (body || '').slice(0, 140); return t('error.mcpHttp') + ' ' + status + (snip ? ' — ' + snip : ''); } /* MCP tool descriptors -> OpenAI tool schema */ function mcpToOpenAITools(tools) { return (tools || []).map((t) => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: t.inputSchema || t.input_schema || { type: 'object', properties: {} }, }, })); } /* Flatten an MCP tool result into displayable text + error flag */ function readToolResult(result) { if (!result) return { text: '', isError: false }; const parts = (result.content || []).map((c) => { if (c == null) return ''; if (c.type === 'text') return c.text; if (c.type === 'resource' && c.resource) return c.resource.text || JSON.stringify(c.resource); return JSON.stringify(c, null, 2); }); let text = parts.join('\n').trim(); if (!text && result.structuredContent) text = JSON.stringify(result.structuredContent, null, 2); return { text, isError: !!result.isError }; } /* ============================================================ LLM — OpenAI-compatible streaming chat completions cbs: { onReasoning(full), onContent(full), onToolCalls(arr) } returns { content, reasoning, toolCalls:[{id,name,args}], finishReason } ============================================================ */ async function streamChat(cfg, apiMessages, signal, cbs) { const body = { model: cfg.model, messages: apiMessages, stream: true }; if (cfg.tools && cfg.tools.length) { body.tools = cfg.tools; body.tool_choice = 'auto'; } if (cfg.temperature != null && cfg.temperature !== '') body.temperature = +cfg.temperature; if (cfg.maxTokens) body.max_tokens = +cfg.maxTokens; if (cfg.topP != null && cfg.topP !== '') body.top_p = +cfg.topP; if (cfg.thinking && cfg.reasoningEffort && cfg.reasoningEffort !== 'off') { body.reasoning_effort = cfg.reasoningEffort; body.reasoning = { effort: cfg.reasoningEffort }; } let res; try { res = await fetch('api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-opencode-session': opencodeSessionId(), }, body: JSON.stringify(body), signal, }); } catch (e) { if (e.name === 'AbortError') throw e; throw new ApiError('llm', 0, t('error.llmUnreachable') + ' (' + (e.message || t('error.network')) + ')'); } if (!res.ok) { throw new ApiError('llm', res.status, llmHttpMessage(res.status, await safeText(res))); } let content = '', reasoning = '', finishReason = null; let inThink = false, tail = ''; const toolAcc = {}; // route content into reasoning when the model uses tags function feed(delta) { let s = tail + delta; tail = ''; while (s) { if (!inThink) { const o = s.indexOf(''); if (o < 0) { if (/<\/?t?h?i?n?k?>?$/.test(s.slice(-7))) { tail = s; s = ''; } else { content += s; s = ''; } } else { content += s.slice(0, o); s = s.slice(o + 7); inThink = true; } } else { const c = s.indexOf(''); if (c < 0) { if (/<\/?t?h?i?n?k?>?$/.test(s.slice(-8))) { tail = s; s = ''; } else { reasoning += s; s = ''; } } else { reasoning += s.slice(0, c); s = s.slice(c + 8); inThink = false; } } } } await parseSSE(res, (data) => { if (data === '[DONE]') return; let j; try { j = JSON.parse(data); } catch { return; } const ch = j.choices && j.choices[0]; if (!ch) return; const d = ch.delta || {}; const rc = d.reasoning_content != null ? d.reasoning_content : (d.reasoning != null ? d.reasoning : null); if (rc) { reasoning += rc; cbs.onReasoning && cbs.onReasoning(reasoning); } if (d.content) { feed(d.content); cbs.onContent && cbs.onContent(content); if (reasoning) cbs.onReasoning && cbs.onReasoning(reasoning); } if (d.tool_calls) { for (const tc of d.tool_calls) { const i = tc.index != null ? tc.index : 0; const a = toolAcc[i] || (toolAcc[i] = { id: '', name: '', args: '' }); if (tc.id) a.id = tc.id; if (tc.function && tc.function.name) a.name += tc.function.name; if (tc.function && tc.function.arguments) a.args += tc.function.arguments; } cbs.onToolCalls && cbs.onToolCalls(Object.values(toolAcc)); } if (ch.finish_reason) finishReason = ch.finish_reason; }); const toolCalls = Object.values(toolAcc).filter((t) => t.name); return { content, reasoning, toolCalls, finishReason }; } function llmHttpMessage(status, body) { let detail = ''; try { const j = JSON.parse(body); detail = (j.error && (j.error.message || j.error)) || ''; } catch { detail = (body || '').slice(0, 160); } if (status === 429) return t('error.llmRateLimit') + (detail ? ' — ' + detail : t('error.llmRateLimitSuffix')); if (status === 401 || status === 403) return t('error.llmAuth') + ' (' + status + ')' + (detail ? ' — ' + detail : ''); if (status === 402) return t('error.llmPayment') + (detail ? ' — ' + detail : ''); if (status === 404) return t('error.llm404'); if (status >= 500) return t('error.llmServer') + ' (' + status + ')'; return t('error.llmHttp') + ' ' + status + (detail ? ' — ' + detail : ''); } Object.assign(window, { MCPClient, streamChat, mcpToOpenAITools, readToolResult, ApiError, });