This commit is contained in:
seb
2026-10-16 16:36:43 +02:00
parent 99d95ee234
commit 7a4d452109
11 changed files with 13 additions and 498 deletions

View File

@@ -285,66 +285,16 @@ 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).
* that awaits user intervention (ask_user).
* @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;
return name === 'ask_user';
}
const actions = {
@@ -397,7 +347,7 @@ const actions = {
insertExtractionSnippet({
text = '',
label = 'parsed invoice',
instruction = "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file",
instruction = "use 'Process Incoming Product and Invoices' SKILL",
} = {}) {
const trimmed = String(text || '').trim();
if (!trimmed) return;
@@ -408,7 +358,7 @@ const actions = {
pendingExtraction: {
text: trimmed,
label: label || 'parsed invoice',
instruction: instruction || "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file",
instruction: instruction || "use 'Process Incoming Product and Invoices' SKILL",
},
});
ensureCompanyEventsConnected();
@@ -580,7 +530,7 @@ const actions = {
const messages = msgsRes?.messages || [];
const updatedConv = convRes?.conversation;
const hasPendingInteractive = Boolean(
selectPendingAskUser({ messages }) || selectPendingDraftRead({ messages })
selectPendingAskUser({ messages })
);
chatAutomat.setState((prev) => ({
@@ -1661,112 +1611,6 @@ export function selectPendingAskUser(state = chatAutomat.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));
Boolean(selectPendingAskUser(state));
export default chatAutomat;

View File

@@ -1,159 +0,0 @@
/**
* @file draftSync.js
* @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery).
* Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat)
* with backend VFS requests and broadcasts.
*
* Incoming Signals:
* - SSE 'draft': Backend broadcasts an external update to a draft.
*/
import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat';
import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat';
import { getStoredClientId } from '../api/http';
import { getSseClientId } from './sseAutomat';
/**
* Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery').
* Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc.
*
* @param {string} [name]
* @returns {string} Normalized key
*/
export function normalizeDraftName(name) {
if (!name) return '';
return String(name)
.trim()
.replace(/^\/?(api\/)?vfs\//, '')
.replace(/^drafts\//, '')
.replace(/\.json$/, '')
.toLowerCase();
}
/**
* Supported virtual draft names.
*/
export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery'];
/**
* Timestamps of last modifications per draft.
* @type {Map<string, string>}
*/
const draftTimestamps = new Map();
/**
* Resolves active draft content for a given draft name from its managing automat.
*
* @param {string} rawName
* @returns {object|null}
*/
export function getActiveDraftContent(rawName) {
const name = normalizeDraftName(rawName);
if (name === 'quote') {
return quoteDraftAutomat.actions.getManifest();
}
if (name === 'order') {
return orderDraftAutomat.actions.getManifest();
}
if (name === 'delivery') {
return deliveryEditorAutomat.actions.getManifest();
}
return null;
}
/**
* Generates the list of virtual draft files for the VFS listing.
*
* @returns {Array<{ name: string, size: number, updated_at: string }>}
*/
export function getActiveDraftsList() {
const now = new Date().toISOString();
return SUPPORTED_DRAFTS.map((name) => {
const content = getActiveDraftContent(name);
let size = 0;
if (content != null) {
try {
size = JSON.stringify(content).length;
} catch {}
}
const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z');
return {
name,
size,
updated_at: updatedAt,
};
});
}
/**
* Applies incoming draft content update to the managing automat.
*
* @param {string} rawName
* @param {object|string} rawContent
*/
export function applyDraftUpdate(rawName, rawContent) {
const name = normalizeDraftName(rawName);
if (!name) return;
let content = rawContent;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch (err) {
console.error(`[draft] failed to parse incoming draft string for ${name}:`, err);
return;
}
}
draftTimestamps.set(name, new Date().toISOString());
try {
if (name === 'quote') {
quoteDraftAutomat.actions.setManifest(content);
} else if (name === 'order') {
orderDraftAutomat.actions.setManifest(content);
} else if (name === 'delivery') {
const manifest = content?.manifest || content || {};
const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? [];
const lines = Array.isArray(rawLines)
? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l)))
: [];
const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null;
const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null;
deliveryEditorAutomat.actions.setDelivery({
...manifest,
partyId,
deliveryEntity: partyId,
externalNumber,
external_number: externalNumber,
lines,
});
}
} catch (err) {
console.error(`[draft] error applying draft update to ${name} automat:`, err);
}
}
/**
* Handles incoming 'draft' SSE broadcast from backend.
*
* @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload
*/
export function handleDraftEvent(payload) {
if (!payload) return;
const { sender, action, name, target, content } = payload;
const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null);
if (sender && myClientId && sender === myClientId) {
// Ignore self-echo
return;
}
if (action === 'update') {
const draftName = normalizeDraftName(name || target);
applyDraftUpdate(draftName, content);
}
}

View File

@@ -6,7 +6,6 @@
export * from './deliveryEditorAutomat';
export * from './manifestDraftAutomat';
export * from './draftSync';
export { default as authAutomat } from './authAutomat';
export * from './authAutomat';

View File

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