1540 lines
50 KiB
JavaScript
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;
|