diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js
index e185e61..6cb09ae 100644
--- a/src/automats/chatAutomat.js
+++ b/src/automats/chatAutomat.js
@@ -88,6 +88,7 @@ const initialState = {
streamPhase: 'idle',
streamingText: '',
lastUsage: null,
+ activeTurns: [],
};
let chatAutomat;
@@ -190,31 +191,57 @@ function ensureConversationStream(conversationId) {
}
});
- // 2. Live state event: thinking -> working -> done (usage on done)
+ // 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?.state) {
- chatAutomat.actions.applyStreamState(data.state, data.usage);
+ if (data) {
+ chatAutomat.actions.applyStreamState(data);
}
} catch (err) {
console.warn('[chatAutomat] Failed to parse state event payload:', err);
}
});
- // 3. Incremental assistant text chunks (`text` holds the full text so far)
+ // 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 (typeof data?.text === 'string') {
- chatAutomat.setState({ streamingText: data.text });
+ 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() {
@@ -259,6 +286,7 @@ const actions = {
streamPhase: 'idle',
streamingText: '',
lastUsage: null,
+ activeTurns: [],
sendError: null,
});
},
@@ -499,46 +527,439 @@ const actions = {
},
/**
- * 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'
+ * SSE Event: {"state":"connected"}
+ * Handshake turns black. If previous turn was sealed, starts a new turn on a new line!
*/
- 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}`;
+ 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',
+ };
}
- chatAutomat.setState((prev) => ({
- streamPhase: 'idle',
- streamingText: '',
- lastUsage: usage || prev.lastUsage,
- messages: state === 'done' && text ? [...prev.messages, stateLine(text)] : prev.messages,
+ 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"}
+ * Prepares for reasoning in current turn. Any previous unsealed reasoning is sealed.
+ */
+ applyStreamReasoningState() {
+ 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: '', sealed: false }],
+ tools: [],
+ usage: null,
+ content: '',
+ sealed: false,
+ });
+ return { activeTurns: turns, streamPhase: 'thinking' };
+ }
+ const lastIdx = turns.length - 1;
+ const lastTurn = { ...turns[lastIdx] };
+ const existingR = lastTurn.reasonings || [];
+ const lastR = existingR[existingR.length - 1];
+
+ // If the last reasoning is already unsealed and waiting for text, do not create a duplicate
+ if (lastR && !lastR.sealed && !lastR.text?.trim()) {
+ return { streamPhase: 'thinking' };
+ }
+
+ // Filter out empty reasonings and seal existing ones
+ const nextR = existingR
+ .filter((r) => r.text && r.text.trim())
+ .map((r) => ({ ...r, sealed: true }));
+
+ nextR.push({
+ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
+ text: '',
+ sealed: false,
+ });
+ lastTurn.reasonings = nextR;
+ turns[lastIdx] = lastTurn;
+ return { activeTurns: turns, streamPhase: 'thinking' };
+ });
+ },
+
+ /**
+ * SSE Event: event: reasoning, data: {"text":"..."}
+ * Adds brain icon right of handshake icon (if not added yet).
+ * Tooltip shows reasoning text. Updates as chunks stream in.
+ */
+ applyStreamReasoning(data) {
+ const text = typeof data?.text === 'string' ? data.text : (typeof data === 'string' ? data : '');
+ if (!text) 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, sealed: false }],
+ tools: [],
+ usage: null,
+ content: '',
+ sealed: false,
+ });
+ return { activeTurns: turns, streamPhase: 'thinking' };
+ }
+ const lastIdx = turns.length - 1;
+ const lastTurn = { ...turns[lastIdx] };
+ const rList = lastTurn.reasonings ? [...lastTurn.reasonings] : [];
+ let unsealedIdx = -1;
+ for (let i = rList.length - 1; i >= 0; i--) {
+ if (!rList[i].sealed) {
+ unsealedIdx = i;
+ break;
+ }
+ }
+ if (unsealedIdx >= 0) {
+ rList[unsealedIdx] = { ...rList[unsealedIdx], text };
+ } else {
+ rList.push({ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text, 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) {
+ 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: 'working',
+ conversationState: hasAskUser ? 'idle' : prev.conversationState,
+ };
+ });
+ },
+
+ /**
+ * 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 }));
+ 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] };
+ 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 || []).map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)),
}));
- chatAutomat.actions.setConversationState('idle');
+
+ // 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();
}
},
@@ -574,8 +995,20 @@ const actions = {
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: '',
@@ -588,23 +1021,21 @@ const actions = {
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),
+ activeTurns: isConflict ? prev.activeTurns : [],
conversationState: isConflict ? 'busy' : 'idle',
streamPhase: isConflict ? prev.streamPhase : 'idle',
isSending: false,
@@ -646,8 +1077,19 @@ const actions = {
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) => ({
messages: [...prev.messages, optimisticMsg],
+ activeTurns: [initialTurn],
conversationState: 'busy',
streamPhase: 'thinking',
streamingText: '',
@@ -664,18 +1106,17 @@ const actions = {
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),
+ activeTurns: isConflict ? prev.activeTurns : [],
conversationState: isConflict ? 'busy' : 'idle',
streamPhase: isConflict ? prev.streamPhase : 'idle',
isSending: false,
@@ -729,6 +1170,29 @@ const actions = {
hasAskUser = true;
}
} 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;
@@ -739,7 +1203,6 @@ const actions = {
)
: 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,
@@ -792,19 +1255,76 @@ 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 } = 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') continue;
- if (msg.role === 'tool' || msg.role === 'user') return null;
+ 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;
diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx
index 8a3bfb5..b37d799 100644
--- a/src/components/chat/ChatDrawer.jsx
+++ b/src/components/chat/ChatDrawer.jsx
@@ -48,6 +48,7 @@ export default class ChatDrawer extends PureComponent {
loadingConversations: s.loadingConversations,
sseStatus: s.sseStatus,
sendError: s.sendError,
+ activeTurns: s.activeTurns || [],
inputText: '',
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false,
@@ -88,6 +89,7 @@ export default class ChatDrawer extends PureComponent {
loadingConversations: state.loadingConversations,
sseStatus: state.sseStatus,
sendError: state.sendError,
+ activeTurns: state.activeTurns || [],
};
});
@@ -409,7 +411,7 @@ export default class ChatDrawer extends PureComponent {
) : (
diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx
index e8010fb..11571be 100644
--- a/src/components/chat/ChatFlowList.jsx
+++ b/src/components/chat/ChatFlowList.jsx
@@ -1,6 +1,11 @@
import React, { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
+import Tooltip from '@mui/material/Tooltip';
+import ClickAwayListener from '@mui/material/ClickAwayListener';
+import HandshakeRoundedIcon from '@mui/icons-material/HandshakeRounded';
+import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
+import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import HealingMarkdown from './HealingMarkdown';
import {
SHADOW_BUBBLE_ASSISTANT,
@@ -30,7 +35,511 @@ function parseMessagePayload(raw) {
return { content: raw };
}
+function formatUsageText(usage) {
+ if (!usage) return '';
+ const input = Number(usage.input ?? usage.prompt_tokens ?? 0);
+ const output = Number(usage.output ?? usage.completion_tokens ?? 0);
+ const cached = Number(usage.cached ?? 0);
+ const cost = typeof usage.cost === 'number' ? ` · $${usage.cost.toFixed(6)}` : '';
+ const fmt = (n) => n.toLocaleString('de-DE');
+ const cachedPart = cached > 0 ? ` / ${fmt(cached)} Cache` : '';
+ return `${fmt(input)} Prompt${cachedPart} · ${fmt(output)} Antwort${cost}`;
+}
+
+function formatToolTooltip(content, result, parameters) {
+ let text = '';
+ if (parameters !== undefined && parameters !== null && parameters !== '') {
+ try {
+ const parsed = typeof parameters === 'string' ? JSON.parse(parameters) : parameters;
+ text = 'Aufruf:\n' + (typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : String(parameters));
+ } catch {
+ text = 'Aufruf:\n' + String(parameters);
+ }
+ }
+
+ let contentText = '';
+ if (content !== undefined && content !== null && content !== '') {
+ try {
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content;
+ contentText = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : String(content);
+ } catch {
+ contentText = String(content);
+ }
+ }
+
+ if (contentText) {
+ if (text) {
+ text += '\n\nErgebnis:\n' + contentText;
+ } else {
+ text = contentText;
+ }
+ }
+
+ if (result !== undefined && result !== null) {
+ try {
+ const resStr = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result);
+ if (!text.includes(resStr)) {
+ text = text ? text + '\n\n' + resStr : resStr;
+ }
+ } catch {}
+ }
+
+ return text || 'Werkzeug wird ausgeführt…';
+}
+
+class ClickTooltip extends PureComponent {
+ state = { open: false };
+
+ handleClose = () => {
+ if (this.state.open) this.setState({ open: false });
+ };
+
+ handleToggle = (e) => {
+ e?.stopPropagation();
+ this.setState((prev) => ({ open: !prev.open }));
+ };
+
+ render() {
+ const { title, children } = this.props;
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+ }
+}
+
+function HandshakeIconView({ state }) {
+ const isGreen = state === 'green';
+ const isBlack = state === 'black';
+
+ return (
+ ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 24,
+ height: 24,
+ color: isGreen
+ ? theme.palette.mode === 'dark'
+ ? '#66bb6a'
+ : '#2e7d32'
+ : isBlack
+ ? theme.palette.mode === 'dark'
+ ? '#f0f0f0'
+ : '#111111'
+ : '#9e9e9e',
+ transition: 'color 180ms ease-in-out',
+ })}
+ >
+
+
+ );
+}
+
+function BrainIconView({ reasoning }) {
+ const tooltipContent = reasoning?.text?.trim() || '';
+ if (!tooltipContent) return null;
+
+ return (
+
+ {tooltipContent}
+
+ }
+ >
+ ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 24,
+ height: 24,
+ color: theme.palette.mode === 'dark' ? '#b388ff' : '#651fff',
+ borderRadius: 1,
+ '&:hover': {
+ bgcolor: 'action.hover',
+ },
+ })}
+ >
+
+
+
+ );
+}
+
+function WorkIconView({ tool }) {
+ const isGreen = tool?.state === 'green';
+ const tooltipText = formatToolTooltip(tool?.content, tool?.result, tool?.parameters);
+
+ return (
+
+ {tooltipText}
+
+ }
+ >
+ ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ height: 24,
+ gap: 0.5,
+ color: isGreen
+ ? theme.palette.mode === 'dark'
+ ? '#66bb6a'
+ : '#2e7d32'
+ : theme.palette.mode === 'dark'
+ ? '#f0f0f0'
+ : '#111111',
+ px: 0.25,
+ borderRadius: 1,
+ '&:hover': {
+ bgcolor: 'action.hover',
+ },
+ })}
+ >
+
+ {tool?.name ? (
+
+ {tool.name}
+
+ ) : null}
+
+
+ );
+}
+
+function UsageChipView({ usage }) {
+ const text = formatUsageText(usage);
+ if (!text) return null;
+
+ return (
+
+ {text}
+
+ );
+}
+
+export function TurnRow({ turn, isLive = false }) {
+ if (!turn) return null;
+
+ const hasContent = Boolean(turn.content);
+
+ return (
+
+ {/* Turn status line: left of the chat */}
+
+
+
+ {turn.reasonings?.map((r, i) => (
+
+ ))}
+
+ {turn.tools?.map((t, i) => (
+
+ ))}
+
+
+ {/* Assistant message content for this turn (if any) */}
+ {hasContent ? (
+ ({
+ maxWidth: '85%',
+ px: 2,
+ py: 1.25,
+ borderRadius: '16px 16px 16px 4px',
+ bgcolor:
+ theme.palette.mode === 'dark'
+ ? 'background.paper'
+ : SURFACE_RAISED,
+ color: theme.palette.text.primary,
+ boxShadow: SHADOW_BUBBLE_ASSISTANT,
+ border: `1px solid ${theme.palette.divider}`,
+ wordBreak: 'break-word',
+ fontSize: '0.875rem',
+ lineHeight: 1.5,
+ })}
+ >
+
+
+ ) : null}
+
+ {/* Pricing / usage is always on a new line */}
+ {turn.usage ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export function normalizeChatMessages(rawMessages = []) {
+ if (!Array.isArray(rawMessages) || rawMessages.length === 0) return [];
+
+ const result = [];
+ let currentAssistantTurns = [];
+ let currentTurn = null;
+
+ const flushAssistant = () => {
+ if (currentTurn) {
+ if (
+ (currentTurn.reasonings && currentTurn.reasonings.length > 0) ||
+ (currentTurn.tools && currentTurn.tools.length > 0) ||
+ currentTurn.content ||
+ currentTurn.usage
+ ) {
+ currentAssistantTurns.push(currentTurn);
+ }
+ currentTurn = null;
+ }
+ if (currentAssistantTurns.length > 0) {
+ const fullText = currentAssistantTurns
+ .map((t) => t.content)
+ .filter(Boolean)
+ .join('\n\n');
+
+ result.push({
+ id: currentAssistantTurns[0]?.id ? `asst-${currentAssistantTurns[0].id}` : `asst-${result.length}`,
+ role: 'assistant',
+ message: fullText,
+ turns: currentAssistantTurns,
+ created_at: currentAssistantTurns[0]?.created_at,
+ });
+ currentAssistantTurns = [];
+ }
+ };
+
+ let turnSeq = 0;
+ const getOrCreateTurn = (msg) => {
+ const shouldStartNew =
+ !currentTurn ||
+ currentTurn.sealed ||
+ (msg?.role === 'reasoning' && (currentTurn.tools.length > 0 || Boolean(currentTurn.content))) ||
+ (msg?.role === 'tool' && Boolean(currentTurn.content));
+
+ if (!currentTurn) {
+ turnSeq++;
+ currentTurn = {
+ id: msg?.id ? `turn-msg-${msg.id}` : `turn-${result.length}-${turnSeq}`,
+ handshake: 'green',
+ reasonings: [],
+ tools: [],
+ content: '',
+ usage: null,
+ sealed: false,
+ created_at: msg?.created_at,
+ };
+ } else if (shouldStartNew) {
+ currentAssistantTurns.push(currentTurn);
+ turnSeq++;
+ currentTurn = {
+ id: msg?.id ? `turn-msg-${msg.id}` : `turn-${result.length}-${turnSeq}`,
+ handshake: 'green',
+ reasonings: [],
+ tools: [],
+ content: '',
+ usage: null,
+ sealed: false,
+ created_at: msg?.created_at,
+ };
+ }
+ return currentTurn;
+ };
+
+ for (const msg of rawMessages) {
+ if (!msg) continue;
+
+ if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
+ flushAssistant();
+ result.push(msg);
+ continue;
+ }
+
+ const payload = parseMessagePayload(msg.message);
+
+ if (msg.role === 'user') {
+ flushAssistant();
+ result.push({
+ ...msg,
+ message: payload.content !== undefined ? payload.content : msg.message,
+ });
+ continue;
+ }
+
+ if (msg.role === 'reasoning') {
+ const turn = getOrCreateTurn(msg);
+ const text =
+ payload.plain ||
+ payload.text ||
+ payload.content ||
+ payload.thinking ||
+ payload.reasoning ||
+ (typeof msg.message === 'string' ? msg.message : '');
+ turn.reasonings.push({
+ id: msg.id || `r-${turn.reasonings.length}`,
+ text,
+ sealed: true,
+ });
+ continue;
+ }
+
+ if (msg.role === 'tool') {
+ const turn = getOrCreateTurn(msg);
+ turn.tools.push({
+ id: payload.tool_call_id || msg.id,
+ state: 'green',
+ name: payload.name || '',
+ content: payload.content !== undefined ? payload.content : (payload.parameters || ''),
+ parameters: payload.parameters,
+ result: payload.result,
+ });
+ continue;
+ }
+
+ if (msg.role === 'usage') {
+ const turn = getOrCreateTurn(msg);
+ turn.usage = payload;
+ turn.sealed = true;
+ continue;
+ }
+
+ if (msg.role === 'assistant' || msg.role === 'model') {
+ const toolCalls = Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? payload.tool_calls : null;
+ if (toolCalls) {
+ const turn = getOrCreateTurn(msg);
+ for (const tc of toolCalls) {
+ let args = {};
+ try {
+ args = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {});
+ } catch {}
+ turn.tools.push({
+ id: tc.id || `tc-${Date.now()}`,
+ state: 'green',
+ name: tc.function?.name || 'tool',
+ content: JSON.stringify(args, null, 2),
+ result: null,
+ });
+ }
+ }
+
+ const content = payload.content !== undefined ? payload.content : msg.message;
+ if (content && typeof content === 'string') {
+ const turn = getOrCreateTurn(msg);
+ turn.content = content;
+ }
+ continue;
+ }
+
+ flushAssistant();
+ result.push(msg);
+ }
+
+ flushAssistant();
+ return result;
+}
+
function MessageRow({ msg, showTime = true }) {
+ // If the message has structured turns, render each turn
+ if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
+ return (
+
+ {msg.turns.map((t, idx) => (
+
+ ))}
+
+ );
+ }
const payload = parseMessagePayload(msg.message);
const textContent = payload.content !== undefined ? payload.content : msg.message;
@@ -39,7 +548,7 @@ function MessageRow({ msg, showTime = true }) {
const isUser = msg.role === 'user';
const isSystem = msg.role === 'system';
const isTool = msg.role === 'tool';
- const isReasoning = msg.role === "reasoning" || Boolean(payload.reasoning_content && !toolCalls && payload.content === payload.reasoning_content);
+ const isReasoning = msg.role === 'reasoning' || Boolean(payload.reasoning_content && !toolCalls && payload.content === payload.reasoning_content);
const timeStr = showTime ? formatMessageTime(msg.created_at) : '';
if (isSystem) {
@@ -65,52 +574,45 @@ function MessageRow({ msg, showTime = true }) {
}
if (isReasoning) {
+ if (!textContent?.trim()) return null;
+ const singleTurn = {
+ id: msg.id || 'r-turn',
+ handshake: 'green',
+ reasonings: [{ id: msg.id || 'r', text: textContent, sealed: true }],
+ tools: [],
+ };
return (
-
- ({
- bgcolor: 'transparent',
- color: 'text.disabled',
- px: 1.5,
- py: 0.5,
- borderRadius: 2,
- fontSize: '0.75rem',
- fontStyle: 'italic',
- maxWidth: '85%',
- borderLeft: `2px solid ${theme.palette.divider}`,
- whiteSpace: 'pre-wrap',
- })}
- >
- Gedankengang
- {textContent}
-
+
+
);
}
if (toolCalls) {
- const formattedCalls = toolCalls.map((tc) => {
- let args = {};
- try {
- if (typeof tc.function?.arguments === 'string') {
- args = JSON.parse(tc.function.arguments);
- } else if (tc.function?.arguments) {
- args = tc.function.arguments;
+ const formattedCalls = toolCalls
+ .map((tc) => {
+ let args = {};
+ try {
+ if (typeof tc.function?.arguments === 'string') {
+ args = JSON.parse(tc.function.arguments);
+ } else if (tc.function?.arguments) {
+ args = tc.function.arguments;
+ }
+ } catch {}
+
+ if (tc.function?.name === 'ask_user') {
+ const q = args.question || '';
+ return `ask_user: ${q}`;
}
- } catch {}
- if (tc.function?.name === 'ask_user') {
- const q = args.question || '';
- return `ask_user: ${q}`;
- }
-
- const method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL');
- const path = args.path || args.url || '';
- if (path) {
- return `${method} ${path}`;
- }
- return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`;
- }).join('\n');
+ const method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL');
+ const path = args.path || args.url || '';
+ if (path) {
+ return `${method} ${path}`;
+ }
+ return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`;
+ })
+ .join('\n');
return (
@@ -194,7 +696,9 @@ function MessageRow({ msg, showTime = true }) {
? resObj.answer || resObj.content || ''
: statusText
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
- : (bodyLength != null ? `${bodyLength} byte` : String(textContent));
+ : bodyLength != null
+ ? `${bodyLength} byte`
+ : String(textContent);
return (
@@ -280,12 +784,8 @@ function MessageRow({ msg, showTime = true }) {
: theme.palette.mode === 'dark'
? 'background.paper'
: SURFACE_RAISED,
- color: isUser
- ? theme.palette.primary.contrastText
- : theme.palette.text.primary,
- boxShadow: isUser
- ? SHADOW_BUBBLE_USER
- : SHADOW_BUBBLE_ASSISTANT,
+ color: isUser ? theme.palette.primary.contrastText : theme.palette.text.primary,
+ boxShadow: isUser ? SHADOW_BUBBLE_USER : SHADOW_BUBBLE_ASSISTANT,
border: isUser ? 'none' : `1px solid ${theme.palette.divider}`,
wordBreak: 'break-word',
whiteSpace: isUser ? 'pre-wrap' : 'normal',
@@ -316,7 +816,8 @@ export default class ChatFlowList extends PureComponent {
componentDidUpdate(prevProps) {
if (
this.props.messages !== prevProps.messages ||
- this.props.messages?.length !== prevProps.messages?.length
+ this.props.messages?.length !== prevProps.messages?.length ||
+ this.props.activeTurns !== prevProps.activeTurns
) {
this.scrollToBottom('smooth');
}
@@ -348,9 +849,10 @@ export default class ChatFlowList extends PureComponent {
};
render() {
- const { messages = [] } = this.props;
+ const { messages = [], activeTurns = [] } = this.props;
+ const normalizedMessages = normalizeChatMessages(messages);
- if (messages.length === 0) {
+ if (normalizedMessages.length === 0 && activeTurns.length === 0) {
return null;
}
@@ -370,8 +872,8 @@ export default class ChatFlowList extends PureComponent {
gap: 0.25,
}}
>
- {messages.map((msg, index) => {
- const prevMsg = index > 0 ? messages[index - 1] : null;
+ {normalizedMessages.map((msg, index) => {
+ const prevMsg = index > 0 ? normalizedMessages[index - 1] : null;
const currTime = formatMessageTime(msg.created_at);
const prevTime = prevMsg ? formatMessageTime(prevMsg.created_at) : '';
const showTime = Boolean(currTime && currTime !== prevTime);
@@ -384,6 +886,14 @@ export default class ChatFlowList extends PureComponent {
/>
);
})}
+
+ {/* Live streaming turns */}
+ {activeTurns && activeTurns.length > 0
+ ? activeTurns.map((turn, idx) => (
+
+ ))
+ : null}
+
);