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} +
);