Compare commits

..

4 Commits

Author SHA1 Message Date
seb
96f8da8c64 u 2026-10-05 23:59:41 +02:00
seb
4445bc259e u 2026-10-05 23:35:51 +02:00
seb
2daeb7ebbf u 2026-10-05 23:22:35 +02:00
seb
dd011a26f2 u 2026-10-05 22:17:57 +02:00
8 changed files with 1108 additions and 256 deletions

View File

@@ -1,3 +1,4 @@
import { lazy } from 'react';
import { Navigate, createBrowserRouter } from 'react-router-dom';
import AppShell from './components/AppShell';
import SectionLayout from './components/SectionLayout';
@@ -24,11 +25,10 @@ import KundeListePage from './routes/kunde';
import KundeNeuPage from './routes/kunde/neu';
import KundeGruppenPage from './routes/kunde/gruppen';
import KundeEditPage from './routes/kunde/_entityId';
import {
ArticleHistoryPage,
GlobalHistoryPage,
StatisticsPage,
} from './routes/infos';
import StatisticsPage from './routes/infos/StatisticsPage';
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
const routes = [
{

View File

@@ -9,7 +9,7 @@
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
*/
import { createGridAutomat } from 'react-virtual-aggridautomat';
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
import { article } from '../api';
import authAutomat from './authAutomat';

View File

@@ -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,413 @@ 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"}
* Sets stream phase to thinking and seals any previous reasoning.
* Does NOT create an empty icon — the icon is only created when reasoning text arrives.
*/
applyStreamReasoningState() {
chatAutomat.setState((prev) => {
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
if (turns.length === 0) return { streamPhase: 'thinking' };
const lastIdx = turns.length - 1;
const lastTurn = { ...turns[lastIdx] };
// Seal any existing reasoning that has text
lastTurn.reasonings = (lastTurn.reasonings || [])
.filter((r) => r.text && r.text.trim())
.map((r) => ({ ...r, sealed: true }));
turns[lastIdx] = lastTurn;
return { activeTurns: turns, streamPhase: 'thinking' };
});
},
/**
* SSE Event: event: reasoning, data: {"text":"..."}
* Adds brain icon with text as chunks stream in.
*/
applyStreamReasoning(data) {
const text = typeof data?.text === 'string' ? data.text : (typeof data === 'string' ? data : '');
if (!text || !text.trim()) 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 || []).map((r) => ({ ...r }));
let unsealedIdx = -1;
for (let i = rList.length - 1; i >= 0; i--) {
if (!rList[i].sealed) {
unsealedIdx = i;
break;
}
}
if (unsealedIdx >= 0) {
rList[unsealedIdx].text = 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 +969,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 +995,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 +1051,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 +1080,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 +1144,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 +1177,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 +1229,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;

View File

@@ -9,7 +9,7 @@
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
*/
import { createGridAutomat } from 'react-virtual-aggridautomat';
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
import { globalHistory } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, Suspense } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Popover from '@mui/material/Popover';
import MenuList from '@mui/material/MenuList';
import MenuItem from '@mui/material/MenuItem';
@@ -198,7 +199,15 @@ export default function SectionLayout({ sectionPath }) {
showTitle={!isArticleDetail}
titleAction={titleAction}
>
<Outlet />
<Suspense
fallback={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
}
>
<Outlet />
</Suspense>
</PageLayout>
);
}

View File

@@ -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 {
) : (
<ChatFlowList
messages={
streamingText
streamingText && (!this.state.activeTurns || this.state.activeTurns.length === 0)
? [
...messages,
{
@@ -422,6 +424,7 @@ export default class ChatDrawer extends PureComponent {
]
: messages
}
activeTurns={this.state.activeTurns}
conversationState={conversationState}
model={model}
/>

View File

@@ -1,13 +1,17 @@
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 {
CHROMA,
SHADOW_BUBBLE_ASSISTANT,
SHADOW_BUBBLE_USER,
SURFACE_RAISED,
SURFACE_SUBTLE_PAIR,
byMode,
} from '../../colors';
function formatMessageTime(isoString) {
@@ -30,19 +34,517 @@ 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) {
const parts = [];
if (parameters !== undefined && parameters !== null && parameters !== '') {
try {
const parsed = typeof parameters === 'string' ? JSON.parse(parameters) : parameters;
parts.push(typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : String(parameters));
} catch {
parts.push(String(parameters));
}
}
if (content !== undefined && content !== null && content !== '') {
try {
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
const str = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : String(content);
if (!parts.includes(str)) {
parts.push(str);
}
} catch {
const str = String(content);
if (!parts.includes(str)) {
parts.push(str);
}
}
}
if (result !== undefined && result !== null) {
try {
const resStr = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result);
if (!parts.some((p) => p.includes(resStr))) {
parts.push(resStr);
}
} catch {}
}
return parts.join('\n\n') || '';
}
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 (
<ClickAwayListener onClickAway={this.handleClose}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', height: 24 }}>
<Tooltip
open={this.state.open}
onClose={this.handleClose}
disableFocusListener
disableHoverListener
disableTouchListener
enterDelay={0}
leaveDelay={0}
TransitionProps={{ timeout: 0 }}
slotProps={{ transition: { timeout: 0 } }}
title={title}
arrow
placement="top"
>
<Box
onClick={this.handleToggle}
sx={{
display: 'inline-flex',
alignItems: 'center',
height: 24,
cursor: 'pointer',
}}
>
{children}
</Box>
</Tooltip>
</Box>
</ClickAwayListener>
);
}
}
function HandshakeIconView({ state }) {
const isGreen = state === 'green';
const isBlack = state === 'black';
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
color: isGreen
? CHROMA.GREEN.dark
: isBlack
? '#000000'
: '#9e9e9e',
transition: 'color 180ms ease-in-out',
}}
>
<HandshakeRoundedIcon sx={{ fontSize: 20, color: 'inherit' }} />
</Box>
);
}
function BrainIconView({ reasoning }) {
const tooltipContent = reasoning?.text?.trim() || '';
if (!tooltipContent) return null;
return (
<ClickTooltip
title={
<Box
sx={{
p: 0.5,
maxWidth: 440,
maxHeight: 280,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
fontSize: '0.75rem',
lineHeight: 1.45,
}}
>
{tooltipContent}
</Box>
}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
color: CHROMA.PURPLE.dark,
borderRadius: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 20, color: 'inherit' }} />
</Box>
</ClickTooltip>
);
}
function WorkIconView({ tool }) {
const isGreen = tool?.state === 'green';
const tooltipText = formatToolTooltip(tool?.content, tool?.result, tool?.parameters);
return (
<ClickTooltip
title={
<Box
sx={{
p: 0.5,
maxWidth: 480,
maxHeight: 300,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: '0.72rem',
lineHeight: 1.4,
}}
>
{tooltipText}
</Box>
}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
height: 24,
gap: 0.5,
color: isGreen ? CHROMA.GREEN.dark : '#000000',
px: 0.25,
borderRadius: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<BuildRoundedIcon sx={{ fontSize: 18, color: 'inherit' }} />
{tool?.name ? (
<Typography
component="span"
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: 'text.secondary',
lineHeight: 1,
}}
>
{tool.name}
</Typography>
) : null}
</Box>
</ClickTooltip>
);
}
function UsageChipView({ usage }) {
const text = formatUsageText(usage);
if (!text) return null;
return (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '0.68rem',
fontFamily: 'monospace',
color: 'text.secondary',
bgcolor: 'action.hover',
px: 0.8,
py: 0.25,
borderRadius: 1.5,
whiteSpace: 'nowrap',
}}
>
{text}
</Box>
);
}
export function TurnRow({ turn, isLive = false }) {
if (!turn) return null;
const hasReasonings = Boolean(
turn.reasonings &&
turn.reasonings.some((r) => Boolean(r.text && r.text.trim()))
);
const hasTools = Boolean(turn.tools && turn.tools.length > 0);
const hasContent = Boolean(turn.content);
const showHandshake = !hasReasonings && !hasTools && !hasContent;
const showStatusLine = showHandshake || hasReasonings || hasTools;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
px: 2,
py: 0.4,
gap: 0.75,
maxWidth: '100%',
}}
>
{/* Turn status line: left of the chat */}
{showStatusLine ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
minHeight: 24,
lineHeight: 1,
}}
>
{showHandshake ? <HandshakeIconView state={turn.handshake} /> : null}
{turn.reasonings?.map((r, i) => (
<BrainIconView key={r.id || i} reasoning={r} />
))}
{turn.tools?.map((t, i) => (
<WorkIconView key={t.id || i} tool={t} />
))}
</Box>
) : null}
{/* Assistant message content for this turn (if any) */}
{hasContent ? (
<Box
sx={(theme) => ({
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,
})}
>
<HealingMarkdown text={turn.content} streaming={isLive && !turn.sealed} />
</Box>
) : null}
{/* Pricing / usage is always on a new line */}
{turn.usage ? (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<UsageChipView usage={turn.usage} />
</Box>
) : null}
</Box>
);
}
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{msg.turns.map((t, idx) => (
<TurnRow key={t.id || idx} turn={t} isLive={false} />
))}
</Box>
);
}
const payload = parseMessagePayload(msg.message);
const textContent = payload.content !== undefined ? payload.content : msg.message;
const toolCalls = Array.isArray(payload.tool_calls) && payload.tool_calls.length > 0 ? payload.tool_calls : null;
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 timeStr = showTime ? formatMessageTime(msg.created_at) : '';
if (isSystem) {
if (msg.role === 'system') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 0.75, px: 2 }}>
<Box
@@ -64,179 +566,8 @@ function MessageRow({ msg, showTime = true }) {
);
}
if (isReasoning) {
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.5, px: 2 }}>
<Box
sx={(theme) => ({
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',
})}
>
<Box sx={{ fontWeight: 600, fontSize: '0.68rem', mb: 0.25 }}>Gedankengang</Box>
{textContent}
</Box>
</Box>
);
}
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;
}
} 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');
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
<Box
sx={(theme) => ({
bgcolor: byMode(theme, SURFACE_SUBTLE_PAIR),
color: 'text.secondary',
px: 1.25,
py: 0.35,
borderRadius: 2,
fontSize: '0.72rem',
maxWidth: '90%',
fontFamily: 'monospace',
border: `1px solid ${theme.palette.divider}`,
display: 'inline-flex',
alignItems: 'center',
gap: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
<Box
component="span"
sx={{
fontWeight: 600,
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'text.disabled',
flexShrink: 0,
}}
>
Werkzeug
</Box>
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formattedCalls}
</Box>
</Box>
</Box>
);
}
if (isTool) {
let statusText = '';
let bodyLength = null;
let resObj = null;
if (typeof textContent === 'object' && textContent !== null) {
resObj = textContent;
} else if (typeof textContent === 'string') {
try {
const parsed = JSON.parse(textContent);
if (parsed && typeof parsed === 'object') {
resObj = parsed;
}
} catch {}
}
if (resObj && ('status' in resObj || 'statusCode' in resObj || 'body' in resObj)) {
const status = resObj.status ?? resObj.statusCode ?? 200;
statusText = `Status ${status}`;
if (typeof resObj.body === 'string') {
bodyLength = resObj.body.length;
} else if (resObj.body != null) {
bodyLength = JSON.stringify(resObj.body).length;
} else if (resObj.data != null) {
bodyLength = JSON.stringify(resObj.data).length;
}
} else if (typeof textContent === 'string') {
bodyLength = textContent.length;
}
const isToolAnswer =
Boolean(resObj) &&
('answer' in resObj || 'content' in resObj) &&
'tool_call_id' in resObj &&
!('status' in resObj);
const displayText = isToolAnswer
? resObj.answer || resObj.content || ''
: statusText
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
: (bodyLength != null ? `${bodyLength} byte` : String(textContent));
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
<Box
sx={(theme) => ({
bgcolor: byMode(theme, SURFACE_SUBTLE_PAIR),
color: 'text.secondary',
px: 1.25,
py: 0.35,
borderRadius: 2,
fontSize: '0.72rem',
maxWidth: '90%',
fontFamily: 'monospace',
border: `1px solid ${theme.palette.divider}`,
display: 'inline-flex',
alignItems: 'center',
gap: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
<Box
component="span"
sx={{
fontWeight: 600,
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'text.disabled',
flexShrink: 0,
}}
>
Ergebnis
</Box>
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{displayText}
</Box>
</Box>
</Box>
);
}
const isUser = msg.role === 'user';
const timeStr = showTime ? formatMessageTime(msg.created_at) : '';
return (
<Box
@@ -280,12 +611,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 +643,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 +676,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 +699,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 +713,14 @@ export default class ChatFlowList extends PureComponent {
/>
);
})}
{/* Live streaming turns */}
{activeTurns && activeTurns.length > 0
? activeTurns.map((turn, idx) => (
<TurnRow key={turn.id || idx} turn={turn} isLive={true} />
))
: null}
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
</Box>
);

View File

@@ -287,6 +287,15 @@ export default defineConfig(({ command }) => ({
},
},
},
chunkSizeWarningLimit: 1000,
modulePreload: {
resolveDependencies(filename, deps, { hostType }) {
if (hostType === 'html') {
return deps.filter((dep) => !dep.includes('ag-grid') && !dep.includes('AutomatGrid'));
}
return deps;
},
},
},
plugins: [
react(),