This commit is contained in:
ValueOn AG 2026-04-20 00:31:09 +02:00
parent 24150f36d5
commit be748b162c
6 changed files with 469 additions and 9 deletions

View file

@ -392,6 +392,108 @@ export async function deleteWorkflow(
}); });
} }
// -------------------------------------------------------------------------
// Workflow file IO (envelopeVersioned, .workflow.json)
// -------------------------------------------------------------------------
/** envelopeVersioned schema 1.0 — keys mirror the gateway constants. */
export const WORKFLOW_FILE_SCHEMA_VERSION = '1.0';
export const WORKFLOW_FILE_KIND = 'poweron.workflow';
export const WORKFLOW_FILE_EXTENSION = '.workflow.json';
export interface WorkflowFileEnvelope {
$schemaVersion: string;
$kind: string;
$exportedAt?: string;
$gatewayVersion?: string;
label: string;
description?: string;
tags?: string[];
templateScope?: AutoTemplateScope;
sharedReadOnly?: boolean;
notifyOnFailure?: boolean;
graph: Automation2Graph;
invocations?: WorkflowEntryPoint[];
}
export interface ImportWorkflowResponse {
workflow: AutoWorkflow;
warnings: string[];
created: boolean;
}
export interface ImportWorkflowOptions {
/** Inline envelope payload (preferred for round-trip in the editor). */
envelope?: WorkflowFileEnvelope;
/** UDB FileItem.id of a previously uploaded ``.workflow.json``. */
fileId?: string;
/** When set, the existing workflow is replaced instead of a new one being created. */
existingWorkflowId?: string;
}
/** POST /api/workflows/{instanceId}/workflows/import */
export async function importWorkflowFromFile(
request: ApiRequestFunction,
instanceId: string,
options: ImportWorkflowOptions,
): Promise<ImportWorkflowResponse> {
if (!options.envelope && !options.fileId) {
throw new Error('importWorkflowFromFile: either envelope or fileId is required');
}
return await request({
url: `/api/workflows/${instanceId}/workflows/import`,
method: 'post',
data: options,
});
}
export interface ExportWorkflowResult {
fileName: string;
envelope: WorkflowFileEnvelope;
}
/**
* GET /api/workflows/{instanceId}/workflows/{workflowId}/export
*
* The backend returns ``{ fileName, envelope }`` when ``download=false`` and a
* raw JSON download (``Content-Disposition: attachment``) when ``download=true``.
* For programmatic use (e.g. re-uploading to UDB) keep download=false.
*/
export async function exportWorkflowToFile(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
download = false,
): Promise<ExportWorkflowResult> {
return await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}/export`,
method: 'get',
params: { download },
});
}
/** Quick content-sniffing — used by the UDB FilesTab to flag workflow files. */
export function isWorkflowFileContent(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') return false;
const p = payload as Record<string, unknown>;
return (
typeof p.$schemaVersion === 'string' &&
p.$kind === WORKFLOW_FILE_KIND &&
!!p.graph &&
typeof p.graph === 'object'
);
}
/** Suggest a safe filename from the workflow label (mirrors gateway buildFileName). */
export function workflowFileNameFor(label: string): string {
const slug = (label || 'workflow')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'workflow';
return `${slug}${WORKFLOW_FILE_EXTENSION}`;
}
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */ /** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
export async function deleteSystemWorkflow( export async function deleteSystemWorkflow(
request: ApiRequestFunction, request: ApiRequestFunction,

View file

@ -275,14 +275,35 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} else { } else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations); applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
} }
setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf];
const next = prev.slice();
next[idx] = { ...prev[idx], ...wf };
return next;
});
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404) {
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
try {
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (refreshErr) {
console.error(`${LOG} workflows refresh failed`, refreshErr);
}
return;
}
setExecuteResult({ setExecuteResult({
success: false, success: false,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
} }
}, },
[request, instanceId, handleFromApiGraph, applyGraphWithSync] [request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
); );
const handleWorkflowSelect = useCallback( const handleWorkflowSelect = useCallback(

View file

@ -153,15 +153,23 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => { const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]); const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null);
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
React.useEffect(() => { React.useEffect(() => {
if (!instanceId || !request) return; if (!instanceId || !request) return;
request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' }) const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
setLoadError(null);
request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' })
.then((res: unknown) => { .then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> }; const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label }))); setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
}) })
.catch(() => {}); .catch((err: unknown) => {
}, [instanceId, request]); console.error('ConnectionPicker: failed to load connections', err);
setConnections([]);
setLoadError(err instanceof Error ? err.message : String(err));
});
}, [instanceId, request, authority]);
return ( return (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label> <label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
@ -175,6 +183,16 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
<option key={c.id} value={c.id}>{c.label}</option> <option key={c.id} value={c.id}>{c.label}</option>
))} ))}
</select> </select>
{!loadError && connections.length === 0 && (
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{authority
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
</div>
)}
{loadError && (
<div style={{ fontSize: 11, color: '#c00', marginTop: 2 }}>{t('Verbindungen konnten nicht geladen werden')}</div>
)}
</div> </div>
); );
}; };
@ -199,6 +217,205 @@ const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, al
); );
}; };
type SharepointSiteOption = { type: 'site'; value: string; label: string; siteId: string; path: string; webUrl?: string };
type SharepointItemOption = { type: 'folder' | 'file'; value: string; label: string; path: string; siteId: string };
const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, request }) => {
const { t } = useLanguage();
const isFilePicker = param.frontendType === 'sharepointFile';
const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
const hasConnection = !!connectionReference;
const [open, setOpen] = React.useState(false);
const [sites, setSites] = React.useState<SharepointSiteOption[]>([]);
const [selectedSite, setSelectedSite] = React.useState<SharepointSiteOption | null>(null);
const [currentPath, setCurrentPath] = React.useState('');
const [items, setItems] = React.useState<SharepointItemOption[]>([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const loadSites = React.useCallback(async () => {
if (!request || !connectionReference) return;
setLoading(true);
setError(null);
try {
const res = (await request({
url: `/api/sharepoint/folder-options?connectionReference=${encodeURIComponent(connectionReference)}`,
method: 'get',
})) as SharepointSiteOption[] | null;
setSites(Array.isArray(res) ? res : []);
} catch (err: unknown) {
console.error('SharepointPathPicker: failed to load sites', err);
setSites([]);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [request, connectionReference]);
const loadItems = React.useCallback(async (site: SharepointSiteOption, path: string) => {
if (!request) return;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ connectionReference, siteId: site.siteId });
if (path) params.append('path', path);
if (isFilePicker) params.append('includeFiles', 'true');
const res = (await request({
url: `/api/sharepoint/folder-options?${params.toString()}`,
method: 'get',
})) as SharepointItemOption[] | null;
setItems(Array.isArray(res) ? res : []);
} catch (err: unknown) {
console.error('SharepointPathPicker: failed to load items', err);
setItems([]);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [request, connectionReference, isFilePicker]);
React.useEffect(() => {
if (!open) return;
if (sites.length === 0 && !loading) loadSites();
}, [open, sites.length, loading, loadSites]);
React.useEffect(() => {
if (selectedSite) {
setCurrentPath('');
loadItems(selectedSite, '');
} else {
setItems([]);
}
}, [selectedSite, loadItems]);
const navigateInto = (item: SharepointItemOption) => {
if (item.type !== 'folder' || !selectedSite) return;
setCurrentPath(item.path);
loadItems(selectedSite, item.path);
};
const goUp = () => {
if (!selectedSite || !currentPath) return;
const parts = currentPath.split('/');
parts.pop();
const parent = parts.join('/');
setCurrentPath(parent);
loadItems(selectedSite, parent);
};
const buildFullPath = (sub: string) => {
const sitePath = (selectedSite?.path || '').replace(/\/+$/, '');
const cleanSub = sub.replace(/^\/+/, '');
if (!sitePath) return `/${cleanSub}`;
if (!cleanSub) return sitePath;
return `${sitePath}/${cleanSub}`;
};
const pickCurrentFolder = () => {
if (!selectedSite) return;
const fullPath = buildFullPath(currentPath);
onChange(fullPath);
setOpen(false);
};
const pickItem = (item: SharepointItemOption) => {
if (!selectedSite) return;
const fullPath = buildFullPath(item.path);
onChange(fullPath);
if (item.type === 'file') setOpen(false);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
disabled={!hasConnection}
placeholder={hasConnection ? (isFilePicker ? t('SharePoint-Dateipfad') : t('SharePoint-Ordnerpfad')) : t('Zuerst {field} wählen', { field: dependsOn })}
style={{ flex: 1, padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: hasConnection ? 1 : 0.5 }}
/>
<button
type="button"
disabled={!hasConnection}
onClick={() => setOpen((o) => !o)}
title={t('In SharePoint browsen')}
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #0078D4', background: open ? '#0078D4' : '#fff', color: open ? '#fff' : '#0078D4', cursor: hasConnection ? 'pointer' : 'not-allowed', opacity: hasConnection ? 1 : 0.5 }}
>
{open ? t('Schliessen') : t('Browsen')}
</button>
</div>
{open && hasConnection && (
<div style={{ marginTop: 6, border: '1px solid #ddd', borderRadius: 4, padding: 6, background: '#fafafa' }}>
{error && <div style={{ fontSize: 11, color: '#c00', marginBottom: 4 }}>{error}</div>}
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 11, color: '#555' }}>{t('Seite:')}</span>
<select
value={selectedSite?.siteId || ''}
onChange={(e) => {
const s = sites.find((x) => x.siteId === e.target.value) || null;
setSelectedSite(s);
}}
style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
>
<option value="">{loading && sites.length === 0 ? t('Lade Seiten') : t('Seite wählen')}</option>
{sites.map((s) => (
<option key={s.siteId} value={s.siteId}>{s.label}</option>
))}
</select>
<button type="button" onClick={loadSites} title={t('Seiten neu laden')} style={{ padding: '2px 6px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer', fontSize: 11 }}></button>
</div>
{selectedSite && (
<>
<div style={{ fontSize: 11, color: '#555', marginBottom: 4 }}>
<strong>{t('Pfad:')}</strong> {selectedSite.path}/{currentPath || <em>{t('(Stammverzeichnis)')}</em>}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
{currentPath && (
<button type="button" onClick={goUp} style={{ padding: '2px 6px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer', fontSize: 11 }}> {t('Hoch')}</button>
)}
<button type="button" onClick={pickCurrentFolder} style={{ padding: '2px 6px', borderRadius: 4, border: '1px solid #0078D4', background: '#0078D4', color: '#fff', cursor: 'pointer', fontSize: 11 }}>
{isFilePicker ? t('Diesen Ordner als Pfad nehmen') : t('Diesen Ordner wählen')}
</button>
</div>
<div style={{ maxHeight: 180, overflow: 'auto', border: '1px solid #eee', borderRadius: 4, background: '#fff' }}>
{loading && <div style={{ padding: 6, fontSize: 11, color: '#888' }}>{t('Lade')}</div>}
{!loading && items.length === 0 && (
<div style={{ padding: 6, fontSize: 11, color: '#888' }}>{isFilePicker ? t('Keine Dateien oder Ordner') : t('Keine Unterordner')}</div>
)}
{!loading && items.map((item) => (
<div key={`${item.type}:${item.value}`} style={{ display: 'flex', alignItems: 'center', padding: '3px 6px', borderBottom: '1px solid #f0f0f0', fontSize: 12 }}>
<span
onClick={() => (item.type === 'folder' ? navigateInto(item) : pickItem(item))}
style={{ flex: 1, cursor: 'pointer', userSelect: 'none' }}
title={item.type === 'folder' ? t('Öffnen') : t('Wählen')}
>
{item.type === 'folder' ? '📁' : '📄'} {item.label}
</span>
<button
type="button"
onClick={() => pickItem(item)}
style={{ padding: '1px 6px', borderRadius: 3, border: '1px solid #0078D4', background: '#fff', color: '#0078D4', cursor: 'pointer', fontSize: 11 }}
>
{t('Wählen')}
</button>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
};
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const cases = Array.isArray(value) ? value : []; const cases = Array.isArray(value) ? value : [];
@ -402,8 +619,8 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
file: TextInput, file: TextInput,
hidden: HiddenInput, hidden: HiddenInput,
userConnection: ConnectionPicker, userConnection: ConnectionPicker,
sharepointFolder: FolderPicker, sharepointFolder: SharepointPathPicker,
sharepointFile: FolderPicker, sharepointFile: SharepointPathPicker,
clickupList: FolderPicker, clickupList: FolderPicker,
clickupTask: FolderPicker, clickupTask: FolderPicker,
caseList: CaseListEditor, caseList: CaseListEditor,

View file

@ -6,9 +6,9 @@
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger). * Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/ */
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen } from 'react-icons/fa'; import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt'; import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator'; import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
@ -18,7 +18,13 @@ import {
deleteWorkflow, deleteWorkflow,
executeGraph, executeGraph,
updateWorkflow, updateWorkflow,
importWorkflowFromFile,
exportWorkflowToFile,
isWorkflowFileContent,
workflowFileNameFor,
WORKFLOW_FILE_EXTENSION,
type Automation2Workflow, type Automation2Workflow,
type WorkflowFileEnvelope,
} from '../../../api/workflowApi'; } from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time'; import { formatUnixTimestamp } from '../../../utils/time';
@ -56,6 +62,8 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null); const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (paginationParams?: any) => { const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return; if (!instanceId) return;
@ -180,6 +188,69 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load, t] [instanceId, request, showSuccess, showError, load, t]
); );
const handleExport = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
try {
const result = await exportWorkflowToFile(request, instanceId, row.id, false);
const fileName = result.fileName || workflowFileNameFor(row.label);
const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('Workflow als Datei exportiert'));
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
}
},
[instanceId, request, showSuccess, showError, t],
);
const handleImportFileSelected = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !instanceId) return;
setImporting(true);
try {
const text = await file.text();
let envelope: WorkflowFileEnvelope;
try {
envelope = JSON.parse(text) as WorkflowFileEnvelope;
} catch {
showError(t('Datei ist kein gültiges JSON'));
return;
}
if (!isWorkflowFileContent(envelope)) {
showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
return;
}
const result = await importWorkflowFromFile(request, instanceId, { envelope });
const warnings = result?.warnings ?? [];
if (warnings.length > 0) {
showSuccess(
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
);
} else {
showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
}
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
} finally {
setImporting(false);
}
},
[instanceId, request, showSuccess, showError, load, t],
);
const columns: ColumnConfig[] = [ const columns: ColumnConfig[] = [
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true }, { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
{ {
@ -274,6 +345,21 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
</button> </button>
))} ))}
</div> </div>
<button
className={styles.secondaryButton}
onClick={() => importFileInputRef.current?.click()}
disabled={importing || loading}
title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
>
<FaFileImport /> {importing ? t('Importiere...') : t('Importieren')}
</button>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportFileSelected}
/>
<button <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => load()} onClick={() => load()}
@ -337,6 +423,12 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
loading: (row) => executingId === row.id, loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row), visible: (row) => hasManualTrigger(row),
}, },
{
id: 'export',
icon: <FaFileExport />,
title: t('Als Datei exportieren'),
onClick: (row) => handleExport(row),
},
]} ]}
onDelete={(row) => handleDelete(row.id)} onDelete={(row) => handleDelete(row.id)}
hookData={hookData} hookData={hookData}

View file

@ -45,6 +45,8 @@ interface WorkspaceInputProps {
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
pendingAttachDsId?: string; pendingAttachDsId?: string;
onPendingAttachDsConsumed?: () => void; onPendingAttachDsConsumed?: () => void;
pendingAttachFdsId?: string;
onPendingAttachFdsConsumed?: () => void;
onPasteAsFile?: (file: File) => void; onPasteAsFile?: (file: File) => void;
draftAppend?: string; draftAppend?: string;
onDraftAppendConsumed?: () => void; onDraftAppendConsumed?: () => void;
@ -69,6 +71,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onDataSourceDrop, onDataSourceDrop,
pendingAttachDsId, pendingAttachDsId,
onPendingAttachDsConsumed, onPendingAttachDsConsumed,
pendingAttachFdsId,
onPendingAttachFdsConsumed,
onPasteAsFile, onPasteAsFile,
draftAppend, draftAppend,
onDraftAppendConsumed, onDraftAppendConsumed,
@ -105,6 +109,15 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
} }
}, [pendingAttachDsId, onPendingAttachDsConsumed]); }, [pendingAttachDsId, onPendingAttachDsConsumed]);
useEffect(() => {
if (pendingAttachFdsId) {
setAttachedFeatureDataSourceIds(prev =>
prev.includes(pendingAttachFdsId) ? prev : [...prev, pendingAttachFdsId],
);
onPendingAttachFdsConsumed?.();
}
}, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
const promptBeforeVoiceRef = useRef(''); const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef(''); const finalizedTextRef = useRef('');
const currentInterimRef = useRef(''); const currentInterimRef = useRef('');

View file

@ -293,16 +293,29 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
}); });
}, []); }, []);
const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => { const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
try { try {
await api.post(`/api/workspace/${instanceId}/feature-datasources`, { const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: params.featureInstanceId, featureInstanceId: params.featureInstanceId,
featureCode: params.featureCode, featureCode: params.featureCode,
tableName: params.tableName || '', tableName: params.tableName || '',
objectKey: params.objectKey, objectKey: params.objectKey,
label: params.label, label: params.label,
}); });
// Backend response shape parity with /datasources — accept either a flat
// ``id`` or a wrapped ``featureDataSource.id`` so a future API tweak
// doesn't silently break the chip again.
const newId =
res.data?.id ||
res.data?.featureDataSource?.id ||
res.data?.dataSource?.id ||
'';
workspace.refreshFeatureDataSources(); workspace.refreshFeatureDataSources();
if (newId) {
setPendingAttachFdsId(newId);
}
} catch (err) { } catch (err) {
console.error('Failed to add feature source to chat:', err); console.error('Failed to add feature source to chat:', err);
} }
@ -521,6 +534,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onDataSourceDrop={_handleDataSourceDrop} onDataSourceDrop={_handleDataSourceDrop}
pendingAttachDsId={pendingAttachDsId} pendingAttachDsId={pendingAttachDsId}
onPendingAttachDsConsumed={() => setPendingAttachDsId('')} onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
pendingAttachFdsId={pendingAttachFdsId}
onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')}
onPasteAsFile={_uploadAndAttach} onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend} draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')} onDraftAppendConsumed={() => setDraftAppend('')}