Merge pull request #67 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
992c0472c6
14 changed files with 1100 additions and 110 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
|||
'filterExpression',
|
||||
'attachmentBuilder',
|
||||
'json',
|
||||
'modelMultiSelect',
|
||||
]);
|
||||
|
||||
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,32 +450,25 @@ 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 (
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--success-color, #28a745)' }}>
|
||||
{t('Workflow abgeschlossen.')}
|
||||
</span>
|
||||
<a
|
||||
key={docId}
|
||||
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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',
|
||||
|
|
@ -514,25 +476,8 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
📄 {docName}
|
||||
{t('Im Workspace ansehen')}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue