fixed nodes handovers

This commit is contained in:
ValueOn AG 2026-04-30 23:54:51 +02:00
parent ad96c6d861
commit c7e94aea79
3 changed files with 168 additions and 41 deletions

View file

@ -1026,12 +1026,16 @@ export interface WorkspaceRunDetail {
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;
@ -1039,10 +1043,14 @@ export interface WorkspaceRunDetail {
contentType?: string;
sizeBytes?: number;
}>;
unassignedFiles?: Array<{
id: string;
fileName?: string;
}>;
}
export async function fetchWorkspaceRuns(
request: ApiRequestOptions['request'],
request: ApiRequestFunction,
params: {
scope?: 'mine' | 'mandate';
status?: string;
@ -1061,14 +1069,14 @@ export async function fetchWorkspaceRuns(
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' });
const resp = await request({ url, method: 'get' });
return resp as { runs: WorkspaceRun[]; total: number };
}
export async function fetchWorkspaceRunDetail(
request: ApiRequestOptions['request'],
request: ApiRequestFunction,
runId: string,
): Promise<WorkspaceRunDetail> {
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'GET' });
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' });
return resp as WorkspaceRunDetail;
}

View file

@ -34,6 +34,7 @@ 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 }}>
@ -747,7 +748,7 @@ const ModelMultiSelect: React.FC<FieldRendererProps> = ({ param, value, onChange
React.useEffect(() => {
let cancelled = false;
setLoading(true);
fetch('/api/system/ai-models', { credentials: 'include' })
fetch(`${getApiBaseUrl()}/api/system/ai-models`, { credentials: 'include' })
.then((r) => r.json())
.then((data) => {
if (cancelled) return;

View file

@ -1083,6 +1083,100 @@ const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => {
// 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;
@ -1113,20 +1207,20 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
if (!runId) {
return (
<div style={{ padding: '1rem', color: 'var(--text-secondary)' }}>
<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' }}><p>{t('Laden…')}</p></div>;
return <div style={{ padding: '1rem', flex: 1 }}><p>{t('Laden…')}</p></div>;
}
const { run, steps, files, workflow } = runDetail;
const { run, steps, workflow, unassignedFiles } = runDetail;
return (
<div style={{ padding: '1rem' }}>
<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>
@ -1148,41 +1242,65 @@ const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Schritte protokolliert.')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{steps.map((step) => (
<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>}
</summary>
{step.output && Object.keys(step.output).length > 0 && (
<pre style={{ fontSize: '0.75rem', maxHeight: 300, overflow: 'auto', marginTop: '0.5rem', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
{JSON.stringify(step.output, null, 2)}
</pre>
)}
{step.error && <p style={{ color: 'var(--danger-color)', marginTop: '0.25rem' }}>{step.error}</p>}
</details>
))}
{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>
)}
{files.length > 0 && (
{unassignedFiles && unassignedFiles.length > 0 && (
<>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Dokumente')}</h4>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{files.map((f) => (
<a
key={f.id}
href={`/api/files/${f.id}/download`}
download
style={{ padding: '0.5rem 1rem', border: '1px solid var(--border-color)', borderRadius: 6, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.85rem' }}
>
<FaDownload style={{ marginRight: 4 }} />
{f.fileName || f.id}
</a>
))}
</div>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
<_FileLinkList files={unassignedFiles} />
</>
)}
</div>