u
This commit is contained in:
@@ -88,6 +88,7 @@ const initialState = {
|
|||||||
streamPhase: 'idle',
|
streamPhase: 'idle',
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
lastUsage: null,
|
lastUsage: null,
|
||||||
|
activeTurns: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let chatAutomat;
|
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) => {
|
es.addEventListener('state', (event) => {
|
||||||
try {
|
try {
|
||||||
if (!event.data) return;
|
if (!event.data) return;
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data?.state) {
|
if (data) {
|
||||||
chatAutomat.actions.applyStreamState(data.state, data.usage);
|
chatAutomat.actions.applyStreamState(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[chatAutomat] Failed to parse state event payload:', 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) => {
|
es.addEventListener('delta', (event) => {
|
||||||
try {
|
try {
|
||||||
if (!event.data) return;
|
if (!event.data) return;
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (typeof data?.text === 'string') {
|
if (data) {
|
||||||
chatAutomat.setState({ streamingText: data.text });
|
chatAutomat.actions.applyStreamDelta(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[chatAutomat] Failed to parse delta event payload:', 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() {
|
function closeConversationStream() {
|
||||||
@@ -259,6 +286,7 @@ const actions = {
|
|||||||
streamPhase: 'idle',
|
streamPhase: 'idle',
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
lastUsage: null,
|
lastUsage: null,
|
||||||
|
activeTurns: [],
|
||||||
sendError: null,
|
sendError: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -499,46 +527,439 @@ const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a granular SSE state ('thinking' | 'working' | 'done', or legacy 'busy' | 'idle').
|
* SSE Event: {"state":"connected"}
|
||||||
* The DB-level conversationState stays binary (busy while thinking/working).
|
* Handshake turns black. If previous turn was sealed, starts a new turn on a new line!
|
||||||
* @param {string} state
|
|
||||||
* @param {object} [usage] Token/cost usage, only sent with 'done'
|
|
||||||
*/
|
*/
|
||||||
applyStreamState(state, usage) {
|
applyStreamConnected() {
|
||||||
/** Local-only system line shown in the chat flow for each state event. */
|
chatAutomat.setState((prev) => {
|
||||||
const stateLine = (text) => ({
|
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||||
id: `state-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
if (turns.length === 0) {
|
||||||
conversation_id: chatAutomat.state.selectedConversationId,
|
return {
|
||||||
role: 'system',
|
activeTurns: [
|
||||||
message: text,
|
{
|
||||||
created_at: new Date().toISOString(),
|
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
});
|
handshake: 'black',
|
||||||
|
reasonings: [],
|
||||||
if (state === 'thinking' || state === 'working' || state === 'busy') {
|
tools: [],
|
||||||
const phase = state === 'busy' ? 'thinking' : state;
|
usage: null,
|
||||||
const text = phase === 'thinking' ? 'Denkt nach…' : 'Generiert Antwort…';
|
content: '',
|
||||||
chatAutomat.setState((prev) => ({
|
sealed: false,
|
||||||
streamPhase: phase,
|
},
|
||||||
messages: [...prev.messages, stateLine(text)],
|
],
|
||||||
}));
|
conversationState: 'busy',
|
||||||
chatAutomat.actions.setConversationState('busy');
|
streamPhase: 'thinking',
|
||||||
} else if (state === 'done' || state === 'idle') {
|
};
|
||||||
let text = '';
|
|
||||||
if (usage) {
|
|
||||||
const input = usage.input ?? usage.prompt_tokens ?? 0;
|
|
||||||
const output = usage.output ?? usage.completion_tokens ?? 0;
|
|
||||||
const cached = usage.cached ?? 0;
|
|
||||||
const cost = typeof usage.cost === 'number' ? ` · $${usage.cost.toFixed(6)}` : '';
|
|
||||||
const cachedPart = cached ? ` / ${cached} Cache` : '';
|
|
||||||
text = `${input} Prompt${cachedPart} / ${output} Antwort${cost}`;
|
|
||||||
}
|
}
|
||||||
chatAutomat.setState((prev) => ({
|
const lastIdx = turns.length - 1;
|
||||||
streamPhase: 'idle',
|
const lastTurn = { ...turns[lastIdx] };
|
||||||
streamingText: '',
|
if (lastTurn.sealed) {
|
||||||
lastUsage: usage || prev.lastUsage,
|
const newTurn = {
|
||||||
messages: state === 'done' && text ? [...prev.messages, stateLine(text)] : prev.messages,
|
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(),
|
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) => ({
|
chatAutomat.setState((prev) => ({
|
||||||
messages: [...prev.messages, optimisticMsg],
|
messages: [...prev.messages, optimisticMsg],
|
||||||
|
activeTurns: [initialTurn],
|
||||||
conversationState: 'busy',
|
conversationState: 'busy',
|
||||||
streamPhase: 'thinking',
|
streamPhase: 'thinking',
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
@@ -588,23 +1021,21 @@ const actions = {
|
|||||||
const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed });
|
const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed });
|
||||||
const createdMsg = res?.message || optimisticMsg;
|
const createdMsg = res?.message || optimisticMsg;
|
||||||
chatAutomat.setState((prev) => {
|
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);
|
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
|
||||||
if (alreadyHasRealId) {
|
if (alreadyHasRealId) {
|
||||||
return {
|
return {
|
||||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||||
isSending: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
||||||
isSending: false,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const isConflict = err?.status === 409;
|
const isConflict = err?.status === 409;
|
||||||
chatAutomat.setState((prev) => ({
|
chatAutomat.setState((prev) => ({
|
||||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||||
|
activeTurns: isConflict ? prev.activeTurns : [],
|
||||||
conversationState: isConflict ? 'busy' : 'idle',
|
conversationState: isConflict ? 'busy' : 'idle',
|
||||||
streamPhase: isConflict ? prev.streamPhase : 'idle',
|
streamPhase: isConflict ? prev.streamPhase : 'idle',
|
||||||
isSending: false,
|
isSending: false,
|
||||||
@@ -646,8 +1077,19 @@ const actions = {
|
|||||||
created_at: new Date().toISOString(),
|
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) => ({
|
chatAutomat.setState((prev) => ({
|
||||||
messages: [...prev.messages, optimisticMsg],
|
messages: [...prev.messages, optimisticMsg],
|
||||||
|
activeTurns: [initialTurn],
|
||||||
conversationState: 'busy',
|
conversationState: 'busy',
|
||||||
streamPhase: 'thinking',
|
streamPhase: 'thinking',
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
@@ -664,18 +1106,17 @@ const actions = {
|
|||||||
if (alreadyHasRealId) {
|
if (alreadyHasRealId) {
|
||||||
return {
|
return {
|
||||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||||
isSending: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
||||||
isSending: false,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const isConflict = err?.status === 409;
|
const isConflict = err?.status === 409;
|
||||||
chatAutomat.setState((prev) => ({
|
chatAutomat.setState((prev) => ({
|
||||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||||
|
activeTurns: isConflict ? prev.activeTurns : [],
|
||||||
conversationState: isConflict ? 'busy' : 'idle',
|
conversationState: isConflict ? 'busy' : 'idle',
|
||||||
streamPhase: isConflict ? prev.streamPhase : 'idle',
|
streamPhase: isConflict ? prev.streamPhase : 'idle',
|
||||||
isSending: false,
|
isSending: false,
|
||||||
@@ -729,6 +1170,29 @@ const actions = {
|
|||||||
hasAskUser = true;
|
hasAskUser = true;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// If this assistant message matches one we created locally on 'done', update with real DB ID & keep turns
|
||||||
|
const existingIdx = prev.messages.findIndex(
|
||||||
|
(m) =>
|
||||||
|
m.role === 'assistant' &&
|
||||||
|
(m.id === message.id ||
|
||||||
|
(String(m.id).startsWith('msg-') &&
|
||||||
|
(m.message === message.message || !message.message)))
|
||||||
|
);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
const next = [...prev.messages];
|
||||||
|
next[existingIdx] = {
|
||||||
|
...message,
|
||||||
|
turns: next[existingIdx].turns || prev.activeTurns,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
messages: next,
|
||||||
|
activeTurns: [],
|
||||||
|
streamingText: '',
|
||||||
|
conversationState: 'idle',
|
||||||
|
isSending: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
|
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
|
||||||
@@ -739,7 +1203,6 @@ const actions = {
|
|||||||
)
|
)
|
||||||
: prev.conversations;
|
: prev.conversations;
|
||||||
|
|
||||||
// The 'done' state event marks the end of normal response, but ask_user also transitions to idle.
|
|
||||||
return {
|
return {
|
||||||
messages: [...prev.messages, message],
|
messages: [...prev.messages, message],
|
||||||
streamingText: isAssistant ? '' : prev.streamingText,
|
streamingText: isAssistant ? '' : prev.streamingText,
|
||||||
@@ -792,19 +1255,76 @@ export const selectChatConversations = () => chatAutomat.state.conversations;
|
|||||||
export const selectSelectedConversationId = () => chatAutomat.state.selectedConversationId;
|
export const selectSelectedConversationId = () => chatAutomat.state.selectedConversationId;
|
||||||
export const selectSseStatus = () => chatAutomat.state.sseStatus;
|
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.
|
* Extract any pending unanswered ask_user tool call from the given/current chat state.
|
||||||
* @param {ChatState} [state]
|
* @param {ChatState} [state]
|
||||||
* @returns {{ toolCallId: string, question: string, options: string[] } | null}
|
* @returns {{ toolCallId: string, question: string, options: string[] } | null}
|
||||||
*/
|
*/
|
||||||
export function selectPendingAskUser(state = chatAutomat.state) {
|
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;
|
if (streamingText || !messages || messages.length === 0) return null;
|
||||||
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
if (msg.role === 'system') continue;
|
if (msg.role === 'system' || msg.role === 'usage' || msg.role === 'reasoning') continue;
|
||||||
if (msg.role === 'tool' || msg.role === 'user') return null;
|
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') {
|
if (msg.role === 'assistant' || msg.role === 'model') {
|
||||||
let payload = null;
|
let payload = null;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
loadingConversations: s.loadingConversations,
|
loadingConversations: s.loadingConversations,
|
||||||
sseStatus: s.sseStatus,
|
sseStatus: s.sseStatus,
|
||||||
sendError: s.sendError,
|
sendError: s.sendError,
|
||||||
|
activeTurns: s.activeTurns || [],
|
||||||
inputText: '',
|
inputText: '',
|
||||||
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
@@ -88,6 +89,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
loadingConversations: state.loadingConversations,
|
loadingConversations: state.loadingConversations,
|
||||||
sseStatus: state.sseStatus,
|
sseStatus: state.sseStatus,
|
||||||
sendError: state.sendError,
|
sendError: state.sendError,
|
||||||
|
activeTurns: state.activeTurns || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,7 +411,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
) : (
|
) : (
|
||||||
<ChatFlowList
|
<ChatFlowList
|
||||||
messages={
|
messages={
|
||||||
streamingText
|
streamingText && (!this.state.activeTurns || this.state.activeTurns.length === 0)
|
||||||
? [
|
? [
|
||||||
...messages,
|
...messages,
|
||||||
{
|
{
|
||||||
@@ -422,6 +424,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
]
|
]
|
||||||
: messages
|
: messages
|
||||||
}
|
}
|
||||||
|
activeTurns={this.state.activeTurns}
|
||||||
conversationState={conversationState}
|
conversationState={conversationState}
|
||||||
model={model}
|
model={model}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import React, { PureComponent, createRef } from 'react';
|
import React, { PureComponent, createRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
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 HealingMarkdown from './HealingMarkdown';
|
||||||
import {
|
import {
|
||||||
SHADOW_BUBBLE_ASSISTANT,
|
SHADOW_BUBBLE_ASSISTANT,
|
||||||
@@ -30,7 +35,511 @@ function parseMessagePayload(raw) {
|
|||||||
return { content: 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 (
|
||||||
|
<ClickAwayListener onClickAway={this.handleClose}>
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', height: 24 }}>
|
||||||
|
<Tooltip
|
||||||
|
open={this.state.open}
|
||||||
|
onClose={this.handleClose}
|
||||||
|
disableFocusListener
|
||||||
|
disableHoverListener
|
||||||
|
disableTouchListener
|
||||||
|
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={(theme) => ({
|
||||||
|
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',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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={(theme) => ({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
color: theme.palette.mode === 'dark' ? '#b388ff' : '#651fff',
|
||||||
|
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={(theme) => ({
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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 hasContent = Boolean(turn.content);
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
minHeight: 24,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HandshakeIconView state={turn.handshake} />
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* 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 }) {
|
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 payload = parseMessagePayload(msg.message);
|
||||||
const textContent = payload.content !== undefined ? payload.content : 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 isUser = msg.role === 'user';
|
||||||
const isSystem = msg.role === 'system';
|
const isSystem = msg.role === 'system';
|
||||||
const isTool = msg.role === 'tool';
|
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) : '';
|
const timeStr = showTime ? formatMessageTime(msg.created_at) : '';
|
||||||
|
|
||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
@@ -65,52 +574,45 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isReasoning) {
|
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 (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.5, px: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<Box
|
<TurnRow turn={singleTurn} isLive={false} />
|
||||||
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolCalls) {
|
if (toolCalls) {
|
||||||
const formattedCalls = toolCalls.map((tc) => {
|
const formattedCalls = toolCalls
|
||||||
let args = {};
|
.map((tc) => {
|
||||||
try {
|
let args = {};
|
||||||
if (typeof tc.function?.arguments === 'string') {
|
try {
|
||||||
args = JSON.parse(tc.function.arguments);
|
if (typeof tc.function?.arguments === 'string') {
|
||||||
} else if (tc.function?.arguments) {
|
args = JSON.parse(tc.function.arguments);
|
||||||
args = 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 method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL');
|
||||||
const q = args.question || '';
|
const path = args.path || args.url || '';
|
||||||
return `ask_user: ${q}`;
|
if (path) {
|
||||||
}
|
return `${method} ${path}`;
|
||||||
|
}
|
||||||
const method = args.method || (tc.function?.name ? tc.function.name.toUpperCase() : 'CALL');
|
return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`;
|
||||||
const path = args.path || args.url || '';
|
})
|
||||||
if (path) {
|
.join('\n');
|
||||||
return `${method} ${path}`;
|
|
||||||
}
|
|
||||||
return `${tc.function?.name || 'tool'} ${JSON.stringify(args)}`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||||
@@ -194,7 +696,9 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
? resObj.answer || resObj.content || ''
|
? resObj.answer || resObj.content || ''
|
||||||
: statusText
|
: statusText
|
||||||
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
|
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
|
||||||
: (bodyLength != null ? `${bodyLength} byte` : String(textContent));
|
: bodyLength != null
|
||||||
|
? `${bodyLength} byte`
|
||||||
|
: String(textContent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||||
@@ -280,12 +784,8 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
: theme.palette.mode === 'dark'
|
: theme.palette.mode === 'dark'
|
||||||
? 'background.paper'
|
? 'background.paper'
|
||||||
: SURFACE_RAISED,
|
: SURFACE_RAISED,
|
||||||
color: isUser
|
color: isUser ? theme.palette.primary.contrastText : theme.palette.text.primary,
|
||||||
? theme.palette.primary.contrastText
|
boxShadow: isUser ? SHADOW_BUBBLE_USER : SHADOW_BUBBLE_ASSISTANT,
|
||||||
: theme.palette.text.primary,
|
|
||||||
boxShadow: isUser
|
|
||||||
? SHADOW_BUBBLE_USER
|
|
||||||
: SHADOW_BUBBLE_ASSISTANT,
|
|
||||||
border: isUser ? 'none' : `1px solid ${theme.palette.divider}`,
|
border: isUser ? 'none' : `1px solid ${theme.palette.divider}`,
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
whiteSpace: isUser ? 'pre-wrap' : 'normal',
|
whiteSpace: isUser ? 'pre-wrap' : 'normal',
|
||||||
@@ -316,7 +816,8 @@ export default class ChatFlowList extends PureComponent {
|
|||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (
|
if (
|
||||||
this.props.messages !== prevProps.messages ||
|
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');
|
this.scrollToBottom('smooth');
|
||||||
}
|
}
|
||||||
@@ -348,9 +849,10 @@ export default class ChatFlowList extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,8 +872,8 @@ export default class ChatFlowList extends PureComponent {
|
|||||||
gap: 0.25,
|
gap: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{messages.map((msg, index) => {
|
{normalizedMessages.map((msg, index) => {
|
||||||
const prevMsg = index > 0 ? messages[index - 1] : null;
|
const prevMsg = index > 0 ? normalizedMessages[index - 1] : null;
|
||||||
const currTime = formatMessageTime(msg.created_at);
|
const currTime = formatMessageTime(msg.created_at);
|
||||||
const prevTime = prevMsg ? formatMessageTime(prevMsg.created_at) : '';
|
const prevTime = prevMsg ? formatMessageTime(prevMsg.created_at) : '';
|
||||||
const showTime = Boolean(currTime && currTime !== prevTime);
|
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) => (
|
||||||
|
<TurnRow key={turn.id || idx} turn={turn} isLive={true} />
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
|
||||||
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user