Merge pull request #67 from valueonag/feat/demo-system-readieness

Feat/demo system readieness
This commit is contained in:
Patrick Motsch 2026-05-01 00:00:30 +02:00 committed by GitHub
commit 992c0472c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1100 additions and 110 deletions

View file

@ -144,6 +144,8 @@ export interface Automation2Workflow {
label: string;
graph: Automation2Graph;
active?: boolean;
/** Target feature instance for execution data scope (NULL for templates) */
targetFeatureInstanceId?: string | null;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** Enriched: run count */
@ -412,7 +414,12 @@ export async function fetchWorkflow(
export async function createWorkflow(
request: ApiRequestFunction,
instanceId: string,
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
body: {
label: string;
graph: Automation2Graph;
invocations?: WorkflowEntryPoint[];
targetFeatureInstanceId?: string | null;
}
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/workflows`,
@ -431,6 +438,7 @@ export async function updateWorkflow(
invocations?: WorkflowEntryPoint[];
active?: boolean;
notifyOnFailure?: boolean;
targetFeatureInstanceId?: string | null;
}
): Promise<Automation2Workflow> {
return await request({
@ -986,3 +994,95 @@ export async function loadClickupListTasksForDropdown(
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}
// ============================================================================
// AUTOMATION WORKSPACE API (user-facing run workspace)
// ============================================================================
export interface WorkspaceRun {
id: string;
workflowId: string;
workflowLabel?: string;
status: string;
startedAt?: number;
completedAt?: number;
ownerId?: string;
mandateId?: string;
mandateLabel?: string;
targetFeatureInstanceId?: string;
targetInstanceLabel?: string;
costTokens?: number;
costCredits?: number;
error?: string;
}
export interface WorkspaceRunDetail {
run: WorkspaceRun & { nodeOutputs?: Record<string, unknown> };
workflow: {
id: string;
label: string;
targetFeatureInstanceId?: string;
featureInstanceId?: string;
tags?: string[];
} | null;
steps: Array<{
id: string;
runId: string;
nodeId: string;
nodeType: string;
status: string;
inputSnapshot?: Record<string, unknown>;
output?: Record<string, unknown>;
inputFiles?: Array<{ id: string; fileName?: string }>;
outputFiles?: Array<{ id: string; fileName?: string }>;
error?: string;
startedAt?: number;
completedAt?: number;
durationMs?: number;
tokensUsed?: number;
retryCount?: number;
}>;
files: Array<{
id: string;
fileName?: string;
contentType?: string;
sizeBytes?: number;
}>;
unassignedFiles?: Array<{
id: string;
fileName?: string;
}>;
}
export async function fetchWorkspaceRuns(
request: ApiRequestFunction,
params: {
scope?: 'mine' | 'mandate';
status?: string;
targetInstanceId?: string;
workflowId?: string;
limit?: number;
offset?: number;
} = {},
): Promise<{ runs: WorkspaceRun[]; total: number }> {
const query = new URLSearchParams();
if (params.scope) query.set('scope', params.scope);
if (params.status) query.set('status', params.status);
if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId);
if (params.workflowId) query.set('workflowId', params.workflowId);
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
const qs = query.toString();
const url = `/api/automations/runs${qs ? `?${qs}` : ''}`;
const resp = await request({ url, method: 'get' });
return resp as { runs: WorkspaceRun[]; total: number };
}
export async function fetchWorkspaceRunDetail(
request: ApiRequestFunction,
runId: string,
): Promise<WorkspaceRunDetail> {
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' });
return resp as WorkspaceRunDetail;
}

View file

@ -59,6 +59,7 @@ import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
@ -133,6 +134,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
});
@ -297,7 +307,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
setExecuteResult(_buildSaveResult());
} else {
const label = await promptInput(t('Workflow-Name:'), {
@ -313,6 +323,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
targetFeatureInstanceId,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
@ -324,7 +335,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
const handleLoad = useCallback(
async (workflowId: string) => {
@ -335,6 +346,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf];
@ -661,6 +673,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[request, instanceId, handleFromApiGraph]
);
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
@ -836,6 +859,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>

View file

@ -10,6 +10,11 @@ import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
interface TargetInstanceOption {
id: string;
label: string;
}
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
@ -45,6 +50,9 @@ interface CanvasHeaderProps {
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -84,6 +92,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -209,6 +220,21 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
<FaCog />
</button>
)}
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
<select
className={styles.canvasHeaderWorkflowSelect}
value={targetFeatureInstanceId ?? ''}
onChange={(e) => onTargetInstanceChange(e.target.value)}
aria-label={t('Ziel-Instanz')}
title={t('Ziel-Instanz für Daten-Scope')}
style={{ maxWidth: 200, fontSize: '0.8rem' }}
>
<option value="">{t('Ziel-Instanz wählen…')}</option>
{targetInstanceOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)}
</div>
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>

View file

@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'filterExpression',
'attachmentBuilder',
'json',
'modelMultiSelect',
]);
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {

View file

@ -0,0 +1,171 @@
/**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
*/
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
function _refToTemplateToken(ref: DataRef): string {
const pathSegs = (ref.path ?? []).map((p) => String(p));
if (pathSegs.length === 0) {
return `{{${ref.nodeId}}}`;
}
return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
}
function _insertAtCursor(
text: string,
insert: string,
start: number,
end: number,
): { next: string; caret: number } {
const next = text.slice(0, start) + insert + text.slice(end);
const caret = start + insert.length;
return { next, caret };
}
function _parseTokensInTemplate(
template: string,
nodes: Array<{ id: string; title?: string }>,
getNodeLabel: (n: { id: string; title?: string }) => string,
): Array<{ raw: string; label: string }> {
const out: Array<{ raw: string; label: string }> = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
while ((m = re.exec(template)) !== null) {
const inner = m[1].trim();
if (seen.has(inner)) continue;
seen.add(inner);
const parts = inner.split('.');
const nodeId = parts[0];
if (!nodeId) continue;
const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
const ref: DataRef = { type: 'ref', nodeId, path };
const label = formatRefLabel(ref, nodes, (id) =>
getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
);
out.push({ raw: m[0], label });
}
return out;
}
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const tokenLegend = useMemo(() => {
if (!dataFlow || !strVal.includes('{{')) return [];
return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
}, [strVal, dataFlow]);
const handlePick = useCallback(
(picked: DataRef | SystemVarRef) => {
if (isSystemVar(picked)) {
setPickerOpen(false);
return;
}
if (!isRef(picked)) {
setPickerOpen(false);
return;
}
const token = _refToTemplateToken(picked);
const el = textareaRef.current;
const start = el?.selectionStart ?? strVal.length;
const end = el?.selectionEnd ?? strVal.length;
const { next, caret } = _insertAtCursor(strVal, token, start, end);
onChange(next);
setPickerOpen(false);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
},
[onChange, strVal],
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 6, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className={styles.startsInput}
disabled={!hasSources}
onClick={() => setPickerOpen(true)}
title={hasSources ? t('Variable aus vorherigem Node einfügen') : t('Keine vorherigen Nodes verfügbar')}
>
{t('Variable einfügen…')}
</button>
{!hasSources && (
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Keine vorherigen Nodes verfügbar')}</span>
)}
</div>
<textarea
ref={textareaRef}
value={strVal}
onChange={(e) => onChange(e.target.value)}
placeholder={param.name}
rows={6}
spellCheck={false}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: 4,
border: '1px solid #ccc',
resize: 'vertical',
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
minHeight: 120,
}}
/>
{tokenLegend.length > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-secondary)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{t('Eingebundene Variablen')}</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{tokenLegend.map((row) => (
<li key={row.raw} style={{ marginBottom: 2 }}>
<code style={{ fontSize: 10 }}>{row.raw}</code>
{' — '}
{row.label}
</li>
))}
</ul>
</div>
)}
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={handlePick}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -33,6 +33,8 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
@ -736,6 +738,113 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
);
};
const ModelMultiSelect: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const [models, setModels] = React.useState<Array<{ displayName: string; connectorType?: string }>>([]);
const [loading, setLoading] = React.useState(false);
const [open, setOpen] = React.useState(false);
const selected: string[] = Array.isArray(value) ? value : [];
React.useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`${getApiBaseUrl()}/api/system/ai-models`, { credentials: 'include' })
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const items = (data?.models ?? []) as Array<{ displayName: string; connectorType?: string }>;
setModels(items);
})
.catch(() => { if (!cancelled) setModels([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const _toggle = (name: string) => {
const next = selected.includes(name)
? selected.filter((v) => v !== name)
: [...selected, name];
onChange(next);
};
const _removeTag = (name: string) => {
onChange(selected.filter((v) => v !== name));
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div
onClick={() => setOpen((o) => !o)}
style={{
width: '100%',
minHeight: 32,
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #ccc',
cursor: 'pointer',
display: 'flex',
flexWrap: 'wrap',
gap: 4,
alignItems: 'center',
background: '#fff',
}}
>
{selected.length === 0 && (
<span style={{ color: '#999', fontSize: 12 }}>{t('Alle erlaubten Modelle')}</span>
)}
{selected.map((name) => (
<span
key={name}
style={{
background: 'var(--primary-color, #2563eb)',
color: '#fff',
borderRadius: 3,
padding: '1px 6px',
fontSize: 11,
display: 'inline-flex',
alignItems: 'center',
gap: 3,
}}
>
{name}
<span
onClick={(e) => { e.stopPropagation(); _removeTag(name); }}
style={{ cursor: 'pointer', fontWeight: 700 }}
>
x
</span>
</span>
))}
</div>
{open && (
<div style={{ border: '1px solid #ddd', borderRadius: 4, marginTop: 4, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 4 }}>
{loading && <div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Lade Modelle...')}</div>}
{!loading && models.length === 0 && (
<div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
)}
{models.map((m) => (
<label
key={m.displayName}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 4px', fontSize: 12, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={selected.includes(m.displayName)}
onChange={() => _toggle(m.displayName)}
/>
<span>{m.displayName}</span>
{m.connectorType && (
<span style={{ fontSize: 10, color: '#888' }}>({m.connectorType})</span>
)}
</label>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
@ -743,6 +852,7 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
text: TextInput,
textarea: TextareaInput,
templateTextarea: TemplateTextareaRenderer,
number: NumberInput,
checkbox: CheckboxInput,
date: DateInput,
@ -750,6 +860,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
email: TextInput,
select: SelectInput,
multiselect: MultiSelectInput,
modelMultiSelect: ModelMultiSelect,
json: JsonEditor,
file: TextInput,
hidden: HiddenInput,

View file

@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
return out;
}
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
{ path: ['currentItem'], pathLabel: 'currentItem' },
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
{ path: ['count'], pathLabel: 'count' },
];
if (preview && typeof preview === 'object') {
const ci = (preview as Record<string, unknown>).currentItem;
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
paths.push(...buildPickablePaths(v, ['currentItem', k]));
}
}
}
return paths;
}
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths = buildPickablePaths(preview);
if (preview && typeof preview === 'object') {
const rd = (preview as Record<string, unknown>).responseData;
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
for (const k of Object.keys(rd as Record<string, unknown>)) {
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
}
}
}
return paths;
}
export function pickPathsForNode(
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
preview: unknown,
@ -113,6 +144,12 @@ export function pickPathsForNode(
if (nt.startsWith('clickup.')) {
return buildClickUpOutputPaths(preview);
}
if (nt === 'flow.loop') {
return buildLoopCurrentItemPaths(preview);
}
if (nt === 'ai.prompt') {
return buildAiPromptPaths(preview);
}
return buildPickablePaths(preview);
}

View file

@ -76,6 +76,32 @@ export function buildNodeOutputPreview(
return _buildSchemaPreview(port0.schema);
}
function _buildEmailItemPreview(): Record<string, unknown> {
return {
from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
subject: '...',
body: { contentType: 'HTML', content: '...' },
receivedDateTime: '2026-01-01T00:00:00Z',
toRecipients: [],
hasAttachments: false,
id: '...',
};
}
function _buildAiResponseDataPreview(params: Record<string, unknown>): Record<string, unknown> | null {
if (params.resultType !== 'json') return null;
const prompt = String(params.aiPrompt || params.prompt || '');
if (!prompt) return null;
const fields: Record<string, unknown> = {};
const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(prompt)) !== null) {
const f = m[1];
if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
}
return Object.keys(fields).length > 0 ? fields : null;
}
/** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview(
nodes: CanvasNode[],
@ -92,5 +118,32 @@ export function buildNodeOutputsPreview(
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
}
}
for (const n of nodes) {
if (n.id in (nodeOutputsFromRun ?? {})) continue;
if (n.type === 'flow.loop') {
const items = n.parameters?.items;
if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
const ref = items as { nodeId: string; path?: (string | number)[] };
const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
if (sourceSchema === 'EmailList') {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
}
}
}
if (n.type === 'ai.prompt' && n.parameters) {
const rdPreview = _buildAiResponseDataPreview(n.parameters);
if (rdPreview) {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, responseData: rdPreview };
}
}
}
return result;
}

View file

@ -363,6 +363,8 @@ export interface FormGeneratorTableProps<T = any> {
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
initialSearchTerm?: string;
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
/** Pre-set column filters on mount (e.g. {workflowId: "abc"}). Reacts to prop changes. */
initialFilters?: Record<string, any>;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
/** Enable persistent user-defined grouping for this table instance. */
@ -729,6 +731,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
groupActions,
initialSearchTerm = '',
initialSort,
initialFilters,
rowDraggable = false,
onRowDragStart,
groupingConfig,
@ -1081,7 +1084,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
// Multi-column sorting: array of sort configs in order of priority
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
const [filters, setFilters] = useState<Record<string, any>>({});
const [filters, setFilters] = useState<Record<string, any>>(initialFilters || {});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Actions column width - resizable, default based on number of buttons
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);

View file

@ -10,17 +10,21 @@ export interface Tab {
export interface TabsProps {
tabs: Tab[];
defaultTabId?: string;
/** Controlled active tab. When provided, internal state is ignored. */
activeTabId?: string;
onTabChange?: (tabId: string) => void;
className?: string;
}
export function Tabs({ tabs, defaultTabId, onTabChange, className = '' }: TabsProps) {
const [activeTabId, setActiveTabId] = useState<string>(
export function Tabs({ tabs, defaultTabId, activeTabId: controlledTabId, onTabChange, className = '' }: TabsProps) {
const [internalTabId, setInternalTabId] = useState<string>(
defaultTabId || tabs[0]?.id || ''
);
const activeTabId = controlledTabId ?? internalTabId;
const handleTabClick = (tabId: string) => {
setActiveTabId(tabId);
if (!controlledTabId) setInternalTabId(tabId);
onTabChange?.(tabId);
};

View file

@ -7,7 +7,7 @@
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
import { Tabs } from '../components/UiComponents/Tabs';
@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
import { usePrompt } from '../hooks/usePrompt';
import { useApiRequest } from '../hooks/useApi';
import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRunDetail } from '../api/workflowApi';
import { fetchAttributes } from '../api/attributesApi';
import type { AttributeDefinition } from '../api/attributesApi';
import { resolveColumnTypes } from '../utils/columnTypeResolver';
@ -424,7 +424,12 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
// DashboardTab — Metrics + Runs table with backend pagination
// ===========================================================================
const _DashboardTab: React.FC = () => {
interface _DashboardTabProps {
workflowFilter?: string | null;
onRunClick?: (runId: string) => void;
}
const _DashboardTab: React.FC<_DashboardTabProps> = ({ workflowFilter, onRunClick }) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { showError } = useToast();
@ -491,8 +496,7 @@ const _DashboardTab: React.FC = () => {
useEffect(() => {
_loadMetrics();
_loadRuns();
}, [_loadMetrics, _loadRuns]);
}, [_loadMetrics]);
const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
useEffect(() => {
@ -531,13 +535,19 @@ const _DashboardTab: React.FC = () => {
}
}, [showError, t]);
const _initialFilters = useMemo(() => {
if (!workflowFilter) return undefined;
return { workflowId: workflowFilter };
}, [workflowFilter]);
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
{
key: 'workflowLabel',
key: 'workflowId',
label: t('Workflow'),
width: 200,
sortable: true,
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
filterable: true,
displayField: 'workflowLabel',
},
{
key: 'mandateId',
@ -643,7 +653,9 @@ const _DashboardTab: React.FC = () => {
</div>
)}
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8, flexShrink: 0 }}>{t('Letzte Runs')}</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexShrink: 0 }}>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, margin: 0 }}>{t('Letzte Runs')}</h3>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<WorkflowRun>
data={runs}
@ -656,7 +668,9 @@ const _DashboardTab: React.FC = () => {
sortable={true}
selectable={true}
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
initialFilters={_initialFilters}
apiEndpoint="/api/system/workflow-runs"
onRowClick={(row) => onRunClick?.(row.id)}
customActions={[
{
id: 'tracing',
@ -686,7 +700,11 @@ const _DashboardTab: React.FC = () => {
// WorkflowsTab — Central workflow management across all instances
// ===========================================================================
const _WorkflowsTab: React.FC = () => {
interface _WorkflowsTabProps {
onWorkflowClick?: (workflowId: string) => void;
}
const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => {
const { t } = useLanguage();
const navigate = useNavigate();
const { request } = useApiRequest();
@ -1051,6 +1069,7 @@ const _WorkflowsTab: React.FC = () => {
},
]}
onDelete={(row) => _handleDelete(row.id)}
onRowClick={(row) => onWorkflowClick?.(row.id)}
hookData={_hookData}
emptyMessage={t('Keine Workflows gefunden.')}
/>
@ -1061,29 +1080,289 @@ const _WorkflowsTab: React.FC = () => {
};
// ===========================================================================
// Main page with Tabs
// Workspace Tab (run detail only — no table)
// ===========================================================================
const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']);
function _isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function _stripFileRefKeys(value: unknown): unknown {
if (_isPlainObject(value)) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (_FILE_REF_KEYS.has(k)) continue;
const stripped = _stripFileRefKeys(v);
if (stripped !== undefined) out[k] = stripped;
}
return Object.keys(out).length > 0 ? out : undefined;
}
if (Array.isArray(value)) {
const out = value.map((v) => _stripFileRefKeys(v)).filter((v) => v !== undefined);
return out.length > 0 ? out : undefined;
}
return value;
}
function _formatScalar(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return JSON.stringify(v);
}
const _DataBlock: React.FC<{ data: unknown; emptyHint?: string }> = ({ data, emptyHint }) => {
if (data === undefined || data === null) {
return emptyHint ? <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>{emptyHint}</p> : null;
}
if (_isPlainObject(data)) {
const entries = Object.entries(data);
if (entries.length === 0) {
return emptyHint ? <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>{emptyHint}</p> : null;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{entries.map(([k, v]) => {
const isComplex = _isPlainObject(v) || Array.isArray(v);
if (isComplex) {
return (
<details key={k} style={{ fontSize: '0.8rem' }}>
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary)' }}>
<code style={{ fontWeight: 500 }}>{k}</code>
</summary>
<pre style={{ fontSize: '0.75rem', maxHeight: 240, overflow: 'auto', margin: '0.25rem 0 0 1rem', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
{JSON.stringify(v, null, 2)}
</pre>
</details>
);
}
return (
<div key={k} style={{ display: 'flex', gap: '0.5rem', fontSize: '0.8rem', alignItems: 'baseline' }}>
<code style={{ color: 'var(--text-secondary)', minWidth: 140, flexShrink: 0 }}>{k}</code>
<span style={{ wordBreak: 'break-word' }}>{_formatScalar(v)}</span>
</div>
);
})}
</div>
);
}
return (
<pre style={{ fontSize: '0.75rem', maxHeight: 240, overflow: 'auto', margin: 0, background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
{JSON.stringify(data, null, 2)}
</pre>
);
};
const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => {
if (!files.length) return null;
const baseUrl = api.defaults.baseURL || '';
return (
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
{files.map((f) => (
<a
key={f.id}
href={`${baseUrl}/api/files/${f.id}/download`}
download
style={{ padding: '0.3rem 0.6rem', border: '1px solid var(--border-color)', borderRadius: 4, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.78rem' }}
>
<FaDownload style={{ marginRight: 4 }} />
{f.fileName || f.id}
</a>
))}
</div>
);
};
interface _WorkspaceTabProps {
runId: string | null;
onBack: () => void;
}
const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [runDetail, setRunDetail] = useState<Awaited<ReturnType<typeof fetchWorkspaceRunDetail>> | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const _loadDetail = useCallback(async (id: string) => {
setDetailLoading(true);
try {
const detail = await fetchWorkspaceRunDetail(request, id);
setRunDetail(detail);
} catch (e) {
console.error('Workspace run detail failed', e);
} finally {
setDetailLoading(false);
}
}, [request]);
useEffect(() => {
if (runId) _loadDetail(runId);
else setRunDetail(null);
}, [runId, _loadDetail]);
if (!runId) {
return (
<div style={{ padding: '1rem', flex: 1, color: 'var(--text-secondary)' }}>
<p>{t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}</p>
</div>
);
}
if (detailLoading || !runDetail) {
return <div style={{ padding: '1rem', flex: 1 }}><p>{t('Laden…')}</p></div>;
}
const { run, steps, workflow, unassignedFiles } = runDetail;
return (
<div style={{ padding: '1rem', flex: 1, minHeight: 0, overflowY: 'auto' }}>
<button type="button" className={styles.secondaryButton} onClick={onBack} style={{ marginBottom: '1rem' }}>
{t('Zurück zum Dashboard')}
</button>
<h3 style={{ margin: '0.5rem 0' }}>{run.workflowLabel || run.workflowId}</h3>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '1rem' }}>
<span><strong>{t('Status')}:</strong> {run.status}</span>
{run.startedAt && <span><strong>{t('Start')}:</strong> {_formatTs(run.startedAt)}</span>}
{run.completedAt && <span><strong>{t('Ende')}:</strong> {_formatTs(run.completedAt)}</span>}
{workflow?.targetFeatureInstanceId && <span><strong>{t('Ziel-Instanz')}:</strong> {run.targetInstanceLabel || workflow.targetFeatureInstanceId}</span>}
{(run.costTokens ?? 0) > 0 && <span><strong>Tokens:</strong> {run.costTokens}</span>}
</div>
{run.error && (
<div style={{ padding: '0.5rem', background: 'rgba(220,53,69,0.1)', borderRadius: 6, marginBottom: '1rem', color: 'var(--danger-color)' }}>
{run.error}
</div>
)}
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Schritte')}</h4>
{steps.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Schritte protokolliert.')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{steps.map((step) => {
const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
const outputData = _stripFileRefKeys(step.output ?? {});
const inputFiles = step.inputFiles ?? [];
const outputFiles = step.outputFiles ?? [];
const hasInput = inputData !== undefined || inputFiles.length > 0;
const hasOutput = outputData !== undefined || outputFiles.length > 0;
return (
<details key={step.id} style={{ border: '1px solid var(--border-color)', borderRadius: 6, padding: '0.5rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 500 }}>
<span style={{ marginRight: '0.5rem', fontSize: '0.75rem', padding: '2px 6px', borderRadius: 4, background: step.status === 'completed' ? 'rgba(40,167,69,0.15)' : step.status === 'failed' ? 'rgba(220,53,69,0.15)' : 'rgba(0,123,255,0.15)', color: step.status === 'completed' ? 'var(--success-color)' : step.status === 'failed' ? 'var(--danger-color)' : 'var(--primary-color)' }}>
{step.status}
</span>
{step.nodeType} ({step.nodeId})
{step.durationMs != null && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.durationMs}ms</span>}
{(step.tokensUsed ?? 0) > 0 && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.tokensUsed} tokens</span>}
</summary>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '0.5rem' }}>
{hasInput && (
<section>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
{t('Input')}
</div>
<_DataBlock data={inputData} />
<_FileLinkList files={inputFiles} />
</section>
)}
{hasOutput && (
<section>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
{t('Output')}
</div>
<_DataBlock data={outputData} />
<_FileLinkList files={outputFiles} />
</section>
)}
{step.error && (
<section>
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--danger-color)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
{t('Fehler')}
</div>
<p style={{ color: 'var(--danger-color)', margin: 0, fontSize: '0.85rem' }}>{step.error}</p>
</section>
)}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.75rem', color: 'var(--text-secondary)', borderTop: '1px solid var(--border-color)', paddingTop: '0.4rem' }}>
{step.startedAt && <span>{t('Start')}: {_formatTs(step.startedAt)}</span>}
{step.completedAt && <span>{t('Ende')}: {_formatTs(step.completedAt)}</span>}
{(step.retryCount ?? 0) > 0 && <span>{t('Wiederholungen')}: {step.retryCount}</span>}
</div>
</div>
</details>
);
})}
</div>
)}
{unassignedFiles && unassignedFiles.length > 0 && (
<>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
<_FileLinkList files={unassignedFiles} />
</>
)}
</div>
);
};
// ===========================================================================
// Main page with Tabs (Workflows → Dashboard → Workspace)
// ===========================================================================
export const AutomationsDashboardPage: React.FC = () => {
const { t } = useLanguage();
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') || 'workflows';
const initialRunId = searchParams.get('runId') || null;
const [activeTab, setActiveTab] = useState<string>(initialRunId ? 'workspace' : initialTab);
const [selectedRunId, setSelectedRunId] = useState<string | null>(initialRunId);
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null);
const _handleWorkflowClick = useCallback((workflowId: string) => {
setWorkflowFilter(workflowId);
setActiveTab('dashboard');
}, []);
useEffect(() => {
if (workflowFilter) setWorkflowFilter(null);
}, [workflowFilter]);
const _handleRunClick = useCallback((runId: string) => {
setSelectedRunId(runId);
setActiveTab('workspace');
}, []);
const _handleBackFromWorkspace = useCallback(() => {
setSelectedRunId(null);
setActiveTab('dashboard');
}, []);
const tabs = useMemo(() => [
{
id: 'dashboard',
label: t('Dashboard'),
content: <_DashboardTab />,
},
{
id: 'workflows',
label: t('Workflows'),
content: <_WorkflowsTab />,
content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />,
},
], [t]);
{
id: 'dashboard',
label: t('Dashboard'),
content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />,
},
{
id: 'workspace',
label: t('Workspace'),
content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
},
], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<h1 className={styles.pageTitle} style={{ flexShrink: 0 }}>{t('Automatisierung')}</h1>
<Tabs tabs={tabs} defaultTabId="dashboard" />
<Tabs tabs={tabs} activeTabId={activeTab} onTabChange={setActiveTab} />
</div>
);
};

View file

@ -8,7 +8,7 @@
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
@ -76,6 +76,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
export const TrusteeAbschlussView: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@ -325,6 +326,25 @@ export const TrusteeAbschlussView: React.FC = () => {
{runState === 'error' && t('Fehler')}
{runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
{runState === 'completed' && runId && (
<div style={{ marginTop: '0.5rem' }}>
<a
href={`/automations?tab=workspace&runId=${runId}`}
onClick={(e) => {
e.preventDefault();
navigate(`/automations?tab=workspace&runId=${runId}`);
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem', borderRadius: '6px',
background: 'var(--primary-color, #007bff)', color: '#fff',
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
}}
>
{t('Im Workspace ansehen')}
</a>
</div>
)}
</div>
)}
</div>

View file

@ -8,7 +8,7 @@
*/
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
@ -102,6 +102,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
export const TrusteeAnalyseView: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@ -121,9 +122,6 @@ export const TrusteeAnalyseView: React.FC = () => {
const pollTimerRef = useRef<number | null>(null);
const isPollingRef = useRef(false);
const [resultText, setResultText] = useState<string | null>(null);
const [resultDocuments, setResultDocuments] = useState<Array<{ id?: string; fileName?: string; mimeType?: string }>>([]);
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
@ -202,12 +200,6 @@ export const TrusteeAnalyseView: React.FC = () => {
if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
setRunState('completed');
_stopPolling();
const lastStep = [...steps].reverse().find((s) => s.status === 'completed' && s.output);
if (lastStep?.output) {
setResultText(lastStep.output.response || lastStep.output.context || null);
const docs = lastStep.output.documents || lastStep.output.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
}
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
return;
}
@ -234,25 +226,6 @@ export const TrusteeAnalyseView: React.FC = () => {
useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
const _extractResults = useCallback((nodeOutputs: Record<string, any>) => {
const analyseOut = nodeOutputs?.analyse || nodeOutputs?.result;
if (!analyseOut) {
for (const key of Object.keys(nodeOutputs || {})) {
const v = nodeOutputs[key];
if (v && typeof v === 'object' && (v.response || v.documents)) {
setResultText(v.response || v.context || null);
const docs = v.documents || v.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
return;
}
}
return;
}
setResultText(analyseOut.response || analyseOut.context || null);
const docs = analyseOut.documents || analyseOut.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
}, []);
// Reset run state when tab changes
useEffect(() => {
_stopPolling();
@ -260,8 +233,6 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunId(null);
setRunSummary('');
setRunError(null);
setResultText(null);
setResultDocuments([]);
}, [activeTab, _stopPolling]);
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -304,8 +275,6 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunState('starting');
setRunError(null);
setRunSummary(t('Workflow wird gestartet…'));
setResultText(null);
setResultDocuments([]);
try {
const executeBody: Record<string, any> = { workflowId: wf.id };
const payload: Record<string, any> = {
@ -325,9 +294,6 @@ export const TrusteeAnalyseView: React.FC = () => {
} else if (res?.data?.success) {
setRunState('completed');
setRunSummary(t('Workflow synchron abgeschlossen.'));
if (res.data.nodeOutputs) {
_extractResults(res.data.nodeOutputs);
}
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
} else {
throw new Error(res?.data?.error || t('Unerwartete Antwort'));
@ -407,6 +373,9 @@ export const TrusteeAnalyseView: React.FC = () => {
<div style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem' }}>
{t('Budget-Excel hochladen')}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginBottom: '0.5rem' }}>
{t('Ergebnis: Excel-Bericht mit Konten-Tabelle, Uebersichts-Chart und Management-Summary.')}
</div>
{budgetFileName ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem' }}>📄 {budgetFileName}</span>
@ -481,58 +450,34 @@ export const TrusteeAnalyseView: React.FC = () => {
</div>
)}
{/* Results */}
{runState === 'completed' && (resultText || resultDocuments.length > 0) && (
{/* Workspace link (replaces inline results) */}
{runState === 'completed' && runId && (
<div style={{
marginTop: '0.5rem',
padding: '0.75rem 1rem',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: '8px',
overflow: 'hidden',
background: 'rgba(40,167,69,0.08)',
display: 'flex', alignItems: 'center', gap: '0.75rem',
}}>
{resultDocuments.length > 0 && (
<div style={{
padding: '0.75rem 1rem',
background: 'var(--bg-secondary, #f9f9f9)',
borderBottom: resultText ? '1px solid var(--border-color, #e0e0e0)' : 'none',
display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center',
}}>
<strong style={{ fontSize: '0.8125rem' }}>{t('Generierte Dokumente:')}</strong>
{resultDocuments.map((doc, idx) => {
const docId = doc.id || (typeof doc === 'string' ? doc : null);
const docName = doc.fileName || `Dokument ${idx + 1}`;
if (!docId) return null;
return (
<a
key={docId}
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem', borderRadius: '6px',
background: 'var(--primary-color, #007bff)', color: '#fff',
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
}}
>
📄 {docName}
</a>
);
})}
</div>
)}
{resultText && (
<div style={{
padding: '1rem',
fontSize: '0.875rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
maxHeight: '400px',
overflowY: 'auto',
background: 'var(--bg-primary, #fff)',
}}>
{resultText}
</div>
)}
<span style={{ fontSize: '0.875rem', color: 'var(--success-color, #28a745)' }}>
{t('Workflow abgeschlossen.')}
</span>
<a
href={`/automations?tab=workspace&runId=${runId}`}
onClick={(e) => {
e.preventDefault();
navigate(`/automations?tab=workspace&runId=${runId}`);
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem', borderRadius: '6px',
background: 'var(--primary-color, #007bff)', color: '#fff',
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
}}
>
{t('Im Workspace ansehen')}
</a>
</div>
)}
</div>

View file

@ -20,6 +20,17 @@ interface MaxAgentRoundsInfo {
instanceDefault: number;
}
interface AiUserSettings {
requireNeutralization: boolean;
allowedProviders: string[];
allowedModels: string[];
}
interface AiModelEntry {
displayName: string;
connectorType?: string;
}
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
const { t } = useLanguage();
const { request } = useApiRequest();
@ -36,6 +47,16 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
});
const [inputValue, setInputValue] = useState<string>('');
// AI user settings
const [aiSettings, setAiSettings] = useState<AiUserSettings>({
requireNeutralization: false,
allowedProviders: [],
allowedModels: [],
});
const [aiSaving, setAiSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<AiModelEntry[]>([]);
const [modelsOpen, setModelsOpen] = useState(false);
const _loadSettings = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
@ -56,9 +77,37 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
}
}, [instanceId, request]);
const _loadAiSettings = useCallback(async () => {
if (!instanceId) return;
try {
const data = await request({
url: `/api/workspace/${instanceId}/user-settings`,
method: 'get',
}) as AiUserSettings;
setAiSettings({
requireNeutralization: data?.requireNeutralization ?? false,
allowedProviders: data?.allowedProviders ?? [],
allowedModels: data?.allowedModels ?? [],
});
} catch (err: any) {
console.error('[WorkspaceGeneralSettings] AI settings load failed', err);
}
}, [instanceId, request]);
const _loadAvailableModels = useCallback(async () => {
try {
const data = await request({ url: '/api/system/ai-models', method: 'get' }) as { models?: AiModelEntry[] };
setAvailableModels(data?.models ?? []);
} catch {
setAvailableModels([]);
}
}, [request]);
useEffect(() => {
_loadSettings();
}, [_loadSettings]);
_loadAiSettings();
_loadAvailableModels();
}, [_loadSettings, _loadAiSettings, _loadAvailableModels]);
const _handleSave = async () => {
setSaving(true);
@ -94,11 +143,49 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
setInputValue('');
};
const _saveAiSettings = async (patch: Partial<AiUserSettings>) => {
setAiSaving(true);
setError(null);
try {
const data = await request({
url: `/api/workspace/${instanceId}/user-settings`,
method: 'put',
data: patch,
}) as AiUserSettings;
setAiSettings({
requireNeutralization: data?.requireNeutralization ?? false,
allowedProviders: data?.allowedProviders ?? [],
allowedModels: data?.allowedModels ?? [],
});
setSuccess(t('KI-Einstellungen gespeichert'));
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err?.message || 'Fehler beim Speichern der KI-Einstellungen');
} finally {
setAiSaving(false);
}
};
const _toggleModel = (name: string) => {
const next = aiSettings.allowedModels.includes(name)
? aiSettings.allowedModels.filter((m) => m !== name)
: [...aiSettings.allowedModels, name];
setAiSettings((prev) => ({ ...prev, allowedModels: next }));
_saveAiSettings({ allowedModels: next });
};
const _removeModelTag = (name: string) => {
const next = aiSettings.allowedModels.filter((m) => m !== name);
setAiSettings((prev) => ({ ...prev, allowedModels: next }));
_saveAiSettings({ allowedModels: next });
};
if (loading) {
return <div className={styles.loading}>{t('Lade Einstellungen')}</div>;
}
const hasOverride = inputValue.trim() !== '';
const providerNames = [...new Set(availableModels.map((m) => m.connectorType).filter(Boolean))] as string[];
return (
<div className={styles.settings}>
@ -151,6 +238,133 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
>
{saving ? t('Speichern') : t('Einstellungen speichern')}
</button>
{/* AI settings section */}
<div className={styles.section} style={{ marginTop: '1.5rem' }}>
<h3 className={styles.sectionTitle}>{t('KI-Einstellungen')}</h3>
<div className={styles.field}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aiSettings.requireNeutralization}
onChange={(e) => {
const val = e.target.checked;
setAiSettings((prev) => ({ ...prev, requireNeutralization: val }));
_saveAiSettings({ requireNeutralization: val });
}}
disabled={aiSaving}
/>
<span className={styles.label} style={{ marginBottom: 0 }}>
{t('Neutralisierung erzwingen')}
</span>
</label>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
{t('Erzwingt die Neutralisierung von Eingaben vor der KI-Verarbeitung.')}
</span>
</div>
{providerNames.length > 0 && (
<div className={styles.field}>
<label className={styles.label}>{t('Erlaubte Anbieter')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{providerNames.map((prov) => (
<label key={prov} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aiSettings.allowedProviders.includes(prov)}
onChange={() => {
const next = aiSettings.allowedProviders.includes(prov)
? aiSettings.allowedProviders.filter((p) => p !== prov)
: [...aiSettings.allowedProviders, prov];
setAiSettings((prev) => ({ ...prev, allowedProviders: next }));
_saveAiSettings({ allowedProviders: next });
}}
disabled={aiSaving}
/>
{prov}
</label>
))}
</div>
{aiSettings.allowedProviders.length === 0 && (
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
{t('Alle Anbieter erlaubt')}
</span>
)}
</div>
)}
<div className={styles.field}>
<label className={styles.label}>{t('Erlaubte Modelle')}</label>
<div
onClick={() => setModelsOpen((o) => !o)}
style={{
width: '100%',
minHeight: 36,
padding: '6px 10px',
borderRadius: 6,
border: '1px solid var(--border-color, #d0d0d0)',
cursor: 'pointer',
display: 'flex',
flexWrap: 'wrap',
gap: 4,
alignItems: 'center',
background: 'var(--bg-primary, #fff)',
}}
>
{aiSettings.allowedModels.length === 0 && (
<span style={{ color: 'var(--text-secondary, #999)', fontSize: '0.85rem' }}>{t('Alle erlaubten Modelle')}</span>
)}
{aiSettings.allowedModels.map((name) => (
<span
key={name}
style={{
background: 'var(--primary-color, #2563eb)',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
fontSize: '0.8rem',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
{name}
<span
onClick={(e) => { e.stopPropagation(); _removeModelTag(name); }}
style={{ cursor: 'pointer', fontWeight: 700 }}
>
x
</span>
</span>
))}
</div>
{modelsOpen && (
<div style={{ border: '1px solid var(--border-color, #ddd)', borderRadius: 6, marginTop: 4, maxHeight: 220, overflow: 'auto', background: 'var(--bg-primary, #fafafa)', padding: 6 }}>
{availableModels.length === 0 && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
)}
{availableModels.map((m) => (
<label
key={m.displayName}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 6px', fontSize: '0.85rem', cursor: 'pointer' }}
>
<input
type="checkbox"
checked={aiSettings.allowedModels.includes(m.displayName)}
onChange={() => _toggleModel(m.displayName)}
disabled={aiSaving}
/>
<span>{m.displayName}</span>
{m.connectorType && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)' }}>({m.connectorType})</span>
)}
</label>
))}
</div>
)}
</div>
</div>
</div>
);
};