diff --git a/.agents/skills/frontend-routes/SKILL.md b/.agents/skills/frontend-routes/SKILL.md new file mode 100644 index 0000000..80a3f8e --- /dev/null +++ b/.agents/skills/frontend-routes/SKILL.md @@ -0,0 +1,75 @@ +--- +name: frontend-routes +description: >- + Guide and reference for constructing valid client-side frontend routes in Dashfront. + Use this skill whenever an agent needs to navigate the user to specific pages, entities, or views. +--- + +# Dashfront Frontend Routes + +Reference guide for the backend LLM agent when emitting route navigation actions (e.g. redirecting or deep-linking the user). + +--- + +## 1. Quick Route Construction Matrix + +| Destination | Path Pattern | Parameters / Construction | Example | +|:---|:---|:---|:---| +| **Article Search / Index** | `/artikel` | Query params: `?target={query}&filter={filter}&offset={n}` | `/artikel?target=kabel` | +| **Article Detail** | `/artikel/:sku` | Encode SKU: `/artikel/${encodeURIComponent(sku)}` | `/artikel/ART-10042` | +| **Warehouse / Stock** | `/artikel/lager` | Static | `/artikel/lager` | +| **Brands** | `/artikel/marken` | Static | `/artikel/marken` | +| **Label Templates** | `/artikel/vorlagen` | Static | `/artikel/vorlagen` | +| **Attributes & Features** | `/artikel/merkmale` | Static | `/artikel/merkmale` | +| **Category Tree Root** | `/kategorien` | Static | `/kategorien` | +| **Category Subtree** | `/kategorien/*` | Join encoded slugs: `/kategorien/${slug1}/${slug2}` | `/kategorien/elektronik/kabel` | +| **Delivery List** | `/lieferung` | Static | `/lieferung` | +| **New Delivery Draft** | `/lieferung/neu` | Static (receipt & draft line editor) | `/lieferung/neu` | +| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` | +| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` | +| **Quote List (Angebote)** | `/auftrag` | Static | `/auftrag` | +| **New Quote** | `/auftrag/neu` | Static draft form | `/auftrag/neu` | +| **Quote Detail** | `/auftrag/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` | +| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` | +| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` | +| **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` | +| **Customer List** | `/kunde` | Static | `/kunde` | +| **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` | +| **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` | +| **Customer Groups** | `/kunde/gruppen` | Static (pricing & customer tiers) | `/kunde/gruppen` | +| **Article History** | `/infos` or `/infos/articlehistory` | Static | `/infos/articlehistory` | +| **Global History** | `/infos/globalhistory` | Static audit log | `/infos/globalhistory` | +| **Statistics** | `/infos/statistics` | Static KPIs & charts | `/infos/statistics` | + +--- + +## 2. Parameter Details & Rules + +### Articles (`/artikel`) +- **SKU detail**: Always use `encodeURIComponent(sku)` if the SKU contains slashes, spaces, or special characters. + - Path: `/artikel/${encodeURIComponent(sku)}` + - Example: SKU `A/123` becomes `/artikel/A%2F123` +- **Search Query**: + - `target`: Search text (min 3 chars to activate search index). + - `filter`: Category or facet filter. + - `offset`: Pagination offset (integer). + - Example: `/artikel?target=schrauben&offset=20` + +### Categories (`/kategorien`) +- The route uses hierarchical URL slugs, **not** numeric category IDs. +- Format: `/kategorien/{parentSlug}/{childSlug}` +- Note: Legacy `/artikel/kategorien/*` is automatically redirected to `/kategorien/*`. + +### Inbound Deliveries (`/lieferung`) +- `:deliveryId`: ID of the delivery document (manifest). +- To start a new incoming goods intake or receipt reconciliation, route to `/lieferung/neu`. + +### Quotes & Orders (`/auftrag`) +- Distinction: + - Quotes (Angebote) live at `/auftrag` and `/auftrag/:quoteId`. + - Orders (Bestellungen) live at `/auftrag/bestellung` and `/auftrag/bestellung/:orderId`. +- Both IDs (`quoteId`, `orderId`) are document IDs. + +### Customers (`/kunde`) +- `:entityId`: Numeric ID of the entity/customer record (e.g. `entity.id`). +- Opens contact addresses, communications, and customer data tiles. diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js index 8ef56cf..ce628fd 100644 --- a/src/automats/chatAutomat.js +++ b/src/automats/chatAutomat.js @@ -16,6 +16,13 @@ import { Automat } from 'react-automat'; import { modelconversation as modelconversationApi } from '../api'; import authAutomat from './authAutomat'; +import { + attachUserEnv, + getCurrentRoute, + isSetUserRouteTool, + extractRouteFromToolData, + navigateToClientRoute, +} from '../components/chat/userEnv'; /** * @typedef {Object} ModelConversation @@ -752,6 +759,20 @@ const actions = { * Turns the working icon green, shows tooltip with content, displays name behind the work icon. */ applyStreamToolCall(data) { + let toolName = data?.name; + if (!toolName && data?.id) { + const turns = chatAutomat.state.activeTurns; + const lastTurn = turns?.[turns.length - 1]; + const existing = lastTurn?.tools?.find((t) => t.id === data.id); + toolName = existing?.name; + } + if (isSetUserRouteTool(toolName)) { + const targetRoute = extractRouteFromToolData(data); + if (targetRoute) { + void navigateToClientRoute(targetRoute, data.id); + } + } + chatAutomat.setState((prev) => { const turns = prev.activeTurns ? [...prev.activeTurns] : []; if (turns.length === 0) { @@ -1020,6 +1041,9 @@ const actions = { return; } + const activeRoute = + (typeof textOrPayload === 'object' && textOrPayload?.route) || getCurrentRoute(); + const tempId = `temp-${Date.now()}`; let finalContent = trimmed; if (fileAttachments.length > 0) { @@ -1033,11 +1057,14 @@ const actions = { finalContent = trimmed ? `${prefix}\n\n${trimmed}` : prefix; } + const optimisticContent = attachUserEnv(finalContent, activeRoute); + const promptMessage = attachUserEnv(trimmed, activeRoute); + const optimisticMsg = { id: tempId, conversation_id: selectedConversationId, role: 'user', - message: JSON.stringify({ content: finalContent }), + message: JSON.stringify({ content: optimisticContent }), created_at: new Date().toISOString(), }; @@ -1065,7 +1092,7 @@ const actions = { try { const res = await modelconversationApi.addMessage(selectedConversationId, { - message: trimmed, + message: promptMessage, attachments: fileAttachments.length > 0 ? fileAttachments : undefined, }); const createdMsg = res?.message || optimisticMsg; @@ -1255,6 +1282,18 @@ const actions = { } } + if (message.role === 'tool') { + try { + const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message; + if (isSetUserRouteTool(payload?.name)) { + const targetRoute = extractRouteFromToolData(payload); + if (targetRoute) { + void navigateToClientRoute(targetRoute, message.id || payload?.tool_call_id); + } + } + } catch {} + } + const isAssistant = message.role === 'assistant' || message.role === 'model'; let hasAskUser = false; if (isAssistant) { @@ -1263,6 +1302,17 @@ const actions = { if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) { hasAskUser = true; } + if (Array.isArray(payload?.tool_calls)) { + for (const tc of payload.tool_calls) { + const name = tc.function?.name || tc.name; + if (isSetUserRouteTool(name)) { + const targetRoute = extractRouteFromToolData(tc.function || tc); + if (targetRoute) { + void navigateToClientRoute(targetRoute, tc.id); + } + } + } + } } catch {} // If this assistant message matches one we created locally on 'done', update with real DB ID & keep turns diff --git a/src/components/chat/ChatAttachmentItems.jsx b/src/components/chat/ChatAttachmentItems.jsx index 3219672..c71a4a4 100644 --- a/src/components/chat/ChatAttachmentItems.jsx +++ b/src/components/chat/ChatAttachmentItems.jsx @@ -24,6 +24,7 @@ import { SUPPORTED_CATEGORIES, formatFileSize, } from './chatAttachments'; +import { stripUserEnv } from './userEnv'; const KNOWN_FILE_EXTENSIONS = new Set([ 'html', 'htm', 'csv', 'tsv', 'pdf', 'txt', 'text', 'md', 'markdown', 'log', @@ -199,20 +200,28 @@ export function parseAttachmentTag(attrString) { }; } +export { stripUserEnv }; + /** * Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata. + * Also strips tags so route context is never rendered in chat views. * @param {string} rawText * @returns {{ attachments: Array<{ name: string, path: string, type: string, url: string }>, cleanText: string }} */ export function parseAttachmentsFromText(rawText) { - if (typeof rawText !== 'string' || !rawText.includes('[ATTACHMENT')) { - return { attachments: [], cleanText: rawText || '' }; + if (typeof rawText !== 'string') { + return { attachments: [], cleanText: '' }; + } + + const textWithoutUserEnv = stripUserEnv(rawText); + if (!textWithoutUserEnv.includes('[ATTACHMENT')) { + return { attachments: [], cleanText: textWithoutUserEnv }; } const attachments = []; const tagRegex = /\[ATTACHMENT\s+([^\]]+)\]/g; let match; - while ((match = tagRegex.exec(rawText)) !== null) { + while ((match = tagRegex.exec(textWithoutUserEnv)) !== null) { const parsed = parseAttachmentTag(match[1]); if (parsed.name || parsed.path) { attachments.push({ @@ -222,7 +231,7 @@ export function parseAttachmentsFromText(rawText) { } } - const cleanText = rawText.replace(tagRegex, '').trim(); + const cleanText = textWithoutUserEnv.replace(tagRegex, '').trim(); return { attachments, cleanText }; } diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx index fde7756..c3f77d6 100644 --- a/src/components/chat/ChatFlowList.jsx +++ b/src/components/chat/ChatFlowList.jsx @@ -25,8 +25,9 @@ import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import LensBlurIcon from '@mui/icons-material/LensBlur'; import CalculateIcon from '@mui/icons-material/Calculate'; import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; +import DirectionsIcon from '@mui/icons-material/Directions'; import HealingMarkdown from './HealingMarkdown'; -import { VfsAttachmentLink, parseAttachmentsFromText } from './ChatAttachmentItems'; +import { VfsAttachmentLink, parseAttachmentsFromText, stripUserEnv } from './ChatAttachmentItems'; import { CHROMA, SHADOW_BUBBLE_ASSISTANT, @@ -86,6 +87,8 @@ const TOOL_ICON_MAP = { edit_image: LensBlurIcon, net_gross_calc: CalculateIcon, ean_checksum: QrCodeScannerIcon, + 'set-user-route': DirectionsIcon, + set_user_route: DirectionsIcon, }; function formatMessageTime(isoString) { @@ -875,7 +878,8 @@ function MessageRow({ msg, showTime = true }) { } catch { } } - const { attachments: vfsAttachments, cleanText } = parseAttachmentsFromText(textContent); + const { attachments: vfsAttachments, cleanText: rawCleanText } = parseAttachmentsFromText(textContent); + const cleanText = stripUserEnv(rawCleanText); if (msg.role === 'system') { return ( diff --git a/src/components/chat/userEnv.js b/src/components/chat/userEnv.js new file mode 100644 index 0000000..a6606ca --- /dev/null +++ b/src/components/chat/userEnv.js @@ -0,0 +1,147 @@ +/** + * Resolves the active frontend route (path + search query). + * Falls back to window.location if router is not directly accessible. + * @returns {string} e.g. "/artikel" or "/artikel/ART-10042" or "/artikel?target=kabel" + */ +export function getCurrentRoute() { + if (typeof window !== 'undefined' && window.location) { + const { pathname = '/', search = '' } = window.location; + return `${pathname}${search}`; + } + return '/artikel'; +} + +/** + * Builds the ... XML prefix for LLM agent prompts. + * @param {string} [route] + * @returns {string} + */ +export function buildUserEnvPrefix(route) { + const resolvedRoute = route || getCurrentRoute(); + return `${resolvedRoute}`; +} + +export const USER_ENV_REGEX = /[\s\S]*?(?:<\/USERENV>|$)\s*/gi; + +/** + * Strips ... tags from a message so it is not rendered in the chat view. + * @param {string} text + * @returns {string} + */ +export function stripUserEnv(text) { + if (typeof text !== 'string' || !text.includes('... block to the user prompt if not already present. + * @param {string} text + * @param {string} [route] + * @returns {string} + */ +export function attachUserEnv(text = '', route) { + const trimmed = typeof text === 'string' ? text.trim() : ''; + if (trimmed.includes('