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

@@ -144,43 +144,7 @@ export const vfs = {
},
// #endregion
/**
* POST /api/vfs/drafts/response
* Send response to virtual draft request (action: read or list).
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body
*/
// #region POST /api/vfs/drafts/response — respondDraft
async respondDraft(body) {
try {
const res = await fetch('/api/vfs/drafts/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include',
body: JSON.stringify(body),
});
if (!res.ok) {
let errText = '';
try {
const errData = await res.json();
errText = errData.error || errData.message;
} catch {
errText = await res.text().catch(() => '');
}
console.error('[vfs] respondDraft failed:', res.status, errText);
return { ok: false, error: errText };
}
if (res.status === 204) return { ok: true };
const text = await res.text();
return text ? JSON.parse(text) : { ok: true };
} catch (err) {
console.error('[vfs] respondDraft error:', err);
return { ok: false, error: err.message };
}
},
// #endregion
/**
* POST /api/vfs/skills/restore

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 {

View File

@@ -37,12 +37,17 @@ import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
import { setting as settingApi } from '../../api';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
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 {
@@ -836,6 +841,33 @@ 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 {
@@ -1332,6 +1364,7 @@ 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' }}>
@@ -1366,6 +1399,14 @@ export default class ChatDrawer extends PureComponent {
disabled={this.state.isSending}
/>
)}
{pendingDraftRead && (
<DraftRequestPrompt
draftData={pendingDraftRead}
onProvideDraft={this.handleProvideDraft}
disabled={this.state.isSending}
/>
)}
</Box>
);
}
@@ -1388,9 +1429,13 @@ 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;
const canChat =
(Boolean(selectedConversationId) || draftConversation) &&
!pendingAsk &&
!pendingDraftRead;
const canSend =
!isWorking &&
canChat &&
@@ -1762,9 +1807,11 @@ export default class ChatDrawer extends PureComponent {
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
: pendingDraftRead
? 'Bitte Entwurfsanfrage oben beantworten…'
: stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
}
sx={{
px: 0.5,

View File

@@ -0,0 +1,118 @@
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,3 +2,4 @@ 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';