u
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
export * from './deliveryEditorAutomat';
|
||||
export * from './manifestDraftAutomat';
|
||||
export * from './draftSync';
|
||||
|
||||
export { default as authAutomat } from './authAutomat';
|
||||
export * from './authAutomat';
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user