u
This commit is contained in:
843
src/automats/chatAutomat.js
Normal file
843
src/automats/chatAutomat.js
Normal file
@@ -0,0 +1,843 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import { modelconversation as modelconversationApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @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: 'stealth/space-bunny-alpha', label: 'Space Bunny Alpha' },
|
||||
];
|
||||
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,
|
||||
conversationState: 'idle',
|
||||
provider: 'openrouter',
|
||||
model: DEFAULT_MODEL,
|
||||
messages: [],
|
||||
loadingMessages: false,
|
||||
isSending: false,
|
||||
sendError: null,
|
||||
error: null,
|
||||
streamPhase: 'idle',
|
||||
streamingText: '',
|
||||
lastUsage: null,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 es = new EventSource('/api/events', { 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 event: thinking -> working -> done (usage on done)
|
||||
es.addEventListener('state', (event) => {
|
||||
try {
|
||||
if (!event.data) return;
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.state) {
|
||||
chatAutomat.actions.applyStreamState(data.state, data.usage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chatAutomat] Failed to parse state event payload:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Incremental assistant text chunks (`text` holds the full text so far)
|
||||
es.addEventListener('delta', (event) => {
|
||||
try {
|
||||
if (!event.data) return;
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof data?.text === 'string') {
|
||||
chatAutomat.setState({ streamingText: data.text });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chatAutomat] Failed to parse delta 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();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a granular SSE state ('thinking' | 'working' | 'done', or legacy 'busy' | 'idle').
|
||||
* The DB-level conversationState stays binary (busy while thinking/working).
|
||||
* @param {string} state
|
||||
* @param {object} [usage] Token/cost usage, only sent with 'done'
|
||||
*/
|
||||
applyStreamState(state, usage) {
|
||||
/** Local-only system line shown in the chat flow for each state event. */
|
||||
const stateLine = (text) => ({
|
||||
id: `state-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
conversation_id: chatAutomat.state.selectedConversationId,
|
||||
role: 'system',
|
||||
message: text,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (state === 'thinking' || state === 'working' || state === 'busy') {
|
||||
const phase = state === 'busy' ? 'thinking' : state;
|
||||
const text = phase === 'thinking' ? 'Denkt nach…' : 'Generiert Antwort…';
|
||||
chatAutomat.setState((prev) => ({
|
||||
streamPhase: phase,
|
||||
messages: [...prev.messages, stateLine(text)],
|
||||
}));
|
||||
chatAutomat.actions.setConversationState('busy');
|
||||
} else if (state === 'done' || state === 'idle') {
|
||||
let text = '';
|
||||
if (usage) {
|
||||
const input = usage.input ?? usage.prompt_tokens ?? 0;
|
||||
const output = usage.output ?? usage.completion_tokens ?? 0;
|
||||
const cached = usage.cached ?? 0;
|
||||
const cost = typeof usage.cost === 'number' ? ` · $${usage.cost.toFixed(6)}` : '';
|
||||
const cachedPart = cached ? ` / ${cached} Cache` : '';
|
||||
text = `${input} Prompt${cachedPart} / ${output} Antwort${cost}`;
|
||||
}
|
||||
chatAutomat.setState((prev) => ({
|
||||
streamPhase: 'idle',
|
||||
streamingText: '',
|
||||
lastUsage: usage || prev.lastUsage,
|
||||
messages: state === 'done' && text ? [...prev.messages, stateLine(text)] : prev.messages,
|
||||
}));
|
||||
chatAutomat.actions.setConversationState('idle');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a user message via POST /api/modelconversation/:id/message.
|
||||
* Rejects immediately if conversation is busy (409 Conflict avoidance).
|
||||
* @param {string} text
|
||||
*/
|
||||
async sendMessage(text) {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) 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 tempId = `temp-${Date.now()}`;
|
||||
const optimisticMsg = {
|
||||
id: tempId,
|
||||
conversation_id: selectedConversationId,
|
||||
role: 'user',
|
||||
message: trimmed,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: [...prev.messages, optimisticMsg],
|
||||
conversationState: 'busy',
|
||||
streamPhase: 'thinking',
|
||||
streamingText: '',
|
||||
lastUsage: null,
|
||||
isSending: true,
|
||||
sendError: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed });
|
||||
const createdMsg = res?.message || optimisticMsg;
|
||||
chatAutomat.setState((prev) => {
|
||||
// If SSE already added/replaced this message by real ID, avoid adding again
|
||||
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
|
||||
if (alreadyHasRealId) {
|
||||
return {
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
isSending: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
||||
isSending: false,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
const isConflict = err?.status === 409;
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
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,
|
||||
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(),
|
||||
};
|
||||
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: [...prev.messages, optimisticMsg],
|
||||
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),
|
||||
isSending: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
||||
isSending: false,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
const isConflict = err?.status === 409;
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// The 'done' state event marks the end of normal response, but ask_user also transitions to idle.
|
||||
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();
|
||||
if (companyEventSource) {
|
||||
try {
|
||||
companyEventSource.close();
|
||||
} catch {}
|
||||
companyEventSource = null;
|
||||
}
|
||||
if (companyReconnectTimer) {
|
||||
clearTimeout(companyReconnectTimer);
|
||||
companyReconnectTimer = null;
|
||||
}
|
||||
chatAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
chatAutomat = new Automat(initialState, actions, { name: 'chat' });
|
||||
|
||||
// Listen to auth changes: reset on logout or company change
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'anon' || !companyId) {
|
||||
chatAutomat.actions.reset();
|
||||
} else if (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;
|
||||
|
||||
/**
|
||||
* 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 } = state;
|
||||
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') continue;
|
||||
if (msg.role === 'tool' || msg.role === 'user') 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;
|
||||
Reference in New Issue
Block a user