Merge pull request #17 from valueonag/feat/file-tree-system

Feat/file tree system
This commit is contained in:
Patrick Motsch 2026-03-17 23:25:47 +01:00 committed by GitHub
commit 2a0454a9ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 2800 additions and 6245 deletions

65
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@azure/msal-react": "^3.0.12",
"@monaco-editor/react": "^4.7.0",
"@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0",
"axios": "^1.8.3",
@ -1043,6 +1044,27 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1642,6 +1664,13 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true,
"peer": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -2809,6 +2838,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@ -4397,6 +4435,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5354,6 +5404,16 @@
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/motion": {
"version": "12.23.9",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.9.tgz",
@ -6613,6 +6673,11 @@
"node": ">= 10.x"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View file

@ -16,6 +16,7 @@
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@azure/msal-react": "^3.0.12",
"@monaco-editor/react": "^4.7.0",
"@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0",
"axios": "^1.8.3",

View file

@ -40,7 +40,6 @@ import StorePage from './pages/Store';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PlaygroundPage } from './pages/workflows';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin } from './pages/billing';
function App() {
@ -100,18 +99,6 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} />
{/* ============================================== */}
{/* WORKFLOWS ROUTES (deprecated - redirect to /) */}
{/* Workflows are accessed via feature routes: */}
{/* /mandates/:mandateId/chatplayground/:id/workflows */}
{/* /mandates/:mandateId/automation/:id/definitions */}
{/* ============================================== */}
<Route path="workflows">
<Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<Navigate to="/" replace />} />
<Route path="automations" element={<Navigate to="/" replace />} />
</Route>
{/* ============================================== */}
{/* BASISDATEN ROUTES (global) */}
{/* ============================================== */}
@ -162,16 +149,12 @@ function App() {
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
{/* Chat Playground Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
{/* Automation Feature Views */}
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
<Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Code Editor Feature Views */}
{/* Workspace Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} />
{/* Teams Bot Feature Views */}

View file

@ -99,6 +99,7 @@ export interface CreditAddRequest {
export interface CheckoutCreateRequest {
userId?: string;
amount: number;
returnUrl: string;
}
export interface CheckoutCreateResponse {

View file

@ -176,21 +176,116 @@ export async function deleteFiles(
request: ApiRequestFunction,
fileIds: string[]
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
const results = await Promise.allSettled(
fileIds.map(fileId =>
request({
url: `/api/files/${fileId}`,
method: 'delete'
}).then(() => ({ success: true, fileId }))
.catch((error) => ({ success: false, fileId, error }))
)
);
const uniqueIds = [...new Set(fileIds.filter(Boolean))];
if (uniqueIds.length === 0) return [];
await request({
url: '/api/files/batch-delete',
method: 'post',
data: { fileIds: uniqueIds }
});
return uniqueIds.map(fileId => ({ success: true, fileId }));
}
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
return { success: false, fileId: fileIds[index], error: result.reason };
export async function deleteFolders(
request: ApiRequestFunction,
folderIds: string[],
recursiveFolders: boolean = true
): Promise<{ deletedFiles: number; deletedFolders: number }> {
const uniqueIds = [...new Set(folderIds.filter(Boolean))];
if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 };
return await request({
url: '/api/files/batch-delete',
method: 'post',
data: { folderIds: uniqueIds, recursiveFolders }
});
}
// ============================================================================
// FOLDER API FUNCTIONS
// ============================================================================
export interface FolderInfo {
id: string;
name: string;
parentId: string | null;
mandateId?: string;
featureInstanceId?: string;
createdAt?: number;
}
export async function fetchFolders(
request: ApiRequestFunction,
parentId?: string | null
): Promise<FolderInfo[]> {
const params: any = {};
if (parentId !== undefined && parentId !== null) {
params.parentId = parentId;
}
const data = await request({
url: '/api/files/folders',
method: 'get',
params,
});
return Array.isArray(data) ? data : [];
}
export async function createFolder(
request: ApiRequestFunction,
name: string,
parentId?: string | null
): Promise<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId || null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'put',
data: { name },
});
}
export async function deleteFolderApi(
request: ApiRequestFunction,
folderId: string,
recursive: boolean = false
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'delete',
params: { recursive },
});
}
export async function moveFolder(
request: ApiRequestFunction,
folderId: string,
targetParentId: string | null
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}/move`,
method: 'post',
data: { targetParentId },
});
}
export async function moveFile(
request: ApiRequestFunction,
fileId: string,
targetFolderId: string | null
): Promise<any> {
return await request({
url: `/api/files/${fileId}/move`,
method: 'post',
data: { targetFolderId },
});
}

View file

@ -237,7 +237,7 @@ export async function fetchWorkflowLogs(
/**
* Fetch unified chat data (messages, logs, stats, documents)
* Endpoint: GET /api/chatplayground/{instanceId}/workflows/{workflowId}/chatData
* Endpoint: GET /api/automations/{instanceId}/workflows/{workflowId}/chatData
* Query params: afterTimestamp (optional) - fetch only data created after this time
*/
export async function fetchChatData(
@ -248,7 +248,7 @@ export async function fetchChatData(
): Promise<ChatDataResponse> {
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
const requestConfig = {
url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/chatData`,
url: `/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
method: 'get' as const,
params
};
@ -303,7 +303,7 @@ export async function fetchChatData(
/**
* Start a new workflow or continue an existing one
* Endpoint: POST /api/chatplayground/{instanceId}/start
* Endpoint: POST /api/automations/{instanceId}/start
* Query params: workflowId (optional), workflowMode (default: "Dynamic")
*/
export async function startWorkflowApi(
@ -318,7 +318,6 @@ export async function startWorkflowApi(
if (options?.workflowMode) {
params.workflowMode = options.workflowMode;
} else {
// Default to 'Dynamic' if not provided (though it should always be provided)
params.workflowMode = 'Dynamic';
}
@ -326,7 +325,6 @@ export async function startWorkflowApi(
params.workflowId = options.workflowId;
}
// Request body uses 'prompt' field (not 'input') according to API spec
const requestBody: any = {
prompt: workflowData.prompt,
...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }),
@ -336,10 +334,10 @@ export async function startWorkflowApi(
};
const requestConfig = {
url: `/api/chatplayground/${instanceId}/start`,
url: `/api/automations/${instanceId}/start`,
method: 'post' as const,
data: requestBody,
params: params // Always include workflowMode
params: params
};
// Log full request details
@ -359,7 +357,7 @@ export async function startWorkflowApi(
/**
* Stop a running workflow
* Endpoint: POST /api/chatplayground/{instanceId}/workflows/{workflowId}/stop
* Endpoint: POST /api/automations/{instanceId}/workflows/{workflowId}/stop
*/
export async function stopWorkflowApi(
request: ApiRequestFunction,
@ -367,7 +365,7 @@ export async function stopWorkflowApi(
workflowId: string
): Promise<void> {
await request({
url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/stop`,
url: `/api/automations/${instanceId}/workflows/${workflowId}/stop`,
method: 'post'
});
}

View file

@ -0,0 +1,157 @@
.folderTree {
font-size: 0.875rem;
user-select: none;
}
.treeNode {
display: flex;
align-items: center;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
gap: 6px;
min-height: 32px;
position: relative;
}
.treeNode:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.treeNode.selected {
background: var(--color-bg-selected, rgba(25, 118, 210, 0.08));
font-weight: 600;
}
.treeNode.multiSelected {
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
box-shadow: inset 3px 0 0 var(--color-primary, #1976d2);
}
.treeNode.multiSelected:hover {
background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20));
}
.treeNode.dropTarget {
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
outline: 2px dashed var(--color-primary, #1976d2);
outline-offset: -2px;
}
.treeNode.dragging {
opacity: 0.5;
}
.chevron {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease;
color: var(--color-text-secondary, #666);
font-size: 10px;
}
.chevron.expanded {
transform: rotate(90deg);
}
.chevron.empty {
visibility: hidden;
}
.folderIcon {
flex-shrink: 0;
color: var(--color-text-secondary, #888);
font-size: 14px;
}
.folderName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.renameInput {
flex: 1;
border: 1px solid var(--color-primary, #1976d2);
border-radius: 3px;
padding: 1px 4px;
font-size: inherit;
font-family: inherit;
outline: none;
min-width: 0;
}
.actions {
display: none;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.treeNode:hover .actions {
display: flex;
}
.actionBtn {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
color: var(--color-text-secondary, #888);
font-size: 12px;
line-height: 1;
display: flex;
align-items: center;
}
.actionBtn:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.08));
color: var(--color-text-primary, #333);
}
.actionBtn.danger:hover {
color: var(--color-error, #d32f2f);
}
.children {
padding-left: 16px;
}
.rootLabel {
font-weight: 600;
color: var(--color-text-primary, #333);
}
/* File nodes inside the tree */
.fileNode {
cursor: pointer;
}
.fileNode:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.fileIcon {
flex-shrink: 0;
font-size: 12px;
}
.fileSize {
font-size: 10px;
color: var(--color-text-secondary, #999);
flex-shrink: 0;
margin-left: auto;
}
.rootActions {
display: flex;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}

View file

@ -0,0 +1,731 @@
/**
* FolderTree Shared recursive folder/file tree component.
*
* Used on the Files page and in the Workspace chat.
* Supports:
* - Alphabetical sorting per level (folders first, then files)
* - Multi-selection (CTRL+click, SHIFT+click) with visual highlight
* - Batch drag-and-drop for selected items
* - Inline CRUD icons for folders
* - showFiles mode renders files inline under their parent folder
* - Drag-out: sets application/tree-items on dataTransfer for external drop targets
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt } from 'react-icons/fa';
import styles from './FolderTree.module.css';
/* ── Public types ──────────────────────────────────────────────────────── */
export interface FolderNode {
id: string;
name: string;
parentId: string | null;
children?: FolderNode[];
}
export interface FileNode {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
}
export interface TreeItem {
id: string;
type: 'file' | 'folder';
name: string;
}
export interface FolderTreeProps {
folders: FolderNode[];
files?: FileNode[];
showFiles?: boolean;
selectedFolderId: string | null;
onSelect: (folderId: string | null) => void;
onFileSelect?: (fileId: string) => void;
selectedItemIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
expandedIds?: Set<string>;
onToggleExpand?: (id: string) => void;
onRefresh?: () => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
function _buildTree(folders: FolderNode[]): FolderNode[] {
const map = new Map<string, FolderNode>();
const roots: FolderNode[] = [];
for (const f of folders) map.set(f.id, { ...f, children: [] });
for (const f of folders) {
const node = map.get(f.id)!;
if (f.parentId && map.has(f.parentId)) {
map.get(f.parentId)!.children!.push(node);
} else {
roots.push(node);
}
}
const _sortLevel = (nodes: FolderNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
for (const n of nodes) {
if (n.children && n.children.length > 0) _sortLevel(n.children);
}
};
_sortLevel(roots);
return roots;
}
function _groupFilesByFolder(files: FileNode[]): Map<string, FileNode[]> {
const map = new Map<string, FileNode[]>();
for (const f of files) {
const key = f.folderId || '';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(f);
}
for (const [, arr] of map) {
arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
}
return map;
}
function _computeFlatList(
tree: FolderNode[],
expandedIds: Set<string>,
showFiles: boolean,
filesByFolder: Map<string, FileNode[]>,
): TreeItem[] {
const result: TreeItem[] = [];
const _walk = (nodes: FolderNode[]) => {
for (const node of nodes) {
result.push({ id: node.id, type: 'folder', name: node.name });
if (expandedIds.has(node.id)) {
if (node.children) _walk(node.children);
if (showFiles) {
for (const f of (filesByFolder.get(node.id) || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
}
}
};
_walk(tree);
if (showFiles) {
for (const f of (filesByFolder.get('') || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
return result;
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
if (mime.startsWith('video/')) return '\uD83C\uDFA5';
return '\uD83D\uDCC4';
}
/* ── Selection context threaded through the tree ──────────────────────── */
interface SelectionCtx {
selectedItemIds: Set<string>;
selectedFileIds: string[];
selectedFolderIds: string[];
onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void;
onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const isSelected = sel.selectedItemIds.has(file.id);
const multiSelected = sel.selectedItemIds.size > 1;
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
await sel.onRenameFile(file.id, trimmed);
}
setRenaming(false);
}, [renameValue, file.id, file.fileName, sel.onRenameFile]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.onDeleteFile) await sel.onDeleteFile(file.id);
}, [file.id, sel]);
return (
<div
className={[
styles.treeNode,
styles.fileNode,
isSelected ? styles.multiSelected : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ')}
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, file.id, 'file', file.fileName);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
>
<span className={styles.fileIcon}>{_fileIcon(file.mimeType)}</span>
{renaming ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{file.fileName}</span>
)}
{!renaming && file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{!renaming && (
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)
)}
</span>
)}
</div>
);
}
/* ── Tree node (folder) ───────────────────────────────────────────────── */
interface TreeNodeProps {
node: FolderNode;
depth: number;
selectedFolderId: string | null;
expandedIds: Set<string>;
showFiles: boolean;
filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
}
function _TreeNode({
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
}: TreeNodeProps) {
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const isExpanded = expandedIds.has(node.id);
const isNavSelected = selectedFolderId === node.id;
const isMultiSelected = sel.selectedItemIds.has(node.id);
const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : [];
const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0;
useEffect(() => {
if (renaming && inputRef.current) inputRef.current.focus();
}, [renaming]);
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== node.name && onRenameFolder) {
await onRenameFolder(node.id, trimmed);
}
setRenaming(false);
}, [renameValue, node.id, node.name, onRenameFolder]);
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onCreateFolder) return;
const name = prompt('Neuer Ordnername:');
if (name?.trim()) {
await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(node.id);
}
}, [onCreateFolder, node.id, expandedIds, onToggle]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteFolder) await onDeleteFolder(node.id);
}, [onDeleteFolder, node.id]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropOver(true);
}, []);
const _handleDragLeave = useCallback(() => setDropOver(false), []);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) {
await onMoveFolders(folderIds, node.id);
} else if (onMoveFolder) {
for (const fId of folderIds) await onMoveFolder(fId, node.id);
}
if (fileIds.length > 0 && onMoveFiles) {
await onMoveFiles(fileIds, node.id);
} else if (fileIds.length > 0 && onMoveFile) {
for (const fId of fileIds) await onMoveFile(fId, node.id);
}
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && folderId !== node.id && onMoveFolder) {
await onMoveFolder(folderId, node.id);
} else if (fileIdsJson && onMoveFiles) {
await onMoveFiles(JSON.parse(fileIdsJson), node.id);
} else if (fileId && onMoveFile) {
await onMoveFile(fileId, node.id);
}
}, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const nodeClasses = [
styles.treeNode,
isNavSelected && !isMultiSelected ? styles.selected : '',
isMultiSelected ? styles.multiSelected : '',
dropOver ? styles.dropTarget : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ');
return (
<div>
<div
className={nodeClasses}
onClick={(e) => sel.onItemClick(node.id, 'folder', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, node.id, 'folder', node.name);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''} ${!hasChildren ? styles.empty : ''}`}
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{renaming ? (
<input
ref={inputRef}
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{node.name}</span>
)}
<span className={styles.actions}>
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title="Neuer Unterordner">
<FaPlus />
</button>
)}
{onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)}
</span>
</div>
{isExpanded && hasChildren && (
<div className={styles.children}>
{node.children!.map((child) => (
<_TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
onToggle={onToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
/>
))}
{folderFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
)}
</div>
);
}
/* ── Root component ────────────────────────────────────────────────────── */
export default function FolderTree({
folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect,
selectedItemIds: externalSelectedIds, onSelectionChange,
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh,
}: FolderTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(null);
const expandedIds = externalExpandedIds ?? internalExpandedIds;
const tree = useMemo(() => _buildTree(folders), [folders]);
const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]);
const rootFiles = showFiles ? (filesByFolder.get('') || []) : [];
const selectedItemIds = externalSelectedIds ?? internalSelectedIds;
const flatList = useMemo(
() => _computeFlatList(tree, expandedIds, showFiles, filesByFolder),
[tree, expandedIds, showFiles, filesByFolder],
);
const _handleToggle = useCallback((id: string) => {
if (onToggleExpand) {
onToggleExpand(id);
return;
}
setInternalExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const _setSelection = useCallback((ids: Set<string>) => {
if (onSelectionChange) {
onSelectionChange(ids);
} else {
setInternalSelectedIds(ids);
}
}, [onSelectionChange]);
const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
const next = new Set(selectedItemIds);
if (next.has(id)) next.delete(id); else next.add(id);
_setSelection(next);
lastClickedIdRef.current = id;
return;
}
if (e.shiftKey && lastClickedIdRef.current) {
const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current);
const currIdx = flatList.findIndex(i => i.id === id);
if (lastIdx >= 0 && currIdx >= 0) {
const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
const next = new Set(selectedItemIds);
for (let i = from; i <= to; i++) next.add(flatList[i].id);
_setSelection(next);
}
return;
}
_setSelection(new Set([id]));
lastClickedIdRef.current = id;
if (type === 'folder') onSelect(id);
if (type === 'file') onFileSelect?.(id);
}, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]);
const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => {
const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1;
if (isInSelection) {
const items: TreeItem[] = [];
for (const selId of selectedItemIds) {
const item = flatList.find(i => i.id === selId);
if (item) items.push(item);
}
e.dataTransfer.setData('application/tree-items', JSON.stringify(items));
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
if (fileIds.length > 0) {
e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds));
}
} else {
e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }]));
if (type === 'file') {
e.dataTransfer.setData('application/file-id', id);
} else {
e.dataTransfer.setData('application/folder-id', id);
}
}
e.dataTransfer.effectAllowed = 'copyMove';
}, [selectedItemIds, flatList]);
const allFileIds = useMemo(() => {
const ids = new Set<string>();
for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id);
return ids;
}, [filesByFolder]);
const allFolderIds = useMemo(() => {
const ids = new Set<string>();
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
_collect(tree);
return ids;
}, [tree]);
const sel: SelectionCtx = useMemo(() => {
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
return {
selectedItemIds,
selectedFileIds: selFileIds,
selectedFolderIds: selFolderIds,
onItemClick: _handleItemClick,
onItemDragStart: _handleItemDragStart,
onRenameFile,
onDeleteFile,
onDeleteFiles,
onDeleteFolders,
};
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]);
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setRootDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) {
await onMoveFolders(folderIds, null);
} else if (onMoveFolder) {
for (const fId of folderIds) await onMoveFolder(fId, null);
}
if (fileIds.length > 0 && onMoveFiles) {
await onMoveFiles(fileIds, null);
} else if (fileIds.length > 0 && onMoveFile) {
for (const fId of fileIds) await onMoveFile(fId, null);
}
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && onMoveFolder) {
await onMoveFolder(folderId, null);
} else if (fileIdsJson && onMoveFiles) {
await onMoveFiles(JSON.parse(fileIdsJson), null);
} else if (fileId && onMoveFile) {
await onMoveFile(fileId, null);
}
}, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const rootClasses = [
styles.treeNode,
selectedFolderId === null ? styles.selected : '',
rootDropOver ? styles.dropTarget : '',
].filter(Boolean).join(' ');
return (
<div className={styles.folderTree}>
<div
className={rootClasses}
onClick={() => { onSelect(null); _setSelection(new Set()); }}
onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }}
onDragLeave={() => setRootDropOver(false)}
onDrop={_handleRootDrop}
>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>(Global)</span>
<span className={styles.rootActions}>
{onRefresh && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title="Aktualisieren">
<FaSyncAlt />
</button>
)}
{onCreateFolder && (
<button
className={styles.actionBtn}
onClick={async (e) => {
e.stopPropagation();
const name = prompt('Neuer Ordnername:');
if (name?.trim()) await onCreateFolder(name.trim(), null);
}}
title="Neuer Ordner"
>
<FaPlus />
</button>
)}
</span>
</div>
<div className={styles.children}>
{tree.map((node) => (
<_TreeNode
key={node.id}
node={node}
depth={1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
onToggle={_handleToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
/>
))}
{rootFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
</div>
);
}

View file

@ -171,6 +171,8 @@ export interface FormGeneratorTableProps<T = any> {
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
}
export function FormGeneratorTable<T extends Record<string, any>>({
@ -208,7 +210,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
groupRenderer: _groupRenderer,
groupRowData,
groupDefaultExpanded = true,
groupActions
groupActions,
rowDraggable = false,
onRowDragStart,
}: FormGeneratorTableProps<T>) {
const { t, currentLanguage: contextLanguage } = useLanguage();
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
@ -282,7 +286,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Track if we've loaded from localStorage for this storage key
const loadedStorageKeyRef = useRef<string | null>(null);
// Check if backend pagination is supported (hookData has refetch that accepts params)
// Check if backend pagination is supported (hookData has refetch that accepts params).
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
// Debounce search term for backend calls
@ -1971,6 +1975,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
key={`${groupKey}-row-${rowIndex}`}
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, globalIndex)}
draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
{...Object.fromEntries(
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
)}
@ -2084,6 +2090,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
key={index}
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, index)}
draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
{...Object.fromEntries(
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
)}

View file

@ -46,7 +46,7 @@
display: inline-block;
}
/* Trigger Button - matches iconButton style from PlaygroundPage */
/* Trigger Button */
.triggerButton {
display: flex;
align-items: center;

View file

@ -2,7 +2,7 @@
* ProviderSelector Component
*
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im Chat Playground und Automation Editor verwendet werden.
* Kann im AI Workspace und Automation Editor verwendet werden.
*
* Features:
* - Dropdown für Einzelauswahl

View file

@ -110,8 +110,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.trustee': <FaBriefcase />,
'feature.realestate': <FaBuilding />,
'feature.chatworkflow': <FaPlay />,
'feature.chatplayground': <FaPlay />,
'feature.codeeditor': <FaFileAlt />,
'feature.automation': <FaCogs />,
'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />,
@ -119,6 +117,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
// Feature pages - Workspace
'page.feature.workspace.dashboard': <FaPlay />,
'page.feature.workspace.editor': <FaPlay />,
'feature.workspace': <FaPlay />,
};

View file

@ -1,5 +1,9 @@
import React, { createContext, useContext, useCallback } from 'react';
import React, { createContext, useContext, useCallback, useState, useEffect } from 'react';
import api from '../api';
import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles';
import type { FolderInfo } from '../api/fileApi';
export type { FolderInfo };
interface FileContextType {
files: UserFile[];
@ -14,6 +18,18 @@ interface FileContextType {
deletingFiles: Set<string>;
previewingFiles: Set<string>;
downloadingFiles: Set<string>;
folders: FolderInfo[];
foldersLoading: boolean;
refreshFolders: () => Promise<void>;
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
handleDeleteFolder: (folderId: string) => Promise<void>;
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
}
export const FileContext = createContext<FileContextType | undefined>(undefined);
@ -31,45 +47,102 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
downloadingFiles
} = useFileOperations();
// Centralized file upload that updates the shared state
useEffect(() => { refetchFiles(); }, []);
// ── Folder expanded state (persisted in localStorage) ───────────────────
const _STORAGE_KEY = 'folderTree-expandedIds';
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem(_STORAGE_KEY);
return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>();
} catch { return new Set<string>(); }
});
const toggleFolderExpanded = useCallback((id: string) => {
setExpandedFolderIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
try { localStorage.setItem(_STORAGE_KEY, JSON.stringify([...next])); } catch {}
return next;
});
}, []);
// ── Folder state (single source of truth) ──────────────────────────────
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false);
const refreshFolders = useCallback(async () => {
setFoldersLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setFoldersLoading(false);
}
}, []);
useEffect(() => { refreshFolders(); }, [refreshFolders]);
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refreshFolders();
}, [refreshFolders]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refreshFolders();
}, [refreshFolders]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
await refreshFolders();
await refetchFiles();
}, [refreshFolders, refetchFiles]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refreshFolders();
}, [refreshFolders]);
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
await refetchFiles();
}, [refetchFiles]);
const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await api.post('/api/files/batch-move', { fileIds, targetFolderId });
await refetchFiles();
}, [refetchFiles]);
const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
await api.post('/api/files/batch-move', { folderIds, targetParentId });
await refreshFolders();
}, [refreshFolders]);
// ── File operations ────────────────────────────────────────────────────
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
const result = await hookHandleFileUpload(file, workflowId);
if (result.success && result.fileData) {
// The API response structure: { message, file: FileInfo, ... }
// The file data is nested in the 'file' property
const responseData = result.fileData;
const fileData = responseData.file || responseData; // Support both nested and direct structure
if (!fileData || !fileData.id) {
console.error('File upload response missing file data:', responseData);
return result;
}
// File will be added via refetch
// Refetch to ensure we have the latest data (this will update all consumers)
await refetchFiles();
}
return result;
}, [hookHandleFileUpload, refetchFiles]);
// Centralized file delete that updates the shared state
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
const success = await hookHandleFileDelete(fileId, () => {
removeFileOptimistically(fileId);
onOptimisticDelete?.();
});
if (success) {
// Refetch to ensure we have the latest data
await refetchFiles();
}
return success;
}, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]);
// Expose refetch function
const refetch = useCallback(async () => {
await refetchFiles();
}, [refetchFiles]);
@ -86,12 +159,23 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
handleFileDownload: async (fileId: string, fileName: string) => {
await handleFileDownload(fileId, fileName);
// Return void (ignore boolean return value)
},
uploadingFile,
deletingFiles,
previewingFiles,
downloadingFiles
downloadingFiles,
folders,
foldersLoading,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFile,
handleMoveFiles,
handleMoveFolders,
expandedFolderIds,
toggleFolderExpanded,
}}
>
{children}
@ -106,4 +190,3 @@ export function useFileContext() {
}
return context;
}

View file

@ -1,62 +0,0 @@
import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes';
import type { WorkflowMessage, WorkflowLog } from '../../api/workflowApi';
export const sortMessages = (a: WorkflowMessage, b: WorkflowMessage) => {
if (a.publishedAt !== undefined && b.publishedAt !== undefined) {
return a.publishedAt - b.publishedAt;
}
if (a.publishedAt !== undefined) return -1;
if (b.publishedAt !== undefined) return 1;
if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) {
return a.sequenceNr - b.sequenceNr;
}
return 0;
};
export const sortLogs = (a: WorkflowLog, b: WorkflowLog) => {
if (a.timestamp !== undefined && b.timestamp !== undefined) {
return a.timestamp - b.timestamp;
}
if (a.publishedAt !== undefined && b.publishedAt !== undefined) {
return a.publishedAt - b.publishedAt;
}
if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) {
return a.sequenceNr - b.sequenceNr;
}
return 0;
};
export const extractFileIdsFromMessage = (message: WorkflowMessage): Set<string> => {
const fileIds = new Set<string>();
const documents = (message as any).documents as MessageDocument[] | undefined;
const files = (message as any).files as any[] | undefined;
if (documents && Array.isArray(documents)) {
documents.forEach((doc: MessageDocument) => {
if (doc.fileId) fileIds.add(doc.fileId);
});
}
if (files && Array.isArray(files)) {
files.forEach((file: any) => {
const fileId = file.id || file.fileId;
if (fileId) fileIds.add(fileId);
});
}
return fileIds;
};
export const convertFilesToDocuments = (files: any[], messageId: string): MessageDocument[] => {
return files.map((file: any) => ({
id: file.id || file.fileId || file.file_id,
fileId: file.id || file.fileId || file.file_id,
fileName: file.fileName || file.name || file.file_name || 'Unknown File',
fileSize: file.fileSize || file.size || 0,
mimeType: file.mimeType || file.mime_type || 'application/octet-stream',
messageId,
roundNumber: 0,
taskNumber: 0,
actionNumber: 0,
actionId: ''
}));
};

View file

@ -1,849 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useApiRequest } from '../useApi';
import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext';
import { useFileContext } from '../../contexts/FileContext';
import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes';
import { usePrompts } from '../usePrompts';
import { usePermissions } from '../usePermissions';
import { deleteFileFromMessageApi, deleteMessageApi } from '../../api/workflowApi';
import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
import { useWorkflows } from './useWorkflows';
import { useDashboardLogTree } from './useDashboardLogTree';
import { convertFilesToDocuments, sortMessages } from './playgroundUtils';
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
export interface WorkflowFile {
id: string;
fileId: string;
fileName: string;
fileSize: number;
mimeType: string;
messageId?: string;
source?: 'user_uploaded' | 'ai_created';
}
export function useDashboardInputForm(instanceId: string) {
const [inputValue, setInputValue] = useState<string>('');
const [pendingFiles, setPendingFiles] = useState<WorkflowFile[]>([]);
const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false);
const [optimisticMessage, setOptimisticMessage] = useState<WorkflowMessage | null>(null);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect)
const [deletedDocumentFileIds, setDeletedDocumentFileIds] = useState<Set<string>>(new Set());
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(new Set());
const [deletingMessages, setDeletingMessages] = useState<Set<string>>(new Set());
const { checkPermission } = usePermissions();
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(true);
const [chatWorkflowPermission, setChatWorkflowPermission] = useState<any>(null);
const [promptPermission, setPromptPermission] = useState<any>(null);
const [filePermission, setFilePermission] = useState<any>(null);
const { selectedWorkflowId, selectWorkflow: selectWorkflowFromContext, clearWorkflow: clearWorkflowFromContext } = useWorkflowSelection();
const {
workflowId,
workflowStatus,
currentRound,
isRunning,
isStopping,
startingWorkflow,
messages,
dashboardLogs,
unifiedContentLogs,
latestStats,
startWorkflow,
stopWorkflow,
resetWorkflow,
selectWorkflow,
setWorkflowStatusOptimistic
} = useWorkflowLifecycle(instanceId);
// Dashboard log tree hook
const {
tree: dashboardTree,
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
toggleRoundExpanded,
updateCurrentRound,
getChildOperations
} = useDashboardLogTree();
// Ref to prevent infinite sync loops
const isSyncingRef = useRef(false);
const fileContext = useFileContext();
const { request } = useApiRequest();
const { prompts, loading: promptsLoading, permissions: promptsPermissions, fetchPromptById } = usePrompts();
useEffect(() => {
if (promptsPermissions) {
setPromptPermission(promptsPermissions);
}
}, [promptsPermissions]);
useEffect(() => {
const checkPermissions = async () => {
try {
// UI permission is already verified by the navigation/routing layer
// (FeatureAccess + instance role checked before page is reachable).
// We set it to true and load DATA permissions directly.
setPlaygroundUIPermission(true);
const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow');
setChatWorkflowPermission(chatWorkflowPerm);
const promptPerm = await checkPermission('DATA', 'Prompt');
setPromptPermission(promptPerm);
const filePerm = await checkPermission('DATA', 'FileItem');
setFilePermission(filePerm);
} catch (error) {
}
};
checkPermissions();
}, [checkPermission]);
// Sync context -> lifecycle: When context selection changes, update lifecycle
useEffect(() => {
if (isSyncingRef.current) return;
if (selectedWorkflowId && selectedWorkflowId !== workflowId) {
isSyncingRef.current = true;
selectWorkflow(selectedWorkflowId).finally(() => {
isSyncingRef.current = false;
});
} else if (!selectedWorkflowId && workflowId) {
// If context is cleared but lifecycle still has a workflow, reset lifecycle
isSyncingRef.current = true;
resetWorkflow();
isSyncingRef.current = false;
}
}, [selectedWorkflowId, workflowId, selectWorkflow, resetWorkflow]);
// Sync lifecycle -> context: When lifecycle workflowId changes, update context
useEffect(() => {
if (isSyncingRef.current) return;
if (workflowId && workflowId !== selectedWorkflowId) {
isSyncingRef.current = true;
selectWorkflowFromContext(workflowId);
isSyncingRef.current = false;
} else if (!workflowId && selectedWorkflowId) {
// If lifecycle is cleared but context still has selection, clear context
isSyncingRef.current = true;
clearWorkflowFromContext();
isSyncingRef.current = false;
}
}, [workflowId, selectedWorkflowId, selectWorkflowFromContext, clearWorkflowFromContext]);
useEffect(() => {
const handleSetInput = (event: CustomEvent<{ value: string }>) => {
const newValue = event.detail.value;
if (newValue && typeof newValue === 'string') {
setInputValue(newValue);
}
};
window.addEventListener('dashboardSetInput', handleSetInput as EventListener);
return () => {
window.removeEventListener('dashboardSetInput', handleSetInput as EventListener);
};
}, []);
const { workflows, loading: workflowsLoading, refetch: refetchWorkflows } = useWorkflows(instanceId);
// Track processed log IDs to avoid reprocessing
const processedLogIdsRef = useRef<Set<string>>(new Set());
const lastWorkflowIdRef = useRef<string | null>(null);
const lastDashboardLogsLengthRef = useRef<number>(0);
// Clear processed logs when workflow changes
useEffect(() => {
if (workflowId !== lastWorkflowIdRef.current) {
processedLogIdsRef.current.clear();
lastWorkflowIdRef.current = workflowId || null;
lastDashboardLogsLengthRef.current = 0;
if (!workflowId) {
clearDashboard(true);
}
}
}, [workflowId, clearDashboard]);
// Process dashboard logs when they change (only new logs)
useEffect(() => {
if (!dashboardLogs || dashboardLogs.length === 0) {
lastDashboardLogsLengthRef.current = 0;
return;
}
// Only process if the array length changed (indicating new logs)
if (dashboardLogs.length === lastDashboardLogsLengthRef.current) {
return;
}
// Filter to only new logs that haven't been processed
const newLogs = dashboardLogs.filter(log => {
const logId = log.id || `${log.operationId}-${log.timestamp}`;
if (processedLogIdsRef.current.has(logId)) {
return false;
}
processedLogIdsRef.current.add(logId);
return true;
});
// Only process if there are new logs
if (newLogs.length > 0) {
// Convert API WorkflowLog format to LogTypes WorkflowLog format
const convertedLogs: LogTypesWorkflowLog[] = newLogs.map(log => ({
id: log.id || `${log.operationId || 'unknown'}-${log.timestamp || Date.now()}`,
workflowId: log.workflowId || '',
message: log.message || '',
type: log.type,
timestamp: log.timestamp || Date.now(),
status: log.status,
progress: log.progress,
performance: log.performance,
parentId: log.parentId,
operationId: log.operationId
}));
processDashboardLogs(convertedLogs);
}
lastDashboardLogsLengthRef.current = dashboardLogs.length;
}, [dashboardLogs, processDashboardLogs]);
// Update current round in dashboard tree when it changes
useEffect(() => {
if (currentRound !== undefined) {
updateCurrentRound(currentRound);
}
}, [currentRound, updateCurrentRound]);
const workflowFiles = useMemo(() => {
const fileMap = new Map<string, WorkflowFile>();
const pendingFileIds = new Set(pendingFiles.map(f => f.fileId));
const addFilesFromMessage = (message: WorkflowMessage, messageId: string) => {
const documents = (message as any).documents as MessageDocument[] | undefined;
const files = (message as any).files as any[] | undefined;
if (documents && Array.isArray(documents)) {
documents.forEach((doc: MessageDocument) => {
if (!doc.fileId || doc.fileId.trim() === '') return;
if (!fileMap.has(doc.fileId)) {
const source = pendingFileIds.has(doc.fileId) ? 'user_uploaded' : 'ai_created';
fileMap.set(doc.fileId, {
id: doc.id || doc.fileId,
fileId: doc.fileId,
fileName: doc.fileName || 'Unknown File',
fileSize: doc.fileSize || 0,
mimeType: doc.mimeType || 'application/octet-stream',
messageId: doc.messageId || messageId,
source
});
}
});
}
if (files && Array.isArray(files)) {
files.forEach((file: any) => {
const fileId = file.id || file.fileId;
if (!fileId || fileId.trim() === '') return;
if (!fileMap.has(fileId)) {
const source = pendingFileIds.has(fileId) ? 'user_uploaded' : 'ai_created';
fileMap.set(fileId, {
id: fileId,
fileId: fileId,
fileName: file.fileName || file.name || 'Unknown File',
fileSize: file.fileSize || file.size || 0,
mimeType: file.mimeType || file.mime_type || 'application/octet-stream',
messageId: messageId,
source
});
}
});
}
};
if (messages && messages.length > 0) {
messages.forEach((message: WorkflowMessage) => {
addFilesFromMessage(message, message.id);
});
}
if (optimisticMessage) {
addFilesFromMessage(optimisticMessage, optimisticMessage.id || 'optimistic');
}
return Array.from(fileMap.values());
}, [messages, pendingFiles, optimisticMessage]);
useEffect(() => {
if (!messages || messages.length === 0) return;
if (!optimisticMessage) return;
// Clear optimistic message when backend's "first" user message arrives via polling.
// The backend message contains the normalizedRequest (which differs from the original prompt),
// so we match by status="first" instead of content comparison.
const hasFirstMessage = messages.some((msg: WorkflowMessage) =>
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
);
if (hasFirstMessage) {
setOptimisticMessage(null);
}
}, [messages, optimisticMessage]);
const displayMessages = useMemo(() => {
const processedMessages = (messages || [])
// Filter out locally deleted messages
.filter((message: WorkflowMessage) => !deletedMessageIds.has(message.id))
.map((message: WorkflowMessage) => {
const files = (message as any).files as any[] | undefined;
const documents = (message as any).documents as MessageDocument[] | undefined;
let processedDocs = documents;
if (files && Array.isArray(files) && (!documents || documents.length === 0)) {
processedDocs = convertFilesToDocuments(files, message.id);
}
// Filter out locally deleted documents
if (processedDocs && deletedDocumentFileIds.size > 0) {
processedDocs = processedDocs.filter(doc => !deletedDocumentFileIds.has(doc.fileId));
}
return {
...message,
documents: processedDocs
};
});
// If optimistic message is still active (backend "first" message not yet polled),
// show the optimistic message instead of any backend user messages to avoid duplicates.
const allMessages = [...processedMessages];
if (optimisticMessage) {
// Find backend "first" user message to inherit its timestamp for correct ordering
const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) =>
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
);
if (!firstBackendMsg) {
// Backend "first" message not yet arrived - show optimistic message
allMessages.push({ ...optimisticMessage, documents: (optimisticMessage as any).documents });
}
// If firstBackendMsg exists, the useEffect above will clear optimistic on next render
}
return allMessages.sort(sortMessages);
}, [messages, optimisticMessage, workflowId, deletedDocumentFileIds, deletedMessageIds]);
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
const result = await fileContext.handleFileUpload(file, workflowId || undefined);
if (result.success && result.fileData) {
const responseData = result.fileData;
const fileData = responseData.file || responseData;
const fileId = fileData?.id;
if (fileId) {
const newFile: WorkflowFile = {
id: fileId,
fileId: fileId,
fileName: fileData.fileName || file.name,
fileSize: fileData.fileSize || file.size,
mimeType: fileData.mimeType || file.type || 'application/octet-stream',
source: 'user_uploaded'
};
setPendingFiles(prev => {
if (prev.some(f => f.fileId === fileId)) {
return prev;
}
return [...prev, newFile];
});
}
}
return {
success: result.success || false,
data: result.fileData || null
};
}, [workflowId, fileContext]);
const handleFileAttach = useCallback(async (fileId: string): Promise<void> => {
const isInPending = pendingFiles.some(f => f.fileId === fileId);
if (isInPending) {
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
} else {
let workflowFile: WorkflowFile | null = null;
const userFile = fileContext.files.find(f => f.id === fileId);
if (userFile) {
workflowFile = {
id: userFile.id,
fileId: userFile.id,
fileName: userFile.file_name,
fileSize: userFile.size || 0,
mimeType: userFile.mime_type || 'application/octet-stream',
source: 'user_uploaded'
};
} else {
const existingWorkflowFile = workflowFiles.find(f => f.fileId === fileId);
if (existingWorkflowFile) {
workflowFile = {
...existingWorkflowFile,
id: existingWorkflowFile.id || existingWorkflowFile.fileId,
fileId: existingWorkflowFile.fileId,
fileName: existingWorkflowFile.fileName || 'Unknown File',
fileSize: existingWorkflowFile.fileSize || 0,
mimeType: existingWorkflowFile.mimeType || 'application/octet-stream',
source: existingWorkflowFile.source || 'user_uploaded'
};
}
}
if (workflowFile) {
setPendingFiles(prev => {
if (prev.some(f => f.fileId === fileId)) {
return prev;
}
return [...prev, workflowFile!];
});
}
}
}, [pendingFiles, fileContext.files, workflowFiles]);
const handleFileUploadAndAttach = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
return await handleFileUpload(file);
}, [handleFileUpload]);
const handleFileRemove = useCallback(async (file: WorkflowFile) => {
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
}, []);
const handleFileDelete = useCallback(async (file: WorkflowFile) => {
if (!file.fileId) return;
// Immediately remove document from UI for instant feedback
setDeletedDocumentFileIds(prev => new Set([...prev, file.fileId]));
if (workflowId && file.messageId) {
// Document in a message: only remove the ChatDocument reference, keep the file itself
try {
await deleteFileFromMessageApi(request, workflowId, file.messageId, file.fileId);
} catch (error) {
// Restore document in UI on failure
setDeletedDocumentFileIds(prev => {
const next = new Set(prev);
next.delete(file.fileId);
return next;
});
}
} else {
// Standalone file (pending file not yet in a message): delete the actual file
const success = await fileContext.handleFileDelete(file.fileId, () => {
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
});
if (success) {
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
} else {
// Restore document in UI on failure
setDeletedDocumentFileIds(prev => {
const next = new Set(prev);
next.delete(file.fileId);
return next;
});
}
}
}, [workflowId, fileContext, request]);
// handleFileView is a no-op because ViewActionButton's ContentPreview handles the preview internally
const handleFileView = useCallback(async (_file: WorkflowFile) => {
// The ViewActionButton component handles the preview via ContentPreview
// No additional action needed here
}, []);
const handleFileDownload = useCallback(async (file: WorkflowFile) => {
if (!file.fileId) return;
await fileContext.handleFileDownload(file.fileId, file.fileName);
}, [fileContext]);
const handleMessageDelete = useCallback(async (messageId: string) => {
if (!workflowId || !messageId) return;
// Immediately remove message from UI for instant feedback
setDeletedMessageIds(prev => new Set([...prev, messageId]));
setDeletingMessages(prev => new Set([...prev, messageId]));
try {
await deleteMessageApi(request, workflowId, messageId);
} catch (error: any) {
// Restore message in UI on failure
setDeletedMessageIds(prev => {
const next = new Set(prev);
next.delete(messageId);
return next;
});
console.error('Failed to delete message:', error);
} finally {
setDeletingMessages(prev => {
const next = new Set(prev);
next.delete(messageId);
return next;
});
}
}, [workflowId, request]);
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
// Separate stop handler - only stops the workflow without sending new input
const handleStop = useCallback(async () => {
if (!workflowId) return { success: false, error: 'No workflow to stop' };
try {
const result = await stopWorkflow();
return result;
} catch (error: any) {
return { success: false, error: error.message || 'Failed to stop workflow' };
}
}, [workflowId, stopWorkflow]);
const handleSubmit = useCallback(async () => {
const trimmedInput = inputValue.trim();
// If running and no new input, just stop
if (isRunning && workflowId && !trimmedInput) {
try {
await stopWorkflow();
} catch (error) {
// Ignore stop errors
}
return;
}
// If running with new input, stop first then continue with new input
if (isRunning && workflowId && trimmedInput) {
try {
// Stop the current workflow
await stopWorkflow();
// Continue below to send new input
} catch (error) {
// Ignore stop errors, try to continue anyway
}
}
// No input and not running = nothing to do
if (!trimmedInput || startingWorkflow) {
return;
}
if (!trimmedInput || startingWorkflow) {
return;
}
try {
const filesToSend = pendingFiles.filter(file => file.fileId);
const fileIdsToSend = filesToSend.map(f => f.fileId).filter((id): id is string => !!id);
const sentFileIdsSet = new Set(fileIdsToSend);
// Optimistically render user message immediately
const optimisticMsg: WorkflowMessage = {
id: `optimistic-${Date.now()}`,
workflowId: workflowId || '',
message: trimmedInput,
role: 'user',
publishedAt: Date.now(),
documents: filesToSend.map(file => ({
id: file.id || file.fileId,
fileId: file.fileId,
fileName: file.fileName,
fileSize: file.fileSize,
mimeType: file.mimeType,
messageId: `optimistic-${Date.now()}`,
roundNumber: 0,
taskNumber: 0,
actionNumber: 0,
actionId: ''
}))
};
setOptimisticMessage(optimisticMsg);
// Optimistically update workflow status to 'running' immediately
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('running');
}
setPendingFiles(prev => prev.filter(file =>
!file.fileId || !sentFileIdsSet.has(file.fileId)
));
if (!chatWorkflowPermission || chatWorkflowPermission.create === 'n') {
setOptimisticMessage(null);
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('idle');
}
return;
}
const selectedMode = workflowMode || 'Dynamic';
const apiWorkflowMode: 'Dynamic' | 'Automation' = selectedMode;
const workflowOptions: { workflowId?: string; workflowMode: 'Dynamic' | 'Automation' } = {
workflowMode: apiWorkflowMode
};
if (workflowId) {
workflowOptions.workflowId = workflowId;
}
const requestBody = {
prompt: trimmedInput,
listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined,
userLanguage: 'en',
allowedProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider filter (multiselect)
};
// Debug: Log provider selection
console.log('🤖 Provider selection:', { selectedProviders, sentProviders: requestBody.allowedProviders });
const result = await startWorkflow(requestBody, workflowOptions);
if (result.success) {
setInputValue('');
const wasNewWorkflow = !workflowId;
if (wasNewWorkflow && result.data) {
const workflow = result.data as Workflow;
// Dispatch event first to trigger refetch in useWorkflows
window.dispatchEvent(new CustomEvent('workflowCreated', {
detail: { workflow }
}));
// Refetch workflows list to ensure dropdown is updated
await refetchWorkflows();
// Update context first (this will trigger the sync effect to update lifecycle)
selectWorkflowFromContext(workflow.id);
// Also directly update lifecycle to ensure immediate state update
await selectWorkflow(workflow.id);
} else if (workflowId) {
// For resumed workflows, ensure context is synced and update lifecycle
selectWorkflowFromContext(workflowId);
await selectWorkflow(workflowId);
}
} else {
setOptimisticMessage(null);
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('idle');
}
}
} catch (error) {
setOptimisticMessage(null);
if (setWorkflowStatusOptimistic) {
setWorkflowStatusOptimistic('idle');
}
}
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProviders, setWorkflowStatusOptimistic]);
useEffect(() => {
const handleWorkflowCleared = () => {
// Reset all workflow-related state
setPendingFiles([]);
setOptimisticMessage(null);
// Reset workflow lifecycle state
resetWorkflow();
// NOTE: Do NOT call clearWorkflowFromContext() here — this handler is
// triggered BY clearWorkflow() which already set the context to null.
// Calling it again would dispatch another 'workflowCleared' event → infinite recursion.
};
window.addEventListener('workflowCleared', handleWorkflowCleared);
return () => {
window.removeEventListener('workflowCleared', handleWorkflowCleared);
};
}, [resetWorkflow]);
const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) {
clearWorkflowFromContext();
resetWorkflow();
setPendingFiles([]);
setOptimisticMessage(null);
return;
}
const workflowIdToSelect = typeof item.id === 'string' ? item.id : String(item.id);
selectWorkflowFromContext(workflowIdToSelect);
if (selectWorkflow) {
await selectWorkflow(workflowIdToSelect);
}
}, [selectWorkflow, resetWorkflow, selectWorkflowFromContext, clearWorkflowFromContext]);
const handlePromptSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) {
setSelectedPromptId(null);
return;
}
const promptId = typeof item.id === 'string' ? item.id : String(item.id);
if (!promptPermission || promptPermission.read === 'n') {
return;
}
try {
const prompt = await fetchPromptById(promptId);
if (prompt && prompt.content) {
setSelectedPromptId(promptId);
setInputValue(prompt.content);
}
} catch (error: any) {
}
}, [fetchPromptById, promptPermission]);
const handleWorkflowModeSelect = useCallback((item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
if (item === null) {
setWorkflowMode(null);
return;
}
const modeValue = item.value || item.id;
const modeString = typeof modeValue === 'string' ? modeValue : String(modeValue);
if (modeString === 'Dynamic' || modeString === 'Automation') {
const mode = modeString as 'Dynamic' | 'Automation';
setWorkflowMode(mode);
}
}, []);
const workflowItems = useMemo(() => {
console.log('🔄 useDashboardInputForm: Computing workflowItems from workflows:', workflows);
if (!workflows || !Array.isArray(workflows)) {
console.warn('⚠️ useDashboardInputForm: workflows is not an array:', workflows);
return [];
}
if (workflows.length === 0) {
console.log(' useDashboardInputForm: workflows array is empty');
return [];
}
const items = workflows.map(workflow => ({
id: workflow.id,
label: workflow.name || workflow.id,
value: workflow,
metadata: {
status: workflow.status,
workflowMode: workflow.workflowMode
}
}));
console.log(`✅ useDashboardInputForm: Created ${items.length} workflow items:`, items);
return items;
}, [workflows]);
const promptItems = useMemo(() => {
if (!promptPermission || promptPermission.view === false || promptPermission.read === 'n') {
return [];
}
return prompts.map(prompt => ({
id: prompt.id,
label: prompt.name || prompt.id,
value: prompt,
metadata: {
content: prompt.content
}
}));
}, [prompts, promptPermission]);
const workflowModeItems = useMemo(() => [
{
id: 'Automation',
label: 'Automation',
value: 'Automation' as const,
metadata: {
description: 'Automated workflow processing'
}
},
{
id: 'Dynamic',
label: 'Dynamic',
value: 'Dynamic' as const,
metadata: {
description: 'Iterative dynamic-style processing'
}
}
], []);
return {
data: [],
loading: false,
error: null,
inputValue,
onInputChange,
handleSubmit,
handleStop,
isSubmitting: startingWorkflow || isStopping,
isStopping,
workflowId: workflowId || undefined,
workflowStatus,
currentRound,
isRunning,
messages: displayMessages || [],
logs: unifiedContentLogs || [], // Unified content logs (without operationId)
dashboardTree, // Dashboard log tree (logs with operationId)
onToggleOperationExpanded: toggleOperationExpanded,
onToggleRoundExpanded: toggleRoundExpanded,
getChildOperations,
workflowItems,
selectedWorkflowId: workflowId || selectedWorkflowId || null,
onWorkflowSelect: handleWorkflowSelect,
workflowsLoading,
promptItems,
selectedPromptId,
onPromptSelect: handlePromptSelect,
promptsLoading,
promptPermission,
workflowModeItems,
selectedWorkflowMode: workflowMode,
onWorkflowModeSelect: handleWorkflowModeSelect,
playgroundUIPermission,
chatWorkflowPermission,
filePermission,
workflowFiles,
pendingFiles,
handleFileUpload,
handleFileDelete,
handleFileRemove,
handleFileView,
uploadingFile: fileContext.uploadingFile,
deletingFiles: fileContext.deletingFiles,
previewingFiles: fileContext.previewingFiles,
downloadingFiles: fileContext.downloadingFiles,
handleFileDownload,
handleMessageDelete,
deletingMessages,
isFileAttachmentPopupOpen,
setIsFileAttachmentPopupOpen,
allUserFiles: fileContext.files || [],
handleFileAttach,
handleFileUploadAndAttach,
latestStats,
// AI Provider selection (multiselect)
selectedProviders,
onProvidersChange: setSelectedProviders
};
}
export function createDashboardHook(instanceId: string) {
return () => useDashboardInputForm(instanceId);
}

View file

@ -1,354 +0,0 @@
import { useState, useCallback, useRef } from 'react';
import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes';
interface OperationData {
logs: Map<string, WorkflowLog>;
parentId: string | null;
expanded: boolean;
latestProgress: number | null;
latestStatus: string | null;
operationName: string | null; // Stable name from first log
latestMessage: string | null; // Latest status message that updates
roundNumber: number | null; // Track which round this operation belongs to
}
interface RoundData {
operations: Map<string, OperationData>;
rootOperations: string[];
expanded: boolean;
isCompleted: boolean;
}
interface DashboardLogTree {
operations: Map<string, OperationData>;
rootOperations: string[];
logExpandedStates: Map<string, boolean>;
currentRound: number | null;
rounds: Map<number, RoundData>;
}
export function useDashboardLogTree() {
const [tree, setTree] = useState<DashboardLogTree>({
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
currentRound: null,
rounds: new Map()
});
const treeRef = useRef<DashboardLogTree>(tree);
treeRef.current = tree;
const generateLogId = useCallback((log: WorkflowLog): string => {
if (log.id) {
return log.id;
}
return `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}, []);
const processDashboardLogs = useCallback((logs: WorkflowLog[]) => {
setTree(prevTree => {
const newTree: DashboardLogTree = {
operations: new Map(prevTree.operations),
rootOperations: [...prevTree.rootOperations],
logExpandedStates: new Map(prevTree.logExpandedStates),
currentRound: prevTree.currentRound,
rounds: new Map(prevTree.rounds)
};
// Process each log
logs.forEach(log => {
if (!log.operationId) {
return; // Skip logs without operationId
}
const operationId = log.operationId;
const logId = generateLogId(log);
const logRoundNumber = (log as any).roundNumber as number | null | undefined;
// Update current round tracking
if (logRoundNumber !== null && logRoundNumber !== undefined) {
if (newTree.currentRound === null || logRoundNumber > newTree.currentRound) {
newTree.currentRound = logRoundNumber;
}
}
// Get or create operation
const existingOperation = newTree.operations.get(operationId);
// Create new logs Map (copy existing logs if updating)
const logsMap = existingOperation
? new Map(existingOperation.logs)
: new Map();
// Store log (Map ensures uniqueness by logId)
logsMap.set(logId, log);
// Determine stable operation name (only set once, never change)
// Always use formatted operationId as the stable name - don't use log messages
// Log messages are status updates and should go in latestMessage, not operationName
let operationName = existingOperation?.operationName || null;
if (operationName === null) {
// Remove UUIDs and timestamps from operationId before formatting
// UUID pattern: 8-4-4-4-12 hex digits (e.g., "1e6d7b14-4f30-40e2-b7a6-748b63b6a7f5")
// Also remove standalone long hex strings that might be timestamps or IDs
let cleanedId = operationId
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') // Remove UUIDs
.replace(/\b[0-9a-f]{32,}\b/gi, '') // Remove long hex strings (timestamps/IDs)
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
// Format by splitting on dashes/underscores and capitalizing
// This creates a stable, readable name like "Workflow Planning" from "workflow-planning"
const formattedName = cleanedId
.split(/[-_\s]+/)
.filter(word => word.length > 0) // Remove empty strings
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
operationName = formattedName || operationId.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '').trim();
}
// Update latest message (for status tag) - this updates with each poll
const latestMessage = log.message || existingOperation?.latestMessage || null;
// Update parentId if not set yet (from first log entry)
const parentId = existingOperation?.parentId !== null && existingOperation?.parentId !== undefined
? existingOperation.parentId
: (log.parentId !== undefined && log.parentId !== null ? log.parentId : null);
// Update latest progress (use latest value)
const latestProgress = log.progress !== undefined && log.progress !== null
? log.progress
: existingOperation?.latestProgress ?? null;
// Update latest status (use latest value)
const latestStatus = log.status !== undefined && log.status !== null
? log.status
: existingOperation?.latestStatus ?? null;
// Get round number for this operation (from log or existing)
const roundNumber = logRoundNumber !== null && logRoundNumber !== undefined
? logRoundNumber
: existingOperation?.roundNumber ?? null;
// Create new operation object to ensure React detects the change
const operation: OperationData = {
logs: logsMap,
parentId,
expanded: existingOperation?.expanded ?? false,
latestProgress,
latestStatus,
operationName,
latestMessage,
roundNumber
};
newTree.operations.set(operationId, operation);
// Add operation to its round
if (roundNumber !== null) {
if (!newTree.rounds.has(roundNumber)) {
newTree.rounds.set(roundNumber, {
operations: new Map(),
rootOperations: [],
expanded: true, // New rounds start expanded
isCompleted: false
});
}
const round = newTree.rounds.get(roundNumber)!;
round.operations.set(operationId, operation);
}
});
// Rebuild root operations list per round
newTree.rounds.forEach((round, roundNumber) => {
const rootOpsSet = new Set<string>();
round.operations.forEach((op, opId) => {
if (op.parentId === null) {
rootOpsSet.add(opId);
} else {
// Check if parent is in a different round - then this is a root in THIS round
const parentOp = newTree.operations.get(op.parentId);
if (!parentOp || parentOp.roundNumber !== roundNumber) {
rootOpsSet.add(opId);
}
}
});
// Sort by timestamp
round.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => {
const opA = round.operations.get(opIdA);
const opB = round.operations.get(opIdB);
if (!opA || !opB) return 0;
const logsA = Array.from(opA.logs.values());
const logsB = Array.from(opB.logs.values());
if (logsA.length === 0 && logsB.length === 0) return 0;
if (logsA.length === 0) return 1;
if (logsB.length === 0) return -1;
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
return earliestA - earliestB;
});
// Update completion status
const allOpsCompleted = Array.from(round.operations.values()).every(op =>
op.latestStatus === 'completed' || op.latestStatus === 'success'
);
round.isCompleted = allOpsCompleted;
// Auto-collapse completed rounds (except current)
if (round.isCompleted && roundNumber !== newTree.currentRound) {
round.expanded = false;
}
});
// Rebuild global root operations list (operations without parentId)
const rootOpsSet = new Set<string>();
newTree.operations.forEach((op, opId) => {
if (op.parentId === null) {
rootOpsSet.add(opId);
}
});
// Sort by timestamp of earliest log entry (chronological order)
newTree.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => {
const opA = newTree.operations.get(opIdA);
const opB = newTree.operations.get(opIdB);
if (!opA || !opB) return 0;
const logsA = Array.from(opA.logs.values());
const logsB = Array.from(opB.logs.values());
if (logsA.length === 0 && logsB.length === 0) return 0;
if (logsA.length === 0) return 1;
if (logsB.length === 0) return -1;
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
return earliestA - earliestB;
});
return newTree;
});
}, [generateLogId]);
const clearDashboard = useCallback((resetRound: boolean = false) => {
setTree({
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
currentRound: resetRound ? null : treeRef.current.currentRound,
rounds: new Map()
});
}, []);
const toggleOperationExpanded = useCallback((operationId: string) => {
setTree(prevTree => {
const operation = prevTree.operations.get(operationId);
if (!operation) {
return prevTree;
}
const newTree: DashboardLogTree = {
...prevTree,
operations: new Map(prevTree.operations)
};
const updatedOperation = {
...operation,
expanded: !operation.expanded
};
newTree.operations.set(operationId, updatedOperation);
return newTree;
});
}, []);
const updateCurrentRound = useCallback((round: number | null) => {
setTree(prevTree => {
// Only update current round, keep all rounds data
// Auto-collapse previous rounds when new round starts
if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) {
const newRounds = new Map(prevTree.rounds);
// Collapse the old current round
const oldRound = newRounds.get(prevTree.currentRound);
if (oldRound) {
newRounds.set(prevTree.currentRound, {
...oldRound,
expanded: false
});
}
return {
...prevTree,
currentRound: round,
rounds: newRounds
};
}
return {
...prevTree,
currentRound: round
};
});
}, []);
const toggleRoundExpanded = useCallback((roundNumber: number) => {
setTree(prevTree => {
const round = prevTree.rounds.get(roundNumber);
if (!round) {
return prevTree;
}
const newRounds = new Map(prevTree.rounds);
newRounds.set(roundNumber, {
...round,
expanded: !round.expanded
});
return {
...prevTree,
rounds: newRounds
};
});
}, []);
const getChildOperations = useCallback((parentId: string | null): string[] => {
const currentTree = treeRef.current;
const childOps = Array.from(currentTree.operations.entries())
.filter(([_, op]) => op.parentId === parentId)
.map(([opId, op]) => ({ opId, op }));
// Sort by timestamp of earliest log entry (chronological order)
return childOps.sort((a, b) => {
const logsA = Array.from(a.op.logs.values());
const logsB = Array.from(b.op.logs.values());
if (logsA.length === 0 && logsB.length === 0) return 0;
if (logsA.length === 0) return 1; // Put operations without logs at the end
if (logsB.length === 0) return -1;
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
return earliestA - earliestB; // Ascending order (oldest first)
}).map(({ opId }) => opId);
}, []);
return {
tree,
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
toggleRoundExpanded,
updateCurrentRound,
getChildOperations
};
}

View file

@ -1,558 +0,0 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useApiRequest } from '../useApi';
import {
type Workflow,
type WorkflowMessage,
type WorkflowLog,
type StartWorkflowRequest,
fetchWorkflow as fetchWorkflowApi,
fetchChatData
} from '../../api/workflowApi';
import { useWorkflowOperations } from './useWorkflowOperations';
import { sortMessages, sortLogs } from './playgroundUtils';
import { useWorkflowPolling } from './useWorkflowPolling';
import { getWorkflowApiBaseUrl } from '../useWorkflows';
interface UnifiedChatDataItem {
type: 'message' | 'log';
item: WorkflowMessage | WorkflowLog;
createdAt: number;
}
/**
* =============================================================================
* WORKFLOW LIFECYCLE STATE MACHINE
* =============================================================================
*
* WORKFLOW STATUS (from Backend):
* idle - No workflow
* running - Workflow is processing
* completed - Round finished (Backend processed "last" message)
* stopped - User stopped the workflow
* failed - Error occurred
*
* UI FLAG:
* hasRenderedLastMessage: boolean
* - true: "last" message was rendered in UI
* - false: "last" message not yet in UI
*
* POLLING LOGIC:
* POLL ACTIVE when:
* status === 'running'
* OR (status === 'completed' AND !hasRenderedLastMessage)
*
* POLL STOPS when:
* status === 'stopped'
* OR status === 'failed'
* OR hasRenderedLastMessage === true
*
* TRANSITIONS:
* [Send Button] (from any status):
* hasRenderedLastMessage = false (new round starts)
* afterTimestamp = now
* Start polling
*
* [Load Workflow]:
* Load all data
* Check if last message has status="last"
* If yes: hasRenderedLastMessage = true, no polling
* If no AND status=running: Start polling
*
* [Message with status="last" rendered]:
* hasRenderedLastMessage = true
* Stop polling
*
* =============================================================================
*/
export function useWorkflowLifecycle(instanceId: string) {
const apiBaseUrl = useMemo(() => getWorkflowApiBaseUrl(instanceId, 'chatplayground'), [instanceId]);
// === STATE ===
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
const [messages, setMessages] = useState<WorkflowMessage[]>([]);
const [logs, setLogs] = useState<WorkflowLog[]>([]);
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null);
// === REFS FOR SYNC ACCESS ===
const statusRef = useRef<string>('idle');
const lastRenderedTimestampRef = useRef<number | null>(null);
// === KEY STATE MACHINE FLAG ===
// This flag tracks if the UI has rendered a message with status="last"
// Polling continues until this is true (even if backend status is "completed")
const hasRenderedLastMessageRef = useRef<boolean>(false);
const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState<boolean>(false);
// Flag to prevent useEffect from stopping polling during active workflow start
const isStartingWorkflowRef = useRef<boolean>(false);
// === HOOKS ===
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
const { request } = useApiRequest();
const pollingController = useWorkflowPolling();
const pollingControllerRef = useRef(pollingController);
pollingControllerRef.current = pollingController;
// === HELPER: Update workflow status ===
const updateWorkflowStatus = useCallback((newStatus: string) => {
statusRef.current = newStatus;
setWorkflowStatus(newStatus);
console.log('📍 Status updated to:', newStatus);
}, []);
// === HELPER: Convert backend log format to frontend format ===
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
return {
id: log.id,
workflowId: log.workflowId || workflowId || '',
message: log.message || '',
type: log.type || 'info',
timestamp: log.timestamp || log.createdAt || Date.now(),
status: log.status || 'running',
progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined,
performance: log.performance,
operationId: log.operationId || null,
parentId: log.parentId || null
};
}, [workflowId]);
// === CORE: Process unified chat data ===
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; workflowCost: number }) => {
console.log('🔄 Processing chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
workflowCost: chatData.workflowCost ?? 0
});
const timeline: UnifiedChatDataItem[] = [];
(chatData.messages || []).forEach((message: WorkflowMessage) => {
timeline.push({
type: 'message',
item: message,
createdAt: message.publishedAt || message.timestamp || Date.now()
});
});
(chatData.logs || []).forEach((log: any) => {
timeline.push({
type: 'log',
item: log,
createdAt: log.timestamp || log.createdAt || Date.now()
});
});
timeline.sort((a, b) => a.createdAt - b.createdAt);
// Update lastRenderedTimestamp
if (timeline.length > 0) {
lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt;
}
// === CHECK FOR "LAST" MESSAGE ===
// This is the key state machine logic: detect when a "last" message arrives
let foundLastMessage = false;
timeline.forEach((item) => {
if (item.type === 'message') {
const message = item.item as WorkflowMessage;
if ((message as any).status === 'last') {
foundLastMessage = true;
console.log('🏁 Found "last" message:', message.id);
}
}
});
// === STATE MACHINE: Handle "last" message ===
if (foundLastMessage && !hasRenderedLastMessageRef.current) {
console.log('🛑 "last" message detected - stopping polling');
hasRenderedLastMessageRef.current = true;
setHasRenderedLastMessage(true);
pollingControllerRef.current.stopPolling();
}
// === UPDATE MESSAGES STATE ===
setMessages(prevMessages => {
const newMessages: WorkflowMessage[] = [...prevMessages];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'message') {
const message = item.item as WorkflowMessage;
if (!message || !message.id) return;
const existingIndex = newMessages.findIndex(m => m.id === message.id);
if (existingIndex >= 0) {
newMessages[existingIndex] = message;
hasChanges = true;
} else {
newMessages.push(message);
hasChanges = true;
}
}
});
if (hasChanges || timeline.some(item => item.type === 'message')) {
return [...newMessages].sort(sortMessages);
}
return prevMessages;
});
// === UPDATE DASHBOARD LOGS (with operationId) ===
setDashboardLogs(prevLogs => {
const newLogs: WorkflowLog[] = [...prevLogs];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'log') {
const frontendLog = convertLogToFrontendFormat(item.item);
if (frontendLog.operationId) {
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
newLogs[existingIndex] = frontendLog;
hasChanges = true;
}
} else {
newLogs.push(frontendLog);
hasChanges = true;
}
}
}
});
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
});
// === UPDATE UNIFIED CONTENT LOGS (without operationId) ===
setUnifiedContentLogs(prevLogs => {
const newLogs: WorkflowLog[] = [...prevLogs];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'log') {
const frontendLog = convertLogToFrontendFormat(item.item);
if (!frontendLog.operationId) {
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
newLogs[existingIndex] = frontendLog;
hasChanges = true;
}
} else {
newLogs.push(frontendLog);
hasChanges = true;
}
}
}
});
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
});
// === UPDATE COMBINED LOGS ===
setLogs(prevLogs => {
const allLogs: WorkflowLog[] = [...prevLogs];
timeline.forEach((item) => {
if (item.type === 'log') {
const frontendLog = convertLogToFrontendFormat(item.item);
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
allLogs[existingIndex] = frontendLog;
} else {
allLogs.push(frontendLog);
}
}
});
return [...allLogs].sort(sortLogs);
});
// === UPDATE COST from billing transactions (single source of truth) ===
const cost = chatData.workflowCost ?? 0;
setLatestStats(cost > 0 ? { priceCHF: cost } : null);
}, [convertLogToFrontendFormat]);
// === POLLING FUNCTION ===
const pollWorkflowData = useCallback(async (id: string) => {
try {
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
// Fetch workflow status
const workflowData = await fetchWorkflowApi(request, id, apiBaseUrl).catch(() => null);
if (workflowData) {
const status = workflowData.status || 'idle';
const round = workflowData.currentRound;
updateWorkflowStatus(status);
if (round !== undefined) setCurrentRound(round);
// === STATE MACHINE: Check if polling should stop based on status ===
if (status === 'stopped' || status === 'failed') {
console.log(`🛑 Workflow ${status} - stopping polling immediately`);
pollingControllerRef.current.stopPolling();
return;
}
}
// Fetch chat data
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
console.log('📊 Polled chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
workflowCost: chatData.workflowCost ?? 0,
afterTimestamp
});
// Process data (this will detect "last" message and stop polling if found)
processUnifiedChatData(chatData);
} catch (error) {
console.error('❌ Polling error:', error);
}
}, [request, instanceId, apiBaseUrl, updateWorkflowStatus, processUnifiedChatData]);
// === POLLING CONTROL EFFECT ===
useEffect(() => {
if (!workflowId) {
pollingControllerRef.current.stopPolling();
return;
}
// Skip if we're actively starting a workflow - handleStartWorkflow manages polling
if (isStartingWorkflowRef.current) {
console.log('📍 Polling decision: Skipping - workflow start in progress');
return;
}
// === STATE MACHINE: Determine if polling should be active ===
// Use ref for immediate value (state may be stale)
const hasLastMessage = hasRenderedLastMessageRef.current;
const shouldPoll =
workflowStatus === 'running' ||
(workflowStatus === 'completed' && !hasLastMessage);
const shouldStopImmediately =
workflowStatus === 'stopped' ||
workflowStatus === 'failed' ||
hasLastMessage;
console.log('📍 Polling decision:', {
workflowStatus,
hasRenderedLastMessage: hasLastMessage,
shouldPoll,
shouldStopImmediately
});
if (shouldPoll) {
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
} else if (shouldStopImmediately) {
pollingControllerRef.current.stopPolling();
}
return () => {
pollingControllerRef.current.stopPolling();
};
}, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]);
// === START WORKFLOW (Send Button) ===
const handleStartWorkflow = useCallback(async (
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
) => {
try {
// Set flag to prevent useEffect from interfering during start
isStartingWorkflowRef.current = true;
const result = await startWorkflow(instanceId, workflowData, options);
if (result.success && result.data) {
const workflow = result.data as Workflow;
// === STATE MACHINE: New round starts ===
console.log('🚀 Starting workflow:', workflow.id);
// Reset state for new round - MUST update refs BEFORE state
hasRenderedLastMessageRef.current = false;
// Set afterTimestamp to NOW - only poll for new data
lastRenderedTimestampRef.current = Date.now();
// Start polling immediately (before state updates trigger useEffect)
pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData);
// Now update state (will trigger re-renders)
setWorkflowId(workflow.id);
setHasRenderedLastMessage(false);
updateWorkflowStatus(workflow.status || 'running');
// Clear the starting flag after a short delay to allow React to settle
setTimeout(() => {
isStartingWorkflowRef.current = false;
}, 100);
return { success: true, data: result.data };
} else {
isStartingWorkflowRef.current = false;
return { success: false, error: result.error || 'Failed to start workflow' };
}
} catch (error: any) {
isStartingWorkflowRef.current = false;
return { success: false, error: error.message || 'Failed to start workflow' };
}
}, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]);
// === STOP WORKFLOW ===
const handleStopWorkflow = useCallback(async () => {
if (!workflowId) {
return { success: false, error: 'No workflow to stop' };
}
try {
const result = await stopWorkflow(instanceId, workflowId);
if (result.success) {
updateWorkflowStatus('stopped');
pollingControllerRef.current.stopPolling();
return { success: true };
} else {
return { success: false, error: result.error || 'Failed to stop workflow' };
}
} catch (error: any) {
return { success: false, error: error.message || 'Failed to stop workflow' };
}
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
// === RESET WORKFLOW ===
const resetWorkflow = useCallback(() => {
console.log('🔄 Resetting workflow state');
setWorkflowId(null);
updateWorkflowStatus('idle');
setCurrentRound(undefined);
setMessages([]);
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
lastRenderedTimestampRef.current = null;
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
pollingControllerRef.current.stopPolling();
}, [updateWorkflowStatus]);
// === SELECT/LOAD WORKFLOW ===
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
try {
console.log('📥 Loading workflow:', workflowIdToSelect);
setWorkflowId(workflowIdToSelect);
lastRenderedTimestampRef.current = null;
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
setLatestStats(null);
// Fetch workflow data
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null);
if (!workflowData) {
setMessages([]);
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
updateWorkflowStatus('idle');
return;
}
const status = workflowData.status || 'idle';
const round = workflowData.currentRound;
updateWorkflowStatus(status);
if (round !== undefined) setCurrentRound(round);
// Fetch all chat data (no afterTimestamp = get everything)
try {
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
console.log('📥 Loaded chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
workflowCost: chatData.workflowCost ?? 0
});
// === STATE MACHINE: Check if last message has status="last" ===
const allMessages = chatData.messages || [];
const sortedMessages = [...allMessages].sort((a, b) => {
const aTime = a.publishedAt || a.timestamp || 0;
const bTime = b.publishedAt || b.timestamp || 0;
return bTime - aTime; // Sort descending (newest first)
});
const lastMessage = sortedMessages[0];
const lastMessageStatus = lastMessage ? (lastMessage as any).status : null;
console.log('📍 Last message status:', lastMessageStatus);
if (lastMessageStatus === 'last') {
// Round is complete - don't start polling
hasRenderedLastMessageRef.current = true;
setHasRenderedLastMessage(true);
console.log('✅ Workflow round complete - no polling needed');
} else if (status === 'running') {
// Workflow is running - polling will start via useEffect
console.log('🔄 Workflow is running - polling will start');
}
// Process the data
processUnifiedChatData(chatData);
} catch (error) {
console.warn('⚠️ Failed to fetch chat data:', error);
updateWorkflowStatus('idle');
}
} catch (error) {
console.error('❌ Error selecting workflow:', error);
}
}, [request, instanceId, apiBaseUrl, updateWorkflowStatus, processUnifiedChatData]);
// === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES ===
const setWorkflowStatusOptimistic = useCallback((status: string) => {
updateWorkflowStatus(status);
}, [updateWorkflowStatus]);
// === COMPUTED VALUES ===
const isRunning = workflowStatus === 'running';
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
return {
workflowId,
workflowStatus,
currentRound,
isRunning,
isStopping,
startingWorkflow,
messages,
logs,
dashboardLogs,
unifiedContentLogs,
latestStats,
hasRenderedLastMessage,
startWorkflow: handleStartWorkflow,
stopWorkflow: handleStopWorkflow,
resetWorkflow,
selectWorkflow,
setWorkflowStatusOptimistic
};
}

View file

@ -1,2 +0,0 @@
// Re-export from consolidated hook
export { useWorkflowOperations } from '../useWorkflows';

View file

@ -1,205 +0,0 @@
import { useRef, useCallback } from 'react';
interface PollingState {
activeWorkflowId: string | null;
isPolling: boolean;
isPollInProgress: boolean;
isPaused: boolean;
currentInterval: number;
failureCount: number;
rateLimitFailureCount: number;
timeoutId: NodeJS.Timeout | null;
}
const BASE_INTERVAL = 5000; // 5 seconds
const MAX_INTERVAL = 10000; // 10 seconds
const BACKOFF_MULTIPLIER = 1.5;
const RATE_LIMIT_BACKOFF_MULTIPLIER = 2.0;
const MAX_RATE_LIMIT_FAILURES = 5;
export type PollCallback = (workflowId: string) => Promise<void>;
export function useWorkflowPolling() {
const stateRef = useRef<PollingState>({
activeWorkflowId: null,
isPolling: false,
isPollInProgress: false,
isPaused: false,
currentInterval: BASE_INTERVAL,
failureCount: 0,
rateLimitFailureCount: 0,
timeoutId: null
});
const pollCallbackRef = useRef<PollCallback | null>(null);
const calculateInterval = useCallback((isRateLimit: boolean = false): number => {
const state = stateRef.current;
const multiplier = isRateLimit ? RATE_LIMIT_BACKOFF_MULTIPLIER : BACKOFF_MULTIPLIER;
const newInterval = Math.min(
BASE_INTERVAL * Math.pow(multiplier, state.failureCount),
MAX_INTERVAL
);
return Math.floor(newInterval);
}, []);
const scheduleNextPoll = useCallback((interval: number) => {
const state = stateRef.current;
// Clear any existing timeout
if (state.timeoutId) {
clearTimeout(state.timeoutId);
state.timeoutId = null;
}
// Don't schedule if not polling or paused
if (!state.isPolling || state.isPaused || !state.activeWorkflowId) {
return;
}
// Schedule next poll
state.timeoutId = setTimeout(() => {
state.timeoutId = null;
doPolling();
}, interval);
}, []);
const doPolling = useCallback(async () => {
const state = stateRef.current;
// Prevent concurrent polls
if (state.isPollInProgress) {
return;
}
// Validate workflow is still active
if (!state.activeWorkflowId || !state.isPolling || state.isPaused) {
return;
}
const workflowId = state.activeWorkflowId;
state.isPollInProgress = true;
try {
if (pollCallbackRef.current) {
await pollCallbackRef.current(workflowId);
}
// Success - reset failure counts and interval
state.failureCount = 0;
state.rateLimitFailureCount = 0;
state.currentInterval = BASE_INTERVAL;
// Schedule next poll
scheduleNextPoll(state.currentInterval);
} catch (error: any) {
// Handle errors
const isRateLimit = error?.status === 429 || error?.response?.status === 429;
if (isRateLimit) {
state.rateLimitFailureCount++;
// Stop polling after too many rate limit errors
if (state.rateLimitFailureCount >= MAX_RATE_LIMIT_FAILURES) {
console.error('Too many rate limit errors, stopping polling');
stopPolling();
return;
}
} else {
state.rateLimitFailureCount = 0; // Reset rate limit count on non-rate-limit errors
}
state.failureCount++;
const nextInterval = calculateInterval(isRateLimit);
state.currentInterval = nextInterval;
console.warn(`Polling error (attempt ${state.failureCount}):`, error);
// Schedule next poll with backoff
scheduleNextPoll(nextInterval);
} finally {
state.isPollInProgress = false;
}
}, [scheduleNextPoll, calculateInterval]);
const startPolling = useCallback((workflowId: string, callback: PollCallback) => {
const state = stateRef.current;
// Stop any existing polling
if (state.isPolling) {
stopPolling();
}
// Validate workflow ID
if (!workflowId || typeof workflowId !== 'string') {
console.error('Invalid workflow ID for polling:', workflowId);
return;
}
// Set up polling state
state.activeWorkflowId = workflowId;
state.isPolling = true;
state.isPaused = false;
state.failureCount = 0;
state.rateLimitFailureCount = 0;
state.currentInterval = BASE_INTERVAL;
pollCallbackRef.current = callback;
// Execute immediate first poll (no delay)
doPolling();
}, [doPolling]);
const stopPolling = useCallback(() => {
const state = stateRef.current;
// Clear timeout
if (state.timeoutId) {
clearTimeout(state.timeoutId);
state.timeoutId = null;
}
// Reset state
state.isPolling = false;
state.isPollInProgress = false;
state.activeWorkflowId = null;
state.failureCount = 0;
state.rateLimitFailureCount = 0;
state.currentInterval = BASE_INTERVAL;
state.isPaused = false;
pollCallbackRef.current = null;
}, []);
const pausePolling = useCallback(() => {
const state = stateRef.current;
state.isPaused = true;
}, []);
const resumePolling = useCallback(() => {
const state = stateRef.current;
if (state.isPolling && state.isPaused) {
state.isPaused = false;
// Resume polling immediately
if (!state.isPollInProgress) {
scheduleNextPoll(0);
}
}
}, [scheduleNextPoll]);
const isPolling = useCallback((): boolean => {
return stateRef.current.isPolling && !stateRef.current.isPaused;
}, []);
const getActiveWorkflowId = useCallback((): string | null => {
return stateRef.current.activeWorkflowId;
}, []);
return {
startPolling,
stopPolling,
pausePolling,
resumePolling,
isPolling,
getActiveWorkflowId
};
}

View file

@ -1,92 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useApiRequest } from '../useApi';
import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext';
import { fetchWorkflows as fetchWorkflowsApi, type Workflow } from '../../api/workflowApi';
import { getWorkflowApiBaseUrl } from '../useWorkflows';
export function useWorkflows(instanceId?: string, featureCode: string = 'chatplayground') {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [isRefetching, setIsRefetching] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { request } = useApiRequest<null, Workflow[]>();
const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection();
const apiBaseUrl = useMemo(
() => getWorkflowApiBaseUrl(instanceId, featureCode),
[instanceId, featureCode]
);
const fetchWorkflows = useCallback(async () => {
if (!apiBaseUrl) {
console.warn('⚠️ useWorkflows: No apiBaseUrl available (missing instanceId), skipping fetch');
return;
}
try {
setLoading(true);
setError(null);
console.log('🔄 useWorkflows: Fetching workflows from API...', { apiBaseUrl });
const workflowList = await fetchWorkflowsApi(request, undefined, apiBaseUrl);
console.log('✅ useWorkflows: Fetched workflows:', workflowList);
if (Array.isArray(workflowList)) {
setWorkflows(workflowList);
console.log(`✅ useWorkflows: Set ${workflowList.length} workflows in state`);
} else {
console.warn('⚠️ useWorkflows: API returned non-array data:', workflowList);
setWorkflows([]);
}
} catch (error: any) {
console.error('❌ useWorkflows: Error fetching workflows:', error);
setError(error.message || 'Failed to fetch workflows');
setWorkflows([]);
} finally {
setLoading(false);
}
}, [request, apiBaseUrl]);
useEffect(() => {
fetchWorkflows();
}, [fetchWorkflows]);
useEffect(() => {
const handleWorkflowDeleted = (event: CustomEvent<{ workflowIds: string[] }>) => {
const deletedIds = event.detail.workflowIds;
fetchWorkflows();
if (selectedWorkflowId && deletedIds.includes(selectedWorkflowId)) {
clearWorkflow();
}
};
const handleWorkflowCreated = () => {
// Immediately refetch workflows list to include the newly created workflow
fetchWorkflows();
};
window.addEventListener('workflowDeleted', handleWorkflowDeleted as EventListener);
window.addEventListener('workflowCreated', handleWorkflowCreated as EventListener);
return () => {
window.removeEventListener('workflowDeleted', handleWorkflowDeleted as EventListener);
window.removeEventListener('workflowCreated', handleWorkflowCreated as EventListener);
};
}, [fetchWorkflows, selectedWorkflowId, clearWorkflow]);
const refetch = useCallback(async () => {
setIsRefetching(true);
try {
await fetchWorkflows();
} finally {
setIsRefetching(false);
}
}, [fetchWorkflows]);
return {
workflows,
loading,
isRefetching,
error,
refetch
};
}

View file

@ -15,8 +15,7 @@ import {
type CoachingContext, type CoachingSession, type CoachingMessage,
type CoachingTask, type CoachingScore, type SSEEvent,
} from '../api/commcoachApi';
export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error';
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
export interface CommcoachHookReturn {
contexts: CoachingContext[];
@ -49,8 +48,11 @@ export interface CommcoachHookReturn {
cancelSession: () => Promise<void>;
stopTts: () => void;
pauseTts: () => void;
resumeTts: () => void;
hasAudioToResume: () => boolean;
ttsIsPlaying: boolean;
ttsIsPaused: boolean;
onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>;
@ -90,12 +92,21 @@ export function useCommcoach(): CommcoachHookReturn {
const [actionLoading, setActionLoading] = useState<string | null>(null);
const isMountedRef = useRef(true);
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null);
const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null);
useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []);
const ttsPlayback = useTtsPlayback({
onPlaying: () => { (window as any).__dlog?.('TTS-PLAYING'); onTtsEventRef.current?.('playing'); },
onEnded: () => { (window as any).__dlog?.('TTS-ENDED'); onTtsEventRef.current?.('ended'); },
onPaused: () => { (window as any).__dlog?.('TTS-PAUSED'); onTtsEventRef.current?.('paused'); },
onError: () => { (window as any).__dlog?.('TTS-ERROR'); onTtsEventRef.current?.('error'); },
});
useEffect(() => {
isMountedRef.current = true;
return () => { isMountedRef.current = false; };
}, []);
const refreshContexts = useCallback(async () => {
if (!instanceId) return;
@ -111,54 +122,21 @@ export function useCommcoach(): CommcoachHookReturn {
}
}, [request, instanceId]);
const _emitTts = useCallback((event: TtsEvent) => {
(window as any).__dlog?.(`TTS-${event.toUpperCase()}`);
onTtsEventRef.current?.(event);
}, []);
const _playTtsAudio = useCallback((audioB64: string) => {
if (!audioB64 || !isMountedRef.current) return;
if (currentAudioRef.current) {
currentAudioRef.current.pause();
currentAudioRef.current = null;
}
try {
const audio = new Audio(`data:audio/mp3;base64,${audioB64}`);
currentAudioRef.current = audio;
audio.onended = () => {
currentAudioRef.current = null;
_emitTts('ended');
};
audio.play().then(() => {
_emitTts('playing');
}).catch(() => {
_emitTts('error');
});
} catch {
_emitTts('error');
}
}, [_emitTts]);
const stopTts = useCallback(() => {
if (currentAudioRef.current) {
currentAudioRef.current.pause();
_emitTts('paused');
}
}, [_emitTts]);
ttsPlayback.stop();
}, [ttsPlayback]);
const pauseTts = useCallback(() => {
ttsPlayback.pause();
}, [ttsPlayback]);
const resumeTts = useCallback(() => {
if (currentAudioRef.current && currentAudioRef.current.paused) {
currentAudioRef.current.play().then(() => {
_emitTts('playing');
}).catch(() => {
_emitTts('error');
});
}
}, [_emitTts]);
ttsPlayback.resume();
}, [ttsPlayback]);
const hasAudioToResume = useCallback(() => {
return !!(currentAudioRef.current && currentAudioRef.current.paused && currentAudioRef.current.currentTime > 0);
}, []);
return ttsPlayback.isPaused;
}, [ttsPlayback]);
const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => {
if (!instanceId) return;
@ -196,7 +174,7 @@ export function useCommcoach(): CommcoachHookReturn {
setMessages(eventData.messages);
}
} else if (eventType === 'ttsAudio' && eventData?.audio) {
_playTtsAudio(eventData.audio);
ttsPlayback.play(eventData.audio);
}
if (eventType === 'complete') setIsStreaming(false);
},
@ -210,7 +188,7 @@ export function useCommcoach(): CommcoachHookReturn {
} catch (err: any) {
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts');
}
}, [request, instanceId, _playTtsAudio]);
}, [request, instanceId, ttsPlayback.play]);
const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => {
if (!instanceId) return;
@ -298,7 +276,7 @@ export function useCommcoach(): CommcoachHookReturn {
return [...prev, msg];
});
} else if (eventType === 'ttsAudio' && eventData?.audio) {
_playTtsAudio(eventData.audio);
ttsPlayback.play(eventData.audio);
} else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'taskCreated' && eventData) {
@ -333,7 +311,7 @@ export function useCommcoach(): CommcoachHookReturn {
} finally {
if (isMountedRef.current) setActionLoading(null);
}
}, [instanceId, selectedContextId, _playTtsAudio]);
}, [instanceId, selectedContextId, ttsPlayback.play]);
const sendMessage = useCallback(async (content: string) => {
const normalizedContent = content.trim();
@ -343,10 +321,7 @@ export function useCommcoach(): CommcoachHookReturn {
const ac = new AbortController();
abortControllerRef.current = ac;
if (currentAudioRef.current) {
currentAudioRef.current.pause();
currentAudioRef.current = null;
}
ttsPlayback.stop();
await _unlockAudioForTts();
setError(null);
setIsStreaming(true);
@ -396,7 +371,7 @@ export function useCommcoach(): CommcoachHookReturn {
});
} else if (eventType === 'ttsAudio' && eventData?.audio) {
setError(null);
_playTtsAudio(eventData.audio);
ttsPlayback.play(eventData.audio);
} else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'taskCreated' && eventData) {
@ -433,14 +408,11 @@ export function useCommcoach(): CommcoachHookReturn {
setIsStreaming(false);
}
}
}, [instanceId, session, _playTtsAudio]);
}, [instanceId, session, ttsPlayback.play]);
const sendAudio = useCallback(async (audioBlob: Blob) => {
if (!instanceId || !session) return;
if (currentAudioRef.current) {
currentAudioRef.current.pause();
currentAudioRef.current = null;
}
ttsPlayback.stop();
await _unlockAudioForTts();
setError(null);
setIsStreaming(true);
@ -474,7 +446,7 @@ export function useCommcoach(): CommcoachHookReturn {
});
} else if (eventType === 'ttsAudio' && eventData?.audio) {
setError(null);
_playTtsAudio(eventData.audio);
ttsPlayback.play(eventData.audio);
} else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) {
@ -585,8 +557,10 @@ export function useCommcoach(): CommcoachHookReturn {
error, inputValue, setInputValue,
selectContext, createContext, archiveContext,
startSession: startSessionCb,
sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb,
stopTts, resumeTts, hasAudioToResume,
sendMessage, sendAudio,
completeSession: completeSessionCb, cancelSession: cancelSessionCb,
stopTts, pauseTts, resumeTts, hasAudioToResume,
ttsIsPlaying: ttsPlayback.isPlaying, ttsIsPaused: ttsPlayback.isPaused,
onTtsEventRef,
actionLoading,
toggleTaskStatus, addTask, removeTask,

View file

@ -11,7 +11,8 @@ import {
fetchFileById as fetchFileByIdApi,
updateFile as updateFileApi,
deleteFile as deleteFileApi,
deleteFiles as deleteFilesApi
deleteFiles as deleteFilesApi,
type FolderInfo,
} from '../api/fileApi';
// File interfaces - exactly matching backend FileItem model
@ -969,3 +970,86 @@ export function useFileOperations() {
isLoading
};
}
// ── Folder management hook ──────────────────────────────────────────────────
export function useFolders() {
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [loading, setLoading] = useState(false);
const { showError } = useToast();
const refresh = useCallback(async () => {
setLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
try {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
throw err;
}
}, [refresh, showError]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
try {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
throw err;
}
}, [refresh, showError]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
try {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
throw err;
}
}, [refresh, showError]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
try {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
throw err;
}
}, [refresh, showError]);
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
try {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
throw err;
}
}, [showError]);
return {
folders,
loading,
refresh,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFile,
};
}

View file

@ -13,9 +13,12 @@ export interface UserInputRequest {
metadata?: Record<string, any>;
}
export type { WorkflowFile } from './playground/useDashboardInputForm';
export { useWorkflows } from './playground/useWorkflows';
export { useWorkflowOperations } from './playground/useWorkflowOperations';
export { useWorkflowLifecycle } from './playground/useWorkflowLifecycle';
export { useDashboardInputForm, createDashboardHook } from './playground/useDashboardInputForm';
export interface WorkflowFile {
id: string;
fileId: string;
fileName: string;
fileSize: number;
mimeType: string;
messageId?: string;
source?: 'user_uploaded' | 'ai_created';
}

View file

@ -0,0 +1,198 @@
/**
* useVoiceStream single hook for mic capture + STT streaming.
*
* Starts MediaRecorder, opens a WebSocket to the generic STT endpoint,
* sends audio chunks, and receives interim/final transcripts from
* Google Streaming Recognition on the backend.
*
* No client-side VAD, no segmentation, no recorder restarts.
* Google handles silence detection and endpoint natively.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import api from '../api';
export type VoiceStreamStatus = 'idle' | 'connecting' | 'listening' | 'error';
export interface VoiceStreamCallbacks {
onInterim?: (text: string) => void;
onFinal?: (text: string) => void;
onStatusChange?: (status: VoiceStreamStatus) => void;
onError?: (error: unknown) => void;
}
export interface VoiceStreamApi {
status: VoiceStreamStatus;
interimText: string;
start: (language?: string) => Promise<void>;
stop: () => void;
}
const _RECORDING_CHUNK_MS = 250;
const _MAX_RECONNECT_ATTEMPTS = 3;
export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi {
const [status, setStatus] = useState<VoiceStreamStatus>('idle');
const [interimText, setInterimText] = useState('');
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
const wsRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const languageRef = useRef('de-DE');
const stoppingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const _setStatus = useCallback((next: VoiceStreamStatus) => {
setStatus(next);
cbRef.current.onStatusChange?.(next);
}, []);
const _pickMimeType = useCallback((): string => {
for (const mime of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']) {
try { if (MediaRecorder.isTypeSupported(mime)) return mime; } catch { /* skip */ }
}
throw new Error('No supported audio MIME type for MediaRecorder');
}, []);
const _closeWs = useCallback(() => {
const ws = wsRef.current;
if (!ws) return;
wsRef.current = null;
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'close' }));
}
ws.close();
} catch { /* ignore */ }
}, []);
const _stopRecorder = useCallback(() => {
const recorder = recorderRef.current;
if (recorder && recorder.state !== 'inactive') {
try { recorder.stop(); } catch { /* ignore */ }
}
recorderRef.current = null;
}, []);
const _releaseDevices = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
}, []);
const stop = useCallback(() => {
stoppingRef.current = true;
_stopRecorder();
_closeWs();
_releaseDevices();
setInterimText('');
_setStatus('idle');
stoppingRef.current = false;
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]);
const start = useCallback(async (language?: string) => {
if (status === 'listening' || status === 'connecting') return;
stoppingRef.current = false;
reconnectAttemptsRef.current = 0;
languageRef.current = language || 'de-DE';
_setStatus('connecting');
try {
if (!streamRef.current) {
streamRef.current = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
});
}
const tokenResp = await api.post('/voice-google/stt/token');
const wsToken: string = tokenResp.data.wsToken;
const baseURL = api.defaults.baseURL || window.location.origin;
const wsBase = baseURL.replace(/^http/i, 'ws');
const wsUrl = `${wsBase}/voice-google/stt/stream?wsToken=${encodeURIComponent(wsToken)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (stoppingRef.current) { ws.close(); return; }
ws.send(JSON.stringify({ type: 'open', language: languageRef.current }));
const mimeType = _pickMimeType();
const recorder = new MediaRecorder(streamRef.current!, { mimeType });
recorderRef.current = recorder;
recorder.ondataavailable = (event: BlobEvent) => {
if (!event.data || event.data.size === 0) return;
if (ws.readyState !== WebSocket.OPEN) return;
const reader = new FileReader();
reader.onloadend = () => {
if (ws.readyState !== WebSocket.OPEN) return;
const dataUrl = reader.result as string;
const b64 = dataUrl.split(',')[1];
if (b64) ws.send(JSON.stringify({ type: 'audio', chunk: b64 }));
};
reader.readAsDataURL(event.data);
};
recorder.start(_RECORDING_CHUNK_MS);
_setStatus('listening');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'interim' && msg.text) {
setInterimText(msg.text);
cbRef.current.onInterim?.(msg.text);
} else if (msg.type === 'final' && msg.text) {
setInterimText('');
cbRef.current.onFinal?.(msg.text);
} else if (msg.type === 'error') {
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error'));
} else if (msg.type === 'reconnect_required') {
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
reconnectAttemptsRef.current++;
_closeWs();
start(languageRef.current).catch(() => {});
}
}
} catch { /* ignore parse errors */ }
};
ws.onerror = () => {
if (!stoppingRef.current) {
cbRef.current.onError?.(new Error('WebSocket connection error'));
_setStatus('error');
}
};
ws.onclose = () => {
if (!stoppingRef.current) {
_setStatus('idle');
}
};
} catch (err) {
cbRef.current.onError?.(err);
_setStatus('error');
_releaseDevices();
throw err;
}
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]);
useEffect(() => {
return () => {
stoppingRef.current = true;
_stopRecorder();
_closeWs();
_releaseDevices();
};
}, [_stopRecorder, _closeWs, _releaseDevices]);
return { status, interimText, start, stop };
}

View file

@ -0,0 +1,79 @@
/**
* useTtsPlayback central hook for TTS audio playback.
*
* Plays base64-encoded audio (MP3), manages current playback state,
* emits lifecycle events. Used by all features (CommCoach, Workspace, etc.).
*/
import { useCallback, useRef, useState } from 'react';
export type TtsEvent = 'playing' | 'paused' | 'ended' | 'error';
export interface TtsPlaybackCallbacks {
onPlaying?: () => void;
onPaused?: () => void;
onEnded?: () => void;
onError?: () => void;
}
export interface TtsPlaybackApi {
isPlaying: boolean;
isPaused: boolean;
play: (base64Audio: string, format?: string) => void;
pause: () => void;
resume: () => void;
stop: () => void;
}
export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
const _emit = useCallback((event: TtsEvent) => {
if (event === 'playing') { setIsPlaying(true); setIsPaused(false); cbRef.current?.onPlaying?.(); }
else if (event === 'paused') { setIsPaused(true); cbRef.current?.onPaused?.(); }
else if (event === 'ended') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onEnded?.(); }
else if (event === 'error') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onError?.(); }
}, []);
const stop = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setIsPlaying(false);
setIsPaused(false);
}, []);
const play = useCallback((base64Audio: string, format?: string) => {
if (!base64Audio) return;
stop();
try {
const mimeType = format === 'wav' ? 'audio/wav' : 'audio/mp3';
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; _emit('ended'); };
audio.onpause = () => { if (audioRef.current === audio && audio.currentTime < audio.duration) _emit('paused'); };
audio.play().then(() => _emit('playing')).catch(() => _emit('error'));
} catch {
_emit('error');
}
}, [stop, _emit]);
const pause = useCallback(() => {
if (audioRef.current && !audioRef.current.paused) {
audioRef.current.pause();
}
}, []);
const resume = useCallback(() => {
if (audioRef.current && audioRef.current.paused) {
audioRef.current.play().then(() => _emit('playing')).catch(() => _emit('error'));
}
}, [_emit]);
return { isPlaying, isPaused, play, pause, resume, stop };
}

View file

@ -49,7 +49,6 @@ export interface PaginationParams {
/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
if (!instanceId || !featureCode) return undefined;
if (featureCode === 'chatplayground') return `/api/chatplayground/${instanceId}`;
if (featureCode === 'automation') return `/api/automations/${instanceId}`;
return undefined;
}

View file

@ -21,3 +21,12 @@ html, body {
padding: 0;
font-family: var(--font-family, "DM Sans", sans-serif);
}
tr[data-highlighted="true"] {
animation: rowHighlight 2s ease-out;
}
@keyframes rowHighlight {
0% { background: rgba(25, 118, 210, 0.25); }
100% { background: transparent; }
}

View file

@ -718,11 +718,6 @@ export default {
'warning.duplicate_file.title': 'Datei bereits vorhanden',
'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.',
// Chat Playground Page
'chatPlayground.title': 'Chat Playground',
'chatPlayground.description': 'Workflow-Ausführung und Chat-Interaktion',
'chatPlayground.subtitle': 'Chat-basierte Workflow-Steuerung',
// Automations Page
'automations.title': 'Automatisierungen',
'automations.description': 'Workflow-Automatisierungen verwalten',

View file

@ -718,11 +718,6 @@ export default {
'warning.duplicate_file.title': 'File Already Exists',
'warning.duplicate_file.message': 'The file "{fileName}" already exists with identical content. The existing file will be reused.',
// Chat Playground Page
'chatPlayground.title': 'Chat Playground',
'chatPlayground.description': 'Workflow execution and chat interaction',
'chatPlayground.subtitle': 'Chat-based workflow control',
// Automations Page
'automations.title': 'Automations',
'automations.description': 'Manage workflow automations',

View file

@ -718,11 +718,6 @@ export default {
'warning.duplicate_file.title': 'Fichier Déjà Existant',
'warning.duplicate_file.message': 'Le fichier "{fileName}" existe déjà avec un contenu identique. Le fichier existant sera réutilisé.',
// Chat Playground Page
'chatPlayground.title': 'Chat Playground',
'chatPlayground.description': 'Exécution de workflow et interaction chat',
'chatPlayground.subtitle': 'Contrôle des workflows par chat',
// Automations Page
'automations.title': 'Automatisations',
'automations.description': 'Gérer les automatisations de workflow',

View file

@ -27,17 +27,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
// RealEstate Views
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
// Chat Playground Views (reusing existing workflow pages)
import { PlaygroundPage, WorkflowsPage } from './workflows';
// Automation Views
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
// CodeEditor Views
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
// Workspace Views
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
// Teamsbot Views
@ -128,21 +123,14 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
chatplayground: {
playground: PlaygroundPage,
workflows: WorkflowsPage,
},
automation: {
definitions: AutomationDefinitionsView,
templates: AutomationTemplatesView,
logs: AutomationLogsView,
},
codeeditor: {
editor: CodeEditorPage,
workflows: CodeEditorWorkflowsPage,
},
workspace: {
dashboard: WorkspacePage,
editor: WorkspaceEditorPage,
settings: WorkspaceSettingsPage,
},
teamsbot: {
@ -208,8 +196,8 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
}
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings') {
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
return null;
}

View file

@ -7,7 +7,7 @@
*/
import React from 'react';
import { FaCogs, FaComments, FaFileAlt, FaHeadset } from 'react-icons/fa';
import { FaCogs, FaHeadset } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore';
import type { StoreFeature } from '../api/storeApi';
@ -15,8 +15,6 @@ import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = {
automation: <FaCogs />,
chatplayground: <FaComments />,
codeeditor: <FaFileAlt />,
teamsbot: <FaHeadset />,
};
@ -26,16 +24,6 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
en: 'Create and manage automations to handle recurring tasks efficiently.',
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
},
chatplayground: {
de: 'Teste und experimentiere mit AI-Chat-Modellen in einer interaktiven Umgebung.',
en: 'Test and experiment with AI chat models in an interactive environment.',
fr: 'Testez et experimentez avec des modeles de chat IA dans un environnement interactif.',
},
codeeditor: {
de: 'AI-gestuetzter Editor fuer Text-Dateien mit Cursor-artigem Chat und Diff-Preview.',
en: 'AI-powered editor for text files with Cursor-style chat and diff preview.',
fr: 'Editeur de fichiers texte assiste par IA avec chat et apercu des modifications.',
},
teamsbot: {
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',

View file

@ -1,15 +1,20 @@
/**
* FilesPage
*
* Page for file management using FormGeneratorTable.
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right.
* Uses useResizablePanels for the divider.
*/
import React, { useState, useMemo, useEffect, useRef } from 'react';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import api from '../../api';
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
import { useFileContext } from '../../contexts/FileContext';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye } from 'react-icons/fa';
import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import styles from '../admin/Admin.module.css';
@ -18,19 +23,29 @@ interface UserFile {
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
featureInstanceId?: string;
[key: string]: any;
}
export const FilesPage: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const {
leftWidth, isDragging, handleMouseDown, containerRef,
} = useResizablePanels({
storageKey: 'filesPage-panelWidth',
defaultLeftWidth: 22,
minLeftWidth: 15,
maxLeftWidth: 40,
});
// Data hook
const {
data: files,
attributes,
permissions,
pagination,
loading,
error,
refetch,
@ -38,7 +53,6 @@ export const FilesPage: React.FC = () => {
updateFileOptimistically,
} = useUserFiles();
// Operations hook
const {
handleFileDownload,
handleFileDelete,
@ -53,16 +67,61 @@ export const FilesPage: React.FC = () => {
previewingFiles,
} = useFileOperations();
const {
folders,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
// Initial fetch
useEffect(() => {
refetch();
}, []);
useEffect(() => { refetch(); }, []);
const treeFileNodes: FileNode[] = useMemo(() => {
if (!files) return [];
return files.map((f: UserFile) => ({
id: f.id,
fileName: f.fileName,
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
}));
}, [files]);
const _handleTreeFileSelect = useCallback((fileId: string) => {
const file = files?.find((f: UserFile) => f.id === fileId);
if (file) {
setSelectedFolderId(file.folderId ?? null);
setHighlightedFileId(fileId);
requestAnimationFrame(() => {
const row = document.querySelector('tr[data-highlighted="true"]');
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
setTimeout(() => setHighlightedFileId(null), 2500);
}
}, [files]);
const filteredFiles = useMemo(() => {
if (!files) return [];
if (selectedFolderId === null) {
return files.filter((f: UserFile) => !f.folderId);
}
return files.filter((f: UserFile) => f.folderId === selectedFolderId);
}, [files, selectedFolderId]);
// Generate columns from attributes - hide internal fields
const columns = useMemo(() => {
const hiddenColumns = ['id', 'mandateId', 'featureInstanceId', 'fileHash'];
const hiddenColumns = ['id', 'mandateId', 'fileHash', 'folderId'];
const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
@ -76,9 +135,10 @@ export const FilesPage: React.FC = () => {
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
}));
// Add _createdBy column with FK resolution to show username
cols.push({
key: '_createdBy',
label: 'Created By',
@ -94,20 +154,15 @@ export const FilesPage: React.FC = () => {
return cols;
}, [attributes]);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click
const handleEditClick = async (file: UserFile) => {
const fullFile = await fetchFileById(file.id);
if (fullFile) {
setEditingFile(fullFile as UserFile);
}
if (fullFile) setEditingFile(fullFile as UserFile);
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<UserFile>) => {
if (!editingFile) return;
const result = await handleFileUpdate(editingFile.id, {
@ -119,29 +174,21 @@ export const FilesPage: React.FC = () => {
}
};
// Handle delete single file (confirmation handled by DeleteActionButton)
const handleDelete = async (file: UserFile) => {
const success = await handleFileDelete(file.id);
if (success) {
refetch();
}
if (success) refetch();
};
// Handle delete multiple files (confirmation handled by FormGenerator)
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
const ids = filesToDelete.map(f => f.id);
const success = await handleFileDeleteMultiple(ids);
if (success) {
refetch();
}
if (success) refetch();
};
// Handle download
const handleDownload = async (file: UserFile) => {
await handleFileDownload(file.id, file.fileName);
};
// Handle preview
const handlePreview = async (file: UserFile) => {
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
if (result.success && result.previewUrl) {
@ -149,36 +196,19 @@ export const FilesPage: React.FC = () => {
}
};
// Handle upload click
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleUploadClick = () => { fileInputRef.current?.click(); };
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (selectedFiles && selectedFiles.length > 0) {
let successCount = 0;
let errorCount = 0;
for (const file of Array.from(selectedFiles)) {
const result = await handleFileUpload(file);
if (result?.success) {
successCount++;
} else {
errorCount++;
}
if (result?.success) successCount++; else errorCount++;
}
// Reset input first
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Refresh table to show new files
if (fileInputRef.current) fileInputRef.current.value = '';
await refetch();
// Show feedback
if (successCount > 0) {
showSuccess(
'Upload erfolgreich',
@ -190,11 +220,75 @@ export const FilesPage: React.FC = () => {
}
};
// Form attributes for edit modal
const _handleNewFolder = useCallback(async () => {
const name = prompt('Neuer Ordnername:');
if (name?.trim()) {
await handleCreateFolder(name.trim(), selectedFolderId);
}
}, [handleCreateFolder, selectedFolderId]);
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
const isInSelection = selectedFiles.some(f => f.id === row.id);
if (isInSelection && selectedFiles.length > 1) {
const ids = selectedFiles.map(f => f.id);
e.dataTransfer.setData('application/file-ids', JSON.stringify(ids));
} else {
e.dataTransfer.setData('application/file-id', row.id);
}
e.dataTransfer.effectAllowed = 'move';
}, [selectedFiles]);
const _handleMoveFilePage = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
await refetch();
}, [handleMoveFile, refetch]);
const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
await refetch();
}, [contextMoveFiles, refetch]);
const _handleRenameFile = useCallback(async (fileId: string, newName: string) => {
await handleFileUpdate(fileId, { fileName: newName });
await refetch();
}, [handleFileUpdate, refetch]);
const _handleDeleteTreeFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
await refetch();
}, [handleFileDelete, refetch]);
const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => {
await handleFileDeleteMultiple(fileIds);
await refetch();
}, [handleFileDeleteMultiple, refetch]);
const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
await refreshFolders();
await refetch();
}, [refreshFolders, refetch]);
const _handleTreeRefresh = useCallback(async () => {
await refetch();
await refreshFolders();
}, [refetch, refreshFolders]);
const _tableRefetch = useCallback(async (params?: any) => {
const nextParams = { ...(params || {}) };
const nextFilters = { ...(nextParams.filters || {}) };
nextFilters.folderId = selectedFolderId;
nextParams.filters = nextFilters;
await refetch(nextParams);
}, [refetch, selectedFolderId]);
useEffect(() => {
_tableRefetch({ page: 1, pageSize: 25 });
}, [selectedFolderId, _tableRefetch]);
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
if (error) {
@ -213,7 +307,6 @@ export const FilesPage: React.FC = () => {
return (
<div className={styles.adminPage}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
@ -228,101 +321,172 @@ export const FilesPage: React.FC = () => {
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={uploadingFile}
>
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!files || files.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Dateien...</span>
</div>
) : !files || files.length === 0 ? (
<div className={styles.emptyState}>
<FaFolder className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Dateien vorhanden</h3>
<p className={styles.emptyDescription}>
Laden Sie eine Datei hoch, um loszulegen.
</p>
{/* Split-view container */}
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
>
{/* Left panel: FolderTree */}
<div style={{
width: `${leftWidth}%`,
minWidth: 0,
overflow: 'auto',
borderRight: '1px solid var(--color-border, #e0e0e0)',
padding: '8px 4px',
}}>
<FolderTree
folders={folders}
files={treeFileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={_handleTreeFileSelect}
selectedItemIds={treeSelectedIds}
onSelectionChange={setTreeSelectedIds}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_handleTreeRefresh}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={async (folderId) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
await refetch();
}}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_handleMoveFilePage}
onMoveFiles={_handleMoveFiles}
onRenameFile={_handleRenameFile}
onDeleteFile={_handleDeleteTreeFile}
onDeleteFiles={_handleDeleteTreeFiles}
onDeleteFolders={_handleDeleteTreeFolders}
/>
</div>
{/* Resizable divider */}
<div
onMouseDown={handleMouseDown}
style={{
width: 6,
cursor: 'col-resize',
background: isDragging ? 'var(--color-primary, #1976d2)' : 'transparent',
transition: isDragging ? 'none' : 'background 0.15s',
flexShrink: 0,
zIndex: 10,
}}
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }}
onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }}
/>
{/* Right panel: File table */}
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Toolbar above table */}
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
}}>
<button className={styles.secondaryButton} onClick={_handleNewFolder}>
<FaFolderPlus /> Neuer Ordner
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={uploadingFile}
>
<FaUpload /> Erste Datei hochladen
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
</button>
)}
</div>
) : (
<FormGeneratorTable
data={files}
columns={columns}
apiEndpoint="/api/files/list"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: UserFile) => deletingFiles.has(row.id),
}] : []),
]}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: handleDownload,
title: 'Herunterladen',
loading: (row: UserFile) => downloadingFiles.has(row.id),
},
{
id: 'preview',
icon: <FaEye />,
onClick: handlePreview,
title: 'Vorschau',
loading: (row: UserFile) => previewingFiles.has(row.id),
},
]}
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleFileDelete,
handleInlineUpdate,
updateOptimistically: updateFileOptimistically,
}}
emptyMessage="Keine Dateien gefunden"
/>
)}
{/* Table content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading && (!files || files.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Dateien...</span>
</div>
) : filteredFiles.length === 0 ? (
<div className={styles.emptyState}>
<FaFolder className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>
{selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'}
</h3>
<p className={styles.emptyDescription}>
{selectedFolderId
? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.'
: 'Laden Sie eine Datei hoch, um loszulegen.'}
</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> Datei hochladen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={filteredFiles}
columns={columns}
apiEndpoint="/api/files/list"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
rowDraggable={true}
onRowDragStart={_onRowDragStart}
getRowDataAttributes={(row: UserFile) =>
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: UserFile) => deletingFiles.has(row.id),
}] : []),
]}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: handleDownload,
title: 'Herunterladen',
loading: (row: UserFile) => downloadingFiles.has(row.id),
},
{
id: 'preview',
icon: <FaEye />,
onClick: handlePreview,
title: 'Vorschau',
loading: (row: UserFile) => previewingFiles.has(row.id),
},
]}
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
hookData={{
refetch: _tableRefetch,
permissions,
handleDelete: handleFileDelete,
handleInlineUpdate,
updateOptimistically: updateFileOptimistically,
}}
emptyMessage="Keine Dateien gefunden"
/>
)}
</div>
</div>
</div>
{/* Edit Modal */}
@ -331,12 +495,7 @@ export const FilesPage: React.FC = () => {
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingFile(null)}
>
</button>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}></button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (

View file

@ -337,15 +337,51 @@ export const BillingDataView: React.FC = () => {
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
const sessionIdParam = searchParams.get('session_id');
useEffect(() => {
if (successParam === 'true') {
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
refetchBalances();
} else if (canceledParam === 'true') {
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
}
}, [successParam, canceledParam, refetchBalances]);
let cancelled = false;
const _confirmCheckoutIfNeeded = async () => {
if (successParam !== 'true') {
if (canceledParam === 'true' && !cancelled) {
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
}
return;
}
if (!sessionIdParam) {
if (!cancelled) {
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
}
refetchBalances();
return;
}
try {
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
if (!cancelled) {
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' });
}
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (!cancelled) {
setCheckoutMessage({
type: 'error',
text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestaetigt werden.'
});
}
} finally {
refetchBalances();
}
};
_confirmCheckoutIfNeeded();
return () => {
cancelled = true;
};
}, [successParam, canceledParam, sessionIdParam, refetchBalances]);
const _clearStripeParams = useCallback(() => {
searchParams.delete('success');
@ -360,9 +396,16 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null);
try {
const currentUser = getUserDataCache();
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete('success');
currentUrl.searchParams.delete('canceled');
currentUrl.searchParams.delete('session_id');
currentUrl.hash = '';
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
const result = await createCheckoutSession(request, mandateId, {
userId: currentUser?.id,
amount,
returnUrl,
});
if (result?.redirectUrl) {
window.location.href = result.redirectUrl;

View file

@ -1,496 +0,0 @@
/* CodeEditor Feature Styles */
.container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.panels {
display: flex;
flex: 1;
overflow: hidden;
}
.filePanel,
.chatPanel,
.diffPanel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mainArea {
display: flex;
overflow: hidden;
}
.divider {
width: 6px;
cursor: col-resize;
background: var(--border-color, #e0e0e0);
flex-shrink: 0;
transition: background 0.15s;
}
.divider:hover {
background: var(--primary-color, #4a90d9);
}
.dragging {
cursor: col-resize;
user-select: none;
}
.dragging .divider {
background: var(--primary-color, #4a90d9);
}
/* File List Panel */
.fileList {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.panelHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.panelHeader h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.selectedCount {
font-size: 12px;
color: var(--text-secondary, #666);
}
.dragHint {
font-size: 11px;
color: var(--text-secondary, #999);
font-style: italic;
}
.fileItems {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.fileItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
cursor: grab;
transition: background 0.1s;
}
.fileItem:hover {
background: var(--hover-bg, #f5f5f5);
}
.fileItem:active {
cursor: grabbing;
opacity: 0.7;
}
.dragHandle {
color: var(--text-secondary, #ccc);
flex-shrink: 0;
font-size: 10px;
}
.fileItem:hover .dragHandle {
color: var(--text-secondary, #999);
}
.dateGroup {
margin-bottom: 4px;
}
.dateGroupHeader {
padding: 6px 16px 2px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary, #999);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fileIcon {
color: var(--text-secondary, #666);
flex-shrink: 0;
font-size: 12px;
}
.fileName {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileSize {
font-size: 11px;
color: var(--text-secondary, #999);
flex-shrink: 0;
}
.emptyState {
padding: 24px 16px;
text-align: center;
color: var(--text-secondary, #999);
font-size: 13px;
}
/* Chat Panel */
.messagesArea {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.inputArea {
border-top: 1px solid var(--border-color, #e0e0e0);
padding: 12px 16px;
}
.input {
width: 100%;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
resize: none;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.input:focus {
border-color: var(--primary-color, #4a90d9);
}
.input:disabled {
background: var(--disabled-bg, #f5f5f5);
}
.inputDropTarget {
border-color: var(--primary-color, #4a90d9);
background: var(--selected-bg, #e8f0fe);
box-shadow: 0 0 0 2px var(--primary-color, #4a90d9) inset;
}
/* Mode Toggle */
.modeToggle {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.modeButton {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: transparent;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
color: var(--text-secondary, #666);
}
.modeButton:hover {
background: var(--hover-bg, #f5f5f5);
}
.modeActive {
background: var(--primary-color, #4a90d9);
color: white;
border-color: var(--primary-color, #4a90d9);
}
.modeActive:hover {
background: var(--primary-dark, #3a7bc8);
}
.modeButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Agent Progress */
.agentProgress {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--info-light, #e8f4fd);
border-radius: 6px;
font-size: 12px;
color: var(--info-dark, #0c5460);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.inputActions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.fileCount {
font-size: 12px;
color: var(--text-secondary, #666);
}
.sendButton,
.stopButton {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: opacity 0.15s;
}
.sendButton {
background: var(--primary-color, #4a90d9);
color: white;
}
.sendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stopButton {
background: var(--danger-color, #dc3545);
color: white;
}
/* Diff Preview Panel */
.diffPreview {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.diffItems {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.diffCard {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.diffCard_pending {
border-color: var(--warning-color, #ffc107);
}
.diffCard_accepted {
border-color: var(--success-color, #28a745);
opacity: 0.7;
}
.diffCard_rejected {
border-color: var(--danger-color, #dc3545);
opacity: 0.5;
}
.diffCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--surface-bg, #f8f9fa);
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.diffFileName {
font-size: 13px;
font-weight: 600;
font-family: monospace;
}
.diffStatus {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
font-weight: 600;
}
.diffStatus_pending {
background: var(--warning-light, #fff3cd);
color: var(--warning-dark, #856404);
}
.diffStatus_accepted {
background: var(--success-light, #d4edda);
color: var(--success-dark, #155724);
}
.diffStatus_rejected {
background: var(--danger-light, #f8d7da);
color: var(--danger-dark, #721c24);
}
.diffContent {
padding: 8px 12px;
max-height: 300px;
overflow-y: auto;
}
.diffOld,
.diffNew {
margin-bottom: 8px;
}
.diffLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #666);
margin-bottom: 4px;
}
.diffOld pre,
.diffNew pre {
margin: 0;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
padding: 8px;
border-radius: 4px;
overflow-x: auto;
}
.diffOld pre {
background: var(--danger-light, #fff0f0);
}
.diffNew pre {
background: var(--success-light, #f0fff0);
}
.diffActions {
display: flex;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.acceptButton,
.rejectButton {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: opacity 0.15s;
}
.acceptButton {
background: var(--success-color, #28a745);
color: white;
}
.rejectButton {
background: var(--danger-color, #dc3545);
color: white;
}
/* Workflows Page */
.workflowsPage {
padding: 16px 24px;
}
.workflowsHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.workflowsHeader h3 {
margin: 0;
}
.refreshButton {
padding: 6px 14px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 13px;
}
.refreshButton:hover {
background: var(--hover-bg, #f5f5f5);
}
.workflowTable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.workflowTable th,
.workflowTable td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.workflowTable th {
font-weight: 600;
color: var(--text-secondary, #666);
font-size: 12px;
text-transform: uppercase;
}
.statusBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.status_running { background: var(--info-light, #e8f4fd); color: var(--info-dark, #0c5460); }
.status_completed { background: var(--success-light, #d4edda); color: var(--success-dark, #155724); }
.status_stopped { background: var(--warning-light, #fff3cd); color: var(--warning-dark, #856404); }
.status_error { background: var(--danger-light, #f8d7da); color: var(--danger-dark, #721c24); }
.status_unknown { background: #f0f0f0; color: #666; }

View file

@ -1,226 +0,0 @@
/**
* CodeEditorPage
*
* Main page for the CodeEditor feature.
* Three-panel layout: FileList (left) | Chat (center) | DiffPreview (right)
* Files are dragged from FileList into the prompt textarea as @fileName references.
*/
import React, { useState, useRef, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useCodeEditor } from './useCodeEditor';
import { FileListPanel } from './FileListPanel';
import { DiffPreviewPanel } from './DiffPreviewPanel';
import { useResizablePanels } from '../../../hooks/useResizablePanels';
import { Messages } from '../../../components/UiComponents';
import { FaPaperPlane, FaStop, FaRobot, FaEdit } from 'react-icons/fa';
import styles from './CodeEditor.module.css';
export const CodeEditorPage: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const inputRef = useRef<HTMLTextAreaElement>(null);
const [inputValue, setInputValue] = useState('');
const [mode, setMode] = useState<'simple' | 'agent'>('simple');
const [isDragOver, setIsDragOver] = useState(false);
const {
messages,
pendingEdits,
acceptEdit,
rejectEdit,
isProcessing,
sendMessage,
stopProcessing,
files,
agentProgress,
} = useCodeEditor(instanceId);
const {
leftWidth: fileListWidth,
handleMouseDown: fileListMouseDown,
containerRef: outerContainerRef,
isDragging: isDraggingLeft,
} = useResizablePanels({
storageKey: 'codeeditor-filelist-width',
defaultLeftWidth: 20,
minLeftWidth: 10,
maxLeftWidth: 40,
});
const {
leftWidth: chatWidth,
handleMouseDown: chatMouseDown,
containerRef: innerContainerRef,
isDragging: isDraggingRight,
} = useResizablePanels({
storageKey: 'codeeditor-chat-width',
defaultLeftWidth: 60,
minLeftWidth: 30,
maxLeftWidth: 80,
});
const handleSubmit = useCallback(() => {
const trimmed = inputValue.trim();
if (!trimmed || isProcessing) return;
sendMessage(trimmed, mode);
setInputValue('');
}, [inputValue, isProcessing, sendMessage, mode]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}, [handleSubmit]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/x-codeeditor-file')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const fileDataStr = e.dataTransfer.getData('application/x-codeeditor-file');
if (!fileDataStr) return;
try {
const fileData = JSON.parse(fileDataStr);
const tag = `@${fileData.fileName}`;
const textarea = inputRef.current;
if (textarea) {
const pos = textarea.selectionStart || inputValue.length;
const before = inputValue.slice(0, pos);
const after = inputValue.slice(pos);
const spaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n') ? ' ' : '';
const spaceAfter = after.length > 0 && !after.startsWith(' ') && !after.startsWith('\n') ? ' ' : '';
const newValue = `${before}${spaceBefore}${tag}${spaceAfter}${after}`;
setInputValue(newValue);
requestAnimationFrame(() => {
const newPos = pos + spaceBefore.length + tag.length + spaceAfter.length;
textarea.focus();
textarea.setSelectionRange(newPos, newPos);
});
} else {
setInputValue(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + tag + ' ');
}
} catch {
// ignore malformed drop data
}
}, [inputValue]);
return (
<div className={styles.container}>
<div
className={`${styles.panels} ${isDraggingLeft || isDraggingRight ? styles.dragging : ''}`}
ref={outerContainerRef}
>
{/* Left: File List */}
<div className={styles.filePanel} style={{ width: `${fileListWidth}%` }}>
<FileListPanel files={files} />
</div>
<div className={styles.divider} onMouseDown={fileListMouseDown} />
{/* Center + Right */}
<div className={styles.mainArea} style={{ width: `${100 - fileListWidth}%` }} ref={innerContainerRef}>
{/* Center: Chat */}
<div className={styles.chatPanel} style={{ width: `${chatWidth}%` }}>
<div className={styles.messagesArea}>
<Messages messages={messages} />
{agentProgress && isProcessing && (
<div className={styles.agentProgress}>
<FaRobot />
<span>
Round {agentProgress.round} | {agentProgress.totalToolCalls} tools |{' '}
{agentProgress.costCHF.toFixed(4)} CHF
</span>
</div>
)}
</div>
<div className={styles.inputArea}>
<div className={styles.modeToggle}>
<button
className={`${styles.modeButton} ${mode === 'simple' ? styles.modeActive : ''}`}
onClick={() => setMode('simple')}
disabled={isProcessing}
title="Single AI call -- drag files into prompt as @references"
>
<FaEdit /> Simple
</button>
<button
className={`${styles.modeButton} ${mode === 'agent' ? styles.modeActive : ''}`}
onClick={() => setMode('agent')}
disabled={isProcessing}
title="AI agent with tools (reads files autonomously, multi-step)"
>
<FaRobot /> Agent
</button>
</div>
<textarea
ref={inputRef}
className={`${styles.input} ${isDragOver ? styles.inputDropTarget : ''}`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
placeholder={mode === 'agent'
? "Describe a complex task (e.g. 'Document all Python files')..."
: "Drag files here and describe changes (e.g. 'In @config.yaml change the port to 8080')..."
}
rows={3}
disabled={isProcessing}
/>
<div className={styles.inputActions}>
<span className={styles.fileCount}>
{mode === 'simple'
? `Drag files from the list into your prompt`
: `Agent mode: AI reads files autonomously`
}
</span>
{isProcessing ? (
<button className={styles.stopButton} onClick={stopProcessing}>
<FaStop /> Stop
</button>
) : (
<button
className={styles.sendButton}
onClick={handleSubmit}
disabled={!inputValue.trim()}
>
<FaPaperPlane /> Send
</button>
)}
</div>
</div>
</div>
<div className={styles.divider} onMouseDown={chatMouseDown} />
{/* Right: Diff Preview */}
<div className={styles.diffPanel} style={{ width: `${100 - chatWidth}%` }}>
<DiffPreviewPanel
edits={pendingEdits}
onAccept={acceptEdit}
onReject={rejectEdit}
/>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,83 +0,0 @@
/**
* CodeEditorWorkflowsPage
*
* Lists CodeEditor workflows for the current feature instance.
* Uses the codeeditor-specific API endpoint instead of the generic /api/workflows/.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import styles from './CodeEditor.module.css';
interface WorkflowItem {
id: string;
label?: string;
status?: string;
workflowMode?: string;
startedAt?: number;
lastActivity?: number;
}
export const CodeEditorWorkflowsPage: React.FC = () => {
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
const [loading, setLoading] = useState(false);
const loadWorkflows = useCallback(() => {
if (!instanceId) return;
setLoading(true);
api.get(`/api/codeeditor/${instanceId}/workflows`)
.then(res => {
const items = res.data?.items || res.data?.workflows || [];
setWorkflows(Array.isArray(items) ? items : []);
})
.catch(err => console.error('Failed to load workflows:', err))
.finally(() => setLoading(false));
}, [instanceId]);
useEffect(() => { loadWorkflows(); }, [loadWorkflows]);
return (
<div className={styles.workflowsPage}>
<div className={styles.workflowsHeader}>
<h3>CodeEditor Workflows</h3>
<button className={styles.refreshButton} onClick={loadWorkflows} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
{workflows.length === 0 ? (
<div className={styles.emptyState}>
{loading ? 'Loading workflows...' : 'No workflows yet. Start a conversation in the Editor view.'}
</div>
) : (
<table className={styles.workflowTable}>
<thead>
<tr>
<th>Label</th>
<th>Status</th>
<th>Started</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
{workflows.map(wf => (
<tr key={wf.id}>
<td>{wf.label || wf.id.slice(0, 8)}</td>
<td>
<span className={`${styles.statusBadge} ${styles[`status_${wf.status || 'unknown'}`]}`}>
{wf.status || 'unknown'}
</span>
</td>
<td>{wf.startedAt ? new Date(wf.startedAt * 1000).toLocaleString() : '-'}</td>
<td>{wf.lastActivity ? new Date(wf.lastActivity * 1000).toLocaleString() : '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};

View file

@ -1,95 +0,0 @@
/**
* DiffPreviewPanel
*
* Shows file edit proposals as side-by-side text diffs.
* Each edit has Accept/Reject buttons.
*/
import React from 'react';
import { FaCheck, FaTimes } from 'react-icons/fa';
import styles from './CodeEditor.module.css';
export interface FileEditProposal {
id: string;
fileId: string;
fileName: string;
oldContent: string | null;
newContent: string;
status: 'pending' | 'accepted' | 'rejected';
}
interface DiffPreviewPanelProps {
edits: FileEditProposal[];
onAccept: (editId: string) => void;
onReject: (editId: string) => void;
}
export const DiffPreviewPanel: React.FC<DiffPreviewPanelProps> = ({ edits, onAccept, onReject }) => {
const pendingEdits = edits.filter(e => e.status === 'pending');
const resolvedEdits = edits.filter(e => e.status !== 'pending');
return (
<div className={styles.diffPreview}>
<div className={styles.panelHeader}>
<h3>Changes ({pendingEdits.length} pending)</h3>
</div>
<div className={styles.diffItems}>
{edits.length === 0 ? (
<div className={styles.emptyState}>No changes proposed yet</div>
) : (
<>
{pendingEdits.map((edit) => (
<DiffCard key={edit.id} edit={edit} onAccept={onAccept} onReject={onReject} />
))}
{resolvedEdits.map((edit) => (
<DiffCard key={edit.id} edit={edit} onAccept={onAccept} onReject={onReject} />
))}
</>
)}
</div>
</div>
);
};
const DiffCard: React.FC<{
edit: FileEditProposal;
onAccept: (id: string) => void;
onReject: (id: string) => void;
}> = ({ edit, onAccept, onReject }) => {
const isPending = edit.status === 'pending';
return (
<div className={`${styles.diffCard} ${styles[`diffCard_${edit.status}`]}`}>
<div className={styles.diffCardHeader}>
<span className={styles.diffFileName}>{edit.fileName}</span>
<span className={`${styles.diffStatus} ${styles[`diffStatus_${edit.status}`]}`}>
{edit.status}
</span>
</div>
<div className={styles.diffContent}>
{edit.oldContent && (
<div className={styles.diffOld}>
<div className={styles.diffLabel}>Old</div>
<pre>{edit.oldContent}</pre>
</div>
)}
<div className={styles.diffNew}>
<div className={styles.diffLabel}>New</div>
<pre>{edit.newContent}</pre>
</div>
</div>
{isPending && (
<div className={styles.diffActions}>
<button className={styles.acceptButton} onClick={() => onAccept(edit.id)}>
<FaCheck /> Accept
</button>
<button className={styles.rejectButton} onClick={() => onReject(edit.id)}>
<FaTimes /> Reject
</button>
</div>
)}
</div>
);
};

View file

@ -1,110 +0,0 @@
/**
* FileListPanel
*
* Lists text files grouped by date, draggable into the prompt textarea.
* Drag a file into the chat input to insert an @fileName reference.
*/
import React, { useMemo } from 'react';
import { FaFile, FaGripVertical } from 'react-icons/fa';
import styles from './CodeEditor.module.css';
export interface FileInfo {
fileId: string;
fileName: string;
mimeType: string;
sizeBytes: number;
modifiedAt?: number;
}
interface FileListPanelProps {
files: FileInfo[];
}
interface DateGroup {
label: string;
files: FileInfo[];
}
export const FileListPanel: React.FC<FileListPanelProps> = ({ files }) => {
const groups = useMemo(() => _groupByDate(files), [files]);
const handleDragStart = (e: React.DragEvent, file: FileInfo) => {
e.dataTransfer.setData('application/x-codeeditor-file', JSON.stringify({
fileId: file.fileId,
fileName: file.fileName,
}));
e.dataTransfer.setData('text/plain', `@${file.fileName}`);
e.dataTransfer.effectAllowed = 'copy';
};
return (
<div className={styles.fileList}>
<div className={styles.panelHeader}>
<h3>Files ({files.length})</h3>
<span className={styles.dragHint}>drag into prompt</span>
</div>
<div className={styles.fileItems}>
{files.length === 0 ? (
<div className={styles.emptyState}>No text files uploaded yet</div>
) : (
groups.map((group) => (
<div key={group.label} className={styles.dateGroup}>
<div className={styles.dateGroupHeader}>{group.label}</div>
{group.files.map((file) => (
<div
key={file.fileId}
className={styles.fileItem}
draggable
onDragStart={(e) => handleDragStart(e, file)}
>
<FaGripVertical className={styles.dragHandle} />
<FaFile className={styles.fileIcon} />
<span className={styles.fileName}>{file.fileName}</span>
<span className={styles.fileSize}>{_formatSize(file.sizeBytes)}</span>
</div>
))}
</div>
))
)}
</div>
</div>
);
};
function _groupByDate(files: FileInfo[]): DateGroup[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const yesterdayStart = todayStart - 86400;
const today: FileInfo[] = [];
const yesterday: FileInfo[] = [];
const older: FileInfo[] = [];
const sorted = [...files].sort((a, b) => (b.modifiedAt || 0) - (a.modifiedAt || 0));
for (const file of sorted) {
const ts = file.modifiedAt || 0;
if (ts >= todayStart) {
today.push(file);
} else if (ts >= yesterdayStart) {
yesterday.push(file);
} else {
older.push(file);
}
}
const groups: DateGroup[] = [];
if (today.length > 0) groups.push({ label: 'Today', files: today });
if (yesterday.length > 0) groups.push({ label: 'Yesterday', files: yesterday });
if (older.length > 0) groups.push({ label: 'Older', files: older });
if (groups.length === 0 && files.length > 0) groups.push({ label: 'All files', files: sorted });
return groups;
}
function _formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -1,2 +0,0 @@
export { CodeEditorPage } from './CodeEditorPage';
export { CodeEditorWorkflowsPage } from './CodeEditorWorkflowsPage';

View file

@ -1,260 +0,0 @@
/**
* useCodeEditor Hook
*
* Manages SSE connection, message state, edit proposals, and agent progress.
* File references are extracted from @fileName tags in the prompt text.
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import api from '../../../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../../../utils/csrfUtils';
import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { FileInfo } from './FileListPanel';
import type { FileEditProposal } from './DiffPreviewPanel';
export interface AgentProgress {
round: number;
totalAiCalls: number;
totalToolCalls: number;
costCHF: number;
}
interface UseCodeEditorReturn {
messages: Message[];
pendingEdits: FileEditProposal[];
acceptEdit: (editId: string) => void;
rejectEdit: (editId: string) => void;
isProcessing: boolean;
sendMessage: (prompt: string, mode?: 'simple' | 'agent') => void;
stopProcessing: () => void;
files: FileInfo[];
agentProgress: AgentProgress | null;
}
export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
const [messages, setMessages] = useState<Message[]>([]);
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [files, setFiles] = useState<FileInfo[]>([]);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!instanceId) return;
_loadFiles(instanceId, setFiles);
}, [instanceId]);
const sendMessage = useCallback((prompt: string, mode: 'simple' | 'agent' = 'simple') => {
if (!instanceId || isProcessing) return;
const referencedFileIds = _extractFileRefs(prompt, files);
setIsProcessing(true);
setAgentProgress(null);
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
workflowId: workflowId || '',
role: 'user',
message: prompt,
publishedAt: Date.now() / 1000,
}]);
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
const params = new URLSearchParams();
if (workflowId) params.set('workflowId', workflowId);
params.set('mode', mode);
const baseURL = api.defaults.baseURL || '';
const url = `${baseURL}/api/codeeditor/${instanceId}/start/stream?${params.toString()}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const authToken = localStorage.getItem('authToken');
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
if (!getCSRFToken()) {
generateAndStoreCSRFToken();
}
addCSRFTokenToHeaders(headers);
fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
prompt: prompt,
listFileId: referencedFileIds,
}),
credentials: 'include',
signal: abortRef.current.signal,
}).then(async (response) => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6);
try {
const event = JSON.parse(jsonStr);
_handleSseEvent(event, setMessages, setPendingEdits, setWorkflowId, setAgentProgress);
if (event.type === 'complete' || event.type === 'error' || event.type === 'stopped') {
setIsProcessing(false);
}
} catch {
// skip unparseable lines
}
}
}
}
setIsProcessing(false);
}).catch((err) => {
if (err.name === 'AbortError') return;
console.error('CodeEditor SSE error:', err);
setMessages(prev => [...prev, {
id: `error-${Date.now()}`,
workflowId: '',
role: 'system',
message: `Connection error: ${err.message}`,
publishedAt: Date.now() / 1000,
}]);
setIsProcessing(false);
});
}, [instanceId, isProcessing, workflowId, files]);
const stopProcessing = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
}
if (!instanceId || !workflowId) return;
api.post(`/api/codeeditor/${instanceId}/${workflowId}/stop`).catch(console.error);
setIsProcessing(false);
}, [instanceId, workflowId]);
const acceptEdit = useCallback((editId: string) => {
const edit = pendingEdits.find(e => e.id === editId);
if (!edit || !instanceId || !workflowId) return;
api.post(`/api/codeeditor/${instanceId}/${workflowId}/apply`, {
fileId: edit.fileId,
fileName: edit.fileName,
newContent: edit.newContent,
}).then(() => {
setPendingEdits(prev => prev.map(e =>
e.id === editId ? { ...e, status: 'accepted' as const } : e
));
_loadFiles(instanceId, setFiles);
}).catch(console.error);
}, [pendingEdits, instanceId, workflowId]);
const rejectEdit = useCallback((editId: string) => {
setPendingEdits(prev => prev.map(e =>
e.id === editId ? { ...e, status: 'rejected' as const } : e
));
}, []);
return {
messages,
pendingEdits,
acceptEdit,
rejectEdit,
isProcessing,
sendMessage,
stopProcessing,
files,
agentProgress,
};
}
function _loadFiles(instanceId: string, setFiles: React.Dispatch<React.SetStateAction<FileInfo[]>>) {
api.get(`/api/codeeditor/${instanceId}/files`)
.then(res => setFiles(res.data.files || []))
.catch(err => console.error('Failed to load files:', err));
}
function _extractFileRefs(prompt: string, files: FileInfo[]): string[] {
const atPattern = /@([\w.\-]+)/g;
const matchedIds: string[] = [];
let match;
while ((match = atPattern.exec(prompt)) !== null) {
const refName = match[1];
const file = files.find(f => f.fileName === refName || f.fileName.toLowerCase() === refName.toLowerCase());
if (file && !matchedIds.includes(file.fileId)) {
matchedIds.push(file.fileId);
}
}
return matchedIds;
}
function _handleSseEvent(
event: any,
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
setPendingEdits: React.Dispatch<React.SetStateAction<FileEditProposal[]>>,
setWorkflowId: React.Dispatch<React.SetStateAction<string | null>>,
setAgentProgress: React.Dispatch<React.SetStateAction<AgentProgress | null>>
) {
if (event.type === 'message' && event.item) {
const item = event.item;
setMessages(prev => [...prev, {
id: item.id || `msg-${Date.now()}-${Math.random()}`,
workflowId: item.workflowId || '',
role: item.role || 'assistant',
message: item.content || '',
publishedAt: item.createdAt || Date.now() / 1000,
documents: item.documents,
}]);
} else if (event.type === 'file_edit_proposal' && event.item) {
setPendingEdits(prev => [...prev, event.item]);
} else if (event.type === 'status') {
setMessages(prev => {
const lastIsStatus = prev.length > 0 && prev[prev.length - 1].role === 'status';
const statusMsg: Message = {
id: `status-${Date.now()}`,
workflowId: '',
role: 'status',
message: event.label || '',
publishedAt: Date.now() / 1000,
};
return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg];
});
} else if (event.type === 'agent_progress' && event.item) {
setAgentProgress(event.item);
} else if (event.type === 'agent_summary' && event.item) {
const s = event.item;
setMessages(prev => [...prev, {
id: `summary-${Date.now()}`,
workflowId: '',
role: 'system',
message: `Agent completed: ${s.rounds} rounds, ${s.totalToolCalls} tool calls, ${s.costCHF} CHF, ${s.processingTime}s`,
publishedAt: Date.now() / 1000,
}]);
setAgentProgress(null);
} else if (event.type === 'complete' && event.workflowId) {
setWorkflowId(event.workflowId);
}
}

View file

@ -6,7 +6,8 @@
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useCommcoach, type TtsEvent } from '../../../hooks/useCommcoach';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
@ -46,7 +47,9 @@ export const CommcoachDossierView: React.FC = () => {
const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage;
const voice = useVoiceController((text) => sendMessageRef.current(text));
const voice = useVoiceController({
onFinalText: (text) => sendMessageRef.current(text),
});
// #region agent log
const debugLogsRef = useRef<string[]>([]);
@ -116,13 +119,13 @@ export const CommcoachDossierView: React.FC = () => {
}, [activeTab, coach.session?.id, voice]);
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
voice.cancelPendingSpeech();
await coach.sendMessage(coach.inputValue);
}, [coach, voice]);
}, [coach]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
@ -335,7 +338,10 @@ export const CommcoachDossierView: React.FC = () => {
<span className={styles.sessionLabel}>Session aktiv</span>
<div className={styles.sessionActions}>
{voice.state === 'botSpeaking' && (
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
<>
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
</>
)}
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>

View file

@ -4,18 +4,15 @@
* States: idle | listening | botSpeaking | interrupted
* Muted: orthogonal boolean flag (independent of main state)
*
* Recognition is STOPPED during botSpeaking or when muted=true.
* Recognition is STARTED when entering listening/interrupted AND muted=false.
* Each start() creates a fresh results session (processedIndex resets to 0).
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
* Google Streaming STT handles silence detection natively.
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { useState, useRef, useCallback } from 'react';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
const SILENCE_TIMEOUT_MS = 1000;
const REC_AUTORESTART_DELAY_MS = 300;
export interface VoiceControllerApi {
state: VoiceState;
muted: boolean;
@ -26,28 +23,25 @@ export interface VoiceControllerApi {
ttsPaused: () => void;
ttsEnded: () => void;
toggleMute: () => void;
cancelPendingSpeech: () => void;
}
export function useVoiceController(onMessage: (text: string) => void): VoiceControllerApi {
export interface VoiceControllerCallbacks {
onFinalText?: (text: string) => void | Promise<void>;
onInterimText?: (text: string) => void;
}
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const stateRef = useRef<VoiceState>('idle');
const mutedRef = useRef(false);
const streamRef = useRef<MediaStream | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
const processedIndexRef = useRef(0);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
const _dlog = useCallback((tag: string, info?: string) => {
const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
(window as any).__dlog?.(entry);
(window as any).__dlog?.(`[${ts}] ${tag}${info ? ' ' + info : ''}`);
}, []);
const _setState = useCallback((next: VoiceState) => {
@ -64,183 +58,51 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
_dlog('MUTED', String(next));
}, [_dlog]);
const _cancelSilenceTimer = useCallback(() => {
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = null;
}
}, []);
const _finalizeTranscript = useCallback(() => {
const full = transcriptPartsRef.current.join(' ').trim();
_dlog('SEND', `"${full.substring(0, 80)}"`);
if (full) onMessageRef.current(full);
transcriptPartsRef.current = [];
setLiveTranscript('');
}, [_dlog]);
const _resetSilenceTimer = useCallback(() => {
_cancelSilenceTimer();
silenceTimerRef.current = setTimeout(() => {
_finalizeTranscript();
}, SILENCE_TIMEOUT_MS);
}, [_cancelSilenceTimer, _finalizeTranscript]);
const _startRecognition = useCallback(() => {
if (mutedRef.current) return;
const rec = recognitionRef.current;
if (!rec) return;
try {
rec.start();
_dlog('REC-START', 'ok');
} catch {
_dlog('REC-START', 'failed');
}
}, [_dlog]);
const _stopRecognition = useCallback(() => {
const rec = recognitionRef.current;
if (!rec) return;
try {
rec.stop();
} catch {
/* ignore */
}
}, []);
const _createRecognition = useCallback(() => {
const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognitionApi) return;
const recognition = new SpeechRecognitionApi();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'de-DE';
recognition.onspeechstart = () => {
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
_resetSilenceTimer();
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
const interimParts: string[] = [];
for (let i = processedIndexRef.current; i < event.results.length; i++) {
const r = event.results[i];
if (r.isFinal) {
const text = r[0].transcript.trim();
if (text) transcriptPartsRef.current.push(text);
processedIndexRef.current = i + 1;
} else {
const text = r[0].transcript.trim();
if (text) interimParts.push(text);
}
}
const currentInterim = interimParts.join(' ');
const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
setLiveTranscript(preview);
if (preview) _resetSilenceTimer();
};
recognition.onspeechend = () => {
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
_resetSilenceTimer();
};
recognition.onend = () => {
_dlog('REC-END', `state=${stateRef.current} muted=${mutedRef.current}`);
if (recognitionRef.current !== recognition) return;
const cur = stateRef.current;
if (cur === 'botSpeaking' || cur === 'idle' || mutedRef.current) return;
processedIndexRef.current = 0;
setTimeout(() => {
if (recognitionRef.current !== recognition) return;
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
if (mutedRef.current) return;
try {
recognition.start();
_dlog('REC-AUTOSTART', 'ok');
} catch {
_dlog('REC-AUTOSTART', 'failed');
}
}, REC_AUTORESTART_DELAY_MS);
};
recognition.onerror = (event: any) => {
_dlog('REC-ERR', event.error);
if (event.error === 'no-speech' || event.error === 'aborted') return;
console.warn('SpeechRecognition error:', event.error);
};
recognitionRef.current = recognition;
_startRecognition();
}, [_dlog, _resetSilenceTimer, _startRecognition]);
const voiceStream = useVoiceStream({
onFinal: (text) => {
cbRef.current.onFinalText?.(text);
},
onInterim: (text) => {
cbRef.current.onInterimText?.(text);
},
onError: (err) => _dlog('VOICE-ERR', String(err)),
});
const activate = useCallback(async () => {
if (stateRef.current !== 'idle') return;
_setState('listening');
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setLiveTranscript('');
try {
if (!streamRef.current) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
});
streamRef.current = stream;
}
_createRecognition();
await voiceStream.start('de-DE');
} catch (err) {
console.warn('Mic access failed:', err);
_dlog('MIC-ERR', String(err));
_setState('idle');
}
}, [_setState, _createRecognition]);
}, [_setState, voiceStream, _dlog]);
const deactivate = useCallback(() => {
_cancelSilenceTimer();
voiceStream.stop();
_setState('idle');
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch { /* ignore */ }
recognitionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setLiveTranscript('');
}, [_setState, _cancelSilenceTimer]);
}, [_setState, voiceStream]);
const ttsPlaying = useCallback(() => {
const cur = stateRef.current;
if (cur === 'idle') return;
_cancelSilenceTimer();
_finalizeTranscript();
_stopRecognition();
voiceStream.stop();
_setState('botSpeaking');
}, [_setState, _cancelSilenceTimer, _finalizeTranscript, _stopRecognition]);
}, [_setState, voiceStream]);
const ttsPaused = useCallback(() => {
const cur = stateRef.current;
if (cur !== 'botSpeaking') return;
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setLiveTranscript('');
if (stateRef.current !== 'botSpeaking') return;
_setState('interrupted');
_startRecognition();
}, [_setState, _startRecognition]);
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, voiceStream, _dlog]);
const ttsEnded = useCallback(() => {
const cur = stateRef.current;
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setLiveTranscript('');
_setState('listening');
_startRecognition();
}, [_setState, _startRecognition]);
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, voiceStream, _dlog]);
const toggleMute = useCallback(() => {
const cur = stateRef.current;
@ -248,45 +110,23 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
if (mutedRef.current) {
_setMuted(false);
if (cur === 'listening' || cur === 'interrupted') {
_startRecognition();
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
}
} else {
_setMuted(true);
_stopRecognition();
voiceStream.stop();
}
}, [_setMuted, _startRecognition, _stopRecognition]);
const cancelPendingSpeech = useCallback(() => {
_cancelSilenceTimer();
transcriptPartsRef.current = [];
setLiveTranscript('');
_dlog('CANCEL-SPEECH', 'pending speech cleared for text input');
}, [_cancelSilenceTimer, _dlog]);
useEffect(() => {
return () => {
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch { /* ignore */ }
recognitionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
};
}, []);
}, [_setMuted, voiceStream, _dlog]);
return {
state,
muted,
liveTranscript,
liveTranscript: voiceStream.interimText,
activate,
deactivate,
ttsPlaying,
ttsPaused,
ttsEnded,
toggleMute,
cancelPendingSpeech,
};
}

View file

@ -444,7 +444,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
const connectionRef = getConnectionReference(msftConnection);
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
await api.post(`/api/automations/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
} catch (err: any) {
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';

View file

@ -166,7 +166,7 @@ export const TrusteeScanUploadView: React.FC = () => {
isPollingRef.current = true;
try {
const chatDataRes = await api.get(
`/api/chatplayground/${instanceId}/${workflowId}/chatData`,
`/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
{
params: latestTimestampRef.current
? { afterTimestamp: latestTimestampRef.current }
@ -307,7 +307,7 @@ export const TrusteeScanUploadView: React.FC = () => {
const template = buildTemplate(fileIds);
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
const response = await api.post(
`/api/chatplayground/${instanceId}/start`,
`/api/automations/${instanceId}/start`,
{ prompt },
{ params: { workflowMode: 'Automation' } }
);

View file

@ -19,6 +19,7 @@ interface ChatStreamProps {
pendingEdits: FileEditProposal[];
onAcceptEdit: (editId: string) => void;
onRejectEdit: (editId: string) => void;
onOpenEditor?: () => void;
}
export const ChatStream: React.FC<ChatStreamProps> = ({
@ -28,6 +29,7 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
pendingEdits,
onAcceptEdit,
onRejectEdit,
onOpenEditor,
}) => {
const bottomRef = useRef<HTMLDivElement>(null);
@ -138,10 +140,9 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
</div>
))}
{/* File edit proposals */}
{pendingEdits.filter(e => e.status === 'pending').map((edit) => (
{/* File edit proposals -- compact notification cards */}
{pendingEdits.filter(e => e.status === 'pending').length > 0 && (
<div
key={edit.id}
style={{
flexShrink: 0,
padding: 12,
@ -152,49 +153,54 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
maxWidth: '85%',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#ff9800' }}></span>
File Edit Proposal: {edit.fileName}
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
</div>
<pre style={{
fontSize: 12,
maxHeight: 160,
overflow: 'auto',
margin: 0,
padding: 8,
background: '#1e1e1e',
color: '#d4d4d4',
borderRadius: 4,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{edit.newContent?.slice(0, 800)}
{(edit.newContent?.length || 0) > 800 && '\n...'}
</pre>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
{pendingEdits.filter(e => e.status === 'pending').map(edit => (
<div key={edit.id} style={{ fontSize: 12, color: '#555', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ff9800', flexShrink: 0 }} />
{edit.fileName}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{onOpenEditor && (
<button
onClick={onOpenEditor}
style={{
padding: '5px 14px', borderRadius: 4, border: 'none',
background: 'var(--primary-color, #1976d2)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Im Editor pruefen
</button>
)}
<button
onClick={() => onAcceptEdit(edit.id)}
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onAcceptEdit(e.id))}
style={{
padding: '4px 14px', borderRadius: 4, border: 'none',
padding: '5px 14px', borderRadius: 4, border: 'none',
background: 'var(--success-color, #4caf50)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Accept
Alle annehmen
</button>
<button
onClick={() => onRejectEdit(edit.id)}
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onRejectEdit(e.id))}
style={{
padding: '4px 14px', borderRadius: 4,
padding: '5px 14px', borderRadius: 4,
border: '1px solid var(--border-color, #ccc)',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}
>
Reject
Alle ablehnen
</button>
</div>
</div>
))}
)}
{/* Agent progress */}
{isProcessing && agentProgress && (

View file

@ -303,21 +303,29 @@ export const ConversationList: React.FC<ConversationListProps> = ({
}}
/>
) : (
<span
style={{
fontSize: 13,
fontWeight: isActive ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
title={conv.name}
>
{conv.name}
</span>
<>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0, marginRight: 6 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
<span
style={{
fontSize: 13,
fontWeight: isActive ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
title={conv.name}
>
{conv.name}
</span>
</>
)}
{/* Action buttons (visible on hover) */}
@ -383,29 +391,6 @@ export const ConversationList: React.FC<ConversationListProps> = ({
)}
</div>
{/* Status + last activity */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 3 }}>
<span style={{ fontSize: 10, color: '#999' }}>
{conv.status === 'active' && (
<span style={{ color: '#4caf50' }}>{'\u25CF'} aktiv</span>
)}
{conv.status === 'completed' && (
<span style={{ color: '#888' }}>{'\u25CF'} abgeschlossen</span>
)}
{conv.status === 'archived' && (
<span style={{ color: '#ff9800' }}>{'\u25CF'} archiviert</span>
)}
{!['active', 'completed', 'archived'].includes(conv.status) && (
<span>{conv.status}</span>
)}
</span>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
</div>
</div>
);
})}

View file

@ -1,76 +1,84 @@
/**
* FileBrowser -- Tree-structured file browser.
* FileBrowser -- Folder-tree file browser for workspace.
*
* Level 1: Feature instance (group header, collapsible)
* Level 2: Files sorted alphabetically
*
* Supports search, drag-and-drop upload, and file selection.
* Uses useFileContext() for folders (shared state with Dateien page).
* Uses FolderTree with showFiles=true so folders and files render inline.
*/
import React, { useState, useCallback, useRef, useMemo } from 'react';
import api from '../../../api';
import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace';
import FolderTree from '../../../components/FolderTree/FolderTree';
import type { FileNode } from '../../../components/FolderTree/FolderTree';
import { useFileContext } from '../../../contexts/FileContext';
import type { WorkspaceFile } from './useWorkspace';
interface FileBrowserProps {
instanceId: string;
files: WorkspaceFile[];
folders: WorkspaceFolder[];
onRefresh: () => void;
onFileSelect?: (fileId: string) => void;
}
interface _InstanceGroup {
instanceId: string;
label: string;
files: WorkspaceFile[];
}
export const FileBrowser: React.FC<FileBrowserProps> = ({
instanceId,
files,
folders: _folders,
onRefresh,
onFileSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const _filteredFiles = useMemo(() => {
if (!searchQuery.trim()) return files;
const q = searchQuery.toLowerCase();
return files.filter(f =>
f.fileName.toLowerCase().includes(q)
|| (f.tags || []).some(t => t.toLowerCase().includes(q)),
);
const {
folders,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
const _folderNodes = useMemo(() =>
folders.map(f => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
})),
[folders],
);
const _fileNodes: FileNode[] = useMemo(() => {
let result: WorkspaceFile[] = files;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(f =>
f.fileName.toLowerCase().includes(q)
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
);
}
return result
.sort((a, b) => a.fileName.localeCompare(b.fileName))
.map(f => ({
id: f.id,
fileName: f.fileName,
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
}));
}, [files, searchQuery]);
const _groups = useMemo((): _InstanceGroup[] => {
const map: Record<string, _InstanceGroup> = {};
for (const f of _filteredFiles) {
const key = f.featureInstanceId || '_workspace';
if (!map[key]) {
map[key] = {
instanceId: key,
label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)),
files: [],
};
}
map[key].files.push(f);
}
for (const g of Object.values(map)) {
g.files.sort((a, b) => a.fileName.localeCompare(b.fileName));
}
const groups = Object.values(map);
groups.sort((a, b) => a.label.localeCompare(b.label));
return groups;
}, [_filteredFiles]);
const _toggleGroup = (key: string) => {
setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
};
const _refreshAll = useCallback(() => {
onRefresh();
refreshFolders();
}, [onRefresh, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!instanceId || uploading) return;
@ -84,18 +92,20 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
headers: { 'Content-Type': 'multipart/form-data' },
});
}
onRefresh();
_refreshAll();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [instanceId, uploading, onRefresh]);
}, [instanceId, uploading, _refreshAll]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
@ -120,9 +130,46 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
}
}, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
onRefresh();
}, [handleMoveFile, onRefresh]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
onRefresh();
}, [contextMoveFiles, onRefresh]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
onRefresh();
}, [handleDeleteFolder, selectedFolderId, onRefresh]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
onRefresh();
}, [onRefresh]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
onRefresh();
}, [handleFileDelete, onRefresh]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
onRefresh();
}, [onRefresh]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
refreshFolders();
onRefresh();
}, [refreshFolders, onRefresh]);
return (
<div
style={{ padding: 8, position: 'relative' }}
style={{ padding: 8, position: 'relative', display: 'flex', flexDirection: 'column', gap: 4 }}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
@ -140,7 +187,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
)}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
@ -151,7 +198,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
>
{uploading ? '...' : '+'}
</button>
<button onClick={onRefresh} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
<button onClick={_refreshAll} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
</div>
</div>
@ -165,94 +212,39 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
border: '1px solid #ddd', boxSizing: 'border-box',
}}
/>
{/* Tree */}
{_groups.length === 0 && (
{/* Folder tree with inline files */}
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
{_groups.map(group => {
const isCollapsed = !!collapsed[group.instanceId];
return (
<div key={group.instanceId} style={{ marginBottom: 4 }}>
{/* Group header */}
<div
onClick={() => _toggleGroup(group.instanceId)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 6px', cursor: 'pointer', borderRadius: 4,
background: 'var(--bg-secondary, #f5f5f5)',
marginBottom: 2,
}}
onMouseEnter={e => (e.currentTarget.style.background = '#eee')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)')}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center' }}>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
<span style={{ fontSize: 12 }}>{'\uD83D\uDCC1'}</span>
<span style={{ fontSize: 12, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{group.label}
</span>
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>{group.files.length}</span>
</div>
{/* Files */}
{!isCollapsed && group.files.map(file => (
<div
key={file.id}
onClick={() => onFileSelect?.(file.id)}
style={{
padding: '4px 8px 4px 28px', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
borderRadius: 4,
cursor: onFileSelect ? 'pointer' : 'default',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={e => (e.currentTarget.style.background = '')}
>
<span style={{ fontSize: 11, flexShrink: 0 }}>{_fileIcon(file.mimeType)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.fileName}
</div>
{file.tags && file.tags.length > 0 && (
<div style={{ display: 'flex', gap: 3, marginTop: 2 }}>
{file.tags.map(tag => (
<span key={tag} style={{ fontSize: 9, padding: '1px 5px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
{tag}
</span>
))}
</div>
)}
</div>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
</div>
))}
</div>
);
})}
</div>
);
};
function _fileIcon(mime: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
if (mime.startsWith('video/')) return '\uD83C\uDFA5';
return '\uD83D\uDCC4';
}

View file

@ -0,0 +1,278 @@
/**
* WorkspaceEditorPage -- Diff editor for reviewing AI agent file edit proposals.
*
* Full-page layout with:
* - Header: back-to-dashboard, accept-all / reject-all
* - Tab bar: one tab per pending edit
* - Center: Monaco DiffEditor (original vs. modified)
* - Footer: status bar with counts and file metadata
*/
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { DiffEditor } from '@monaco-editor/react';
import type { editor as monacoEditor } from 'monaco-editor';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
function _getMonacoLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const langMap: Record<string, string> = {
js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
py: 'python', json: 'json', html: 'html', css: 'css', md: 'markdown',
xml: 'xml', yaml: 'yaml', yml: 'yaml', sh: 'shell', sql: 'sql',
txt: 'plaintext', csv: 'plaintext', log: 'plaintext',
};
return langMap[ext] || 'plaintext';
}
function _formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const WorkspaceEditorPage: React.FC = () => {
const instanceId = useInstanceId() || '';
const navigate = useNavigate();
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
mandateId: string; featureCode: string; instanceId: string;
}>();
const editor = useWorkspaceEditor(instanceId);
const activeEdit = useMemo(
() => editor.edits.find(e => e.id === editor.activeEditId) || null,
[editor.edits, editor.activeEditId],
);
const pendingEdits = useMemo(
() => editor.edits.filter(e => e.status === 'pending'),
[editor.edits],
);
const _goBack = () => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/dashboard`);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
Keine Workspace-Instanz ausgewaehlt.
</div>
);
}
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
background: 'var(--bg-primary, #fff)',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 16px', borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)', flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={_goBack} style={_btnStyle} title="Zurueck zum Dashboard">
<FaArrowLeft size={14} />
</button>
<span style={{ fontWeight: 600, fontSize: 15 }}>
File Edit Review
</span>
<span style={{ fontSize: 13, color: '#888' }}>
{editor.pendingCount} pending
</span>
<button onClick={editor.refresh} style={_btnStyle} title="Aktualisieren">
<FaSync size={12} />
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={editor.acceptAll}
disabled={editor.pendingCount === 0}
style={{ ..._actionBtnStyle, background: 'var(--success-color, #4caf50)', color: '#fff' }}
>
<FaCheckDouble size={12} /> Accept All
</button>
<button
onClick={editor.rejectAll}
disabled={editor.pendingCount === 0}
style={{ ..._actionBtnStyle, background: 'transparent', border: '1px solid var(--border-color, #ccc)' }}
>
<FaBan size={12} /> Reject All
</button>
</div>
</div>
{/* Tab bar */}
{pendingEdits.length > 0 && (
<div style={{
display: 'flex', overflowX: 'auto', flexShrink: 0,
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)',
}}>
{pendingEdits.map(edit => (
<_EditorTab
key={edit.id}
edit={edit}
isActive={edit.id === editor.activeEditId}
onClick={() => editor.setActiveEditId(edit.id)}
/>
))}
</div>
)}
{/* Main content */}
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{editor.isLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888' }}>
Lade Aenderungsvorschlaege...
</div>
) : pendingEdits.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888', gap: 12 }}>
<span style={{ fontSize: 48, opacity: 0.3 }}></span>
<span style={{ fontSize: 16 }}>Keine offenen Aenderungsvorschlaege</span>
<button onClick={_goBack} style={{ ..._actionBtnStyle, marginTop: 8 }}>
Zurueck zum Dashboard
</button>
</div>
) : activeEdit ? (
<_SafeDiffEditor
key={activeEdit.id}
original={activeEdit.oldContent}
modified={activeEdit.newContent}
language={_getMonacoLanguage(activeEdit.fileName)}
/>
) : null}
</div>
{/* Footer / action bar for active edit */}
{activeEdit && activeEdit.status === 'pending' && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 16px', borderTop: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)', flexShrink: 0,
}}>
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
<span>{activeEdit.fileName}</span>
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => editor.acceptEdit(activeEdit.id)}
style={{ ..._actionBtnStyle, background: 'var(--success-color, #4caf50)', color: '#fff' }}
>
<FaCheck size={12} /> Accept
</button>
<button
onClick={() => editor.rejectEdit(activeEdit.id)}
style={{ ..._actionBtnStyle, border: '1px solid var(--error-color, #f44336)', color: 'var(--error-color, #f44336)' }}
>
<FaTimes size={12} /> Reject
</button>
</div>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Safe DiffEditor wrapper -- prevents "TextModel got disposed" errors
// by tracking the editor ref and skipping disposal when already torn down.
// ---------------------------------------------------------------------------
const _SafeDiffEditor: React.FC<{
original: string;
modified: string;
language: string;
}> = ({ original, modified, language }) => {
const editorRef = useRef<monacoEditor.IDiffEditor | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setReady(true);
return () => {
if (editorRef.current) {
try {
editorRef.current.dispose();
} catch { /* already disposed */ }
editorRef.current = null;
}
};
}, []);
if (!ready) return null;
return (
<DiffEditor
original={original}
modified={modified}
language={language}
theme="vs-dark"
onMount={(diffEditor) => { editorRef.current = diffEditor; }}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
originalEditable: false,
}}
/>
);
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const _EditorTab: React.FC<{
edit: EditorFileEdit;
isActive: boolean;
onClick: () => void;
}> = ({ edit, isActive, onClick }) => (
<button
onClick={onClick}
style={{
padding: '6px 16px',
fontSize: 13,
border: 'none',
borderBottom: isActive ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
background: isActive ? 'var(--bg-primary, #fff)' : 'transparent',
cursor: 'pointer',
whiteSpace: 'nowrap',
color: isActive ? 'var(--text-primary, #333)' : 'var(--text-secondary, #888)',
fontWeight: isActive ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: edit.status === 'pending' ? '#ff9800'
: edit.status === 'accepted' ? '#4caf50' : '#f44336',
flexShrink: 0,
}} />
{edit.fileName}
</button>
);
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
const _btnStyle: React.CSSProperties = {
padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border-color, #ddd)',
background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center',
};
const _actionBtnStyle: React.CSSProperties = {
padding: '5px 14px', borderRadius: 4, border: 'none',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 6,
};
export default WorkspaceEditorPage;

View file

@ -1,10 +1,11 @@
/**
* WorkspaceInput -- Prompt input with @file autocomplete, attachment bar,
* voice toggle (live transcript via SpeechRecognition), and data source selection.
* voice toggle (generic audio capture hook), and data source selection.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource } from './useWorkspace';
const _STT_LANGUAGES = [
@ -22,13 +23,16 @@ const _STT_LANGUAGES = [
{ code: 'zh-CN', label: 'Chinese' },
];
function _getSpeechRecognitionApi(): (new () => SpeechRecognition) | null {
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
}
interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
interface TreeItemDrop {
id: string;
type: 'file' | 'folder';
name: string;
}
interface WorkspaceInputProps {
@ -45,6 +49,8 @@ interface WorkspaceInputProps {
selectedProviders?: string[];
onProvidersChange?: (providers: string[]) => void;
isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void;
}
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
@ -61,21 +67,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
selectedProviders = [],
onProvidersChange,
isMobile = false,
onTreeItemsDrop,
onPasteAsFile,
}) => {
const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState('');
const [treeDropOver, setTreeDropOver] = useState(false);
const [voiceActive, setVoiceActive] = useState(false);
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
const [, setLiveTranscript] = useState('');
const [showLangPicker, setShowLangPicker] = useState(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
const processedIndexRef = useRef(0);
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
useEffect(() => {
localStorage.setItem('workspace_stt_lang', voiceLanguage);
@ -171,98 +178,60 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
);
}, []);
const _stopRecognition = useCallback(() => {
if (recognitionRef.current) {
try { recognitionRef.current.stop(); } catch { /* ignore */ }
recognitionRef.current = null;
}
const finalText = transcriptPartsRef.current.join(' ').trim();
if (finalText) {
setPrompt(() => {
const base = promptBeforeVoiceRef.current;
return base ? `${base} ${finalText}` : finalText;
});
}
setLiveTranscript('');
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setVoiceActive(false);
const _buildPromptFromRefs = useCallback(() => {
const parts = [
promptBeforeVoiceRef.current,
finalizedTextRef.current,
currentInterimRef.current,
].filter(Boolean);
return parts.join(' ');
}, []);
const voiceStream = useVoiceStream({
onFinal: (text) => {
finalizedTextRef.current = finalizedTextRef.current
? `${finalizedTextRef.current} ${text}`
: text;
currentInterimRef.current = '';
setPrompt(_buildPromptFromRefs());
},
onInterim: (text) => {
currentInterimRef.current = text;
setPrompt(_buildPromptFromRefs());
},
onError: (error) => {
console.warn('Workspace voice stream error', error);
},
});
const _stopVoiceCapture = useCallback(() => {
if (currentInterimRef.current) {
finalizedTextRef.current = finalizedTextRef.current
? `${finalizedTextRef.current} ${currentInterimRef.current}`
: currentInterimRef.current;
currentInterimRef.current = '';
}
setPrompt(_buildPromptFromRefs());
voiceStream.stop();
setVoiceActive(false);
}, [voiceStream, _buildPromptFromRefs]);
const _toggleVoice = useCallback(async () => {
if (voiceActive) {
_stopRecognition();
return;
}
const SpeechRecognitionApi = _getSpeechRecognitionApi();
if (!SpeechRecognitionApi) {
console.error('SpeechRecognition not supported in this browser');
return;
}
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
console.error('Microphone access denied');
_stopVoiceCapture();
return;
}
promptBeforeVoiceRef.current = prompt;
transcriptPartsRef.current = [];
processedIndexRef.current = 0;
setLiveTranscript('');
const recognition = new SpeechRecognitionApi();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = voiceLanguage;
recognition.onresult = (event: SpeechRecognitionEvent) => {
const interimParts: string[] = [];
for (let i = processedIndexRef.current; i < event.results.length; i++) {
const r = event.results[i];
if (r.isFinal) {
const text = r[0].transcript.trim();
if (text) transcriptPartsRef.current.push(text);
processedIndexRef.current = i + 1;
} else {
const text = r[0].transcript.trim();
if (text) interimParts.push(text);
}
}
const finalSoFar = transcriptPartsRef.current.join(' ');
const interim = interimParts.join(' ');
const combined = [finalSoFar, interim].filter(Boolean).join(' ');
setLiveTranscript(combined);
const base = promptBeforeVoiceRef.current;
const display = base ? `${base} ${combined}` : combined;
setPrompt(display);
};
recognition.onerror = (event: any) => {
if (event.error === 'no-speech' || event.error === 'aborted') return;
console.warn('SpeechRecognition error:', event.error);
};
recognition.onend = () => {
if (!recognitionRef.current) return;
processedIndexRef.current = 0;
setTimeout(() => {
if (!recognitionRef.current) return;
try { recognitionRef.current.start(); } catch { /* ignore */ }
}, 300);
};
finalizedTextRef.current = '';
currentInterimRef.current = '';
try {
recognition.start();
recognitionRef.current = recognition;
setVoiceActive(true);
} catch (err) {
console.error('SpeechRecognition start failed:', err);
await voiceStream.start(voiceLanguage);
} catch {
setVoiceActive(false);
}
}, [voiceActive, voiceLanguage, prompt, _stopRecognition]);
}, [voiceActive, prompt, voiceStream, voiceLanguage, _stopVoiceCapture]);
const filteredFiles = showAutocomplete
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
@ -272,12 +241,52 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const _horizontalPadding = isMobile ? 12 : 24;
const _controlSize = isMobile ? 38 : 40;
const _handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (!onPasteAsFile) return;
const text = e.clipboardData.getData('text/plain');
if (text && text.length >= 1000) {
e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], `pasted-text-${Date.now()}.txt`, { type: 'text/plain' });
onPasteAsFile(file);
}
}, [onPasteAsFile]);
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true);
}
}, []);
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson && onTreeItemsDrop) {
e.preventDefault();
e.stopPropagation();
setTreeDropOver(false);
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
onTreeItemsDrop(items);
}
}, [onTreeItemsDrop]);
return (
<div style={{
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
}}>
<div
style={{
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
outline: treeDropOver ? '2px dashed #1976d2' : 'none',
background: treeDropOver ? 'rgba(25, 118, 210, 0.04)' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handlePromptDragOver}
onDragLeave={_handlePromptDragLeave}
onDrop={_handlePromptDrop}
>
{/* Pending uploaded files */}
{pendingFiles.length > 0 && (
<div style={{
@ -294,11 +303,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#fff3e0', color: '#e65100', fontWeight: 500,
border: '1px solid #ffe0b2',
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500,
border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
}}
>
📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
{pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
{onRemovePendingFile && (
<button
onClick={() => onRemovePendingFile(pf.fileId)}
@ -426,6 +437,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
value={prompt}
onChange={_handleChange}
onKeyDown={_handleKeyDown}
onPaste={_handlePaste}
placeholder="Type a message... Use @filename to reference files"
disabled={isProcessing}
style={{

View file

@ -8,6 +8,7 @@
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useFileOperations } from '../../../hooks/useFiles';
import { useWorkspace } from './useWorkspace';
@ -57,6 +58,7 @@ type RightTab = 'activity' | 'preview';
interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
interface WorkspacePageProps {
@ -68,6 +70,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const instanceId = persistentInstanceId || instance?.id || '';
const workspace = useWorkspace(instanceId);
const fileOps = useFileOperations();
const navigate = useNavigate();
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
mandateId: string; featureCode: string; instanceId: string;
}>();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const _leftResize = _useResizable(280, 200, 450);
@ -156,6 +162,20 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => {
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.fileId));
const toAdd: PendingFile[] = [];
for (const item of items) {
if (!existing.has(item.id)) {
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
existing.add(item.id);
}
}
return [...prev, ...toAdd];
});
}, []);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
@ -212,7 +232,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
<FileBrowser
instanceId={instanceId}
files={workspace.files}
folders={workspace.folders}
onRefresh={workspace.refreshFiles}
onFileSelect={_handleFileSelect}
/>
@ -372,6 +391,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
pendingEdits={workspace.pendingEdits}
onAcceptEdit={workspace.acceptEdit}
onRejectEdit={workspace.rejectEdit}
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
/>
<WorkspaceInput
instanceId={instanceId}
@ -391,6 +411,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
selectedProviders={selectedProviders}
onProvidersChange={setSelectedProviders}
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach}
/>
</main>

View file

@ -60,7 +60,11 @@ export interface FileEditProposal {
id: string;
fileId: string;
fileName: string;
newContent: string;
mimeType?: string;
oldContent?: string;
newContent?: string;
oldSize?: number;
newSize?: number;
status: 'pending' | 'accepted' | 'rejected';
}
@ -232,8 +236,17 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
setAgentProgress(null);
},
onFileEditProposal: (event) => {
if (event.item) {
setPendingEdits(prev => [...prev, event.item]);
const data = event.item || event.data || {};
if (data.id) {
setPendingEdits(prev => [...prev, {
id: data.id,
fileId: data.fileId || '',
fileName: data.fileName || '',
mimeType: data.mimeType || '',
oldSize: data.oldSize || 0,
newSize: data.newSize || 0,
status: 'pending' as const,
}]);
}
},
onFileVersion: (event) => {
@ -359,21 +372,38 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const acceptEdit = useCallback(
(editId: string) => {
const edit = pendingEdits.find(e => e.id === editId);
if (!edit || !instanceId || !workflowId) return;
if (!instanceId) return;
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)),
);
refreshFiles();
api.post(`/api/workspace/${instanceId}/edit/${editId}/accept`)
.then(() => refreshFiles())
.catch(err => {
console.error('Failed to accept edit:', err);
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)),
);
});
},
[pendingEdits, instanceId, workflowId, refreshFiles],
[instanceId, refreshFiles],
);
const rejectEdit = useCallback((editId: string) => {
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)),
);
}, []);
const rejectEdit = useCallback(
(editId: string) => {
if (!instanceId) return;
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)),
);
api.post(`/api/workspace/${instanceId}/edit/${editId}/reject`)
.catch(err => {
console.error('Failed to reject edit:', err);
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)),
);
});
},
[instanceId],
);
return {
messages,

View file

@ -0,0 +1,127 @@
/**
* useWorkspaceEditor Hook
*
* State management for the workspace editor page.
* Loads pending file edit proposals from the API,
* provides accept/reject actions, and tracks the active tab.
*/
import { useState, useCallback, useEffect } from 'react';
import api from '../../../api';
export interface EditorFileEdit {
id: string;
fileId: string;
fileName: string;
mimeType: string;
oldContent: string;
newContent: string;
status: 'pending' | 'accepted' | 'rejected';
workflowId: string;
}
interface UseWorkspaceEditorReturn {
edits: EditorFileEdit[];
activeEditId: string | null;
isLoading: boolean;
setActiveEditId: (id: string | null) => void;
acceptEdit: (editId: string) => Promise<void>;
rejectEdit: (editId: string) => Promise<void>;
acceptAll: () => Promise<void>;
rejectAll: () => Promise<void>;
refresh: () => void;
pendingCount: number;
}
export function useWorkspaceEditor(instanceId: string): UseWorkspaceEditorReturn {
const [edits, setEdits] = useState<EditorFileEdit[]>([]);
const [activeEditId, setActiveEditId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const refresh = useCallback(() => {
if (!instanceId) return;
setIsLoading(true);
api.get(`/api/workspace/${instanceId}/pending-edits`)
.then(res => {
const loadedEdits: EditorFileEdit[] = (res.data.edits || []).map((e: any) => ({
id: e.id,
fileId: e.fileId || '',
fileName: e.fileName || '',
mimeType: e.mimeType || '',
oldContent: e.oldContent || '',
newContent: e.newContent || '',
status: e.status || 'pending',
workflowId: e.workflowId || '',
}));
setEdits(loadedEdits);
if (loadedEdits.length > 0 && !activeEditId) {
setActiveEditId(loadedEdits[0].id);
}
})
.catch(err => console.error('Failed to load pending edits:', err))
.finally(() => setIsLoading(false));
}, [instanceId, activeEditId]);
useEffect(() => {
refresh();
}, [instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
const acceptEdit = useCallback(async (editId: string) => {
if (!instanceId) return;
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)));
try {
await api.post(`/api/workspace/${instanceId}/edit/${editId}/accept`);
} catch (err) {
console.error('Failed to accept edit:', err);
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)));
}
}, [instanceId]);
const rejectEdit = useCallback(async (editId: string) => {
if (!instanceId) return;
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)));
try {
await api.post(`/api/workspace/${instanceId}/edit/${editId}/reject`);
} catch (err) {
console.error('Failed to reject edit:', err);
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)));
}
}, [instanceId]);
const acceptAll = useCallback(async () => {
if (!instanceId) return;
setEdits(prev => prev.map(e => (e.status === 'pending' ? { ...e, status: 'accepted' as const } : e)));
try {
await api.post(`/api/workspace/${instanceId}/edit/accept-all`);
} catch (err) {
console.error('Failed to accept all edits:', err);
refresh();
}
}, [instanceId, refresh]);
const rejectAll = useCallback(async () => {
if (!instanceId) return;
setEdits(prev => prev.map(e => (e.status === 'pending' ? { ...e, status: 'rejected' as const } : e)));
try {
await api.post(`/api/workspace/${instanceId}/edit/reject-all`);
} catch (err) {
console.error('Failed to reject all edits:', err);
refresh();
}
}, [instanceId, refresh]);
const pendingCount = edits.filter(e => e.status === 'pending').length;
return {
edits,
activeEditId,
isLoading,
setActiveEditId,
acceptEdit,
rejectEdit,
acceptAll,
rejectAll,
refresh,
pendingCount,
};
}

View file

@ -1,593 +0,0 @@
/**
* PlaygroundPage Styles
*
* Resizable two-column layout for Chat Playground.
* Uses existing Nyla CSS variables and design patterns.
*/
/* Main container */
.playgroundContainer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
padding: 1rem;
gap: 1rem;
}
/* Page header */
.pageHeader {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.headerLeft {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.headerTitleRow {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.headerStats {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
background-color: var(--bg-secondary);
padding: 0.25rem 0.75rem;
border-radius: 12px;
}
.headerStatItem {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.pageSubtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0.25rem 0 0 0;
}
.headerControls {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Main content area with resizable columns */
.mainContent {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
min-height: 0;
}
/* Left panel - Chat/Messages */
.leftPanel {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 300px;
}
/* Resizable divider between panels */
.resizeDivider {
width: 8px;
cursor: col-resize;
background-color: transparent;
position: relative;
flex-shrink: 0;
z-index: 10;
transition: background-color 0.15s ease;
}
.resizeDivider:hover,
.resizeDivider.dragging {
background-color: var(--border-color);
}
.dividerHandle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 40px;
border-radius: 2px;
background-color: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s ease;
}
.resizeDivider:hover .dividerHandle,
.resizeDivider.dragging .dividerHandle {
opacity: 0.5;
}
/* Right panel - Dashboard */
.rightPanel {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 200px;
background: var(--surface-color);
border-left: 1px solid var(--border-color);
border-radius: 0 8px 8px 0;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.panelTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.panelContent {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Content section */
.contentSection {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.contentHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.contentArea {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
/* Messages container */
.messagesContainer {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Empty state */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
text-align: center;
}
.emptyIcon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.emptyTitle {
font-size: 1.125rem;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
}
.emptyDescription {
margin: 0;
max-width: 400px;
}
/* Footer / Input area */
.inputFooter {
flex-shrink: 0;
padding: 1rem;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.inputRow {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.selectors {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.inputWrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.textareaWrapper {
position: relative;
}
.inputTextarea {
width: 100%;
min-height: 80px;
max-height: 200px;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
resize: vertical;
transition: border-color 0.2s;
}
.inputTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.inputTextarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.inputControls {
display: flex;
justify-content: space-between;
align-items: center;
}
.fileButtons {
display: flex;
gap: 0.5rem;
}
.actionButtons {
display: flex;
gap: 0.5rem;
}
/* Buttons */
.iconButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.iconButton:hover:not(:disabled) {
background: var(--bg-secondary);
color: var(--text-primary);
}
.iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.primaryButton:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.stopButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--danger-color, #e53e3e);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.stopButton:hover:not(:disabled) {
background: #c53030;
}
.secondaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.secondaryButton:hover:not(:disabled) {
background: var(--bg-secondary);
}
/* Select/Dropdown */
.selector {
min-width: 150px;
}
.selectDropdown {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
min-width: 150px;
}
.selectDropdown:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
/* Pending files */
.pendingFiles {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
}
.pendingFile {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.75rem;
}
.pendingFileName {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeFileButton {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
}
.removeFileButton:hover {
background: var(--danger-color, #e53e3e);
color: white;
}
/* Dragging state - prevent text selection */
.mainContent.dragging {
user-select: none;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.mainContent {
flex-direction: column;
}
.leftPanel,
.rightPanel {
width: 100% !important;
}
.resizeDivider {
display: none;
}
.rightPanel {
border-left: none;
border-top: 1px solid var(--border-color);
border-radius: 0 0 8px 8px;
max-height: 300px;
}
}
/* Loading spinner */
.loadingSpinner {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Drag & Drop Styles */
.dragOver {
position: relative;
}
.dragOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(var(--primary-rgb, 242, 88, 67), 0.1);
border: 2px dashed var(--primary-color, #f25843);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.dragOverlayContent {
text-align: center;
color: var(--primary-color, #f25843);
font-size: 1rem;
font-weight: 500;
}
.dragOverFooter {
border-color: var(--primary-color, #f25843);
background: rgba(var(--primary-rgb, 242, 88, 67), 0.05);
}
/* Prompts Row */
.promptsRow {
display: flex;
align-items: center;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 0.75rem;
}
.promptsSelect {
display: flex;
align-items: center;
flex: 1;
max-width: 400px;
}
.promptDropdown {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
}
.promptDropdown:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.promptDropdown:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Voice Recording Button */
.iconButton.recording {
background: var(--danger-color, #e53e3e);
border-color: var(--danger-color, #e53e3e);
color: white;
animation: pulse 1.5s infinite;
}
.iconButton.recording:hover {
background: #c53030;
border-color: #c53030;
color: white;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(229, 62, 62, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(229, 62, 62, 0);
}
}

View file

@ -1,811 +0,0 @@
/**
* PlaygroundPage (Chat Playground)
*
* Global page for workflow execution and chat interaction.
* Features a resizable two-column layout with chat on the left and dashboard on the right.
* Includes: Drag & Drop file upload, Prompts selection, Voice input
*/
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDashboardInputForm } from '../../hooks/usePlayground';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { usePrompts } from '../../hooks/usePrompts';
import { useCurrentInstance } from '../../hooks/useCurrentInstance';
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents';
import { ProviderMultiSelect } from '../../components/ProviderSelector';
import type { Message } from '../../components/UiComponents/Messages/MessagesTypes';
import api from '../../api';
import styles from './PlaygroundPage.module.css';
export const PlaygroundPage: React.FC = () => {
// Read workflowId from URL query parameters
const [searchParams] = useSearchParams();
const urlWorkflowId = searchParams.get('workflowId');
// Get feature instance context
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
// Main hook for input form and data
const hookData = useDashboardInputForm(instanceId);
const {
inputValue,
onInputChange,
isRunning,
isStopping,
handleSubmit,
handleStop,
isSubmitting,
workflowStatus,
messages,
dashboardTree,
onToggleOperationExpanded,
onToggleRoundExpanded,
currentRound,
workflowId,
onWorkflowSelect,
workflowItems,
pendingFiles,
handleFileRemove,
handleFileDelete,
handleFileView,
handleFileDownload,
latestStats,
playgroundUIPermission,
deletingFiles,
previewingFiles,
downloadingFiles,
handleMessageDelete,
deletingMessages,
selectedProviders,
onProvidersChange,
} = hookData;
const { prompts, refetch: refetchPrompts } = usePrompts();
const { showError, showSuccess } = useToast();
// Resizable panels hook
const {
leftWidth,
isDragging,
handleMouseDown,
containerRef,
} = useResizablePanels({
storageKey: 'playground-panel-width',
defaultLeftWidth: 70,
minLeftWidth: 40,
maxLeftWidth: 85,
});
// File input ref for hidden file input
const fileInputRef = useRef<HTMLInputElement>(null);
// Drag & Drop state
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
// Voice recording state
const [isRecording, setIsRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
// Voice language selection (defaults to user profile language)
const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage();
// Prompts dropdown state
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
// Load prompts on mount
useEffect(() => {
refetchPrompts();
}, []);
// Load workflow from URL parameter
const urlWorkflowLoadedRef = useRef(false);
// Debug: Log URL parameter status
useEffect(() => {
console.log('🔍 PlaygroundPage URL debug:', {
urlWorkflowId,
currentWorkflowId: workflowId,
hasOnWorkflowSelect: !!onWorkflowSelect,
alreadyLoaded: urlWorkflowLoadedRef.current,
fullUrl: window.location.href
});
}, [urlWorkflowId, workflowId, onWorkflowSelect]);
useEffect(() => {
// Only load once on mount, and only if we have a URL workflowId
if (urlWorkflowId && !urlWorkflowLoadedRef.current && onWorkflowSelect) {
urlWorkflowLoadedRef.current = true;
console.log('🔗 Loading workflow from URL:', urlWorkflowId);
// Small delay to ensure hooks are initialized
setTimeout(() => {
onWorkflowSelect({ id: urlWorkflowId, label: '', value: urlWorkflowId });
}, 100);
}
}, [urlWorkflowId, onWorkflowSelect]);
// Handle prompt selection
const handlePromptSelect = (promptId: string) => {
setSelectedPromptId(promptId);
if (promptId) {
const prompt = prompts?.find((p: any) => p.id === promptId);
if (prompt && prompt.content) {
// Append prompt content to input
const currentText = inputValue || '';
const newText = currentText ? `${currentText}\n\n${prompt.content}` : prompt.content;
onInputChange(newText);
}
}
};
// Drag & Drop handlers
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0 && hookData.handleFileUpload) {
for (const file of Array.from(files)) {
await hookData.handleFileUpload(file);
}
}
}, [hookData.handleFileUpload]);
// Voice recording handlers
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Find supported MIME type
const mimeTypes = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
];
let mimeType = '';
for (const type of mimeTypes) {
if (MediaRecorder.isTypeSupported(type)) {
mimeType = type;
break;
}
}
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunks.push(e.data);
}
};
recorder.onstop = async () => {
// Stop all tracks
stream.getTracks().forEach(track => track.stop());
// Process recording
if (chunks.length > 0) {
const audioBlob = new Blob(chunks, { type: mimeType || 'audio/webm' });
await processVoiceRecording(audioBlob);
}
};
recorder.start();
setMediaRecorder(recorder);
setIsRecording(true);
} catch (error: any) {
console.error('Error starting recording:', error);
showError('Mikrofonzugriff verweigert', 'Bitte erlauben Sie den Mikrofonzugriff in Ihren Browser-Einstellungen.');
}
};
const stopRecording = () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
setIsRecording(false);
setMediaRecorder(null);
}
};
const processVoiceRecording = async (audioBlob: Blob) => {
try {
// Create FormData for speech-to-text API
const formData = new FormData();
formData.append('audioFile', audioBlob, 'voice_recording.webm');
formData.append('language', voiceLanguage);
// Call speech-to-text API (Google Cloud)
const response = await api.post('/voice-google/speech-to-text', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data?.success && response.data?.text) {
const transcribedText = response.data.text.trim();
// Append transcribed text to input
const currentText = inputValue || '';
const newText = currentText ? `${currentText} ${transcribedText}` : transcribedText;
onInputChange(newText);
showSuccess('Transkription erfolgreich', 'Text wurde hinzugefügt.');
} else {
showError('Transkription fehlgeschlagen', response.data?.error || 'Unbekannter Fehler');
}
} catch (error: any) {
console.error('Error processing voice recording:', error);
showError('Transkription fehlgeschlagen', error.message || 'Fehler bei der Sprachverarbeitung');
}
};
const handleVoiceClick = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
// Simple wrapper for workflow selection
const handleWorkflowChange = (id: string | null) => {
if (!id) {
onWorkflowSelect(null);
} else {
const item = workflowItems?.find((w: any) => w.id === id);
if (item) {
onWorkflowSelect(item);
}
}
};
// Handle file upload click
const handleFileClick = () => {
fileInputRef.current?.click();
};
// Handle file change
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && hookData.handleFileUpload) {
for (const file of Array.from(files)) {
await hookData.handleFileUpload(file);
}
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Render messages using the Messages component with document support
const renderMessages = () => {
if (!messages || messages.length === 0) {
return (
<div className={styles.emptyState}>
<FaComment className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Nachrichten</h3>
<p className={styles.emptyDescription}>
Starten Sie einen neuen Workflow oder wählen Sie einen bestehenden aus.
</p>
</div>
);
}
return (
<Messages
messages={messages as any as Message[]}
variant="chat"
showDocuments={true}
showMetadata={false}
onFileDelete={handleFileDelete}
onFileRemove={handleFileRemove}
onFileView={handleFileView}
onFileDownload={handleFileDownload}
deletingFiles={deletingFiles}
previewingFiles={previewingFiles}
downloadingFiles={downloadingFiles}
workflowId={workflowId}
onMessageDelete={handleMessageDelete}
deletingMessages={deletingMessages}
emptyMessage="Keine Nachrichten"
/>
);
};
// Render dashboard tree with rounds
const renderDashboard = () => {
// Check if we have rounds data
const hasRounds = dashboardTree && dashboardTree.rounds && dashboardTree.rounds.size > 0;
const hasOperations = dashboardTree && dashboardTree.rootOperations.length > 0;
if (!hasRounds && !hasOperations) {
return (
<div className={styles.emptyState} style={{ padding: '2rem' }}>
<FaTasks className={styles.emptyIcon} style={{ fontSize: '2rem' }} />
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>
Keine aktiven Operationen
</p>
</div>
);
}
const renderOperation = (operationId: string, depth: number = 0, roundOperations?: Map<string, any>) => {
const operation = roundOperations?.get(operationId) || dashboardTree.operations.get(operationId);
if (!operation) return null;
const childOps = Array.from(dashboardTree.operations.entries())
.filter(([_, op]) => op.parentId === operationId)
.map(([id]) => id);
return (
<div
key={operationId}
style={{
paddingLeft: `${depth * 1}rem`,
paddingTop: '0.5rem',
paddingBottom: '0.5rem',
borderBottom: depth === 0 ? '1px solid var(--border-color)' : 'none',
}}
>
<div
onClick={() => onToggleOperationExpanded(operationId)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: childOps.length > 0 ? 'pointer' : 'default',
}}
>
{childOps.length > 0 && (
<span style={{
fontSize: '0.75rem',
color: 'var(--text-secondary)',
transform: operation.expanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
}}>
</span>
)}
<span style={{
flex: 1,
fontSize: '0.8125rem',
color: 'var(--text-primary)',
fontWeight: depth === 0 ? 500 : 400,
}}>
{operation.operationName || operationId.slice(0, 20)}
</span>
{operation.latestProgress !== null && operation.latestProgress < 1 && (
<span style={{
fontSize: '0.6875rem',
color: 'var(--text-secondary)',
}}>
{Math.round(operation.latestProgress * 100)}%
</span>
)}
{operation.latestStatus && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
? 'var(--success-bg, #dcfce7)'
: operation.latestStatus === 'running'
? 'var(--info-bg, #dbeafe)'
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
? 'var(--danger-bg, #fee2e2)'
: 'var(--bg-secondary)',
color: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
? 'var(--success-color, #16a34a)'
: operation.latestStatus === 'running'
? 'var(--info-color, #2563eb)'
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
? 'var(--danger-color, #dc2626)'
: 'var(--text-secondary)',
}}>
{operation.latestStatus}
</span>
)}
</div>
{operation.expanded && childOps.length > 0 && (
<div style={{ marginTop: '0.25rem' }}>
{childOps.map(childId => renderOperation(childId, depth + 1, roundOperations))}
</div>
)}
</div>
);
};
// If we have rounds, render them
if (hasRounds) {
const sortedRounds = Array.from(dashboardTree.rounds.entries()).sort((a, b) => a[0] - b[0]);
return (
<div>
{sortedRounds.map(([roundNumber, round]) => (
<div key={`round-${roundNumber}`} style={{ marginBottom: '0.5rem' }}>
{/* Round Header */}
<div
onClick={() => onToggleRoundExpanded(roundNumber)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
background: roundNumber === currentRound
? 'var(--primary-bg, #eff6ff)'
: 'var(--bg-secondary)',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: round.expanded ? '0.5rem' : '0',
}}
>
<span style={{
fontSize: '0.75rem',
color: 'var(--text-secondary)',
transform: round.expanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
}}>
</span>
<span style={{
flex: 1,
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--text-primary)',
}}>
Runde {roundNumber}
</span>
{round.isCompleted && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: 'var(--success-bg, #dcfce7)',
color: 'var(--success-color, #16a34a)',
}}>
abgeschlossen
</span>
)}
{roundNumber === currentRound && !round.isCompleted && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: 'var(--info-bg, #dbeafe)',
color: 'var(--info-color, #2563eb)',
}}>
aktiv
</span>
)}
</div>
{/* Round Operations */}
{round.expanded && (
<div style={{
paddingLeft: '0.5rem',
borderLeft: '2px solid var(--border-color)',
marginLeft: '0.5rem',
}}>
{round.rootOperations.map(opId => renderOperation(opId, 0, round.operations))}
</div>
)}
</div>
))}
</div>
);
}
// Fallback: render without rounds (for backward compatibility)
return (
<div>
{dashboardTree.rootOperations.map(opId => renderOperation(opId))}
</div>
);
};
// Permission check - also show while loading
if (playgroundUIPermission === false) {
return (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>Kein Zugriff</h3>
<p className={styles.emptyDescription}>
Sie haben keine Berechtigung für den Chat Playground.
</p>
</div>
</div>
);
}
// Show loading state while permission is being checked (undefined)
if (playgroundUIPermission === undefined) {
return (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
<p>Lade...</p>
</div>
</div>
);
}
return (
<div className={styles.playgroundContainer}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* Page Header */}
<header className={styles.pageHeader}>
<div className={styles.headerLeft}>
<div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1>
{latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
<div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Kosten">
CHF {latestStats.priceCHF.toFixed(2)}
</span>
</div>
)}
</div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div>
<div className={styles.headerControls}>
<select
className={styles.selectDropdown}
value={workflowId || ''}
onChange={(e) => handleWorkflowChange(e.target.value || null)}
disabled={isRunning}
>
<option value="">Neuer Workflow</option>
{workflowItems?.map((item: any) => (
<option key={item.id} value={item.id}>
{item.label || item.id}
</option>
))}
</select>
</div>
</header>
{/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
<div
ref={containerRef}
className={`${styles.mainContent} ${isDragging ? styles.dragging : ''} ${isDragOver ? styles.dragOver : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className={styles.dragOverlay}>
<div className={styles.dragOverlayContent}>
<FaFile style={{ fontSize: '3rem', marginBottom: '1rem' }} />
<p>Dateien hier ablegen</p>
</div>
</div>
)}
{/* Left Panel - Chat Messages */}
<div
className={styles.leftPanel}
style={{ width: `${leftWidth}%` }}
>
<div className={styles.contentSection}>
<div className={styles.contentHeader}>
<h3 className={styles.panelTitle}>
<FaComment style={{ marginRight: '0.5rem' }} />
Nachrichten
</h3>
</div>
<div className={styles.contentArea}>
{renderMessages()}
</div>
</div>
</div>
{/* Resize Divider */}
<div
className={`${styles.resizeDivider} ${isDragging ? styles.dragging : ''}`}
onMouseDown={handleMouseDown}
>
<div className={styles.dividerHandle} />
</div>
{/* Right Panel - Dashboard */}
<div
className={styles.rightPanel}
style={{ width: `${100 - leftWidth}%` }}
>
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<FaTasks style={{ marginRight: '0.5rem' }} />
Dashboard
</h3>
</div>
<div className={styles.panelContent}>
{renderDashboard()}
</div>
</div>
</div>
{/* Input Footer */}
<div
className={`${styles.inputFooter} ${isDragOver ? styles.dragOverFooter : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Prompts Selection Row */}
<div className={styles.promptsRow}>
<div className={styles.promptsSelect}>
<FaFileAlt style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginRight: '0.5rem' }} />
<select
className={styles.promptDropdown}
value={selectedPromptId}
onChange={(e) => handlePromptSelect(e.target.value)}
disabled={isRunning}
>
<option value="">Prompt-Vorlage wählen...</option>
{prompts?.map((prompt: any) => (
<option key={prompt.id} value={prompt.id}>
{prompt.name || prompt.content?.substring(0, 50) + '...'}
</option>
))}
</select>
</div>
</div>
{/* Pending files */}
{pendingFiles && pendingFiles.length > 0 && (
<div className={styles.pendingFiles}>
{pendingFiles.map((file: any) => (
<div key={file.fileId} className={styles.pendingFile}>
<FaFile style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }} />
<span className={styles.pendingFileName}>{file.fileName}</span>
<button
className={styles.removeFileButton}
onClick={() => handleFileRemove(file)}
title="Entfernen"
>
×
</button>
</div>
))}
</div>
)}
{/* Input row */}
<div className={styles.inputRow}>
<div className={styles.inputWrapper}>
<div className={styles.textareaWrapper}>
<textarea
className={styles.inputTextarea}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
placeholder={
isRunning
? "Workflow läuft. Neue Eingabe zum Unterbrechen und Neustarten..."
: workflowStatus === 'completed'
? "Workflow abgeschlossen. Neue Eingabe zum Fortsetzen..."
: workflowStatus === 'failed'
? "Workflow fehlgeschlagen. Neue Eingabe zum Wiederholen..."
: workflowStatus === 'stopped'
? "Workflow gestoppt. Neue Eingabe zum Fortfahren..."
: !workflowId
? "Geben Sie einen Prompt ein, um zu starten..."
: "Geben Sie Ihre Nachricht ein oder ziehen Sie Dateien hierher..."
}
disabled={false}
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<div className={styles.inputControls}>
<div className={styles.fileButtons}>
<button
className={styles.iconButton}
onClick={handleFileClick}
disabled={false}
title="Datei anhängen"
>
<FaPlus />
</button>
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
excludeByDefault={['privatellm']}
/>
<VoiceLanguageSelect
value={voiceLanguage}
onChange={setVoiceLanguage}
disabled={isRecording}
compact={true}
title="Sprache für Spracherkennung"
/>
<button
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
onClick={handleVoiceClick}
disabled={false}
title={isRecording ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
>
{isRecording ? <FaSquare /> : <FaMicrophone />}
</button>
</div>
<div className={styles.actionButtons}>
{/* Stop button - only visible when running */}
{isRunning && (
<button
type="button"
className={styles.stopButton}
onClick={handleStop}
disabled={isStopping}
title="Workflow stoppen"
>
<FaStop />
{isStopping ? 'Stoppt...' : 'Stop'}
</button>
)}
{/* Send button - always visible with dynamic text */}
<button
type="button"
className={styles.primaryButton}
onClick={handleSubmit}
disabled={!inputValue.trim() || isSubmitting}
>
<FaPaperPlane />
{isSubmitting
? 'Senden...'
: isRunning
? 'Neue Eingabe'
: !workflowId
? 'Starten'
: 'Senden'
}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default PlaygroundPage;

View file

@ -1,298 +0,0 @@
/* WorkflowPages.module.css - Shared styles for workflow pages */
.page {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.header {
margin-bottom: 2rem;
}
.header h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-text-primary, #1a1a2e);
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--color-text-secondary, #6b7280);
margin: 0;
}
.content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 1.5rem;
}
.section h2 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary, #1a1a2e);
margin: 0 0 1rem 0;
}
/* Loading, Error, Empty states */
.loading,
.error,
.empty {
text-align: center;
padding: 3rem;
color: var(--color-text-secondary, #6b7280);
}
.error {
color: var(--color-error, #dc2626);
}
/* Table styles */
.tableContainer {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th,
.table td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.table th {
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface-secondary, #f9fafb);
}
.table tbody tr:hover {
background: var(--color-surface-hover, #f3f4f6);
}
/* Badge styles */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-surface-secondary, #f3f4f6);
color: var(--color-text-secondary, #6b7280);
}
.badge.running,
.badge.active {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
}
.badge.completed {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.badge.error,
.badge.failed {
background: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
}
.badge.stopped,
.badge.pending {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
}
/* Button styles */
.actions {
display: flex;
gap: 0.5rem;
}
.deleteButton,
.executeButton,
.submitButton,
.stopButton,
.toggleButton {
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.deleteButton {
background: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
border-color: var(--color-error, #dc2626);
}
.deleteButton:hover:not(:disabled) {
background: var(--color-error, #dc2626);
color: white;
}
.executeButton {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
border-color: var(--color-info, #2563eb);
}
.executeButton:hover:not(:disabled) {
background: var(--color-info, #2563eb);
color: white;
}
.submitButton {
background: var(--color-primary, #4f46e5);
color: white;
}
.submitButton:hover:not(:disabled) {
background: var(--color-primary-dark, #4338ca);
}
.stopButton {
background: var(--color-error, #dc2626);
color: white;
}
.stopButton:hover:not(:disabled) {
background: var(--color-error-dark, #b91c1c);
}
.toggleButton {
background: var(--color-surface-secondary, #f3f4f6);
color: var(--color-text-secondary, #6b7280);
border-color: var(--color-border, #e5e7eb);
}
.toggleButton.active {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
border-color: var(--color-success, #16a34a);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Form styles */
.select {
width: 100%;
max-width: 400px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-surface, #ffffff);
font-size: 0.875rem;
}
.inputForm {
display: flex;
flex-direction: column;
gap: 1rem;
}
.textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
resize: vertical;
min-height: 100px;
}
.textarea:focus {
outline: none;
border-color: var(--color-primary, #4f46e5);
box-shadow: 0 0 0 3px var(--color-primary-light, rgba(79, 70, 229, 0.1));
}
.buttonGroup {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Messages display */
.messagesContainer {
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: var(--color-surface-secondary, #f9fafb);
border-radius: 6px;
}
.messageRole {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
}
.messageContent {
color: var(--color-text-primary, #1a1a2e);
white-space: pre-wrap;
}
.emptyMessage {
text-align: center;
color: var(--color-text-secondary, #6b7280);
padding: 2rem;
}
/* Log display */
.logContainer {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: monospace;
font-size: 0.8rem;
}
.logEntry {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--color-surface-secondary, #f9fafb);
border-radius: 4px;
}
.logStatus {
font-weight: 600;
color: var(--color-info, #2563eb);
}
.logMessage {
color: var(--color-text-primary, #1a1a2e);
}

View file

@ -1,262 +0,0 @@
/**
* WorkflowsPage
*
* Page for viewing and managing workflows using FormGeneratorTable.
* Follows the pattern established in AdminUsersPage.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useUserWorkflows, useWorkflowOperations, getWorkflowApiBaseUrl } from '../../hooks/useWorkflows';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaList, FaPlay } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../hooks/useCurrentInstance';
import styles from '../admin/Admin.module.css';
interface Workflow {
id: string;
name?: string;
status: string;
workflowMode?: string;
[key: string]: any;
}
export const WorkflowsPage: React.FC = () => {
const navigate = useNavigate();
const { instanceId, featureCode } = useCurrentInstance();
const workflowOptions = instanceId && featureCode ? { instanceId, featureCode } : undefined;
const apiBaseUrl = getWorkflowApiBaseUrl(instanceId, featureCode);
const apiEndpoint = apiBaseUrl ? `${apiBaseUrl}/workflows` : '';
// Data hook - pass instance context when in feature route
const {
data: workflows,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchWorkflowById,
updateOptimistically,
} = useUserWorkflows(workflowOptions);
// Operations hook - pass instance context when in feature route
const {
handleWorkflowDelete,
handleWorkflowDeleteMultiple,
handleWorkflowUpdate,
handleInlineUpdate,
deletingWorkflows,
} = useWorkflowOperations(workflowOptions);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
// Initial fetch on mount
useEffect(() => {
refetch();
}, []);
// Generate columns from attributes
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
}));
}, [attributes]);
// Check permissions
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click - fetch full workflow data
const handleEditClick = async (workflow: Workflow) => {
const fullWorkflow = await fetchWorkflowById(workflow.id);
if (fullWorkflow) {
setEditingWorkflow(fullWorkflow as Workflow);
}
};
// Handle continue workflow - navigate to playground within same feature instance
// Uses relative navigation since WorkflowsPage is rendered under same instance route as playground
const handleContinueWorkflow = (workflow: Workflow) => {
// Navigate relatively to playground (sibling route under same instance)
navigate(`../playground?workflowId=${workflow.id}`);
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<Workflow>) => {
if (!editingWorkflow) return;
const result = await handleWorkflowUpdate(editingWorkflow.id, data);
if (result.success) {
setEditingWorkflow(null);
refetch();
}
};
// Handle delete single workflow (confirmation handled by DeleteActionButton)
const handleDelete = async (workflow: Workflow) => {
const success = await handleWorkflowDelete(workflow.id);
if (success) {
refetch();
}
};
// Handle delete multiple workflows (confirmation handled by FormGenerator)
const handleDeleteMultiple = async (workflowsToDelete: Workflow[]) => {
const ids = workflowsToDelete.map(w => w.id);
const success = await handleWorkflowDeleteMultiple(ids);
if (success) {
refetch();
}
};
// Form attributes for edit modal - filter out non-editable fields
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Workflows: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Workflows</h1>
<p className={styles.pageSubtitle}>Übersicht aller Workflows</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!workflows || workflows.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Workflows...</span>
</div>
) : !workflows || workflows.length === 0 ? (
<div className={styles.emptyState}>
<FaList className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Workflows vorhanden</h3>
<p className={styles.emptyDescription}>
Starten Sie einen neuen Workflow im Chat Playground.
</p>
</div>
) : (
<FormGeneratorTable
data={workflows}
columns={columns}
apiEndpoint={apiEndpoint}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: Workflow) => deletingWorkflows.has(row.id),
}] : []),
]}
customActions={[
{
id: 'continue',
icon: <FaPlay />,
onClick: handleContinueWorkflow,
title: 'Workflow fortsetzen',
}
]}
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleWorkflowDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Workflows gefunden"
/>
)}
</div>
{/* Edit Modal */}
{editingWorkflow && (
<div className={styles.modalOverlay} onClick={() => setEditingWorkflow(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Workflow bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingWorkflow(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingWorkflow}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingWorkflow(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default WorkflowsPage;

View file

@ -1,3 +0,0 @@
export { PlaygroundPage } from './PlaygroundPage';
export { WorkflowsPage } from './WorkflowsPage';

View file

@ -241,24 +241,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
]
},
chatplayground: {
code: 'chatplayground',
label: { de: 'Chat Playground', en: 'Chat Playground' },
icon: 'message',
views: [
{ code: 'playground', label: { de: 'Playground', en: 'Playground' }, path: 'playground' },
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
]
},
codeeditor: {
code: 'codeeditor',
label: { de: 'Code Editor', en: 'Code Editor' },
icon: 'description',
views: [
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
]
},
teamsbot: {
code: 'teamsbot',
label: { de: 'Teams Bot', en: 'Teams Bot' },
@ -307,6 +289,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'psychology',
views: [
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
]
},

View file

@ -2,8 +2,7 @@
* Shared SSE Client Utility
*
* Generic fetch-based SSE streaming for POST requests with JSON body.
* Extracted from useCodeEditor.ts and chatbotApi.ts to provide a single
* reusable SSE implementation across all workspace features.
* Reusable SSE implementation across all workspace features.
*/
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './csrfUtils';
@ -19,6 +18,8 @@ export interface SseEventHandlers {
onStatus?: (event: SseEvent) => void;
onFileEditProposal?: (event: SseEvent) => void;
onFileVersion?: (event: SseEvent) => void;
onFileEditRejected?: (event: SseEvent) => void;
onFileUpdated?: (event: SseEvent) => void;
onToolCall?: (event: SseEvent) => void;
onToolResult?: (event: SseEvent) => void;
onAgentProgress?: (event: SseEvent) => void;
@ -50,6 +51,10 @@ const _EVENT_ROUTER: Record<string, keyof SseEventHandlers> = {
fileEditProposal: 'onFileEditProposal',
file_version: 'onFileVersion',
fileVersion: 'onFileVersion',
file_edit_rejected: 'onFileEditRejected',
fileEditRejected: 'onFileEditRejected',
file_updated: 'onFileUpdated',
fileUpdated: 'onFileUpdated',
toolCall: 'onToolCall',
toolResult: 'onToolResult',
agent_progress: 'onAgentProgress',