diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 28a95dbc..7989be60 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -57,6 +57,7 @@ const endpoints = { TOOL_ANALYTICS: '/api/get_tool_analytics', SCHEDULE_ANALYTICS: '/api/get_schedule_analytics', LOGS: `/api/get_user_logs`, + TRACES: (params: string) => `/api/traces?${params}`, MANAGE_SYNC: '/api/manage_sync', SYNC_SOURCE: '/api/sync_source', REINGEST_SOURCE: '/api/sources/reingest', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index fd9d81f6..85253b01 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -109,6 +109,17 @@ const userService = { apiClient.post(endpoints.USER.SCHEDULE_ANALYTICS, data, token), getLogs: (data: any, token: string | null): Promise => apiClient.post(endpoints.USER.LOGS, data, token), + getTraces: ( + params: Record, + token: string | null, + signal?: AbortSignal, + ): Promise => + apiClient.get( + endpoints.USER.TRACES(new URLSearchParams(params).toString()), + token, + {}, + signal, + ), manageSync: (data: any, token: string | null): Promise => apiClient.post(endpoints.USER.MANAGE_SYNC, data, token), syncSource: (data: any, token: string | null): Promise => diff --git a/frontend/src/conversation/AnswerFlow.tsx b/frontend/src/conversation/AnswerFlow.tsx index c9f32892..6fa071fa 100644 --- a/frontend/src/conversation/AnswerFlow.tsx +++ b/frontend/src/conversation/AnswerFlow.tsx @@ -333,7 +333,7 @@ function InlineToolCallChip({ ); } -function ToolCallPanel({ +export function ToolCallPanel({ title, copyText, children, diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 061b5480..90cdca21 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -411,7 +411,9 @@ "schedule": "Geplant", "webhook": "Webhook", "workflow": "Workflow", - "system": "System" + "system": "System", + "search": "Suche", + "graph": "Graph-Aufbau" }, "detail": { "agent": "Agent", @@ -431,7 +433,112 @@ "workflow": "Workflow", "activity": "Aktivität", "steps": "Schritte", - "result": "Ergebnis" + "result": "Ergebnis", + "source": "Quelle" + }, + "trace": { + "view": "Trace anzeigen", + "viewRounds_one": "Trace anzeigen ({{count}} Runde)", + "viewRounds_other": "Trace anzeigen ({{count}} Runden)", + "title": "Ausführungs-Trace", + "subtitle": "Jeder Schritt hinter diesem Eintrag – Abruf, Modellaufrufe und Tool-Aufrufe – mit seiner Dauer.", + "empty": "Für diesen Eintrag wurde kein Trace aufgezeichnet.", + "failed": "Der Trace konnte nicht geladen werden.", + "noSpans": "Für diese Ausführung wurde nichts aufgezeichnet.", + "round": "Runde {{n}}", + "step": "Schritt", + "toggleChildren": "Verschachtelte Schritte ein- oder ausblenden", + "droppedSpans_one": "{{count}} weiterer Schritt wurde nicht aufgezeichnet (Größenlimit des Traces).", + "droppedSpans_other": "{{count}} weitere Schritte wurden nicht aufgezeichnet (Größenlimit des Traces).", + "chips": { + "llmCalls_one": "{{count}} LLM-Aufruf", + "llmCalls_other": "{{count}} LLM-Aufrufe", + "tokens": "{{input}} ein / {{output}} aus", + "toolCalls_one": "{{count}} Tool-Aufruf", + "toolCalls_other": "{{count}} Tool-Aufrufe", + "retrieval": "RAG {{duration}}", + "errors_one": "{{count}} Fehler", + "errors_other": "{{count}} Fehler" + }, + "headline": { + "tokens": "{{input}} → {{output}} Tok.", + "chunks_one": "{{count}} Abschnitt", + "chunks_other": "{{count}} Abschnitte", + "hits_one": "{{count}} Treffer", + "hits_other": "{{count}} Treffer", + "cached": "aus Cache" + }, + "status": { + "ok": "OK", + "error": "Fehler", + "cancelled": "Abgebrochen", + "paused": "Pausiert", + "pending": "Wartet auf Freigabe", + "denied": "Abgelehnt", + "skipped": "Übersprungen" + }, + "kinds": { + "agent": "Agent", + "llm": "LLM-Aufruf", + "tool": "Tool-Aufruf", + "retrieval": "Abruf", + "search": "Vektorsuche", + "embedding": "Embedding", + "rerank": "Neusortierung", + "guardrail": "Guardrail", + "step": "Schritt" + }, + "sources": { + "stream": "Chat", + "answer": "Antwort-API", + "v1": "OpenAI-kompatible API", + "webhook": "Webhook", + "schedule": "Geplante Ausführung", + "search": "Such-API", + "mcp": "MCP-Suche", + "graph_extraction": "Graph-Aufbau", + "headless": "Hintergrundausführung" + }, + "fields": { + "status": "Status", + "duration": "Dauer", + "provider": "Anbieter", + "model": "Modell", + "inputTokens": "Eingabe-Tokens", + "outputTokens": "Ausgabe-Tokens", + "cachedTokens": "Gecachte Tokens", + "timeToFirstToken": "Zeit bis zum ersten Token", + "cost": "Kosten", + "tokenSource": "Zweck", + "cacheHit": "Aus dem Cache", + "estimatedUsage": "Geschätzte Tokens", + "yes": "Ja", + "tool": "Tool", + "action": "Aktion", + "callId": "Aufruf-ID", + "retriever": "Retriever", + "sources": "Quellen", + "topK": "Top K", + "chunks": "Abschnitte", + "candidates": "Kandidaten", + "topScore": "Beste Bewertung", + "vectorStore": "Vektorspeicher", + "agentType": "Agententyp", + "toolCalls": "Tool-Aufrufe", + "stage": "Phase", + "checks": "Prüfungen", + "triggered": "Ausgelöst", + "blocked": "Blockiert", + "redacted": "Geschwärzt", + "nodeType": "Knotentyp", + "phase": "Abschnitt", + "query": "Anfrage", + "arguments": "Argumente", + "result": "Ergebnis", + "output": "Ausgabe", + "retrievedChunks": "Abgerufene Abschnitte", + "allAttributes": "Alle Attribute" + } } }, "teams": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 665e746c..d02926ce 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -416,7 +416,9 @@ "schedule": "Scheduled", "webhook": "Webhook", "workflow": "Workflow", - "system": "System" + "system": "System", + "search": "Search", + "graph": "Graph build" }, "detail": { "agent": "Agent", @@ -436,7 +438,112 @@ "workflow": "Workflow", "activity": "Activity", "steps": "Steps", - "result": "Result" + "result": "Result", + "source": "Source" + }, + "trace": { + "view": "View trace", + "viewRounds_one": "View trace ({{count}} round)", + "viewRounds_other": "View trace ({{count}} rounds)", + "title": "Execution trace", + "subtitle": "Every step behind this entry — retrieval, model calls and tool calls — with its timing.", + "empty": "No trace was recorded for this entry.", + "failed": "Could not load the trace.", + "noSpans": "Nothing was recorded for this run.", + "round": "Round {{n}}", + "step": "Step", + "toggleChildren": "Show or hide nested steps", + "droppedSpans_one": "{{count}} more step was not recorded (trace size limit).", + "droppedSpans_other": "{{count}} more steps were not recorded (trace size limit).", + "chips": { + "llmCalls_one": "{{count}} LLM call", + "llmCalls_other": "{{count}} LLM calls", + "tokens": "{{input}} in / {{output}} out", + "toolCalls_one": "{{count}} tool call", + "toolCalls_other": "{{count}} tool calls", + "retrieval": "RAG {{duration}}", + "errors_one": "{{count}} error", + "errors_other": "{{count}} errors" + }, + "headline": { + "tokens": "{{input}} → {{output}} tok", + "chunks_one": "{{count}} chunk", + "chunks_other": "{{count}} chunks", + "hits_one": "{{count}} hit", + "hits_other": "{{count}} hits", + "cached": "cached" + }, + "status": { + "ok": "OK", + "error": "Error", + "cancelled": "Cancelled", + "paused": "Paused", + "pending": "Awaiting approval", + "denied": "Denied", + "skipped": "Skipped" + }, + "kinds": { + "agent": "Agent", + "llm": "LLM call", + "tool": "Tool call", + "retrieval": "Retrieval", + "search": "Vector search", + "embedding": "Embedding", + "rerank": "Rerank", + "guardrail": "Guardrail", + "step": "Step" + }, + "sources": { + "stream": "Chat", + "answer": "Answer API", + "v1": "OpenAI-compatible API", + "webhook": "Webhook", + "schedule": "Scheduled run", + "search": "Search API", + "mcp": "MCP search", + "graph_extraction": "Graph build", + "headless": "Background run" + }, + "fields": { + "status": "Status", + "duration": "Duration", + "provider": "Provider", + "model": "Model", + "inputTokens": "Input tokens", + "outputTokens": "Output tokens", + "cachedTokens": "Cached tokens", + "timeToFirstToken": "Time to first token", + "cost": "Cost", + "tokenSource": "Purpose", + "cacheHit": "Served from cache", + "estimatedUsage": "Estimated tokens", + "yes": "Yes", + "tool": "Tool", + "action": "Action", + "callId": "Call ID", + "retriever": "Retriever", + "sources": "Sources", + "topK": "Top K", + "chunks": "Chunks", + "candidates": "Candidates", + "topScore": "Top score", + "vectorStore": "Vector store", + "agentType": "Agent type", + "toolCalls": "Tool calls", + "stage": "Stage", + "checks": "Checks", + "triggered": "Triggered", + "blocked": "Blocked", + "redacted": "Redacted", + "nodeType": "Node type", + "phase": "Phase", + "query": "Query", + "arguments": "Arguments", + "result": "Result", + "output": "Output", + "retrievedChunks": "Retrieved chunks", + "allAttributes": "All attributes" + } } }, "teams": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 1c27a945..e1a3c307 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -411,7 +411,9 @@ "schedule": "Programado", "webhook": "Webhook", "workflow": "Flujo de trabajo", - "system": "Sistema" + "system": "Sistema", + "search": "Búsqueda", + "graph": "Creación de grafo" }, "detail": { "agent": "Agente", @@ -431,7 +433,112 @@ "workflow": "Flujo de trabajo", "activity": "Actividad", "steps": "Pasos", - "result": "Resultado" + "result": "Resultado", + "source": "Origen" + }, + "trace": { + "view": "Ver traza", + "viewRounds_one": "Ver traza ({{count}} ronda)", + "viewRounds_other": "Ver traza ({{count}} rondas)", + "title": "Traza de ejecución", + "subtitle": "Cada paso detrás de esta entrada (recuperación, llamadas al modelo y llamadas a herramientas) con su duración.", + "empty": "No se registró ninguna traza para esta entrada.", + "failed": "No se pudo cargar la traza.", + "noSpans": "No se registró nada en esta ejecución.", + "round": "Ronda {{n}}", + "step": "Paso", + "toggleChildren": "Mostrar u ocultar pasos anidados", + "droppedSpans_one": "{{count}} paso más no se registró (límite de tamaño de la traza).", + "droppedSpans_other": "{{count}} pasos más no se registraron (límite de tamaño de la traza).", + "chips": { + "llmCalls_one": "{{count}} llamada al LLM", + "llmCalls_other": "{{count}} llamadas al LLM", + "tokens": "{{input}} entrada / {{output}} salida", + "toolCalls_one": "{{count}} llamada a herramienta", + "toolCalls_other": "{{count}} llamadas a herramientas", + "retrieval": "RAG {{duration}}", + "errors_one": "{{count}} error", + "errors_other": "{{count}} errores" + }, + "headline": { + "tokens": "{{input}} → {{output}} tok", + "chunks_one": "{{count}} fragmento", + "chunks_other": "{{count}} fragmentos", + "hits_one": "{{count}} resultado", + "hits_other": "{{count}} resultados", + "cached": "en caché" + }, + "status": { + "ok": "OK", + "error": "Error", + "cancelled": "Cancelado", + "paused": "En pausa", + "pending": "Esperando aprobación", + "denied": "Denegado", + "skipped": "Omitido" + }, + "kinds": { + "agent": "Agente", + "llm": "Llamada al LLM", + "tool": "Llamada a herramienta", + "retrieval": "Recuperación", + "search": "Búsqueda vectorial", + "embedding": "Embedding", + "rerank": "Reordenación", + "guardrail": "Guardrail", + "step": "Paso" + }, + "sources": { + "stream": "Chat", + "answer": "API de respuestas", + "v1": "API compatible con OpenAI", + "webhook": "Webhook", + "schedule": "Ejecución programada", + "search": "API de búsqueda", + "mcp": "Búsqueda MCP", + "graph_extraction": "Creación de grafo", + "headless": "Ejecución en segundo plano" + }, + "fields": { + "status": "Estado", + "duration": "Duración", + "provider": "Proveedor", + "model": "Modelo", + "inputTokens": "Tokens de entrada", + "outputTokens": "Tokens de salida", + "cachedTokens": "Tokens en caché", + "timeToFirstToken": "Tiempo hasta el primer token", + "cost": "Coste", + "tokenSource": "Propósito", + "cacheHit": "Servido desde caché", + "estimatedUsage": "Tokens estimados", + "yes": "Sí", + "tool": "Herramienta", + "action": "Acción", + "callId": "ID de llamada", + "retriever": "Recuperador", + "sources": "Fuentes", + "topK": "Top K", + "chunks": "Fragmentos", + "candidates": "Candidatos", + "topScore": "Mejor puntuación", + "vectorStore": "Almacén vectorial", + "agentType": "Tipo de agente", + "toolCalls": "Llamadas a herramientas", + "stage": "Etapa", + "checks": "Comprobaciones", + "triggered": "Activadas", + "blocked": "Bloqueado", + "redacted": "Censurado", + "nodeType": "Tipo de nodo", + "phase": "Fase", + "query": "Consulta", + "arguments": "Argumentos", + "result": "Resultado", + "output": "Salida", + "retrievedChunks": "Fragmentos recuperados", + "allAttributes": "Todos los atributos" + } } }, "teams": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 537a6373..ef316ca3 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -410,7 +410,9 @@ "schedule": "スケジュール", "webhook": "Webhook", "workflow": "ワークフロー", - "system": "システム" + "system": "システム", + "search": "検索", + "graph": "グラフ構築" }, "detail": { "agent": "エージェント", @@ -430,7 +432,112 @@ "workflow": "ワークフロー", "activity": "アクティビティ", "steps": "ステップ", - "result": "結果" + "result": "結果", + "source": "ソース" + }, + "trace": { + "view": "トレースを表示", + "viewRounds_one": "トレースを表示({{count}} ラウンド)", + "viewRounds_other": "トレースを表示({{count}} ラウンド)", + "title": "実行トレース", + "subtitle": "このエントリの各ステップ(検索、モデル呼び出し、ツール呼び出し)とその所要時間。", + "empty": "このエントリのトレースは記録されていません。", + "failed": "トレースを読み込めませんでした。", + "noSpans": "この実行では何も記録されませんでした。", + "round": "ラウンド {{n}}", + "step": "ステップ", + "toggleChildren": "ネストされたステップの表示を切り替え", + "droppedSpans_one": "さらに {{count}} 個のステップは記録されていません(トレースのサイズ上限)。", + "droppedSpans_other": "さらに {{count}} 個のステップは記録されていません(トレースのサイズ上限)。", + "chips": { + "llmCalls_one": "LLM 呼び出し {{count}} 回", + "llmCalls_other": "LLM 呼び出し {{count}} 回", + "tokens": "入力 {{input}} / 出力 {{output}}", + "toolCalls_one": "ツール呼び出し {{count}} 回", + "toolCalls_other": "ツール呼び出し {{count}} 回", + "retrieval": "RAG {{duration}}", + "errors_one": "エラー {{count}} 件", + "errors_other": "エラー {{count}} 件" + }, + "headline": { + "tokens": "{{input}} → {{output}} トークン", + "chunks_one": "{{count}} チャンク", + "chunks_other": "{{count}} チャンク", + "hits_one": "{{count}} 件ヒット", + "hits_other": "{{count}} 件ヒット", + "cached": "キャッシュ" + }, + "status": { + "ok": "OK", + "error": "エラー", + "cancelled": "キャンセル", + "paused": "一時停止", + "pending": "承認待ち", + "denied": "拒否", + "skipped": "スキップ" + }, + "kinds": { + "agent": "エージェント", + "llm": "LLM 呼び出し", + "tool": "ツール呼び出し", + "retrieval": "検索", + "search": "ベクトル検索", + "embedding": "埋め込み", + "rerank": "再ランク付け", + "guardrail": "ガードレール", + "step": "ステップ" + }, + "sources": { + "stream": "チャット", + "answer": "回答 API", + "v1": "OpenAI 互換 API", + "webhook": "Webhook", + "schedule": "スケジュール実行", + "search": "検索 API", + "mcp": "MCP 検索", + "graph_extraction": "グラフ構築", + "headless": "バックグラウンド実行" + }, + "fields": { + "status": "ステータス", + "duration": "所要時間", + "provider": "プロバイダー", + "model": "モデル", + "inputTokens": "入力トークン", + "outputTokens": "出力トークン", + "cachedTokens": "キャッシュされたトークン", + "timeToFirstToken": "最初のトークンまでの時間", + "cost": "コスト", + "tokenSource": "用途", + "cacheHit": "キャッシュから提供", + "estimatedUsage": "推定トークン", + "yes": "はい", + "tool": "ツール", + "action": "アクション", + "callId": "呼び出し ID", + "retriever": "リトリーバー", + "sources": "ソース", + "topK": "Top K", + "chunks": "チャンク", + "candidates": "候補", + "topScore": "最高スコア", + "vectorStore": "ベクトルストア", + "agentType": "エージェントの種類", + "toolCalls": "ツール呼び出し", + "stage": "ステージ", + "checks": "チェック", + "triggered": "発動", + "blocked": "ブロック", + "redacted": "マスク済み", + "nodeType": "ノードの種類", + "phase": "フェーズ", + "query": "クエリ", + "arguments": "引数", + "result": "結果", + "output": "出力", + "retrievedChunks": "取得したチャンク", + "allAttributes": "すべての属性" + } } }, "teams": { diff --git a/frontend/src/locale/logsTrace.test.ts b/frontend/src/locale/logsTrace.test.ts new file mode 100644 index 00000000..98f36f1a --- /dev/null +++ b/frontend/src/locale/logsTrace.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import de from './de.json'; +import en from './en.json'; +import es from './es.json'; +import jp from './jp.json'; +import ru from './ru.json'; +import zhTW from './zh-TW.json'; +import zh from './zh.json'; + +type Tree = { [key: string]: string | Tree }; + +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; + +const flatten = (tree: Tree, prefix = ''): string[] => + Object.entries(tree).flatMap(([key, value]) => + typeof value === 'string' + ? [prefix + key.replace(PLURAL_SUFFIX, '')] + : flatten(value, `${prefix}${key}.`), + ); + +const traceBlock = (locale: { settings: object }): Tree => + ((locale.settings as Tree).logs as Tree).trace as Tree; + +const keysOf = (locale: { settings: object }): string[] => + Array.from(new Set(flatten(traceBlock(locale)))).sort(); + +const LOCALES = { es, de, jp, ru, zh, zhTW }; + +describe('settings.logs.trace locale block', () => { + it.each(Object.entries(LOCALES))( + '%s has the same keys as en', + (_name, locale) => { + expect(keysOf(locale)).toEqual(keysOf(en)); + }, + ); + + it.each(Object.entries(LOCALES))( + '%s is translated, not an English copy', + (_name, locale) => { + expect(traceBlock(locale).title).not.toBe(traceBlock(en).title); + expect((traceBlock(locale).fields as Tree).duration).not.toBe( + (traceBlock(en).fields as Tree).duration, + ); + }, + ); + + it('every locale provides both plural forms i18next falls back through', () => { + [en, ...Object.values(LOCALES)].forEach((locale) => { + const chips = traceBlock(locale).chips as Tree; + expect(chips.llmCalls_one).toBeTruthy(); + expect(chips.llmCalls_other).toBeTruthy(); + }); + }); + + it('every locale labels the new log event types', () => { + [en, ...Object.values(LOCALES)].forEach((locale) => { + const types = ((locale.settings as Tree).logs as Tree).types as Tree; + expect(types.search).toBeTruthy(); + expect(types.graph).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 243954cb..bf090d6b 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -423,7 +423,9 @@ "schedule": "По расписанию", "webhook": "Вебхук", "workflow": "Рабочий процесс", - "system": "Система" + "system": "Система", + "search": "Поиск", + "graph": "Построение графа" }, "detail": { "agent": "Агент", @@ -443,7 +445,126 @@ "workflow": "Рабочий процесс", "activity": "Активность", "steps": "Шаги", - "result": "Результат" + "result": "Результат", + "source": "Источник" + }, + "trace": { + "view": "Открыть трассировку", + "viewRounds_one": "Открыть трассировку ({{count}} раунд)", + "viewRounds_few": "Открыть трассировку ({{count}} раунда)", + "viewRounds_many": "Открыть трассировку ({{count}} раундов)", + "viewRounds_other": "Открыть трассировку ({{count}} раунда)", + "title": "Трассировка выполнения", + "subtitle": "Каждый шаг за этой записью — поиск, вызовы модели и инструментов — с длительностью.", + "empty": "Для этой записи трассировка не сохранена.", + "failed": "Не удалось загрузить трассировку.", + "noSpans": "Для этого запуска ничего не записано.", + "round": "Раунд {{n}}", + "step": "Шаг", + "toggleChildren": "Показать или скрыть вложенные шаги", + "droppedSpans_one": "Ещё {{count}} шаг не записан (лимит размера трассировки).", + "droppedSpans_few": "Ещё {{count}} шага не записаны (лимит размера трассировки).", + "droppedSpans_many": "Ещё {{count}} шагов не записаны (лимит размера трассировки).", + "droppedSpans_other": "Ещё {{count}} шага не записаны (лимит размера трассировки).", + "chips": { + "llmCalls_one": "{{count}} вызов LLM", + "llmCalls_few": "{{count}} вызова LLM", + "llmCalls_many": "{{count}} вызовов LLM", + "llmCalls_other": "{{count}} вызова LLM", + "tokens": "{{input}} вход / {{output}} выход", + "toolCalls_one": "{{count}} вызов инструмента", + "toolCalls_few": "{{count}} вызова инструментов", + "toolCalls_many": "{{count}} вызовов инструментов", + "toolCalls_other": "{{count}} вызова инструментов", + "retrieval": "RAG {{duration}}", + "errors_one": "{{count}} ошибка", + "errors_few": "{{count}} ошибки", + "errors_many": "{{count}} ошибок", + "errors_other": "{{count}} ошибки" + }, + "headline": { + "tokens": "{{input}} → {{output}} ток.", + "chunks_one": "{{count}} фрагмент", + "chunks_few": "{{count}} фрагмента", + "chunks_many": "{{count}} фрагментов", + "chunks_other": "{{count}} фрагмента", + "hits_one": "{{count}} совпадение", + "hits_few": "{{count}} совпадения", + "hits_many": "{{count}} совпадений", + "hits_other": "{{count}} совпадения", + "cached": "из кэша" + }, + "status": { + "ok": "ОК", + "error": "Ошибка", + "cancelled": "Отменено", + "paused": "Приостановлено", + "pending": "Ожидает подтверждения", + "denied": "Отклонено", + "skipped": "Пропущено" + }, + "kinds": { + "agent": "Агент", + "llm": "Вызов LLM", + "tool": "Вызов инструмента", + "retrieval": "Поиск", + "search": "Векторный поиск", + "embedding": "Эмбеддинг", + "rerank": "Переранжирование", + "guardrail": "Ограничитель", + "step": "Шаг" + }, + "sources": { + "stream": "Чат", + "answer": "API ответов", + "v1": "OpenAI-совместимый API", + "webhook": "Вебхук", + "schedule": "Запуск по расписанию", + "search": "API поиска", + "mcp": "Поиск MCP", + "graph_extraction": "Построение графа", + "headless": "Фоновый запуск" + }, + "fields": { + "status": "Статус", + "duration": "Длительность", + "provider": "Провайдер", + "model": "Модель", + "inputTokens": "Входные токены", + "outputTokens": "Выходные токены", + "cachedTokens": "Токены из кэша", + "timeToFirstToken": "Время до первого токена", + "cost": "Стоимость", + "tokenSource": "Назначение", + "cacheHit": "Из кэша", + "estimatedUsage": "Оценочные токены", + "yes": "Да", + "tool": "Инструмент", + "action": "Действие", + "callId": "ID вызова", + "retriever": "Ретривер", + "sources": "Источники", + "topK": "Top K", + "chunks": "Фрагменты", + "candidates": "Кандидаты", + "topScore": "Лучшая оценка", + "vectorStore": "Векторное хранилище", + "agentType": "Тип агента", + "toolCalls": "Вызовы инструментов", + "stage": "Этап", + "checks": "Проверки", + "triggered": "Сработали", + "blocked": "Заблокировано", + "redacted": "Скрыто", + "nodeType": "Тип узла", + "phase": "Фаза", + "query": "Запрос", + "arguments": "Аргументы", + "result": "Результат", + "output": "Вывод", + "retrievedChunks": "Найденные фрагменты", + "allAttributes": "Все атрибуты" + } } }, "teams": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 3e10c564..c7184ad8 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -410,7 +410,9 @@ "schedule": "排程", "webhook": "Webhook", "workflow": "工作流程", - "system": "系統" + "system": "系統", + "search": "搜尋", + "graph": "圖譜建置" }, "detail": { "agent": "代理", @@ -430,7 +432,112 @@ "workflow": "工作流程", "activity": "活動", "steps": "步驟", - "result": "結果" + "result": "結果", + "source": "來源" + }, + "trace": { + "view": "檢視追蹤", + "viewRounds_one": "檢視追蹤({{count}} 輪)", + "viewRounds_other": "檢視追蹤({{count}} 輪)", + "title": "執行追蹤", + "subtitle": "此項目背後的每個步驟(檢索、模型呼叫和工具呼叫)及其耗時。", + "empty": "此項目沒有記錄追蹤。", + "failed": "無法載入追蹤。", + "noSpans": "此次執行沒有記錄任何內容。", + "round": "第 {{n}} 輪", + "step": "步驟", + "toggleChildren": "顯示或隱藏巢狀步驟", + "droppedSpans_one": "另有 {{count}} 個步驟未被記錄(追蹤大小限制)。", + "droppedSpans_other": "另有 {{count}} 個步驟未被記錄(追蹤大小限制)。", + "chips": { + "llmCalls_one": "{{count}} 次 LLM 呼叫", + "llmCalls_other": "{{count}} 次 LLM 呼叫", + "tokens": "輸入 {{input}} / 輸出 {{output}}", + "toolCalls_one": "{{count}} 次工具呼叫", + "toolCalls_other": "{{count}} 次工具呼叫", + "retrieval": "RAG {{duration}}", + "errors_one": "{{count}} 個錯誤", + "errors_other": "{{count}} 個錯誤" + }, + "headline": { + "tokens": "{{input}} → {{output}} 詞元", + "chunks_one": "{{count}} 個片段", + "chunks_other": "{{count}} 個片段", + "hits_one": "{{count}} 個命中", + "hits_other": "{{count}} 個命中", + "cached": "快取" + }, + "status": { + "ok": "正常", + "error": "錯誤", + "cancelled": "已取消", + "paused": "已暫停", + "pending": "等待核准", + "denied": "已拒絕", + "skipped": "已略過" + }, + "kinds": { + "agent": "代理", + "llm": "LLM 呼叫", + "tool": "工具呼叫", + "retrieval": "檢索", + "search": "向量搜尋", + "embedding": "嵌入", + "rerank": "重新排序", + "guardrail": "防護機制", + "step": "步驟" + }, + "sources": { + "stream": "聊天", + "answer": "回答 API", + "v1": "OpenAI 相容 API", + "webhook": "Webhook", + "schedule": "排程執行", + "search": "搜尋 API", + "mcp": "MCP 搜尋", + "graph_extraction": "圖譜建置", + "headless": "背景執行" + }, + "fields": { + "status": "狀態", + "duration": "耗時", + "provider": "供應商", + "model": "模型", + "inputTokens": "輸入詞元", + "outputTokens": "輸出詞元", + "cachedTokens": "快取詞元", + "timeToFirstToken": "首個詞元耗時", + "cost": "費用", + "tokenSource": "用途", + "cacheHit": "來自快取", + "estimatedUsage": "估算詞元", + "yes": "是", + "tool": "工具", + "action": "動作", + "callId": "呼叫 ID", + "retriever": "檢索器", + "sources": "來源", + "topK": "Top K", + "chunks": "片段", + "candidates": "候選", + "topScore": "最高分", + "vectorStore": "向量儲存", + "agentType": "代理類型", + "toolCalls": "工具呼叫", + "stage": "階段", + "checks": "檢查項目", + "triggered": "已觸發", + "blocked": "已封鎖", + "redacted": "已遮蔽", + "nodeType": "節點類型", + "phase": "階段", + "query": "查詢", + "arguments": "參數", + "result": "結果", + "output": "輸出", + "retrievedChunks": "檢索到的片段", + "allAttributes": "所有屬性" + } } }, "teams": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 201b6c4c..9db7d12f 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -410,7 +410,9 @@ "schedule": "计划", "webhook": "Webhook", "workflow": "工作流", - "system": "系统" + "system": "系统", + "search": "搜索", + "graph": "图谱构建" }, "detail": { "agent": "代理", @@ -430,7 +432,112 @@ "workflow": "工作流", "activity": "活动", "steps": "步骤", - "result": "结果" + "result": "结果", + "source": "来源" + }, + "trace": { + "view": "查看追踪", + "viewRounds_one": "查看追踪({{count}} 轮)", + "viewRounds_other": "查看追踪({{count}} 轮)", + "title": "执行追踪", + "subtitle": "此条目背后的每一步(检索、模型调用和工具调用)及其耗时。", + "empty": "此条目没有记录追踪。", + "failed": "无法加载追踪。", + "noSpans": "此次运行没有记录任何内容。", + "round": "第 {{n}} 轮", + "step": "步骤", + "toggleChildren": "显示或隐藏嵌套步骤", + "droppedSpans_one": "另有 {{count}} 个步骤未被记录(追踪大小限制)。", + "droppedSpans_other": "另有 {{count}} 个步骤未被记录(追踪大小限制)。", + "chips": { + "llmCalls_one": "{{count}} 次 LLM 调用", + "llmCalls_other": "{{count}} 次 LLM 调用", + "tokens": "输入 {{input}} / 输出 {{output}}", + "toolCalls_one": "{{count}} 次工具调用", + "toolCalls_other": "{{count}} 次工具调用", + "retrieval": "RAG {{duration}}", + "errors_one": "{{count}} 个错误", + "errors_other": "{{count}} 个错误" + }, + "headline": { + "tokens": "{{input}} → {{output}} 词元", + "chunks_one": "{{count}} 个片段", + "chunks_other": "{{count}} 个片段", + "hits_one": "{{count}} 个命中", + "hits_other": "{{count}} 个命中", + "cached": "缓存" + }, + "status": { + "ok": "正常", + "error": "错误", + "cancelled": "已取消", + "paused": "已暂停", + "pending": "等待批准", + "denied": "已拒绝", + "skipped": "已跳过" + }, + "kinds": { + "agent": "智能体", + "llm": "LLM 调用", + "tool": "工具调用", + "retrieval": "检索", + "search": "向量搜索", + "embedding": "嵌入", + "rerank": "重排序", + "guardrail": "防护栏", + "step": "步骤" + }, + "sources": { + "stream": "聊天", + "answer": "回答 API", + "v1": "OpenAI 兼容 API", + "webhook": "Webhook", + "schedule": "定时运行", + "search": "搜索 API", + "mcp": "MCP 搜索", + "graph_extraction": "图谱构建", + "headless": "后台运行" + }, + "fields": { + "status": "状态", + "duration": "耗时", + "provider": "提供商", + "model": "模型", + "inputTokens": "输入词元", + "outputTokens": "输出词元", + "cachedTokens": "缓存词元", + "timeToFirstToken": "首个词元耗时", + "cost": "费用", + "tokenSource": "用途", + "cacheHit": "来自缓存", + "estimatedUsage": "估算词元", + "yes": "是", + "tool": "工具", + "action": "操作", + "callId": "调用 ID", + "retriever": "检索器", + "sources": "来源", + "topK": "Top K", + "chunks": "片段", + "candidates": "候选", + "topScore": "最高分", + "vectorStore": "向量存储", + "agentType": "智能体类型", + "toolCalls": "工具调用", + "stage": "阶段", + "checks": "检查项", + "triggered": "已触发", + "blocked": "已拦截", + "redacted": "已脱敏", + "nodeType": "节点类型", + "phase": "阶段", + "query": "查询", + "arguments": "参数", + "result": "结果", + "output": "输出", + "retrievedChunks": "检索到的片段", + "allAttributes": "全部属性" + } } }, "teams": { diff --git a/frontend/src/settings/Logs.tsx b/frontend/src/settings/Logs.tsx index b5b80a9b..8f38af82 100644 --- a/frontend/src/settings/Logs.tsx +++ b/frontend/src/settings/Logs.tsx @@ -1,3 +1,4 @@ +import { Activity } from 'lucide-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -16,7 +17,10 @@ import { } from '../components/ui/select'; import { useLoaderState } from '../hooks'; import { selectToken } from '../preferences/preferenceSlice'; -import { LogData } from './types'; +import TraceChips from './traces/TraceChips'; +import TraceSheet from './traces/TraceSheet'; +import { formatDurationMs } from './traces/traceUtils'; +import { LogData, TraceRef } from './types'; type LogsProps = { agentId?: string; @@ -35,6 +39,7 @@ export default function Logs({ agentId, tableHeader }: LogsProps) { const [typeFilter, setTypeFilter] = useState('all'); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); + const [openTrace, setOpenTrace] = useState(null); const logs = Object.values(logsByPage).flat(); @@ -127,6 +132,8 @@ export default function Logs({ agentId, tableHeader }: LogsProps) { { label: t('settings.logs.types.webhook'), value: 'webhook' }, { label: t('settings.logs.types.workflow'), value: 'workflow' }, { label: t('settings.logs.types.system'), value: 'system' }, + { label: t('settings.logs.types.search'), value: 'search' }, + { label: t('settings.logs.types.graph'), value: 'graph' }, ]; return ( @@ -178,8 +185,14 @@ export default function Logs({ agentId, tableHeader }: LogsProps) { setPage={setPage} loading={loadingLogs} tableHeader={tableHeader} + onViewTrace={setOpenTrace} /> + setOpenTrace(null)} + /> ); } @@ -189,8 +202,15 @@ type LogsTableProps = { setPage: React.Dispatch>; loading: boolean; tableHeader?: string; + onViewTrace: (ref: TraceRef) => void; }; -function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) { +function LogsTable({ + logs, + setPage, + loading, + tableHeader, + onViewTrace, +}: LogsTableProps) { const { t } = useTranslation(); const observerRef = useRef(null); const [openLogId, setOpenLogId] = useState(null); @@ -248,6 +268,7 @@ function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) { log={log} isOpen={openLogId === log.id} onToggle={handleLogToggle} + onViewTrace={onViewTrace} /> ); @@ -258,6 +279,7 @@ function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) { log={log} isOpen={openLogId === log.id} onToggle={handleLogToggle} + onViewTrace={onViewTrace} /> ); })} @@ -271,17 +293,19 @@ function formatDuration(start?: string, end?: string): string | null { if (!start || !end) return null; const ms = new Date(end).getTime() - new Date(start).getTime(); if (isNaN(ms) || ms < 0) return null; - return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; + return formatDurationMs(ms); } function Log({ log, isOpen, onToggle, + onViewTrace, }: { log: LogData; isOpen: boolean; onToggle: (id: string) => void; + onViewTrace: (ref: TraceRef) => void; }) { const { t } = useTranslation(); const logLevelColor = { @@ -336,6 +360,17 @@ function Log({ } else if (log.event_type === 'system' || log.event_type === 'webhook') { if (log.endpoint) detailRows.push([t('settings.logs.detail.endpoint'), log.endpoint]); + } else if (log.event_type === 'search' || log.event_type === 'graph') { + if (log.source) + detailRows.push([ + t('settings.logs.detail.source'), + t(`settings.logs.trace.sources.${log.source}`, log.source), + ]); + if (log.status) + detailRows.push([ + t('settings.logs.detail.status'), + t(`settings.logs.trace.status.${log.status}`, log.status), + ]); } const textBlocks: { label: string; text: string; isError?: boolean }[] = []; @@ -412,6 +447,11 @@ function Log({ )}

{`[${log.action}]`}

+ {log.trace && ( +

+ {formatDurationMs(log.trace.duration_ms)} +

+ )}

@@ -423,6 +463,26 @@ function Log({ {isOpen && (
+ {log.trace && ( +
+ + +
+ )} {detailRows.length > 0 && (
{detailRows.map(([label, value]) => ( diff --git a/frontend/src/settings/traces/TraceChips.tsx b/frontend/src/settings/traces/TraceChips.tsx new file mode 100644 index 00000000..e5aa32c7 --- /dev/null +++ b/frontend/src/settings/traces/TraceChips.tsx @@ -0,0 +1,93 @@ +import { + Brain, + Coins, + Search, + Timer, + TriangleAlert, + Wrench, +} from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { TraceCounts } from '../types'; +import { formatDurationMs, formatTokens } from './traceUtils'; + +type TraceChipsProps = { + durationMs?: number; + counts: TraceCounts; +}; + +/** Compact stat chips for a trace: duration, LLM calls, tokens, tools, RAG time, errors. */ +export default function TraceChips({ durationMs, counts }: TraceChipsProps) { + const { t } = useTranslation(); + const chips: { + key: string; + icon: React.ReactNode; + label: string; + tone?: 'danger'; + }[] = []; + if (durationMs !== undefined) + chips.push({ + key: 'duration', + icon: , + label: formatDurationMs(durationMs), + }); + if (counts.llm_calls) + chips.push({ + key: 'llm', + icon: , + label: t('settings.logs.trace.chips.llmCalls', { + count: counts.llm_calls, + }), + }); + if (counts.input_tokens || counts.output_tokens) + chips.push({ + key: 'tokens', + icon: , + label: t('settings.logs.trace.chips.tokens', { + input: formatTokens(counts.input_tokens), + output: formatTokens(counts.output_tokens), + }), + }); + if (counts.tool_calls) + chips.push({ + key: 'tools', + icon: , + label: t('settings.logs.trace.chips.toolCalls', { + count: counts.tool_calls, + }), + }); + if (counts.retrieval_calls) + chips.push({ + key: 'retrieval', + icon: , + label: t('settings.logs.trace.chips.retrieval', { + duration: formatDurationMs(counts.retrieval_ms), + }), + }); + if (counts.errors) + chips.push({ + key: 'errors', + icon: , + label: t('settings.logs.trace.chips.errors', { count: counts.errors }), + tone: 'danger', + }); + if (!chips.length) return null; + return ( +
+ {chips.map((chip) => ( + + {chip.icon} + {chip.label} + + ))} +
+ ); +} diff --git a/frontend/src/settings/traces/TraceSheet.tsx b/frontend/src/settings/traces/TraceSheet.tsx new file mode 100644 index 00000000..027c4172 --- /dev/null +++ b/frontend/src/settings/traces/TraceSheet.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { Pill } from '../../admin/AdminUI'; +import userService from '../../api/services/userService'; +import Spinner from '../../components/Spinner'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '../../components/ui/sheet'; +import { selectToken } from '../../preferences/preferenceSlice'; +import { formatDateTime } from '../../utils/dateTimeUtils'; +import { Trace, TraceRef } from '../types'; +import TraceChips from './TraceChips'; +import TraceWaterfall from './TraceWaterfall'; + +type TraceSheetProps = { + traceRef: TraceRef | null; + agentId?: string; + onClose: () => void; +}; + +const STATUS_TONE: Record = + { + ok: 'success', + error: 'danger', + paused: 'warning', + cancelled: 'muted', + }; + +/** + * Side panel showing the execution trace(s) behind one Logs row. A chat turn + * paused for tool approval has one trace per round, shown in order. + */ +export default function TraceSheet({ + traceRef, + agentId, + onClose, +}: TraceSheetProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [traces, setTraces] = useState([]); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!traceRef) return; + const controller = new AbortController(); + setLoading(true); + setFailed(false); + setTraces([]); + const params: Record = { + [traceRef.field]: traceRef.value, + }; + if (agentId) params.api_key_id = agentId; + userService + .getTraces(params, token, controller.signal) + .then(async (response: Response) => { + if (!response.ok) throw new Error('Failed to load traces'); + const data = await response.json(); + setTraces(data.traces ?? []); + }) + .catch((error: unknown) => { + if ((error as Error)?.name === 'AbortError') return; + console.error(error); + setFailed(true); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [traceRef?.field, traceRef?.value, agentId, token]); + + return ( + !open && onClose()}> + + + {t('settings.logs.trace.title')} + + {t('settings.logs.trace.subtitle')} + + +
+ {loading && ( +
+ +
+ )} + {!loading && failed && ( +

+ {t('settings.logs.trace.failed')} +

+ )} + {!loading && !failed && traces.length === 0 && ( +

+ {t('settings.logs.trace.empty')} +

+ )} + {traces.map((trace, index) => ( +
+
+ {traces.length > 1 && ( + + {t('settings.logs.trace.round', { n: index + 1 })} + + )} + + {t( + `settings.logs.trace.sources.${trace.source}`, + trace.source, + )} + + + {formatDateTime(trace.started_at)} + + + {t( + `settings.logs.trace.status.${trace.status}`, + trace.status, + )} + +
+ + {trace.dropped_spans > 0 && ( +

+ {t('settings.logs.trace.droppedSpans', { + count: trace.dropped_spans, + })} +

+ )} + +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/settings/traces/TraceSpanDetails.tsx b/frontend/src/settings/traces/TraceSpanDetails.tsx new file mode 100644 index 00000000..576c42a7 --- /dev/null +++ b/frontend/src/settings/traces/TraceSpanDetails.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ToolCallPanel } from '../../conversation/AnswerFlow'; +import { TraceSpan } from '../types'; +import { formatDurationMs, formatTokens } from './traceUtils'; + +type Row = [string, string]; + +function str(value: unknown): string | undefined { + if (value === null || value === undefined || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(', '); + return String(value); +} + +function jsonText(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); +} + +type ChunkPreview = { + title?: string; + source?: string; + score?: number; + text?: string; +}; + +/** Everything recorded for one span: its key facts, previews and raw attributes. */ +export default function TraceSpanDetails({ span }: { span: TraceSpan }) { + const { t } = useTranslation(); + const a = span.attributes || {}; + const f = (key: string) => t(`settings.logs.trace.fields.${key}`); + const yes = t('settings.logs.trace.fields.yes'); + const rows: Row[] = []; + const push = (label: string, value: unknown) => { + const text = str(value); + if (text !== undefined) rows.push([label, text]); + }; + + push( + f('status'), + t(`settings.logs.trace.status.${span.status}`, span.status), + ); + push(f('duration'), formatDurationMs(span.duration_ms)); + switch (span.kind) { + case 'llm': { + push(f('provider'), a['gen_ai.provider.name']); + push(f('model'), a['gen_ai.request.model']); + const input = a['gen_ai.usage.input_tokens']; + const output = a['gen_ai.usage.output_tokens']; + if (typeof input === 'number') + push(f('inputTokens'), formatTokens(input)); + if (typeof output === 'number') + push(f('outputTokens'), formatTokens(output)); + push(f('cachedTokens'), a['gen_ai.usage.cache_read.input_tokens']); + if (typeof a['docsgpt.ttft_ms'] === 'number') + push(f('timeToFirstToken'), formatDurationMs(a['docsgpt.ttft_ms'])); + if ( + typeof a['docsgpt.cost_usd'] === 'number' && + a['docsgpt.cost_usd'] > 0 + ) + push(f('cost'), `$${a['docsgpt.cost_usd'].toFixed(6)}`); + push(f('tokenSource'), a['docsgpt.token_source']); + if (a['docsgpt.cache_hit']) push(f('cacheHit'), yes); + if (a['docsgpt.usage_estimated']) push(f('estimatedUsage'), yes); + break; + } + case 'tool': + push(f('tool'), a['docsgpt.tool']); + push(f('action'), a['docsgpt.action'] ?? a['gen_ai.tool.name']); + push(f('callId'), a['gen_ai.tool.call.id']); + break; + case 'retrieval': + case 'search': + case 'embedding': + case 'rerank': + push(f('retriever'), a['docsgpt.retriever']); + push(f('sources'), a['docsgpt.source_ids'] ?? a['gen_ai.data_source.id']); + push(f('model'), a['gen_ai.request.model']); + push(f('topK'), a['docsgpt.top_k']); + push(f('chunks'), a['docsgpt.chunk_count'] ?? a['docsgpt.kept_count']); + push(f('candidates'), a['docsgpt.candidate_count']); + push(f('topScore'), a['docsgpt.top_score']); + push(f('vectorStore'), a['docsgpt.vector_store']); + break; + case 'agent': + push(f('agentType'), a['docsgpt.agent_type']); + push(f('model'), a['gen_ai.request.model']); + push(f('sources'), a['docsgpt.source_count']); + push(f('toolCalls'), a['docsgpt.tool_call_count']); + break; + case 'guardrail': + push(f('stage'), a['docsgpt.guardrail.stage']); + push(f('checks'), a['docsgpt.guardrail.checks']); + push(f('triggered'), a['docsgpt.guardrail.triggered']); + if (a['docsgpt.guardrail.blocked']) push(f('blocked'), yes); + if (a['docsgpt.guardrail.redacted']) push(f('redacted'), yes); + break; + case 'step': + push(f('nodeType'), a['docsgpt.workflow.node_type']); + push(f('phase'), a['docsgpt.research.phase']); + break; + } + + const preview = span.preview || {}; + const chunks = Array.isArray(preview.chunks) + ? (preview.chunks as ChunkPreview[]) + : []; + const otherPreviews = Object.entries(preview).filter( + ([key]) => + !['arguments', 'result', 'query', 'chunks', 'output'].includes(key), + ); + + return ( +
+
+ {rows.map(([label, value]) => ( + + {label} + {value} + + ))} +
+ {span.error && ( +
+ {span.error} +
+ )} + {preview.query !== undefined && ( + +

+ {jsonText(preview.query)} +

+
+ )} + {preview.arguments !== undefined && ( + +

+ {jsonText(preview.arguments)} +

+
+ )} + {preview.result !== undefined && ( + +

+ {jsonText(preview.result)} +

+
+ )} + {preview.output !== undefined && ( + +

+ {jsonText(preview.output)} +

+
+ )} + {chunks.length > 0 && ( + +
    + {chunks.map((chunk, index) => ( +
  1. + + {index + 1}. {chunk.title || chunk.source || '—'} + {typeof chunk.score === 'number' && ( + + {chunk.score.toFixed(3)} + + )} + + {chunk.text && ( + + {chunk.text} + + )} +
  2. + ))} +
+
+ )} + {otherPreviews.map(([key, value]) => ( + +

+ {jsonText(value)} +

+
+ ))} +
+ + {f('allAttributes')} + +
+          {JSON.stringify(a, null, 2)}
+        
+
+
+ ); +} diff --git a/frontend/src/settings/traces/TraceWaterfall.tsx b/frontend/src/settings/traces/TraceWaterfall.tsx new file mode 100644 index 00000000..f7f70f05 --- /dev/null +++ b/frontend/src/settings/traces/TraceWaterfall.tsx @@ -0,0 +1,187 @@ +import { + Binary, + Bot, + Brain, + ChevronRight, + Database, + ListFilter, + ListTree, + Search, + ShieldCheck, + Wrench, +} from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Trace, TraceSpan } from '../types'; +import TraceSpanDetails from './TraceSpanDetails'; +import { + barGeometry, + buildSpanRows, + formatDurationMs, + spanHeadline, + traceTotalMs, +} from './traceUtils'; + +const KIND_STYLE: Record = { + agent: { icon: Bot, bar: 'bg-violet-500' }, + llm: { icon: Brain, bar: 'bg-sky-500' }, + tool: { icon: Wrench, bar: 'bg-amber-500' }, + retrieval: { icon: Search, bar: 'bg-emerald-500' }, + search: { icon: Database, bar: 'bg-emerald-400' }, + embedding: { icon: Binary, bar: 'bg-teal-500' }, + rerank: { icon: ListFilter, bar: 'bg-lime-500' }, + guardrail: { icon: ShieldCheck, bar: 'bg-rose-400' }, + step: { icon: ListTree, bar: 'bg-indigo-400' }, +}; + +function barClass(span: TraceSpan): string { + if (span.status === 'error') return 'bg-red-500'; + if (['cancelled', 'pending', 'denied', 'skipped'].includes(span.status)) + return 'bg-gray-400 dark:bg-gray-500'; + return (KIND_STYLE[span.kind] ?? KIND_STYLE.step).bar; +} + +const SCALE_STEPS = [0, 0.25, 0.5, 0.75, 1]; +const INDENT_PX = 14; + +/** A waterfall of one trace's spans; click a row for its details. */ +export default function TraceWaterfall({ trace }: { trace: Trace }) { + const { t } = useTranslation(); + const rows = useMemo(() => buildSpanRows(trace.spans), [trace.spans]); + const totalMs = traceTotalMs(trace.duration_ms, trace.spans); + const [selectedId, setSelectedId] = useState(null); + const [collapsed, setCollapsed] = useState>(new Set()); + + const visibleRows = useMemo(() => { + const out: typeof rows = []; + let hiddenBelowDepth: number | null = null; + for (const row of rows) { + if (hiddenBelowDepth !== null && row.depth > hiddenBelowDepth) continue; + hiddenBelowDepth = collapsed.has(row.span.id) ? row.depth : null; + out.push(row); + } + return out; + }, [rows, collapsed]); + + const toggleCollapsed = (id: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + if (!rows.length) { + return ( +

+ {t('settings.logs.trace.noSpans')} +

+ ); + } + + return ( +
+
+ + {t('settings.logs.trace.step')} + +
+ {SCALE_STEPS.map((step) => ( + + {formatDurationMs(totalMs * step)} + + ))} +
+
+ {visibleRows.map(({ span, depth, hasChildren }) => { + const Icon = (KIND_STYLE[span.kind] ?? KIND_STYLE.step).icon; + const { left, width } = barGeometry(span, totalMs); + const selected = selectedId === span.id; + const headline = spanHeadline(span, t); + return ( +
+
setSelectedId(selected ? null : span.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedId(selected ? null : span.id); + } + }} + className={`grid w-full cursor-pointer grid-cols-[minmax(0,5fr)_minmax(0,4fr)] items-center gap-3 rounded-md py-1 text-left ${ + selected + ? 'bg-muted dark:bg-white/10' + : 'hover:bg-muted/60 dark:hover:bg-white/5' + }`} + > + + {hasChildren ? ( + + ) : ( + + )} + + + {span.name} + + {headline && ( + + {headline} + + )} + + {formatDurationMs(span.duration_ms)} + + + + + +
+ {selected && ( +
+ +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/settings/traces/traceUtils.test.ts b/frontend/src/settings/traces/traceUtils.test.ts new file mode 100644 index 00000000..1a07167e --- /dev/null +++ b/frontend/src/settings/traces/traceUtils.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { TraceSpan } from '../types'; +import { + barGeometry, + buildSpanRows, + formatDurationMs, + formatTokens, + spanHeadline, + traceTotalMs, +} from './traceUtils'; + +const span = (overrides: Partial): TraceSpan => ({ + id: 's', + parent_id: null, + kind: 'llm', + name: 'chat m', + status: 'ok', + offset_ms: 0, + duration_ms: 10, + attributes: {}, + ...overrides, +}); + +describe('buildSpanRows', () => { + it('orders depth-first by start offset with depths', () => { + const rows = buildSpanRows([ + span({ id: 'llm2', parent_id: 'agent', offset_ms: 50 }), + span({ id: 'agent', kind: 'agent', offset_ms: 5, duration_ms: 100 }), + span({ id: 'retrieval', kind: 'retrieval', offset_ms: 0 }), + span({ id: 'tool', kind: 'tool', parent_id: 'agent', offset_ms: 20 }), + span({ id: 'inner', parent_id: 'tool', offset_ms: 21 }), + ]); + expect(rows.map((r) => [r.span.id, r.depth])).toEqual([ + ['retrieval', 0], + ['agent', 0], + ['tool', 1], + ['inner', 2], + ['llm2', 1], + ]); + }); + + it('treats spans whose parent is missing as roots', () => { + const rows = buildSpanRows([span({ id: 'a', parent_id: 'gone' })]); + expect(rows).toHaveLength(1); + expect(rows[0].depth).toBe(0); + }); + + it('marks rows that have children', () => { + const rows = buildSpanRows([ + span({ id: 'p', kind: 'agent' }), + span({ id: 'c', parent_id: 'p' }), + ]); + expect(rows[0].hasChildren).toBe(true); + expect(rows[1].hasChildren).toBe(false); + }); +}); + +describe('traceTotalMs', () => { + it('uses the larger of the stored duration and the last span end', () => { + expect(traceTotalMs(100, [span({ offset_ms: 90, duration_ms: 30 })])).toBe( + 120, + ); + expect(traceTotalMs(500, [span({ offset_ms: 0, duration_ms: 30 })])).toBe( + 500, + ); + }); + + it('never returns zero', () => { + expect(traceTotalMs(0, [])).toBeGreaterThan(0); + }); +}); + +describe('barGeometry', () => { + it('positions a span as percentages of the total', () => { + expect(barGeometry(span({ offset_ms: 25, duration_ms: 50 }), 100)).toEqual({ + left: 25, + width: 50, + }); + }); + + it('keeps tiny spans visible and inside the track', () => { + const tiny = barGeometry(span({ offset_ms: 100, duration_ms: 0 }), 100); + expect(tiny.width).toBeGreaterThan(0); + expect(tiny.left + tiny.width).toBeLessThanOrEqual(100); + }); +}); + +describe('formatDurationMs', () => { + it.each([ + [0.4, '<1 ms'], + [12.4, '12 ms'], + [999, '999 ms'], + [1234, '1.23 s'], + [15432, '15.4 s'], + [65000, '1m 05s'], + ])('%s -> %s', (ms, expected) => { + expect(formatDurationMs(ms)).toBe(expected); + }); + + it('shows a dash for missing values', () => { + expect(formatDurationMs(undefined)).toBe('—'); + }); +}); + +describe('formatTokens', () => { + it('abbreviates large counts', () => { + expect(formatTokens(950)).toBe('950'); + expect(formatTokens(12345)).toBe('12.3k'); + expect(formatTokens(2_500_000)).toBe('2.5M'); + }); +}); + +const HEADLINES: Record = { + 'settings.logs.trace.headline.tokens': '{{input}} → {{output}} tok', + 'settings.logs.trace.headline.chunks': '{{count}} chunks', + 'settings.logs.trace.headline.hits': '{{count}} hits', + 'settings.logs.trace.headline.cached': 'cached', +}; + +const t = (key: string, options: Record = {}) => + (HEADLINES[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name) => + String(options[name]), + ); + +describe('spanHeadline', () => { + it('summarises an llm span by tokens', () => { + expect( + spanHeadline( + span({ + attributes: { + 'gen_ai.usage.input_tokens': 1200, + 'gen_ai.usage.output_tokens': 80, + }, + }), + t, + ), + ).toBe('1.2k → 80 tok'); + }); + + it('summarises a retrieval span by chunk count', () => { + expect( + spanHeadline( + span({ kind: 'retrieval', attributes: { 'docsgpt.chunk_count': 4 } }), + t, + ), + ).toBe('4 chunks'); + }); + + it('is empty when nothing is known', () => { + expect(spanHeadline(span({ kind: 'step' }), t)).toBe(''); + }); + + it('labels a cached llm call', () => { + expect( + spanHeadline(span({ attributes: { 'docsgpt.cache_hit': true } }), t), + ).toBe('cached'); + }); +}); diff --git a/frontend/src/settings/traces/traceUtils.ts b/frontend/src/settings/traces/traceUtils.ts new file mode 100644 index 00000000..3f748328 --- /dev/null +++ b/frontend/src/settings/traces/traceUtils.ts @@ -0,0 +1,140 @@ +import { TraceSpan } from '../types'; + +export type SpanRow = { + span: TraceSpan; + depth: number; + hasChildren: boolean; +}; + +/** + * Flatten a trace's spans into display rows: depth-first, children in start + * order under their parent. A span whose parent is missing (dropped by the + * span cap) is shown at the top level rather than hidden. + */ +export function buildSpanRows(spans: TraceSpan[]): SpanRow[] { + const ids = new Set(spans.map((s) => s.id)); + const children = new Map(); + for (const span of spans) { + const parent = + span.parent_id && ids.has(span.parent_id) ? span.parent_id : null; + const list = children.get(parent) ?? []; + list.push(span); + children.set(parent, list); + } + for (const list of children.values()) { + list.sort((a, b) => a.offset_ms - b.offset_ms); + } + const rows: SpanRow[] = []; + const visit = (parent: string | null, depth: number) => { + for (const span of children.get(parent) ?? []) { + const kids = children.get(span.id); + rows.push({ span, depth, hasChildren: Boolean(kids?.length) }); + visit(span.id, depth + 1); + } + }; + visit(null, 0); + return rows; +} + +/** Width of the timeline: the stored duration, or the last span end if later. */ +export function traceTotalMs( + durationMs: number | undefined, + spans: TraceSpan[], +): number { + const lastEnd = spans.reduce( + (max, s) => Math.max(max, (s.offset_ms || 0) + (s.duration_ms || 0)), + 0, + ); + return Math.max(durationMs || 0, lastEnd, 1); +} + +const MIN_BAR_PERCENT = 0.6; + +/** A span's bar as left offset and width, in percent of the timeline. */ +export function barGeometry( + span: TraceSpan, + totalMs: number, +): { left: number; width: number } { + const total = totalMs > 0 ? totalMs : 1; + const width = Math.max( + ((span.duration_ms || 0) / total) * 100, + MIN_BAR_PERCENT, + ); + const left = Math.min( + Math.max(((span.offset_ms || 0) / total) * 100, 0), + 100 - width, + ); + return { left: round(left), width: round(width) }; +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +/** Human duration: `<1 ms`, `12 ms`, `1.23 s`, `15.4 s`, `1m 05s`. */ +export function formatDurationMs(ms: number | null | undefined): string { + if (ms === null || ms === undefined || Number.isNaN(ms)) return '—'; + if (ms < 1) return '<1 ms'; + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 10_000) return `${(ms / 1000).toFixed(2)} s`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + return `${minutes}m ${String(seconds).padStart(2, '0')}s`; +} + +/** Token counts abbreviated: `950`, `12.3k`, `2.5M`. */ +export function formatTokens(n: number | null | undefined): string { + const value = n ?? 0; + if (value >= 1_000_000) return `${trimZero((value / 1_000_000).toFixed(1))}M`; + if (value >= 1000) return `${trimZero((value / 1000).toFixed(1))}k`; + return String(Math.round(value)); +} + +function trimZero(s: string): string { + return s.endsWith('.0') ? s.slice(0, -2) : s; +} + +function num(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +type Translate = (key: string, options?: Record) => string; + +/** + * A short, kind-specific summary shown next to a span's name, e.g. + * `1.2k → 80 tok` for an LLM call or `4 chunks` for a retrieval. + */ +export function spanHeadline(span: TraceSpan, t: Translate): string { + const a = span.attributes || {}; + switch (span.kind) { + case 'llm': { + if (a['docsgpt.cache_hit']) + return t('settings.logs.trace.headline.cached'); + const input = num(a['gen_ai.usage.input_tokens']); + const output = num(a['gen_ai.usage.output_tokens']); + if (input === undefined && output === undefined) return ''; + return t('settings.logs.trace.headline.tokens', { + input: formatTokens(input), + output: formatTokens(output), + }); + } + case 'retrieval': + case 'rerank': { + const chunks = num(a['docsgpt.chunk_count'] ?? a['docsgpt.kept_count']); + return chunks === undefined + ? '' + : t('settings.logs.trace.headline.chunks', { count: chunks }); + } + case 'search': { + const hits = num(a['docsgpt.candidate_count']); + return hits === undefined + ? '' + : t('settings.logs.trace.headline.hits', { count: hits }); + } + default: + return ''; + } +} diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 81e557a8..76006b84 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -17,7 +17,74 @@ export type ChunkType = { }; export type LogEventType = - 'chat' | 'schedule' | 'webhook' | 'workflow' | 'system'; + 'chat' | 'schedule' | 'webhook' | 'workflow' | 'system' | 'search' | 'graph'; + +/** Counts rolled up from a trace's spans; every field may be absent. */ +export type TraceCounts = { + llm_calls?: number; + tool_calls?: number; + retrieval_calls?: number; + retrieval_ms?: number; + input_tokens?: number; + output_tokens?: number; + errors?: number; +}; + +/** The id a Logs row's traces are looked up by. */ +export type TraceRef = { + field: 'id' | 'request_id' | 'message_id' | 'activity_id' | 'workflow_run_id'; + value: string; +}; + +/** Trace summary attached to a Logs row (merged over its rounds). */ +export type LogTraceSummary = { + ref: TraceRef; + count: number; + duration_ms: number; + status?: string; + started_at?: string; + summary: TraceCounts; +}; + +export type TraceSpanKind = + | 'agent' + | 'llm' + | 'tool' + | 'retrieval' + | 'search' + | 'embedding' + | 'rerank' + | 'guardrail' + | 'step'; + +export type TraceSpan = { + id: string; + parent_id: string | null; + kind: TraceSpanKind | string; + name: string; + status: string; + offset_ms: number; + duration_ms: number; + attributes: Record; + preview?: Record; + error?: string; +}; + +export type Trace = { + id: string; + request_id?: string | null; + message_id?: string | null; + source: string; + name?: string | null; + status: string; + started_at: string; + duration_ms: number; + span_count: number; + dropped_spans: number; + summary: TraceCounts; + spans: TraceSpan[]; + otel_trace_id?: string | null; +}; export type LogData = { id: string; @@ -33,9 +100,17 @@ export type LogData = { tool_calls?: Record[]; agent_id?: string; attachments?: string[]; + request_id?: string; + message_id?: string; // system + webhook events (stack_logs) endpoint?: string; stacks?: Record[]; + activity_id?: string; + // search + graph events (request_traces) + source?: string; + duration_ms?: number; + // Present when the row has a stored execution trace. + trace?: LogTraceSummary; // workflow events (workflow_runs) workflow_name?: string; result?: Record;