/** * @file chatAutomat.js * @description State container for the AI assistant chat drawer, conversation * list, message history, live SSE streaming tokens, and interactive tool calls. * * Upstream Triggers: * - `authAutomat`: Subscribes to auth state changes — reconnects SSE stream and * reloads conversations when `'authed'` with a `selectedCompanyId`; calls * `actions.reset()` on `'anon'` or when company is unselected. * - Assistant SSE stream (`/api/modelconversation/events`): Emits streaming * deltas, state changes (`idle` | `busy`), and tool calls (`ask_user`). * - UI / Drawer: Actions (`toggleChat`, `selectConversation`, `sendMessage`, * `answerAskUser`, `newConversation`). */ import { Automat } from 'react-automat'; import { modelconversation as modelconversationApi } from '../api'; import authAutomat from './authAutomat'; import { getSseClientId } from './sseAutomat'; import { attachUserEnv, getCurrentRoute, isSetUserRouteTool, extractRouteFromToolData, navigateToClientRoute, } from '../components/chat/userEnv'; /** * @typedef {Object} ModelConversation * @property {number} id * @property {number} companyuser_id * @property {string} provider * @property {string} model * @property {string} created_at * @property {string} updated_at * @property {'idle'|'busy'} state */ /** * @typedef {Object} ModelMessage * @property {number|string} id * @property {number} conversation_id * @property {string} created_at * @property {'user'|'assistant'|'system'|'tool'} role * @property {string} message */ /** * @typedef {Object} ChatState * @property {boolean} isOpen * @property {'idle'|'connecting'|'connected'|'error'} sseStatus * @property {boolean} sseEstablished * @property {ModelConversation[]} conversations * @property {boolean} loadingConversations * @property {number|null} selectedConversationId * @property {'idle'|'busy'} conversationState * @property {string} provider * @property {string} model * @property {ModelMessage[]} messages * @property {boolean} loadingMessages * @property {boolean} isSending * @property {string|null} sendError * @property {string|null} error * @property {'idle'|'thinking'|'working'} streamPhase Live SSE phase of the assistant * @property {string} streamingText Partial assistant text received via `delta` events * @property {{prompt_tokens:number, completion_tokens:number, total_tokens:number, cost:number}|null} lastUsage */ /** Selectable model presets; the first entry is the default for new conversations. */ export const PRESET_MODELS = [ { value: 'openai/gpt-6-luna', label: 'GPT-6 Luna (Standard)' }, ]; const DEFAULT_MODEL = PRESET_MODELS[0].value; /** True once the chat was opened in this browser session (module lifetime). */ let chatOpenedThisSession = false; /** @type {ChatState} */ const initialState = { isOpen: false, sseStatus: 'idle', sseEstablished: false, conversations: [], loadingConversations: false, selectedConversationId: null, /** First-open draft: no server conversation yet, created on first send. */ draftConversation: false, /** Prefilled input text waiting to be consumed by the chat UI. */ pendingInput: null, conversationState: 'idle', provider: 'openrouter', model: DEFAULT_MODEL, messages: [], loadingMessages: false, isSending: false, sendError: null, error: null, streamPhase: 'idle', streamingText: '', lastUsage: null, activeTurns: [], }; let chatAutomat; /** Company-wide events EventSource (/api/events) */ let companyEventSource = null; let companyReconnectTimer = null; /** Per-conversation EventSource (/api/modelconversation/:id/stream) */ let conversationStream = null; let activeStreamConversationId = null; /** * Close the company-wide SSE stream (/api/events). Called on logout so no * stale stream survives; reconnected on relogin via `ensureCompanyEventsConnected()`. */ function disconnectCompanyEvents() { if (companyReconnectTimer) { clearTimeout(companyReconnectTimer); companyReconnectTimer = null; } if (companyEventSource) { try { companyEventSource.close(); } catch {} companyEventSource = null; } } /** * Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events. */ function ensureCompanyEventsConnected() { if (companyEventSource && companyEventSource.readyState !== EventSource.CLOSED) { return; } if (companyEventSource) { try { companyEventSource.close(); } catch {} companyEventSource = null; } if (companyReconnectTimer) { clearTimeout(companyReconnectTimer); companyReconnectTimer = null; } const url = new URL('/api/events', window.location.origin); const cid = getSseClientId(); if (cid) url.searchParams.set('cid', cid); const es = new EventSource(url.href, { withCredentials: true }); companyEventSource = es; es.addEventListener('modelconversation', () => { void chatAutomat.actions.loadConversations(true); }); es.onerror = () => { if (chatAutomat.state.sseEstablished) { if (companyReconnectTimer) clearTimeout(companyReconnectTimer); companyReconnectTimer = setTimeout(() => { if (chatAutomat.state.sseEstablished) { ensureCompanyEventsConnected(); } }, 4000); } }; } /** * Connect to the dedicated per-conversation SSE stream (GET /api/modelconversation/:id/stream). * @param {number|string|null} conversationId */ function ensureConversationStream(conversationId) { if (!conversationId) { closeConversationStream(); return; } // If already connected to this conversation stream, keep it if ( conversationStream && activeStreamConversationId === conversationId && conversationStream.readyState !== EventSource.CLOSED ) { return; } closeConversationStream(); chatAutomat.setState({ sseStatus: 'connecting', sseEstablished: true }); activeStreamConversationId = conversationId; const streamUrl = `/api/modelconversation/${encodeURIComponent(conversationId)}/stream`; const es = new EventSource(streamUrl, { withCredentials: true }); conversationStream = es; es.onopen = () => { if (activeStreamConversationId === conversationId) { chatAutomat.setState({ sseStatus: 'connected', error: null }); } }; es.onerror = () => { if (activeStreamConversationId === conversationId) { chatAutomat.setState({ sseStatus: 'error' }); } }; // 1. Live message event from server (role: 'user' | 'assistant' | 'system' | 'tool') es.addEventListener('message', (event) => { try { if (!event.data) return; const msg = JSON.parse(event.data); if (msg && (msg.conversation_id === conversationId || !msg.conversation_id)) { chatAutomat.actions.receiveMessage(msg); } } catch (err) { console.warn('[chatAutomat] Failed to parse message event payload:', err); } }); // 2. Live state events: connected, firstevent, reasoning, working, toolcall, content, done es.addEventListener('state', (event) => { try { if (!event.data) return; const data = JSON.parse(event.data); if (data) { chatAutomat.actions.applyStreamState(data); } } catch (err) { console.warn('[chatAutomat] Failed to parse state event payload:', err); } }); // 3. Reasoning streaming chunks es.addEventListener('reasoning', (event) => { try { if (!event.data) return; const data = JSON.parse(event.data); if (data) { chatAutomat.actions.applyStreamReasoning(data); } } catch (err) { console.warn('[chatAutomat] Failed to parse reasoning event payload:', err); } }); // 4. Incremental assistant text chunks es.addEventListener('delta', (event) => { try { if (!event.data) return; const data = JSON.parse(event.data); if (data) { chatAutomat.actions.applyStreamDelta(data); } } catch (err) { console.warn('[chatAutomat] Failed to parse delta event payload:', err); } }); // 5. Per-turn usage statistics es.addEventListener('usage', (event) => { try { if (!event.data) return; const data = JSON.parse(event.data); if (data) { chatAutomat.actions.applyStreamUsage(data); } } catch (err) { console.warn('[chatAutomat] Failed to parse usage event payload:', err); } }); } function closeConversationStream() { if (conversationStream) { try { conversationStream.close(); } catch {} conversationStream = null; } activeStreamConversationId = null; } const actions = { /** * Open the chat drawer and initialize connection & conversation list. */ openChat() { chatAutomat.setState({ isOpen: true, sseEstablished: true }); ensureCompanyEventsConnected(); if (!chatOpenedThisSession) { // First open in this browser session: prepare a draft with the first preset // model. The conversation is only created once the first message is sent. chatOpenedThisSession = true; chatAutomat.actions.startDraft(); } void chatAutomat.actions.loadConversations(); }, /** * Open the chat drawer with a fresh draft conversation and prefilled prompt. * @param {string} [prompt=''] */ openWithPrompt(prompt = '') { chatOpenedThisSession = true; closeConversationStream(); chatAutomat.setState({ isOpen: true, sseEstablished: true, draftConversation: true, selectedConversationId: null, provider: 'openrouter', model: DEFAULT_MODEL, messages: [], conversationState: 'idle', streamPhase: 'idle', streamingText: '', lastUsage: null, activeTurns: [], sendError: null, pendingInput: prompt, }); ensureCompanyEventsConnected(); void chatAutomat.actions.loadConversations(); }, /** * Start an implicit new conversation with the first preset model. Nothing is * created on the server until the first message is sent. */ startDraft() { closeConversationStream(); chatAutomat.setState({ draftConversation: true, selectedConversationId: null, provider: 'openrouter', model: DEFAULT_MODEL, messages: [], conversationState: 'idle', streamPhase: 'idle', streamingText: '', lastUsage: null, activeTurns: [], sendError: null, }); }, /** * Close the chat drawer (keeps SSE connection alive for background responses). */ closeChat() { chatAutomat.setState({ isOpen: false }); }, /** * Toggle the chat drawer open/closed. */ toggleChat() { if (chatAutomat.state.isOpen) { chatAutomat.actions.closeChat(); } else { chatAutomat.actions.openChat(); } }, /** * Fetch conversations list from GET /api/modelconversation. * Auto-selects the first conversation if none is selected. * @param {boolean} [silent=false] */ async loadConversations(silent = false) { if (!silent) { chatAutomat.setState({ loadingConversations: true, error: null }); } try { const data = await modelconversationApi.list(); const conversations = data?.conversations || []; const currentSelectedId = chatAutomat.state.selectedConversationId; let nextSelectedId = currentSelectedId; let nextConvState = chatAutomat.state.conversationState; let nextProvider = chatAutomat.state.provider; let nextModel = chatAutomat.state.model; if (currentSelectedId) { const found = conversations.find((c) => c.id === currentSelectedId); if (found) { nextConvState = found.state || 'idle'; nextProvider = found.provider || nextProvider; nextModel = found.model || nextModel; } else if (conversations.length > 0) { nextSelectedId = conversations[0].id; nextConvState = conversations[0].state || 'idle'; nextProvider = conversations[0].provider || nextProvider; nextModel = conversations[0].model || nextModel; } else { nextSelectedId = null; nextConvState = 'idle'; } } else if (conversations.length > 0 && !chatAutomat.state.draftConversation) { nextSelectedId = conversations[0].id; nextConvState = conversations[0].state || 'idle'; nextProvider = conversations[0].provider || nextProvider; nextModel = conversations[0].model || nextModel; } chatAutomat.setState({ conversations, selectedConversationId: nextSelectedId, conversationState: nextConvState, provider: nextProvider, model: nextModel, loadingConversations: false, }); if (nextSelectedId && nextSelectedId !== currentSelectedId) { void chatAutomat.actions.loadMessages(nextSelectedId); } else if (nextSelectedId && (!conversationStream || activeStreamConversationId !== nextSelectedId)) { ensureConversationStream(nextSelectedId); } } catch (err) { chatAutomat.setState({ loadingConversations: false, error: err?.message || String(err), }); } }, /** * Switch the active conversation and load its messages history & connect SSE stream. * @param {number} id */ async selectConversation(id) { const conv = chatAutomat.state.conversations.find((c) => c.id === id); chatAutomat.setState({ selectedConversationId: id, draftConversation: false, conversationState: conv?.state || 'idle', provider: conv?.provider || chatAutomat.state.provider, model: conv?.model || chatAutomat.state.model, sendError: null, streamPhase: conv?.state === 'busy' ? 'thinking' : 'idle', streamingText: '', lastUsage: null, }); ensureConversationStream(id); await chatAutomat.actions.loadMessages(id); }, /** * Load historical messages for a conversation (GET /api/modelconversation/:id/message). * Also refreshes the conversation state via GET /api/modelconversation/:id. * @param {number} id */ async loadMessages(id) { if (!id) return; chatAutomat.setState({ loadingMessages: true }); try { const [msgsRes, convRes] = await Promise.all([ modelconversationApi.listMessages(id), modelconversationApi.get(id).catch(() => null), ]); const messages = msgsRes?.messages || []; const updatedConv = convRes?.conversation; chatAutomat.setState((prev) => ({ messages, loadingMessages: false, conversationState: updatedConv?.state || prev.conversationState, conversations: updatedConv ? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c)) : prev.conversations, })); } catch (err) { console.warn('[chatAutomat] loadMessages failed:', err); chatAutomat.setState({ loadingMessages: false }); } }, /** * Create a new model conversation via POST /api/modelconversation. * Automatically selects the new conversation and connects its stream. * @param {{ provider?: string, model?: string }} [params] */ async createConversation({ provider = 'openrouter', model = DEFAULT_MODEL } = {}) { chatAutomat.setState({ loadingConversations: true, error: null }); try { const res = await modelconversationApi.create({ provider, model }); const newConv = res?.conversation; if (!newConv) throw new Error('Unterhaltung konnte nicht erstellt werden'); chatAutomat.setState((prev) => ({ conversations: [newConv, ...prev.conversations.filter((c) => c.id !== newConv.id)], selectedConversationId: newConv.id, draftConversation: false, conversationState: newConv.state || 'idle', provider: newConv.provider || provider, model: newConv.model || model, messages: [], loadingConversations: false, sendError: null, })); ensureConversationStream(newConv.id); return newConv; } catch (err) { chatAutomat.setState({ loadingConversations: false, error: err?.message || String(err), }); throw err; } }, /** * Delete a model conversation via DELETE /api/modelconversation/:id. * If the currently selected conversation was deleted, selects the next available * or starts a fresh draft. * @param {number} id */ async deleteConversation(id) { if (!id) return; try { await modelconversationApi.delete(id); } catch (err) { console.warn('[chatAutomat] deleteConversation API error:', err); // Even if network fails or already deleted, proceed with local cleanup if 404 if (err?.status !== 404) { throw err; } } const prevConversations = chatAutomat.state.conversations; const remaining = prevConversations.filter((c) => c.id !== id); const wasSelected = chatAutomat.state.selectedConversationId === id; if (wasSelected) { if (remaining.length > 0) { const next = remaining[0]; chatAutomat.setState({ conversations: remaining, selectedConversationId: next.id, conversationState: next.state || 'idle', provider: next.provider || 'openrouter', model: next.model || DEFAULT_MODEL, draftConversation: false, messages: [], streamPhase: 'idle', streamingText: '', lastUsage: null, sendError: null, }); ensureConversationStream(next.id); void chatAutomat.actions.loadMessages(next.id); } else { chatAutomat.setState({ conversations: [], }); chatAutomat.actions.startDraft(); } } else { chatAutomat.setState({ conversations: remaining, }); } }, /** * Update conversation state ('idle' | 'busy') and sync in conversation list. * @param {'idle'|'busy'} state */ setConversationState(state) { chatAutomat.setState((prev) => ({ conversationState: state, conversations: prev.conversations.map((c) => c.id === prev.selectedConversationId ? { ...c, state } : c ), })); }, /** * SSE Event: {"state":"connected"} * Handshake turns black. If previous turn was sealed, starts a new turn on a new line! */ applyStreamConnected() { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { return { activeTurns: [ { id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'black', reasonings: [], tools: [], usage: null, content: '', sealed: false, }, ], conversationState: 'busy', streamPhase: 'thinking', }; } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; if (lastTurn.sealed) { const newTurn = { id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'black', reasonings: [], tools: [], usage: null, content: '', sealed: false, }; return { activeTurns: [...turns, newTurn], conversationState: 'busy', streamPhase: 'thinking', }; } lastTurn.handshake = 'black'; turns[lastIdx] = lastTurn; return { activeTurns: turns, conversationState: 'busy', streamPhase: 'thinking', }; }); }, /** * SSE Event: {"state":"firstevent"} * Handshake turns green. */ applyStreamFirstEvent() { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [], tools: [], usage: null, content: '', sealed: false, }); return { activeTurns: turns }; } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.handshake = 'green'; turns[lastIdx] = lastTurn; return { activeTurns: turns }; }); }, /** * SSE Event: {"state":"reasoning"} * Sets stream phase to thinking and seals any previous reasoning. * Does NOT create an empty icon — the icon is only created when reasoning text arrives. */ applyStreamReasoningState() { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) return { streamPhase: 'thinking' }; const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; // Seal any existing reasoning that has text lastTurn.reasonings = (lastTurn.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })); turns[lastIdx] = lastTurn; return { activeTurns: turns, streamPhase: 'thinking' }; }); }, /** * SSE Event: event: reasoning, data: {"text":"..."} * Adds brain icon with text as chunks stream in. */ applyStreamReasoning(data) { let text = ''; if (typeof data === 'string') { text = data; } else if (data && typeof data === 'object') { if (typeof data.plain === 'string') { text = data.plain; } else if (data.encrypted) { text = ''; } else if (typeof data.text === 'string') { text = data.text; } } if (!text || !text.trim()) return; const trimmed = text.trim(); if (trimmed.startsWith('{') && (trimmed.includes('"encrypted"') || trimmed.includes('"plain"'))) { return; } chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [{ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text: trimmed, sealed: false }], tools: [], usage: null, content: '', sealed: false, }); return { activeTurns: turns, streamPhase: 'thinking' }; } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name)); const rList = (lastTurn.reasonings || []).map((r) => ({ ...r })); let unsealedIdx = -1; for (let i = rList.length - 1; i >= 0; i--) { if (!rList[i].sealed) { unsealedIdx = i; break; } } if (unsealedIdx >= 0) { rList[unsealedIdx].text = trimmed; } else { rList.push({ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text: trimmed, sealed: false }); } lastTurn.reasonings = rList; turns[lastIdx] = lastTurn; return { activeTurns: turns, streamPhase: 'thinking' }; }); }, /** * SSE Event: {"state":"working"} * "produces a black working icon, which turns green when this arrives [toolcall]" * Also seals any unsealed reasoning. */ applyStreamWorking() { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [], tools: [{ id: `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, state: 'black', name: '', content: '' }], usage: null, content: '', sealed: false, }); return { activeTurns: turns, streamPhase: 'working' }; } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.reasonings = (lastTurn.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })); lastTurn.tools = [ ...(lastTurn.tools || []), { id: `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, state: 'black', name: '', content: '' }, ]; turns[lastIdx] = lastTurn; return { activeTurns: turns, streamPhase: 'working' }; }); }, /** * SSE Event: {"state":"toolcall", "id":"...", "name":"...", "arguments":"...", "content":"...", "result":{...}} * Turns the working icon green, shows tooltip with content, displays name behind the work icon. */ applyStreamToolCall(data) { let toolName = data?.name; if (!toolName && data?.id) { const turns = chatAutomat.state.activeTurns; const lastTurn = turns?.[turns.length - 1]; const existing = lastTurn?.tools?.find((t) => t.id === data.id); toolName = existing?.name; } if (isSetUserRouteTool(toolName)) { const targetRoute = extractRouteFromToolData(data); if (targetRoute) { void navigateToClientRoute(targetRoute, data.id); } } chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [], tools: [], usage: null, content: '', sealed: false, }); } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.reasonings = (lastTurn.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })); const tools = lastTurn.tools ? [...lastTurn.tools] : []; let toolIdx = -1; if (data.id) { toolIdx = tools.findIndex((t) => t.id === data.id); } if (toolIdx === -1) { for (let i = tools.length - 1; i >= 0; i--) { if (tools[i].state === 'black') { toolIdx = i; break; } } } const updatedTool = { id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, state: 'green', name: data.name || '', arguments: data.arguments, content: data.content !== undefined ? data.content : data.arguments || '', result: data.result, }; if (toolIdx >= 0) { tools[toolIdx] = { ...tools[toolIdx], ...updatedTool }; } else { tools.push(updatedTool); } lastTurn.tools = tools; turns[lastIdx] = lastTurn; const hasAskUser = data.name === 'ask_user'; return { activeTurns: turns, streamPhase: hasAskUser ? 'idle' : 'working', conversationState: hasAskUser ? 'idle' : prev.conversationState, isSending: hasAskUser ? false : prev.isSending, }; }); }, /** * SSE Event: {"state":"content"} * Signals that text response content is starting. */ applyStreamContent() { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [], tools: [], usage: null, content: '', sealed: false, }); return { activeTurns: turns, streamPhase: 'working' }; } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.reasonings = (lastTurn.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })); lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name)); turns[lastIdx] = lastTurn; return { activeTurns: turns, streamPhase: 'working' }; }); }, /** * SSE Event: event: delta, data: {"delta":"...", "text":"..."} * Incremental text for the current turn. */ applyStreamDelta(data) { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [], tools: [], usage: null, content: '', sealed: false, }); } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.tools = (lastTurn.tools || []).filter((tool) => Boolean(tool.name)); const currentText = typeof data.text === 'string' ? data.text : ((lastTurn.content || '') + (data.delta || '')); lastTurn.content = currentText; turns[lastIdx] = lastTurn; return { activeTurns: turns, streamingText: currentText, streamPhase: 'working', }; }); }, /** * SSE Event: event: usage, data: {"input":..., "cached":..., "output":..., "cost":...} * "usage seals the turn and now usage is displayed (per turn)" */ applyStreamUsage(data) { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { turns.push({ id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'green', reasonings: [], tools: [], usage: data, content: '', sealed: true, }); return { activeTurns: turns, lastUsage: data }; } const lastIdx = turns.length - 1; const lastTurn = { ...turns[lastIdx] }; lastTurn.reasonings = (lastTurn.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })); lastTurn.usage = data; lastTurn.sealed = true; turns[lastIdx] = lastTurn; return { activeTurns: turns, lastUsage: data }; }); }, /** * SSE Event: {"state":"done"} * "after this the user can send again." * Commits the completed assistant message with its turns into messages, * resets conversationState to 'idle' and isSending to false. */ applyStreamDone() { chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; const sealedTurns = turns.map((t) => ({ ...t, sealed: true, reasonings: (t.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })), tools: (t.tools || []) .filter((tool) => Boolean(tool.name)) .map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)), })); // Gather full cumulative content from all turns let fullContent = ''; for (const t of sealedTurns) { if (t.content) { fullContent = fullContent ? `${fullContent}\n\n${t.content}` : t.content; } } if (!fullContent && prev.streamingText) { fullContent = prev.streamingText; } let nextMessages = prev.messages; if (sealedTurns.length > 0 || fullContent) { const assistantMsg = { id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, conversation_id: prev.selectedConversationId, role: 'assistant', message: fullContent, turns: sealedTurns, created_at: new Date().toISOString(), }; nextMessages = [...prev.messages, assistantMsg]; } return { messages: nextMessages, activeTurns: [], streamingText: '', conversationState: 'idle', isSending: false, streamPhase: 'idle', }; }); }, /** * Master router for incoming 'state' event data. */ applyStreamState(data) { const state = typeof data === 'string' ? data : data?.state; if (!state) return; if (state === 'connected') { chatAutomat.actions.applyStreamConnected(); } else if (state === 'firstevent') { chatAutomat.actions.applyStreamFirstEvent(); } else if (state === 'reasoning') { chatAutomat.actions.applyStreamReasoningState(); } else if (state === 'working') { chatAutomat.actions.applyStreamWorking(); } else if (state === 'toolcall') { chatAutomat.actions.applyStreamToolCall(data); } else if (state === 'content') { chatAutomat.actions.applyStreamContent(); } else if (state === 'done' || state === 'idle') { chatAutomat.actions.applyStreamDone(); } else if (state === 'thinking' || state === 'busy') { chatAutomat.actions.applyStreamReasoningState(); } }, /** * Send a user message via POST /api/modelconversation/:id/message. * Rejects immediately if conversation is busy (409 Conflict avoidance). * @param {string|{ text?: string, attachments?: object[] }} textOrPayload * @param {object[]} [maybeAttachments] */ async sendMessage(textOrPayload, maybeAttachments = []) { const trimmed = typeof textOrPayload === 'object' && textOrPayload !== null ? (textOrPayload.text || '').trim() : (textOrPayload || '').trim(); const fileAttachments = typeof textOrPayload === 'object' && textOrPayload !== null ? (Array.isArray(textOrPayload.attachments) ? textOrPayload.attachments : []) : (Array.isArray(maybeAttachments) ? maybeAttachments : []); if (!trimmed && fileAttachments.length === 0) return; if (!chatAutomat.state.selectedConversationId && chatAutomat.state.draftConversation) { // Draft: the conversation really starts now, with the first message. const { provider, model } = chatAutomat.state; await chatAutomat.actions.createConversation({ provider, model }); } const { selectedConversationId, conversationState, isSending } = chatAutomat.state; if (!selectedConversationId) { throw new Error('Keine Unterhaltung ausgewählt'); } if (conversationState === 'busy' || isSending) { return; } const activeRoute = (typeof textOrPayload === 'object' && textOrPayload?.route) || getCurrentRoute(); const tempId = `temp-${Date.now()}`; let finalContent = trimmed; if (fileAttachments.length > 0) { const formatAttr = (val) => String(val).includes(' ') ? `"${val}"` : String(val); const tags = fileAttachments.map((f) => { const vfsRelPath = f.path || `attachments/${f.name}`; const mime = f.mimeType || f.type || 'application/octet-stream'; return `[ATTACHMENT name=${formatAttr(f.name)} path=${formatAttr(vfsRelPath)} type=${formatAttr(mime)}]`; }); const prefix = tags.join(' '); finalContent = trimmed ? `${prefix}\n\n${trimmed}` : prefix; } const optimisticContent = attachUserEnv(finalContent, activeRoute); const promptMessage = attachUserEnv(trimmed, activeRoute); const optimisticMsg = { id: tempId, conversation_id: selectedConversationId, role: 'user', message: JSON.stringify({ content: optimisticContent }), created_at: new Date().toISOString(), }; // "pressing enter adds a gray handshake icon to the left of the chat which turns black at {"state":"connected"} and green at {"state":"firstevent"}" const initialTurn = { id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'gray', reasonings: [], tools: [], usage: null, content: '', sealed: false, }; chatAutomat.setState((prev) => ({ messages: [...prev.messages, optimisticMsg], activeTurns: [initialTurn], conversationState: 'busy', streamPhase: 'thinking', streamingText: '', lastUsage: null, isSending: true, sendError: null, })); try { const res = await modelconversationApi.addMessage(selectedConversationId, { message: promptMessage, attachments: fileAttachments.length > 0 ? fileAttachments : undefined, }); const createdMsg = res?.message || optimisticMsg; chatAutomat.setState((prev) => { const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId); if (alreadyHasRealId) { return { messages: prev.messages.filter((m) => m.id !== tempId), }; } return { messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)), }; }); } catch (err) { const isConflict = err?.status === 409; chatAutomat.setState((prev) => ({ messages: prev.messages.filter((m) => m.id !== tempId), activeTurns: isConflict ? prev.activeTurns : [], conversationState: isConflict ? 'busy' : 'idle', streamPhase: isConflict ? prev.streamPhase : 'idle', isSending: false, sendError: isConflict ? 'Konversation ist beschäftigt, bitte warten.' : (err?.message || 'Fehler beim Senden'), })); throw err; } }, /** * Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message. * Body: { role: 'tool', tool_call_id, content, answer } * @param {{ tool_call_id: string, answer: string }} params */ async sendToolResponse({ tool_call_id, answer }) { const { selectedConversationId, isSending } = chatAutomat.state; if (!selectedConversationId) { throw new Error('Keine Unterhaltung ausgewählt'); } if (isSending) { return; } const payload = { role: 'tool', tool_call_id, name: 'ask_user', content: answer, answer, }; const tempId = `temp-${Date.now()}`; const optimisticMsg = { id: tempId, conversation_id: selectedConversationId, role: 'tool', message: JSON.stringify(payload), created_at: new Date().toISOString(), }; const initialTurn = { id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, handshake: 'gray', reasonings: [], tools: [], usage: null, content: '', sealed: false, }; chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; const sealedTurns = turns.map((t) => ({ ...t, sealed: true, reasonings: (t.reasonings || []) .filter((r) => r.text && r.text.trim()) .map((r) => ({ ...r, sealed: true })), tools: (t.tools || []) .filter((tool) => Boolean(tool.name)) .map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)), })); const hasActiveTurns = sealedTurns.some( (t) => (t.reasonings && t.reasonings.length > 0) || (t.tools && t.tools.length > 0) || t.content || t.usage ); let foundExisting = false; const nextMessages = prev.messages.map((m) => { if (m.role === 'assistant' && !m.turns) { try { const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) { foundExisting = true; return { ...m, turns: sealedTurns }; } } catch {} } return m; }); if (!foundExisting && hasActiveTurns) { nextMessages.push({ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, conversation_id: selectedConversationId, role: 'assistant', message: '', turns: sealedTurns, created_at: new Date().toISOString(), }); } nextMessages.push(optimisticMsg); return { messages: nextMessages, activeTurns: [initialTurn], conversationState: 'busy', streamPhase: 'thinking', streamingText: '', lastUsage: null, isSending: true, sendError: null, }; }); try { const res = await modelconversationApi.addMessage(selectedConversationId, payload); const createdMsg = res?.message || optimisticMsg; chatAutomat.setState((prev) => { const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId); if (alreadyHasRealId) { return { messages: prev.messages.filter((m) => m.id !== tempId), }; } return { messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)), }; }); } catch (err) { const isConflict = err?.status === 409; chatAutomat.setState((prev) => ({ messages: prev.messages.filter((m) => m.id !== tempId), activeTurns: isConflict ? prev.activeTurns : [], conversationState: isConflict ? 'busy' : 'idle', streamPhase: isConflict ? prev.streamPhase : 'idle', isSending: false, sendError: isConflict ? 'Konversation ist beschäftigt, bitte warten.' : (err?.message || 'Fehler beim Senden'), })); throw err; } }, /** * Receive an incoming message (via SSE message event or fallback). * Prevents duplicates and replaces temporary optimistic messages. * @param {ModelMessage} message */ receiveMessage(message) { if (!message) return; chatAutomat.setState((prev) => { // 1. Check if message already exists by ID const exists = prev.messages.some((m) => m.id === message.id); if (exists) return prev; // 2. Check if this is the user/tool message echoing back and we have a temp optimistic message if (message.role === 'user' || message.role === 'tool') { const tempIdx = prev.messages.findIndex((m) => { if (!String(m.id).startsWith('temp-')) return false; if (m.message === message.message) return true; try { const parsed = JSON.parse(message.message); if (parsed && typeof parsed === 'object') { if (parsed.content === m.message) return true; if (parsed.tool_call_id && parsed.tool_call_id === message.tool_call_id) return true; } } catch {} return false; }); if (tempIdx !== -1) { const next = [...prev.messages]; next[tempIdx] = message; return { messages: next, isSending: false }; } } if (message.role === 'tool') { try { const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message; if (isSetUserRouteTool(payload?.name)) { const targetRoute = extractRouteFromToolData(payload); if (targetRoute) { void navigateToClientRoute(targetRoute, message.id || payload?.tool_call_id); } } } catch {} } const isAssistant = message.role === 'assistant' || message.role === 'model'; let hasAskUser = false; if (isAssistant) { try { const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message; if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) { hasAskUser = true; } if (Array.isArray(payload?.tool_calls)) { for (const tc of payload.tool_calls) { const name = tc.function?.name || tc.name; if (isSetUserRouteTool(name)) { const targetRoute = extractRouteFromToolData(tc.function || tc); if (targetRoute) { void navigateToClientRoute(targetRoute, tc.id); } } } } } catch {} // If this assistant message matches one we created locally on 'done', update with real DB ID & keep turns const existingIdx = prev.messages.findIndex( (m) => m.role === 'assistant' && (m.id === message.id || (String(m.id).startsWith('msg-') && (m.message === message.message || !message.message))) ); if (existingIdx !== -1) { const next = [...prev.messages]; next[existingIdx] = { ...message, turns: next[existingIdx].turns || prev.activeTurns, }; return { messages: next, activeTurns: [], streamingText: '', conversationState: 'idle', isSending: false, }; } } const nextConversationState = hasAskUser ? 'idle' : prev.conversationState; const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase; const nextConversations = hasAskUser ? prev.conversations.map((c) => c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c ) : prev.conversations; return { messages: [...prev.messages, message], streamingText: isAssistant ? '' : prev.streamingText, conversationState: nextConversationState, streamPhase: nextStreamPhase, conversations: nextConversations, }; }); }, /** * Reset all chat state, close streams, and cancel timers. */ reset() { closeConversationStream(); disconnectCompanyEvents(); chatAutomat.setState(initialState); }, }; chatAutomat = new Automat(initialState, actions, { name: 'chat' }); // Listen to auth changes: disconnect on logout, reconnect on relogin/company change. let chatWasAuthed = false; authAutomat.subscribe((auth) => { const companyId = auth.user?.selectedCompanyId; const isAuthed = auth.status === 'authed' && companyId != null; const relogin = isAuthed && !chatWasAuthed; chatWasAuthed = isAuthed; if (auth.status === 'anon' || !companyId) { disconnectCompanyEvents(); chatAutomat.actions.reset(); } else if (relogin || (auth.status === 'authed' && companyId)) { if (chatAutomat.state.sseEstablished) { ensureCompanyEventsConnected(); void chatAutomat.actions.loadConversations(true); } } }); // Plain helper selectors export const selectChatIsOpen = () => chatAutomat.state.isOpen; export const selectChatState = () => chatAutomat.state.conversationState; export const selectChatMessages = () => chatAutomat.state.messages; export const selectChatConversations = () => chatAutomat.state.conversations; export const selectSelectedConversationId = () => chatAutomat.state.selectedConversationId; export const selectSseStatus = () => chatAutomat.state.sseStatus; export const selectChatActiveTurns = () => chatAutomat.state.activeTurns; /** * Extract any pending unanswered ask_user tool call from the given/current chat state. * @param {ChatState} [state] * @returns {{ toolCallId: string, question: string, options: string[] } | null} */ export function selectPendingAskUser(state = chatAutomat.state) { const { messages, streamingText, activeTurns } = state; // 1. Check live activeTurns for any ask_user tool call if (activeTurns && activeTurns.length > 0) { for (let i = activeTurns.length - 1; i >= 0; i--) { const turn = activeTurns[i]; const tools = turn.tools || []; for (let j = tools.length - 1; j >= 0; j--) { const tool = tools[j]; if (tool.name === 'ask_user') { let args = {}; try { if (typeof tool.content === 'string') { args = JSON.parse(tool.content); } else if (tool.content && typeof tool.content === 'object') { args = tool.content; } } catch {} if (!args.question && tool.arguments) { try { args = typeof tool.arguments === 'string' ? JSON.parse(tool.arguments) : tool.arguments; } catch {} } if (args.question) { return { toolCallId: tool.id, question: args.question, options: Array.isArray(args.options) ? args.options : [], }; } } } } } // 2. Check message history for any un-answered ask_user if (streamingText || !messages || messages.length === 0) return null; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === 'system' || msg.role === 'usage' || msg.role === 'reasoning') continue; if (msg.role === 'user') return null; if (msg.role === 'tool') { try { const p = typeof msg.message === 'string' ? JSON.parse(msg.message) : msg.message; if (p?.name === 'ask_user' && !p.content && !p.answer) { let args = {}; try { args = typeof p.parameters === 'string' ? JSON.parse(p.parameters) : (p.parameters || {}); } catch {} if (args.question) { return { toolCallId: p.tool_call_id || msg.id, question: args.question, options: Array.isArray(args.options) ? args.options : [], }; } } } catch {} return null; } if (msg.role === 'assistant' || msg.role === 'model') { let payload = null; if (typeof msg.message === 'string') { try { payload = JSON.parse(msg.message); } catch {} } else if (typeof msg.message === 'object' && msg.message !== null) { payload = msg.message; } if (!payload?.tool_calls || !Array.isArray(payload.tool_calls) || payload.tool_calls.length === 0) { return null; } const askCall = payload.tool_calls.find((tc) => tc.function?.name === 'ask_user'); if (!askCall) return null; const answered = messages.slice(i + 1).some((m) => { if (m.role !== 'tool') return false; try { const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; return p?.tool_call_id === askCall.id; } catch { return false; } }); if (answered) return null; let args = {}; try { if (typeof askCall.function?.arguments === 'string') { args = JSON.parse(askCall.function.arguments); } else if (askCall.function?.arguments) { args = askCall.function.arguments; } } catch {} return { toolCallId: askCall.id, question: args.question || '', options: Array.isArray(args.options) ? args.options : [], }; } return null; } return null; } export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state)); export default chatAutomat;