u
This commit is contained in:
@@ -88,6 +88,7 @@ const initialState = {
|
||||
streamPhase: 'idle',
|
||||
streamingText: '',
|
||||
lastUsage: null,
|
||||
activeTurns: [],
|
||||
};
|
||||
|
||||
let chatAutomat;
|
||||
@@ -190,31 +191,57 @@ function ensureConversationStream(conversationId) {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Live state event: thinking -> working -> done (usage on done)
|
||||
// 2. Live state events: connected, firstevent, reasoning, working, toolcall, content, done
|
||||
es.addEventListener('state', (event) => {
|
||||
try {
|
||||
if (!event.data) return;
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.state) {
|
||||
chatAutomat.actions.applyStreamState(data.state, data.usage);
|
||||
if (data) {
|
||||
chatAutomat.actions.applyStreamState(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chatAutomat] Failed to parse state event payload:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Incremental assistant text chunks (`text` holds the full text so far)
|
||||
// 3. Reasoning streaming chunks
|
||||
es.addEventListener('reasoning', (event) => {
|
||||
try {
|
||||
if (!event.data) return;
|
||||
const data = JSON.parse(event.data);
|
||||
if (data) {
|
||||
chatAutomat.actions.applyStreamReasoning(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chatAutomat] Failed to parse reasoning event payload:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Incremental assistant text chunks
|
||||
es.addEventListener('delta', (event) => {
|
||||
try {
|
||||
if (!event.data) return;
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof data?.text === 'string') {
|
||||
chatAutomat.setState({ streamingText: data.text });
|
||||
if (data) {
|
||||
chatAutomat.actions.applyStreamDelta(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chatAutomat] Failed to parse delta event payload:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Per-turn usage statistics
|
||||
es.addEventListener('usage', (event) => {
|
||||
try {
|
||||
if (!event.data) return;
|
||||
const data = JSON.parse(event.data);
|
||||
if (data) {
|
||||
chatAutomat.actions.applyStreamUsage(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[chatAutomat] Failed to parse usage event payload:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeConversationStream() {
|
||||
@@ -259,6 +286,7 @@ const actions = {
|
||||
streamPhase: 'idle',
|
||||
streamingText: '',
|
||||
lastUsage: null,
|
||||
activeTurns: [],
|
||||
sendError: null,
|
||||
});
|
||||
},
|
||||
@@ -499,46 +527,439 @@ const actions = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a granular SSE state ('thinking' | 'working' | 'done', or legacy 'busy' | 'idle').
|
||||
* The DB-level conversationState stays binary (busy while thinking/working).
|
||||
* @param {string} state
|
||||
* @param {object} [usage] Token/cost usage, only sent with 'done'
|
||||
* SSE Event: {"state":"connected"}
|
||||
* Handshake turns black. If previous turn was sealed, starts a new turn on a new line!
|
||||
*/
|
||||
applyStreamState(state, usage) {
|
||||
/** Local-only system line shown in the chat flow for each state event. */
|
||||
const stateLine = (text) => ({
|
||||
id: `state-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
conversation_id: chatAutomat.state.selectedConversationId,
|
||||
role: 'system',
|
||||
message: text,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (state === 'thinking' || state === 'working' || state === 'busy') {
|
||||
const phase = state === 'busy' ? 'thinking' : state;
|
||||
const text = phase === 'thinking' ? 'Denkt nach…' : 'Generiert Antwort…';
|
||||
chatAutomat.setState((prev) => ({
|
||||
streamPhase: phase,
|
||||
messages: [...prev.messages, stateLine(text)],
|
||||
}));
|
||||
chatAutomat.actions.setConversationState('busy');
|
||||
} else if (state === 'done' || state === 'idle') {
|
||||
let text = '';
|
||||
if (usage) {
|
||||
const input = usage.input ?? usage.prompt_tokens ?? 0;
|
||||
const output = usage.output ?? usage.completion_tokens ?? 0;
|
||||
const cached = usage.cached ?? 0;
|
||||
const cost = typeof usage.cost === 'number' ? ` · $${usage.cost.toFixed(6)}` : '';
|
||||
const cachedPart = cached ? ` / ${cached} Cache` : '';
|
||||
text = `${input} Prompt${cachedPart} / ${output} Antwort${cost}`;
|
||||
applyStreamConnected() {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
return {
|
||||
activeTurns: [
|
||||
{
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'black',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
},
|
||||
],
|
||||
conversationState: 'busy',
|
||||
streamPhase: 'thinking',
|
||||
};
|
||||
}
|
||||
chatAutomat.setState((prev) => ({
|
||||
streamPhase: 'idle',
|
||||
streamingText: '',
|
||||
lastUsage: usage || prev.lastUsage,
|
||||
messages: state === 'done' && text ? [...prev.messages, stateLine(text)] : prev.messages,
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
if (lastTurn.sealed) {
|
||||
const newTurn = {
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'black',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
};
|
||||
return {
|
||||
activeTurns: [...turns, newTurn],
|
||||
conversationState: 'busy',
|
||||
streamPhase: 'thinking',
|
||||
};
|
||||
}
|
||||
lastTurn.handshake = 'black';
|
||||
turns[lastIdx] = lastTurn;
|
||||
return {
|
||||
activeTurns: turns,
|
||||
conversationState: 'busy',
|
||||
streamPhase: 'thinking',
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: {"state":"firstevent"}
|
||||
* Handshake turns green.
|
||||
*/
|
||||
applyStreamFirstEvent() {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
return { activeTurns: turns };
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
lastTurn.handshake = 'green';
|
||||
turns[lastIdx] = lastTurn;
|
||||
return { activeTurns: turns };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: {"state":"reasoning"}
|
||||
* Prepares for reasoning in current turn. Any previous unsealed reasoning is sealed.
|
||||
*/
|
||||
applyStreamReasoningState() {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [{ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text: '', sealed: false }],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
return { activeTurns: turns, streamPhase: 'thinking' };
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
const existingR = lastTurn.reasonings || [];
|
||||
const lastR = existingR[existingR.length - 1];
|
||||
|
||||
// If the last reasoning is already unsealed and waiting for text, do not create a duplicate
|
||||
if (lastR && !lastR.sealed && !lastR.text?.trim()) {
|
||||
return { streamPhase: 'thinking' };
|
||||
}
|
||||
|
||||
// Filter out empty reasonings and seal existing ones
|
||||
const nextR = existingR
|
||||
.filter((r) => r.text && r.text.trim())
|
||||
.map((r) => ({ ...r, sealed: true }));
|
||||
|
||||
nextR.push({
|
||||
id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
text: '',
|
||||
sealed: false,
|
||||
});
|
||||
lastTurn.reasonings = nextR;
|
||||
turns[lastIdx] = lastTurn;
|
||||
return { activeTurns: turns, streamPhase: 'thinking' };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: event: reasoning, data: {"text":"..."}
|
||||
* Adds brain icon right of handshake icon (if not added yet).
|
||||
* Tooltip shows reasoning text. Updates as chunks stream in.
|
||||
*/
|
||||
applyStreamReasoning(data) {
|
||||
const text = typeof data?.text === 'string' ? data.text : (typeof data === 'string' ? data : '');
|
||||
if (!text) return;
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [{ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text, sealed: false }],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
return { activeTurns: turns, streamPhase: 'thinking' };
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
const rList = lastTurn.reasonings ? [...lastTurn.reasonings] : [];
|
||||
let unsealedIdx = -1;
|
||||
for (let i = rList.length - 1; i >= 0; i--) {
|
||||
if (!rList[i].sealed) {
|
||||
unsealedIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (unsealedIdx >= 0) {
|
||||
rList[unsealedIdx] = { ...rList[unsealedIdx], text };
|
||||
} else {
|
||||
rList.push({ id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, text, sealed: false });
|
||||
}
|
||||
lastTurn.reasonings = rList;
|
||||
turns[lastIdx] = lastTurn;
|
||||
return { activeTurns: turns, streamPhase: 'thinking' };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: {"state":"working"}
|
||||
* "produces a black working icon, which turns green when this arrives [toolcall]"
|
||||
* Also seals any unsealed reasoning.
|
||||
*/
|
||||
applyStreamWorking() {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [],
|
||||
tools: [{ id: `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, state: 'black', name: '', content: '' }],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
return { activeTurns: turns, streamPhase: 'working' };
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
lastTurn.reasonings = (lastTurn.reasonings || [])
|
||||
.filter((r) => r.text && r.text.trim())
|
||||
.map((r) => ({ ...r, sealed: true }));
|
||||
lastTurn.tools = [
|
||||
...(lastTurn.tools || []),
|
||||
{ id: `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, state: 'black', name: '', content: '' },
|
||||
];
|
||||
turns[lastIdx] = lastTurn;
|
||||
return { activeTurns: turns, streamPhase: 'working' };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: {"state":"toolcall", "id":"...", "name":"...", "arguments":"...", "content":"...", "result":{...}}
|
||||
* Turns the working icon green, shows tooltip with content, displays name behind the work icon.
|
||||
*/
|
||||
applyStreamToolCall(data) {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
lastTurn.reasonings = (lastTurn.reasonings || [])
|
||||
.filter((r) => r.text && r.text.trim())
|
||||
.map((r) => ({ ...r, sealed: true }));
|
||||
|
||||
const tools = lastTurn.tools ? [...lastTurn.tools] : [];
|
||||
let toolIdx = -1;
|
||||
if (data.id) {
|
||||
toolIdx = tools.findIndex((t) => t.id === data.id);
|
||||
}
|
||||
if (toolIdx === -1) {
|
||||
for (let i = tools.length - 1; i >= 0; i--) {
|
||||
if (tools[i].state === 'black') {
|
||||
toolIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTool = {
|
||||
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
state: 'green',
|
||||
name: data.name || '',
|
||||
arguments: data.arguments,
|
||||
content: data.content !== undefined ? data.content : data.arguments || '',
|
||||
result: data.result,
|
||||
};
|
||||
|
||||
if (toolIdx >= 0) {
|
||||
tools[toolIdx] = { ...tools[toolIdx], ...updatedTool };
|
||||
} else {
|
||||
tools.push(updatedTool);
|
||||
}
|
||||
lastTurn.tools = tools;
|
||||
turns[lastIdx] = lastTurn;
|
||||
|
||||
const hasAskUser = data.name === 'ask_user';
|
||||
return {
|
||||
activeTurns: turns,
|
||||
streamPhase: 'working',
|
||||
conversationState: hasAskUser ? 'idle' : prev.conversationState,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: {"state":"content"}
|
||||
* Signals that text response content is starting.
|
||||
*/
|
||||
applyStreamContent() {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
return { activeTurns: turns, streamPhase: 'working' };
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
lastTurn.reasonings = (lastTurn.reasonings || [])
|
||||
.filter((r) => r.text && r.text.trim())
|
||||
.map((r) => ({ ...r, sealed: true }));
|
||||
turns[lastIdx] = lastTurn;
|
||||
return { activeTurns: turns, streamPhase: 'working' };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: event: delta, data: {"delta":"...", "text":"..."}
|
||||
* Incremental text for the current turn.
|
||||
*/
|
||||
applyStreamDelta(data) {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
const currentText = typeof data.text === 'string'
|
||||
? data.text
|
||||
: ((lastTurn.content || '') + (data.delta || ''));
|
||||
lastTurn.content = currentText;
|
||||
turns[lastIdx] = lastTurn;
|
||||
return {
|
||||
activeTurns: turns,
|
||||
streamingText: currentText,
|
||||
streamPhase: 'working',
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: event: usage, data: {"input":..., "cached":..., "output":..., "cost":...}
|
||||
* "usage seals the turn and now usage is displayed (per turn)"
|
||||
*/
|
||||
applyStreamUsage(data) {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
if (turns.length === 0) {
|
||||
turns.push({
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'green',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: data,
|
||||
content: '',
|
||||
sealed: true,
|
||||
});
|
||||
return { activeTurns: turns, lastUsage: data };
|
||||
}
|
||||
const lastIdx = turns.length - 1;
|
||||
const lastTurn = { ...turns[lastIdx] };
|
||||
lastTurn.reasonings = (lastTurn.reasonings || [])
|
||||
.filter((r) => r.text && r.text.trim())
|
||||
.map((r) => ({ ...r, sealed: true }));
|
||||
lastTurn.usage = data;
|
||||
lastTurn.sealed = true;
|
||||
turns[lastIdx] = lastTurn;
|
||||
return { activeTurns: turns, lastUsage: data };
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE Event: {"state":"done"}
|
||||
* "after this the user can send again."
|
||||
* Commits the completed assistant message with its turns into messages,
|
||||
* resets conversationState to 'idle' and isSending to false.
|
||||
*/
|
||||
applyStreamDone() {
|
||||
chatAutomat.setState((prev) => {
|
||||
const turns = prev.activeTurns ? [...prev.activeTurns] : [];
|
||||
const sealedTurns = turns.map((t) => ({
|
||||
...t,
|
||||
sealed: true,
|
||||
reasonings: (t.reasonings || [])
|
||||
.filter((r) => r.text && r.text.trim())
|
||||
.map((r) => ({ ...r, sealed: true })),
|
||||
tools: (t.tools || []).map((tool) => (tool.state === 'black' ? { ...tool, state: 'green' } : tool)),
|
||||
}));
|
||||
chatAutomat.actions.setConversationState('idle');
|
||||
|
||||
// Gather full cumulative content from all turns
|
||||
let fullContent = '';
|
||||
for (const t of sealedTurns) {
|
||||
if (t.content) {
|
||||
fullContent = fullContent ? `${fullContent}\n\n${t.content}` : t.content;
|
||||
}
|
||||
}
|
||||
if (!fullContent && prev.streamingText) {
|
||||
fullContent = prev.streamingText;
|
||||
}
|
||||
|
||||
let nextMessages = prev.messages;
|
||||
if (sealedTurns.length > 0 || fullContent) {
|
||||
const assistantMsg = {
|
||||
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
conversation_id: prev.selectedConversationId,
|
||||
role: 'assistant',
|
||||
message: fullContent,
|
||||
turns: sealedTurns,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
nextMessages = [...prev.messages, assistantMsg];
|
||||
}
|
||||
|
||||
return {
|
||||
messages: nextMessages,
|
||||
activeTurns: [],
|
||||
streamingText: '',
|
||||
conversationState: 'idle',
|
||||
isSending: false,
|
||||
streamPhase: 'idle',
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Master router for incoming 'state' event data.
|
||||
*/
|
||||
applyStreamState(data) {
|
||||
const state = typeof data === 'string' ? data : data?.state;
|
||||
if (!state) return;
|
||||
|
||||
if (state === 'connected') {
|
||||
chatAutomat.actions.applyStreamConnected();
|
||||
} else if (state === 'firstevent') {
|
||||
chatAutomat.actions.applyStreamFirstEvent();
|
||||
} else if (state === 'reasoning') {
|
||||
chatAutomat.actions.applyStreamReasoningState();
|
||||
} else if (state === 'working') {
|
||||
chatAutomat.actions.applyStreamWorking();
|
||||
} else if (state === 'toolcall') {
|
||||
chatAutomat.actions.applyStreamToolCall(data);
|
||||
} else if (state === 'content') {
|
||||
chatAutomat.actions.applyStreamContent();
|
||||
} else if (state === 'done' || state === 'idle') {
|
||||
chatAutomat.actions.applyStreamDone();
|
||||
} else if (state === 'thinking' || state === 'busy') {
|
||||
chatAutomat.actions.applyStreamReasoningState();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -574,8 +995,20 @@ const actions = {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// "pressing enter adds a gray handshake icon to the left of the chat which turns black at {"state":"connected"} and green at {"state":"firstevent"}"
|
||||
const initialTurn = {
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'gray',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
};
|
||||
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: [...prev.messages, optimisticMsg],
|
||||
activeTurns: [initialTurn],
|
||||
conversationState: 'busy',
|
||||
streamPhase: 'thinking',
|
||||
streamingText: '',
|
||||
@@ -588,23 +1021,21 @@ const actions = {
|
||||
const res = await modelconversationApi.addMessage(selectedConversationId, { message: trimmed });
|
||||
const createdMsg = res?.message || optimisticMsg;
|
||||
chatAutomat.setState((prev) => {
|
||||
// If SSE already added/replaced this message by real ID, avoid adding again
|
||||
const alreadyHasRealId = prev.messages.some((m) => m.id === createdMsg.id && m.id !== tempId);
|
||||
if (alreadyHasRealId) {
|
||||
return {
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
isSending: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
||||
isSending: false,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
const isConflict = err?.status === 409;
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
activeTurns: isConflict ? prev.activeTurns : [],
|
||||
conversationState: isConflict ? 'busy' : 'idle',
|
||||
streamPhase: isConflict ? prev.streamPhase : 'idle',
|
||||
isSending: false,
|
||||
@@ -646,8 +1077,19 @@ const actions = {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const initialTurn = {
|
||||
id: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
handshake: 'gray',
|
||||
reasonings: [],
|
||||
tools: [],
|
||||
usage: null,
|
||||
content: '',
|
||||
sealed: false,
|
||||
};
|
||||
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: [...prev.messages, optimisticMsg],
|
||||
activeTurns: [initialTurn],
|
||||
conversationState: 'busy',
|
||||
streamPhase: 'thinking',
|
||||
streamingText: '',
|
||||
@@ -664,18 +1106,17 @@ const actions = {
|
||||
if (alreadyHasRealId) {
|
||||
return {
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
isSending: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: prev.messages.map((m) => (m.id === tempId ? createdMsg : m)),
|
||||
isSending: false,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
const isConflict = err?.status === 409;
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages: prev.messages.filter((m) => m.id !== tempId),
|
||||
activeTurns: isConflict ? prev.activeTurns : [],
|
||||
conversationState: isConflict ? 'busy' : 'idle',
|
||||
streamPhase: isConflict ? prev.streamPhase : 'idle',
|
||||
isSending: false,
|
||||
@@ -729,6 +1170,29 @@ const actions = {
|
||||
hasAskUser = true;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// If this assistant message matches one we created locally on 'done', update with real DB ID & keep turns
|
||||
const existingIdx = prev.messages.findIndex(
|
||||
(m) =>
|
||||
m.role === 'assistant' &&
|
||||
(m.id === message.id ||
|
||||
(String(m.id).startsWith('msg-') &&
|
||||
(m.message === message.message || !message.message)))
|
||||
);
|
||||
if (existingIdx !== -1) {
|
||||
const next = [...prev.messages];
|
||||
next[existingIdx] = {
|
||||
...message,
|
||||
turns: next[existingIdx].turns || prev.activeTurns,
|
||||
};
|
||||
return {
|
||||
messages: next,
|
||||
activeTurns: [],
|
||||
streamingText: '',
|
||||
conversationState: 'idle',
|
||||
isSending: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
|
||||
@@ -739,7 +1203,6 @@ const actions = {
|
||||
)
|
||||
: prev.conversations;
|
||||
|
||||
// The 'done' state event marks the end of normal response, but ask_user also transitions to idle.
|
||||
return {
|
||||
messages: [...prev.messages, message],
|
||||
streamingText: isAssistant ? '' : prev.streamingText,
|
||||
@@ -792,19 +1255,76 @@ export const selectChatConversations = () => chatAutomat.state.conversations;
|
||||
export const selectSelectedConversationId = () => chatAutomat.state.selectedConversationId;
|
||||
export const selectSseStatus = () => chatAutomat.state.sseStatus;
|
||||
|
||||
export const selectChatActiveTurns = () => chatAutomat.state.activeTurns;
|
||||
|
||||
/**
|
||||
* Extract any pending unanswered ask_user tool call from the given/current chat state.
|
||||
* @param {ChatState} [state]
|
||||
* @returns {{ toolCallId: string, question: string, options: string[] } | null}
|
||||
*/
|
||||
export function selectPendingAskUser(state = chatAutomat.state) {
|
||||
const { messages, streamingText } = state;
|
||||
const { messages, streamingText, activeTurns } = state;
|
||||
|
||||
// 1. Check live activeTurns for any ask_user tool call
|
||||
if (activeTurns && activeTurns.length > 0) {
|
||||
for (let i = activeTurns.length - 1; i >= 0; i--) {
|
||||
const turn = activeTurns[i];
|
||||
const tools = turn.tools || [];
|
||||
for (let j = tools.length - 1; j >= 0; j--) {
|
||||
const tool = tools[j];
|
||||
if (tool.name === 'ask_user') {
|
||||
let args = {};
|
||||
try {
|
||||
if (typeof tool.content === 'string') {
|
||||
args = JSON.parse(tool.content);
|
||||
} else if (tool.content && typeof tool.content === 'object') {
|
||||
args = tool.content;
|
||||
}
|
||||
} catch {}
|
||||
if (!args.question && tool.arguments) {
|
||||
try {
|
||||
args = typeof tool.arguments === 'string' ? JSON.parse(tool.arguments) : tool.arguments;
|
||||
} catch {}
|
||||
}
|
||||
if (args.question) {
|
||||
return {
|
||||
toolCallId: tool.id,
|
||||
question: args.question,
|
||||
options: Array.isArray(args.options) ? args.options : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check message history for any un-answered ask_user
|
||||
if (streamingText || !messages || messages.length === 0) return null;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === 'system') continue;
|
||||
if (msg.role === 'tool' || msg.role === 'user') return null;
|
||||
if (msg.role === 'system' || msg.role === 'usage' || msg.role === 'reasoning') continue;
|
||||
if (msg.role === 'user') return null;
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
try {
|
||||
const p = typeof msg.message === 'string' ? JSON.parse(msg.message) : msg.message;
|
||||
if (p?.name === 'ask_user' && !p.content && !p.answer) {
|
||||
let args = {};
|
||||
try {
|
||||
args = typeof p.parameters === 'string' ? JSON.parse(p.parameters) : (p.parameters || {});
|
||||
} catch {}
|
||||
if (args.question) {
|
||||
return {
|
||||
toolCallId: p.tool_call_id || msg.id,
|
||||
question: args.question,
|
||||
options: Array.isArray(args.options) ? args.options : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant' || msg.role === 'model') {
|
||||
let payload = null;
|
||||
|
||||
Reference in New Issue
Block a user