Files
dashfront/src/automats/chatAutomat.js
2026-10-09 16:44:18 +02:00

1540 lines
50 KiB
JavaScript

/**
* @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;