u
This commit is contained in:
75
.agents/skills/frontend-routes/SKILL.md
Normal file
75
.agents/skills/frontend-routes/SKILL.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 <USERENV> 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
147
src/components/chat/userEnv.js
Normal file
147
src/components/chat/userEnv.js
Normal file
@@ -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 <USERENV><ROUTE>...</ROUTE></USERENV> XML prefix for LLM agent prompts.
|
||||
* @param {string} [route]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildUserEnvPrefix(route) {
|
||||
const resolvedRoute = route || getCurrentRoute();
|
||||
return `<USERENV><ROUTE>${resolvedRoute}</ROUTE></USERENV>`;
|
||||
}
|
||||
|
||||
export const USER_ENV_REGEX = /<USERENV>[\s\S]*?(?:<\/USERENV>|$)\s*/gi;
|
||||
|
||||
/**
|
||||
* Strips <USERENV>...</USERENV> 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('<USERENV')) {
|
||||
return text || '';
|
||||
}
|
||||
return text.replace(USER_ENV_REGEX, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends the <USERENV><ROUTE>...</ROUTE></USERENV> 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('<USERENV')) {
|
||||
return trimmed;
|
||||
}
|
||||
const prefix = buildUserEnvPrefix(route);
|
||||
return trimmed ? `${prefix}\n\n${trimmed}` : prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool name is the set-user-route tool.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSetUserRouteTool(name) {
|
||||
if (!name || typeof name !== 'string') return false;
|
||||
const n = name.trim().toLowerCase();
|
||||
return n === 'set-user-route' || n === 'set_user_route';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the target route string from tool data arguments/parameters/result/content.
|
||||
* @param {object} data
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function extractRouteFromToolData(data) {
|
||||
if (!data) return null;
|
||||
// 1. From result
|
||||
if (data.result) {
|
||||
if (typeof data.result === 'object' && data.result.route) {
|
||||
return String(data.result.route);
|
||||
}
|
||||
if (typeof data.result === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data.result);
|
||||
if (parsed?.route) return String(parsed.route);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
// 2. From arguments or parameters
|
||||
const args = data.arguments || data.parameters;
|
||||
if (args) {
|
||||
if (typeof args === 'object' && args.route) {
|
||||
return String(args.route);
|
||||
}
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
if (parsed?.route) return String(parsed.route);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
// 3. From content
|
||||
if (data.content) {
|
||||
if (typeof data.content === 'object' && data.content.route) {
|
||||
return String(data.content.route);
|
||||
}
|
||||
if (typeof data.content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data.content);
|
||||
if (parsed?.route) return String(parsed.route);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigatedToolCallIds = new Set();
|
||||
|
||||
/**
|
||||
* Navigates the application to the given client route.
|
||||
* @param {string} route
|
||||
* @param {string} [toolCallId]
|
||||
*/
|
||||
export async function navigateToClientRoute(route, toolCallId) {
|
||||
if (!route || typeof route !== 'string') return;
|
||||
const target = route.trim();
|
||||
if (!target) return;
|
||||
|
||||
if (toolCallId) {
|
||||
if (navigatedToolCallIds.has(toolCallId)) return;
|
||||
navigatedToolCallIds.add(toolCallId);
|
||||
}
|
||||
|
||||
try {
|
||||
const { router } = await import('../../appRouter');
|
||||
if (router?.navigate) {
|
||||
const current = router.state?.location
|
||||
? `${router.state.location.pathname}${router.state.location.search || ''}`
|
||||
: (typeof window !== 'undefined' ? `${window.location.pathname}${window.location.search || ''}` : '');
|
||||
if (current !== target) {
|
||||
void router.navigate(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[userEnv] Router navigation failed:', err);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
window.location.href = target;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user