u
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user