This commit is contained in:
seb
2026-10-16 16:03:31 +02:00
parent f5a5cb51e3
commit 99d95ee234
7 changed files with 377 additions and 103 deletions

View File

@@ -285,6 +285,67 @@ function closeConversationStream() {
}
activeStreamConversationId = null;
}
/**
* Normalizes tool arguments to detect a virtual draft read (drafts/delivery, drafts/quote, drafts/order).
* @param {object} args
* @returns {{ filename: string, draftKey: string } | null}
*/
export function getDraftInfoFromToolArgs(args) {
if (!args || typeof args !== 'object') return null;
const raw = args.filename || args.path || args.file || args.name || '';
if (typeof raw !== 'string' || !raw.trim()) return null;
const normalized = raw.trim().replace(/^\/?(api\/)?vfs\//, '');
if (normalized.startsWith('drafts/')) {
const draftKey = normalized.replace(/^drafts\//, '').replace(/\.json$/, '').toLowerCase();
return {
filename: normalized,
draftKey,
};
}
return null;
}
/**
* Checks if a tool call or stream tool event represents an interactive tool
* that awaits user intervention (ask_user or virtual draft read).
* @param {object} item
* @returns {boolean}
*/
export function isInteractiveTool(item) {
if (!item) return false;
const name = item.name || item.function?.name;
if (name === 'ask_user') return true;
if (name === 'read_file') {
let args = {};
const rawContent = item.content !== undefined ? item.content : (item.arguments || item.parameters || item.function?.arguments);
if (typeof rawContent === 'string') {
try {
args = JSON.parse(rawContent);
} catch {
if (rawContent.includes('drafts/')) {
args = { filename: rawContent };
}
}
} else if (rawContent && typeof rawContent === 'object') {
args = rawContent;
}
const secondaryRaw = item.arguments || item.function?.arguments;
if ((!args.filename && !args.path && !args.file) && secondaryRaw && secondaryRaw !== rawContent) {
try {
const parsed = typeof secondaryRaw === 'string' ? JSON.parse(secondaryRaw) : secondaryRaw;
if (parsed && typeof parsed === 'object') {
args = { ...args, ...parsed };
}
} catch {
if (typeof secondaryRaw === 'string' && secondaryRaw.includes('drafts/')) {
args = { filename: secondaryRaw };
}
}
}
return Boolean(getDraftInfoFromToolArgs(args));
}
return false;
}
const actions = {
/**
@@ -518,13 +579,18 @@ const actions = {
const messages = msgsRes?.messages || [];
const updatedConv = convRes?.conversation;
const hasPendingInteractive = Boolean(
selectPendingAskUser({ messages }) || selectPendingDraftRead({ messages })
);
chatAutomat.setState((prev) => ({
messages,
loadingMessages: false,
conversationState: updatedConv?.state || prev.conversationState,
conversationState: hasPendingInteractive ? 'idle' : (updatedConv?.state || prev.conversationState),
streamPhase: hasPendingInteractive ? 'idle' : prev.streamPhase,
isSending: hasPendingInteractive ? false : prev.isSending,
conversations: updatedConv
? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c))
? prev.conversations.map((c) => (c.id === updatedConv.id ? (hasPendingInteractive ? { ...updatedConv, state: 'idle' } : updatedConv) : c))
: prev.conversations,
}));
} catch (err) {
@@ -881,7 +947,7 @@ const actions = {
const updatedTool = {
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
state: 'green',
name: data.name || '',
name: data.name || toolName || (toolIdx >= 0 ? tools[toolIdx].name : '') || '',
arguments: data.arguments,
content: data.content !== undefined ? data.content : data.arguments || '',
result: data.result,
@@ -895,12 +961,19 @@ const actions = {
lastTurn.tools = tools;
turns[lastIdx] = lastTurn;
const hasAskUser = data.name === 'ask_user';
const isInteractive = isInteractiveTool(data) || isInteractiveTool(updatedTool);
const nextConversations = isInteractive
? prev.conversations.map((c) =>
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
)
: prev.conversations;
return {
activeTurns: turns,
streamPhase: hasAskUser ? 'idle' : 'working',
conversationState: hasAskUser ? 'idle' : prev.conversationState,
isSending: hasAskUser ? false : prev.isSending,
streamPhase: isInteractive ? 'idle' : 'working',
conversationState: isInteractive ? 'idle' : prev.conversationState,
isSending: isInteractive ? false : prev.isSending,
conversations: nextConversations,
};
});
},
@@ -1197,10 +1270,10 @@ const actions = {
/**
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
* Body: { role: 'tool', tool_call_id, content, answer }
* @param {{ tool_call_id: string, answer: string }} params
* Body: { role: 'tool', tool_call_id, name, content, answer }
* @param {{ tool_call_id: string, answer?: string, name?: string, content?: any }} params
*/
async sendToolResponse({ tool_call_id, answer }) {
async sendToolResponse({ tool_call_id, answer, name = 'ask_user', content = undefined }) {
const { selectedConversationId, isSending } = chatAutomat.state;
if (!selectedConversationId) {
throw new Error('Keine Unterhaltung ausgewählt');
@@ -1209,12 +1282,13 @@ const actions = {
return;
}
const toolContent = content !== undefined ? content : answer;
const payload = {
role: 'tool',
tool_call_id,
name: 'ask_user',
content: answer,
answer,
name,
content: toolContent,
...(answer !== undefined ? { answer } : {}),
};
const tempId = `temp-${Date.now()}`;
@@ -1258,7 +1332,7 @@ const actions = {
if (m.role === 'assistant' && !m.turns) {
try {
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) {
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === name || tc.function?.name === 'ask_user')) {
foundExisting = true;
return { ...m, turns: sealedTurns };
}
@@ -1367,12 +1441,12 @@ const actions = {
}
const isAssistant = message.role === 'assistant' || message.role === 'model';
let hasAskUser = false;
let hasInteractiveTool = false;
if (isAssistant) {
try {
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) {
hasAskUser = true;
if (payload?.tool_calls?.some((tc) => isInteractiveTool(tc))) {
hasInteractiveTool = true;
}
if (Array.isArray(payload?.tool_calls)) {
for (const tc of payload.tool_calls) {
@@ -1411,9 +1485,9 @@ const actions = {
}
}
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase;
const nextConversations = hasAskUser
const nextConversationState = hasInteractiveTool ? 'idle' : prev.conversationState;
const nextStreamPhase = hasInteractiveTool ? 'idle' : prev.streamPhase;
const nextConversations = hasInteractiveTool
? prev.conversations.map((c) =>
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
)
@@ -1425,6 +1499,7 @@ const actions = {
conversationState: nextConversationState,
streamPhase: nextStreamPhase,
conversations: nextConversations,
isSending: hasInteractiveTool ? false : prev.isSending,
};
});
},
@@ -1585,6 +1660,113 @@ export function selectPendingAskUser(state = chatAutomat.state) {
return null;
}
export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state));
export const selectHasPendingAsk = (state = chatAutomat.state) =>
Boolean(selectPendingAskUser(state) || selectPendingDraftRead(state));
/**
* Returns pending un-answered `read_file` tool call for a virtual draft (`drafts/*`).
* @param {object} [state]
* @returns {{ toolCallId: string, filename: string, draftKey: string } | null}
*/
export function selectPendingDraftRead(state = chatAutomat.state) {
const { messages, streamingText, activeTurns } = state;
// 1. Check live activeTurns for any read_file draft 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 === 'read_file') {
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.filename && !args.path && tool.arguments) {
try {
args = typeof tool.arguments === 'string' ? JSON.parse(tool.arguments) : tool.arguments;
} catch {}
}
const draftInfo = getDraftInfoFromToolArgs(args);
if (draftInfo) {
return {
toolCallId: tool.id,
filename: draftInfo.filename,
draftKey: draftInfo.draftKey,
};
}
}
}
}
}
// 2. Check message history for any un-answered read_file for drafts/*
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' || msg.role === 'usage' || msg.role === 'reasoning') continue;
if (msg.role === 'user') return null;
if (msg.role === 'tool') {
return null;
}
if (msg.role === 'assistant' || msg.role === 'model') {
let payload = null;
if (typeof msg.message === 'string') {
try {
payload = JSON.parse(msg.message);
} catch {}
} else if (typeof msg.message === 'object' && msg.message !== null) {
payload = msg.message;
}
if (!payload?.tool_calls || !Array.isArray(payload.tool_calls) || payload.tool_calls.length === 0) {
return null;
}
const draftCalls = payload.tool_calls.filter((tc) => tc.function?.name === 'read_file');
for (const tc of draftCalls) {
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 {}
const draftInfo = getDraftInfoFromToolArgs(args);
if (draftInfo) {
const answered = messages.slice(i + 1).some((m) => {
if (m.role !== 'tool') return false;
try {
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
return p?.tool_call_id === tc.id;
} catch {
return false;
}
});
if (!answered) {
return {
toolCallId: tc.id,
filename: draftInfo.filename,
draftKey: draftInfo.draftKey,
};
}
}
}
return null;
}
return null;
}
return null;
}
export const selectHasPendingDraftRead = (state = chatAutomat.state) => Boolean(selectPendingDraftRead(state));
export default chatAutomat;

View File

@@ -5,16 +5,11 @@
* with backend VFS requests and broadcasts.
*
* Incoming Signals:
* - SSE 'draft:request': Backend requests a draft (read or list).
* - SSE 'draft': Backend broadcasts an external update to a draft.
*
* Outgoing Calls:
* - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list.
*/
import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat';
import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat';
import { vfs } from '../api/vfs';
import { getStoredClientId } from '../api/http';
import { getSseClientId } from './sseAutomat';
@@ -140,33 +135,7 @@ export function applyDraftUpdate(rawName, rawContent) {
}
}
/**
* Handles incoming 'draft:request' SSE event from backend.
*
* @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload
* @returns {Promise<void>}
*/
export async function handleDraftRequest(payload) {
if (!payload || !payload.requestId) return;
const { requestId, action, name, target } = payload;
try {
if (action === 'read') {
const draftName = normalizeDraftName(name || target);
const content = getActiveDraftContent(draftName);
if (content != null) {
await vfs.respondDraft({ requestId, content });
} else {
await vfs.respondDraft({ requestId, error: 'Draft not open' });
}
} else if (action === 'list') {
const files = getActiveDraftsList();
await vfs.respondDraft({ requestId, files });
}
} catch (err) {
console.error('[draft] error handling draft:request:', err);
}
}
/**
* Handles incoming 'draft' SSE broadcast from backend.

View File

@@ -35,7 +35,7 @@ import supplierAutomat from './supplierAutomat';
import orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat';
import shipmentAutomat from './shipmentAutomat';
import { handleDraftRequest, handleDraftEvent } from './draftSync';
import { handleDraftEvent } from './draftSync';
/**
@@ -265,14 +265,7 @@ function connect() {
});
});
addTrackedListener(es, 'draft:request', (event) => {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
void handleDraftRequest(payload);
} catch (err) {
console.error('[sse] draft:request handling failed', err);
}
});
addTrackedListener(es, 'draft', (event) => {
try {