ui-nyla/src/pages/views/workspace/ToolActivityLog.tsx
2026-04-11 19:44:52 +02:00

267 lines
9.7 KiB
TypeScript

/**
* ToolActivityLog -- Real-time tool call activity display.
*
* Renders tool calls in a human-readable format:
* - Friendly tool names instead of internal identifiers
* - Args filtered to hide UUIDs/internal codes on success
* - Full details shown on error for debugging
*/
import React, { useState, useCallback } from 'react';
import type { ToolActivity } from './useWorkspace';
import { useLanguage } from '../../../providers/language/LanguageContext';
type TranslateFn = (key: string, params?: Record<string, string | number | boolean | null | undefined>) => string;
interface ToolActivityLogProps {
activities: ToolActivity[];
}
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const _HIDDEN_ARG_KEYS = new Set([
'mandateId', 'userId', 'featureInstanceId', 'workflowId', 'sessionId',
]);
function _isInternalValue(v: unknown): boolean {
if (typeof v !== 'string') return false;
if (_UUID_RE.test(v)) return true;
if (v.length > 60 && !v.includes(' ')) return true;
return false;
}
function _formatArgs(args: Record<string, any>, isError: boolean): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(args)) {
if (!isError && _HIDDEN_ARG_KEYS.has(k)) continue;
if (!isError && _isInternalValue(v)) continue;
let display: string;
if (typeof v === 'string') {
display = v.length > 80 ? v.slice(0, 77) + '...' : v;
} else if (typeof v === 'number' || typeof v === 'boolean') {
display = String(v);
} else if (v === null || v === undefined) {
continue;
} else {
const json = JSON.stringify(v);
display = json.length > 80 ? json.slice(0, 77) + '...' : json;
}
parts.push(`${k}: ${display}`);
}
return parts.join(', ') || (isError ? JSON.stringify(args) : '');
}
function _formatResult(result: string, translate: TranslateFn): string {
if (!result) return '';
const trimmed = result.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return translate('{count} Ergebnisse', { count: parsed.length });
}
if (typeof parsed === 'object' && parsed !== null) {
const keys = Object.keys(parsed);
if (parsed.count !== undefined) return translate('{count} Einträge', { count: parsed.count });
if (parsed.total !== undefined) return translate('{count} Einträge', { count: parsed.total });
if (parsed.rows && Array.isArray(parsed.rows)) {
return translate('{count} Zeilen', { count: parsed.rows.length });
}
if (parsed.data && Array.isArray(parsed.data)) {
return translate('{count} Einträge', { count: parsed.data.length });
}
if (parsed.result !== undefined) {
const r = String(parsed.result);
return r.length > 120 ? r.slice(0, 117) + '...' : r;
}
if (keys.length <= 3) {
return keys.map((k) => `${k}: ${String(parsed[k]).slice(0, 40)}`).join(', ');
}
return translate('Objekt ({count} Felder)', { count: keys.length });
}
} catch {
// not valid JSON
}
}
return trimmed.length > 150 ? trimmed.slice(0, 147) + '...' : trimmed;
}
function _getToolLabel(toolName: string, translate: TranslateFn): string {
const labels: Record<string, string> = {
browseTable: translate('Tabelle durchsuchen'),
queryTable: translate('Tabelle abfragen'),
aggregateTable: translate('Tabelle aggregieren'),
browseContainer: translate('Datei durchsuchen'),
readContentObjects: translate('Inhalte lesen'),
extractContainerItem: translate('Element extrahieren'),
queryFeatureInstance: translate('Feature abfragen'),
requestToolbox: translate('Toolbox anfordern'),
searchDocuments: translate('Dokumente suchen'),
getFileInfo: translate('Datei-Info abrufen'),
listFiles: translate('Dateien auflisten'),
listTables: translate('Tabellen auflisten'),
getTableSchema: translate('Tabellenschema abrufen'),
createRecord: translate('Datensatz erstellen'),
updateRecord: translate('Datensatz aktualisieren'),
deleteRecord: translate('Datensatz löschen'),
};
return labels[toolName] || toolName;
}
function _getStatusLabel(status: string, translate: TranslateFn): string {
switch (status) {
case 'calling': return translate('läuft');
case 'success': return translate('OK');
case 'error': return translate('Fehler');
default: return status;
}
}
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
const { t } = useLanguage();
const [expandedId, setExpandedId] = useState<string | null>(null);
const translate = useCallback<TranslateFn>((key, params) => t(key, params), [t]);
if (!activities.length) {
return (
<div style={{ padding: 16, textAlign: 'center', color: 'var(--color-text-secondary, #999)', fontSize: 12 }}>
{t('Noch keine Aktivität')}
</div>
);
}
return (
<div style={{ padding: 8 }}>
{activities.map(activity => {
const isError = activity.status === 'error';
const isExpanded = expandedId === activity.id;
const friendlyName = _getToolLabel(activity.toolName, translate);
const argsText = activity.args && Object.keys(activity.args).length > 0
? _formatArgs(activity.args, isError)
: '';
const resultText = activity.result ? _formatResult(activity.result, translate) : '';
return (
<div
key={activity.id}
style={{
padding: '8px 10px',
marginBottom: 6,
borderRadius: 6,
fontSize: 12,
border: `1px solid ${
activity.status === 'calling'
? 'var(--color-warning, #ffc107)'
: activity.status === 'success'
? 'var(--color-success, #4caf50)'
: 'var(--color-error, #f44336)'
}30`,
background: activity.status === 'calling'
? 'rgba(255, 193, 7, 0.08)'
: activity.status === 'success'
? 'rgba(76, 175, 80, 0.06)'
: 'rgba(244, 67, 54, 0.06)',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onClick={() => setExpandedId(isExpanded ? null : activity.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600 }}>
{friendlyName}
{friendlyName !== activity.toolName && (
<span style={{ fontWeight: 400, opacity: 0.5, marginLeft: 6, fontSize: 10 }}>
{activity.toolName}
</span>
)}
</span>
<span style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 3,
background: activity.status === 'calling'
? 'var(--color-warning, #ffc107)'
: activity.status === 'success'
? 'var(--color-success, #4caf50)'
: 'var(--color-error, #f44336)',
color: '#fff',
}}>
{_getStatusLabel(activity.status, translate)}
</span>
</div>
{argsText && (
<div style={{ marginTop: 4, color: 'var(--color-text-secondary, #666)', fontSize: 11 }}>
{argsText}
</div>
)}
{resultText && !isError && (
<div style={{
marginTop: 4,
color: 'var(--color-success, #388e3c)',
fontSize: 11,
maxHeight: isExpanded ? 'none' : 40,
overflow: 'hidden',
}}>
{resultText}
</div>
)}
{activity.error && (
<div style={{ marginTop: 4, color: 'var(--color-error, #c62828)', fontSize: 11 }}>
{activity.error}
</div>
)}
{isExpanded && activity.args && Object.keys(activity.args).length > 0 && (
<details open style={{ marginTop: 6 }}>
<summary style={{ fontSize: 10, color: 'var(--color-text-secondary, #888)', cursor: 'pointer' }}>
{t('Alle Parameter')}
</summary>
<pre style={{
fontSize: 10,
margin: '4px 0 0',
padding: 6,
background: 'rgba(0,0,0,0.04)',
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{JSON.stringify(activity.args, null, 2)}
</pre>
</details>
)}
{isExpanded && activity.result && (
<details open style={{ marginTop: 4 }}>
<summary style={{ fontSize: 10, color: 'var(--color-text-secondary, #888)', cursor: 'pointer' }}>
{t('Vollständiges Ergebnis')}
</summary>
<pre style={{
fontSize: 10,
margin: '4px 0 0',
padding: 6,
background: 'rgba(0,0,0,0.04)',
borderRadius: 4,
overflow: 'auto',
maxHeight: 300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{activity.result}
</pre>
</details>
)}
</div>
);
})}
</div>
);
};