From 99d95ee234b62f55680745cb0e7e119dacb72215 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 16 Oct 2026 16:03:31 +0200 Subject: [PATCH] u --- src/api/vfs.js | 38 +--- src/automats/chatAutomat.js | 224 +++++++++++++++++++-- src/automats/draftSync.js | 31 --- src/automats/sseAutomat.js | 11 +- src/components/chat/ChatDrawer.jsx | 57 +++++- src/components/chat/DraftRequestPrompt.jsx | 118 +++++++++++ src/components/chat/index.js | 1 + 7 files changed, 377 insertions(+), 103 deletions(-) create mode 100644 src/components/chat/DraftRequestPrompt.jsx diff --git a/src/api/vfs.js b/src/api/vfs.js index 270fcaa..d5c847c 100644 --- a/src/api/vfs.js +++ b/src/api/vfs.js @@ -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, 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 diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js index e16ee44..09d61a2 100644 --- a/src/automats/chatAutomat.js +++ b/src/automats/chatAutomat.js @@ -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; diff --git a/src/automats/draftSync.js b/src/automats/draftSync.js index bc35823..ff21662 100644 --- a/src/automats/draftSync.js +++ b/src/automats/draftSync.js @@ -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} - */ -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. diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 120bb9e..60163de 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -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 { diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx index 197e724..0791152 100644 --- a/src/components/chat/ChatDrawer.jsx +++ b/src/components/chat/ChatDrawer.jsx @@ -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 ( @@ -1366,6 +1399,14 @@ export default class ChatDrawer extends PureComponent { disabled={this.state.isSending} /> )} + + {pendingDraftRead && ( + + )} ); } @@ -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, diff --git a/src/components/chat/DraftRequestPrompt.jsx b/src/components/chat/DraftRequestPrompt.jsx new file mode 100644 index 0000000..7447b35 --- /dev/null +++ b/src/components/chat/DraftRequestPrompt.jsx @@ -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 ( + ({ + 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, + })} + > + + + + Entwurfsanfrage: {filename || `drafts/${draftKey}`} + + + + + {hasContent ? ( + <> + Das Modell möchte den aktuellen {draftLabel}-Entwurf ({linesCount} Position{linesCount === 1 ? '' : 'en'}) einlesen, um die Bearbeitung fortzusetzen. + + ) : ( + <> + Das Modell fordert {filename} an. Aktuell ist kein {draftLabel}-Entwurf geöffnet. + + )} + + + + + + + + + ); +} diff --git a/src/components/chat/index.js b/src/components/chat/index.js index 91d239f..5f7336f 100644 --- a/src/components/chat/index.js +++ b/src/components/chat/index.js @@ -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';