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

@@ -491,7 +491,6 @@ _none_
- `modelconversation`: `{}`
- `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }`
- `draft`: `{ sender, action, name, content }`
### Section: /health

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' });

View File

@@ -238,8 +238,8 @@ export function parseExtractionsFromText(rawText) {
}
// 2. Legacy / fallback: message contains "use 'Process Incoming Product and Invoices' SKILL" + code block
const instructionPhrase = "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file";
if (cleanText.includes(instructionPhrase)) {
const hasInvoiceSkill = cleanText.includes("Process Incoming Product and Invoices");
if (hasInvoiceSkill) {
const codeBlockRegex = /```[a-z]*\s*\n([\s\S]*?)```/g;
let cbMatch;
while ((cbMatch = codeBlockRegex.exec(cleanText)) !== null) {

View File

@@ -39,15 +39,12 @@ import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
import { setting as settingApi } from '../../api';
import chatAutomat, {
selectPendingAskUser,
selectPendingDraftRead,
} from '../../automats/chatAutomat';
import { getActiveDraftContent } from '../../automats/draftSync';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import vfsAutomat from '../../automats/vfsAutomat';
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
import DraftRequestPrompt from './DraftRequestPrompt';
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
import {
@@ -57,7 +54,7 @@ import {
} from '../../colors';
const DEFAULT_DRAWER_WIDTH = 460;
const EXTRACTION_MODEL_INSTRUCTION = "use 'Process Incoming Product and Invoices' SKILL to fill drafts/delivery vfs file";
const EXTRACTION_MODEL_INSTRUCTION = "use 'Process Incoming Product and Invoices' SKILL";
/** Sort by updated_at desc; subagents (started_by set) directly below their parent. */
function sortConversations(list) {
@@ -103,7 +100,7 @@ function conversationTitle(c) {
.replace(/\[EXTRACTION(?:\s+label="[^"]*")?\][\s\S]*?\[\/EXTRACTION\]/gi, '')
.replace(/<extraction(?:\s+label="[^"]*")?>[\s\S]*?<\/extraction>/gi, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/use 'Process Incoming Product and Invoices' SKILL to fill drafts\/delivery vfs file/gi, '')
.replace(/use 'Process Incoming Product and Invoices' SKILL.*/gi, '')
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
.replace(/\s+/g, ' ')
.trim();
@@ -841,33 +838,7 @@ export default class ChatDrawer extends PureComponent {
};
getPendingAskUser = () => selectPendingAskUser(this.state);
getPendingDraftRead = () => selectPendingDraftRead(this.state);
handleProvideDraft = async ({ toolCallId, draftKey, empty = false }) => {
try {
let content;
if (empty) {
content = { error: 'Draft not open or empty' };
} else {
const manifest = getActiveDraftContent(draftKey);
content = {
manifest: manifest || {
kind: draftKey,
mode: 'create',
lines: [],
},
};
}
await chatAutomat.actions.sendToolResponse({
tool_call_id: toolCallId,
name: 'read_file',
content,
});
} catch (err) {
console.error('[ChatDrawer] Failed to provide draft response:', err);
}
};
renderHeader() {
const {
@@ -1364,7 +1335,6 @@ export default class ChatDrawer extends PureComponent {
}
const pendingAsk = this.getPendingAskUser();
const pendingDraftRead = this.getPendingDraftRead();
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
@@ -1399,14 +1369,6 @@ export default class ChatDrawer extends PureComponent {
disabled={this.state.isSending}
/>
)}
{pendingDraftRead && (
<DraftRequestPrompt
draftData={pendingDraftRead}
onProvideDraft={this.handleProvideDraft}
disabled={this.state.isSending}
/>
)}
</Box>
);
}
@@ -1429,13 +1391,11 @@ export default class ChatDrawer extends PureComponent {
} = this.state;
const pendingAsk = this.getPendingAskUser();
const pendingDraftRead = this.getPendingDraftRead();
const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending;
const canChat =
(Boolean(selectedConversationId) || draftConversation) &&
!pendingAsk &&
!pendingDraftRead;
!pendingAsk;
const canSend =
!isWorking &&
canChat &&

View File

@@ -1,118 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
import BlockRoundedIcon from '@mui/icons-material/BlockRounded';
import { getActiveDraftContent } from '../../automats/draftSync';
import {
ASK_PROMPT_BG_PAIR,
ASK_PROMPT_BORDER_PAIR,
byMode,
} from '../../colors';
export default function DraftRequestPrompt({ draftData, onProvideDraft, disabled = false }) {
const { toolCallId, filename, draftKey } = draftData;
const manifest = getActiveDraftContent(draftKey);
const rawLines = manifest?.lines ?? manifest?.lineitems ?? [];
const linesCount = Array.isArray(rawLines) ? rawLines.length : 0;
const hasContent = Boolean(manifest && (linesCount > 0 || manifest.partyId || manifest.supplierId));
const handleSendDraft = () => {
if (disabled) return;
onProvideDraft({ toolCallId, draftKey, empty: false });
};
const handleSendEmpty = () => {
if (disabled) return;
onProvideDraft({ toolCallId, draftKey, empty: true });
};
const draftLabel =
draftKey === 'delivery'
? 'Lieferung'
: draftKey === 'quote'
? 'Angebot'
: draftKey === 'order'
? 'Bestellung'
: draftKey;
return (
<Box
sx={(theme) => ({
mx: 2,
my: 1,
p: 2,
borderRadius: 3,
bgcolor: byMode(theme, ASK_PROMPT_BG_PAIR),
border: `1px solid ${byMode(theme, ASK_PROMPT_BORDER_PAIR)}`,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
})}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'primary.main' }}>
<DescriptionOutlinedIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: 'text.primary' }}>
Entwurfsanfrage: {filename || `drafts/${draftKey}`}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: '0.82rem', lineHeight: 1.4 }}>
{hasContent ? (
<>
Das Modell möchte den aktuellen <strong>{draftLabel}-Entwurf</strong> ({linesCount} Position{linesCount === 1 ? '' : 'en'}) einlesen, um die Bearbeitung fortzusetzen.
</>
) : (
<>
Das Modell fordert <strong>{filename}</strong> an. Aktuell ist kein {draftLabel}-Entwurf geöffnet.
</>
)}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
<Button
variant="contained"
size="small"
disabled={disabled}
onClick={handleSendDraft}
startIcon={<SendRoundedIcon sx={{ fontSize: 16 }} />}
sx={{
borderRadius: 2,
px: 2,
py: 0.75,
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
}}
>
{hasContent ? `Aktuellen ${draftLabel}-Entwurf senden` : 'Leeren Entwurf senden'}
</Button>
<Button
variant="outlined"
size="small"
disabled={disabled}
onClick={handleSendEmpty}
startIcon={<BlockRoundedIcon sx={{ fontSize: 16 }} />}
sx={{
borderRadius: 2,
px: 1.5,
py: 0.75,
textTransform: 'none',
fontSize: '0.82rem',
color: 'text.secondary',
borderColor: 'divider',
'&:hover': {
borderColor: 'error.main',
color: 'error.main',
},
}}
>
Nicht verfügbar melden
</Button>
</Box>
</Box>
);
}

View File

@@ -2,4 +2,3 @@ export { default as ChatFab } from './ChatFab';
export { default as ChatDrawer } from './ChatDrawer';
export { default as ChatFlowList } from './ChatFlowList';
export { default as NewConversationDialog } from './NewConversationDialog';
export { default as DraftRequestPrompt } from './DraftRequestPrompt';

View File

@@ -114,7 +114,7 @@ export class Attachment extends PureComponent {
chatAutomat.actions.insertExtractionSnippet({
text: trimmed,
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",
});
};

View File

@@ -718,7 +718,7 @@ class LieferungNeuPage extends PureComponent {
chatAutomat.actions.insertExtractionSnippet({
text: trimmed,
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",
});
}