diff --git a/package-lock.json b/package-lock.json index 7792924..7cfcea4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ca11c58..456aebc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index ac4ab76..0789bba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> - {/* ============================================== */} - {/* WORKFLOWS ROUTES (deprecated - redirect to /) */} - {/* Workflows are accessed via feature routes: */} - {/* /mandates/:mandateId/chatplayground/:id/workflows */} - {/* /mandates/:mandateId/automation/:id/definitions */} - {/* ============================================== */} - - } /> - } /> - } /> - - {/* ============================================== */} {/* BASISDATEN ROUTES (global) */} {/* ============================================== */} @@ -162,16 +149,12 @@ function App() { } /> } /> - {/* Chat Playground Feature Views */} - } /> - } /> - {/* Automation Feature Views */} } /> } /> } /> - {/* Code Editor Feature Views */} + {/* Workspace Editor */} } /> {/* Teams Bot Feature Views */} diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 5e25261..e82e438 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -99,6 +99,7 @@ export interface CreditAddRequest { export interface CheckoutCreateRequest { userId?: string; amount: number; + returnUrl: string; } export interface CheckoutCreateResponse { diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 8c94328..b079d6a 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -176,21 +176,116 @@ export async function deleteFiles( request: ApiRequestFunction, fileIds: string[] ): Promise> { - 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 })) - ) - ); - - return results.map((result, index) => { - if (result.status === 'fulfilled') { - return result.value; - } - return { success: false, fileId: fileIds[index], error: result.reason }; + 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 })); +} + +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 { + 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 { + 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 { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'put', + data: { name }, + }); +} + +export async function deleteFolderApi( + request: ApiRequestFunction, + folderId: string, + recursive: boolean = false +): Promise { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'delete', + params: { recursive }, + }); +} + +export async function moveFolder( + request: ApiRequestFunction, + folderId: string, + targetParentId: string | null +): Promise { + 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 { + return await request({ + url: `/api/files/${fileId}/move`, + method: 'post', + data: { targetFolderId }, }); } diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index ce902c1..fc890d5 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -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 { 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 { await request({ - url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/stop`, + url: `/api/automations/${instanceId}/workflows/${workflowId}/stop`, method: 'post' }); } diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css new file mode 100644 index 0000000..52df54d --- /dev/null +++ b/src/components/FolderTree/FolderTree.module.css @@ -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; +} diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx new file mode 100644 index 0000000..11665a2 --- /dev/null +++ b/src/components/FolderTree/FolderTree.tsx @@ -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; + onSelectionChange?: (selectedIds: Set) => void; + expandedIds?: Set; + onToggleExpand?: (id: string) => void; + onRefresh?: () => void; + onCreateFolder?: (name: string, parentId: string | null) => Promise; + onRenameFolder?: (folderId: string, newName: string) => Promise; + onDeleteFolder?: (folderId: string) => Promise; + onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; + onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; + onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; + onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; +} + +/* ── Helpers ───────────────────────────────────────────────────────────── */ + +function _buildTree(folders: FolderNode[]): FolderNode[] { + const map = new Map(); + 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 { + const map = new Map(); + 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, + showFiles: boolean, + filesByFolder: Map, +): 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; + 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; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; +} + +/* ── 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 ( +
sel.onItemClick(file.id, 'file', e)} + draggable + onDragStart={(e) => { + sel.onItemDragStart(e, file.id, 'file', file.fileName); + setDragging(true); + }} + onDragEnd={() => setDragging(false)} + > + {_fileIcon(file.mimeType)} + {renaming ? ( + setRenameValue(e.target.value)} + onBlur={_handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') _handleRename(); + if (e.key === 'Escape') setRenaming(false); + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {file.fileName} + )} + {!renaming && file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} + {!renaming && ( + + {sel.onRenameFile && !multiSelected && ( + + )} + {multiSelected && isSelected ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : ( + (sel.onDeleteFile || sel.onDeleteFiles) && ( + + ) + )} + + )} +
+ ); +} + +/* ── Tree node (folder) ───────────────────────────────────────────────── */ + +interface TreeNodeProps { + node: FolderNode; + depth: number; + selectedFolderId: string | null; + expandedIds: Set; + showFiles: boolean; + filesByFolder: Map; + sel: SelectionCtx; + onToggle: (id: string) => void; + onSelect: (id: string | null) => void; + onCreateFolder?: (name: string, parentId: string | null) => Promise; + onRenameFolder?: (folderId: string, newName: string) => Promise; + onDeleteFolder?: (folderId: string) => Promise; + onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; + onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; + onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; + onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; +} + +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(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 ( +
+
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} + > + { e.stopPropagation(); if (hasChildren) onToggle(node.id); }} + > + + + + {isExpanded ? : } + + {renaming ? ( + setRenameValue(e.target.value)} + onBlur={_handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') _handleRename(); + if (e.key === 'Escape') setRenaming(false); + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {node.name} + )} + + {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {isMultiSelected && sel.selectedItemIds.size > 1 ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : onDeleteFolder && ( + + )} + +
+ {isExpanded && hasChildren && ( +
+ {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} /> + ))} +
+ )} +
+ ); +} + +/* ── 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>(new Set()); + const [rootDropOver, setRootDropOver] = useState(false); + const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); + const lastClickedIdRef = useRef(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) => { + 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(); + for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id); + return ids; + }, [filesByFolder]); + + const allFolderIds = useMemo(() => { + const ids = new Set(); + 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 ( +
+
{ onSelect(null); _setSelection(new Set()); }} + onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }} + onDragLeave={() => setRootDropOver(false)} + onDrop={_handleRootDrop} + > + + (Global) + + {onRefresh && ( + + )} + {onCreateFolder && ( + + )} + +
+
+ {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} /> + ))} +
+
+ ); +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 3ba1764..f97d6d8 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -171,6 +171,8 @@ export interface FormGeneratorTableProps { groupRowData?: (groupKey: string, groupRows: T[]) => Record; groupDefaultExpanded?: boolean; groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; + rowDraggable?: boolean; + onRowDragStart?: (e: React.DragEvent, row: T) => void; } export function FormGeneratorTable>({ @@ -208,7 +210,9 @@ export function FormGeneratorTable>({ groupRenderer: _groupRenderer, groupRowData, groupDefaultExpanded = true, - groupActions + groupActions, + rowDraggable = false, + onRowDragStart, }: FormGeneratorTableProps) { 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>({ // Track if we've loaded from localStorage for this storage key const loadedStorageKeyRef = useRef(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>({ 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>({ 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]) )} diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index ddfa9c9..04d2be7 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -46,7 +46,7 @@ display: inline-block; } -/* Trigger Button - matches iconButton style from PlaygroundPage */ +/* Trigger Button */ .triggerButton { display: flex; align-items: center; diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index 1e8e864..e41cefb 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -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 diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index f68c91c..21a8b40 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -110,8 +110,6 @@ export const PAGE_ICONS: Record = { 'feature.trustee': , 'feature.realestate': , 'feature.chatworkflow': , - 'feature.chatplayground': , - 'feature.codeeditor': , 'feature.automation': , 'page.feature.chatbot.conversations': , 'feature.chatbot': , @@ -119,6 +117,7 @@ export const PAGE_ICONS: Record = { // Feature pages - Workspace 'page.feature.workspace.dashboard': , + 'page.feature.workspace.editor': , 'feature.workspace': , }; diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 4d20e64..df0ca11 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -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; previewingFiles: Set; downloadingFiles: Set; + folders: FolderInfo[]; + foldersLoading: boolean; + refreshFolders: () => Promise; + handleCreateFolder: (name: string, parentId: string | null) => Promise; + handleRenameFolder: (folderId: string, newName: string) => Promise; + handleDeleteFolder: (folderId: string) => Promise; + handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise; + handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise; + handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise; + handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise; + expandedFolderIds: Set; + toggleFolderExpanded: (id: string) => void; } export const FileContext = createContext(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>(() => { + try { + const stored = localStorage.getItem(_STORAGE_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { return new Set(); } + }); + + 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([]); + 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; } - diff --git a/src/hooks/playground/playgroundUtils.ts b/src/hooks/playground/playgroundUtils.ts deleted file mode 100644 index 4fda891..0000000 --- a/src/hooks/playground/playgroundUtils.ts +++ /dev/null @@ -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 => { - const fileIds = new Set(); - 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: '' - })); -}; - diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts deleted file mode 100644 index cd5fec0..0000000 --- a/src/hooks/playground/useDashboardInputForm.ts +++ /dev/null @@ -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(''); - const [pendingFiles, setPendingFiles] = useState([]); - const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false); - const [optimisticMessage, setOptimisticMessage] = useState(null); - const [selectedPromptId, setSelectedPromptId] = useState(null); - const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); - const [selectedProviders, setSelectedProviders] = useState([]); // AI provider selection (multiselect) - const [deletedDocumentFileIds, setDeletedDocumentFileIds] = useState>(new Set()); - const [deletedMessageIds, setDeletedMessageIds] = useState>(new Set()); - const [deletingMessages, setDeletingMessages] = useState>(new Set()); - - const { checkPermission } = usePermissions(); - const [playgroundUIPermission, setPlaygroundUIPermission] = useState(true); - const [chatWorkflowPermission, setChatWorkflowPermission] = useState(null); - const [promptPermission, setPromptPermission] = useState(null); - const [filePermission, setFilePermission] = useState(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>(new Set()); - const lastWorkflowIdRef = useRef(null); - const lastDashboardLogsLengthRef = useRef(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(); - 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 => { - 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 } | 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 } | 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 } | 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); -} - diff --git a/src/hooks/playground/useDashboardLogTree.ts b/src/hooks/playground/useDashboardLogTree.ts deleted file mode 100644 index 31258dc..0000000 --- a/src/hooks/playground/useDashboardLogTree.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { useState, useCallback, useRef } from 'react'; -import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes'; - -interface OperationData { - logs: Map; - 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; - rootOperations: string[]; - expanded: boolean; - isCompleted: boolean; -} - -interface DashboardLogTree { - operations: Map; - rootOperations: string[]; - logExpandedStates: Map; - currentRound: number | null; - rounds: Map; -} - -export function useDashboardLogTree() { - const [tree, setTree] = useState({ - operations: new Map(), - rootOperations: [], - logExpandedStates: new Map(), - currentRound: null, - rounds: new Map() - }); - - const treeRef = useRef(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(); - 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(); - 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 - }; -} - diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts deleted file mode 100644 index 085d6f7..0000000 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ /dev/null @@ -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(null); - const [workflowStatus, setWorkflowStatus] = useState('idle'); - const [currentRound, setCurrentRound] = useState(undefined); - const [messages, setMessages] = useState([]); - const [logs, setLogs] = useState([]); - const [dashboardLogs, setDashboardLogs] = useState([]); - const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null); - - // === REFS FOR SYNC ACCESS === - const statusRef = useRef('idle'); - const lastRenderedTimestampRef = useRef(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(false); - const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState(false); - - // Flag to prevent useEffect from stopping polling during active workflow start - const isStartingWorkflowRef = useRef(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 - }; -} diff --git a/src/hooks/playground/useWorkflowOperations.ts b/src/hooks/playground/useWorkflowOperations.ts deleted file mode 100644 index 2e83052..0000000 --- a/src/hooks/playground/useWorkflowOperations.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from consolidated hook -export { useWorkflowOperations } from '../useWorkflows'; diff --git a/src/hooks/playground/useWorkflowPolling.ts b/src/hooks/playground/useWorkflowPolling.ts deleted file mode 100644 index 403f75d..0000000 --- a/src/hooks/playground/useWorkflowPolling.ts +++ /dev/null @@ -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; - -export function useWorkflowPolling() { - const stateRef = useRef({ - activeWorkflowId: null, - isPolling: false, - isPollInProgress: false, - isPaused: false, - currentInterval: BASE_INTERVAL, - failureCount: 0, - rateLimitFailureCount: 0, - timeoutId: null - }); - - const pollCallbackRef = useRef(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 - }; -} - diff --git a/src/hooks/playground/useWorkflows.ts b/src/hooks/playground/useWorkflows.ts deleted file mode 100644 index ebf367b..0000000 --- a/src/hooks/playground/useWorkflows.ts +++ /dev/null @@ -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([]); - const [isRefetching, setIsRefetching] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const { request } = useApiRequest(); - 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 - }; -} - diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index b5f3bf0..1e14ed1 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -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; 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(null); const isMountedRef = useRef(true); - const currentAudioRef = useRef(null); const abortControllerRef = useRef(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, diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index f680077..067874e 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -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 @@ -968,4 +969,87 @@ export function useFileOperations() { handleInlineUpdate, isLoading }; -} \ No newline at end of file +} + +// ── Folder management hook ────────────────────────────────────────────────── + +export function useFolders() { + const [folders, setFolders] = useState([]); + 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, + }; +} \ No newline at end of file diff --git a/src/hooks/usePlayground.ts b/src/hooks/usePlayground.ts index 046b361..9ffda7f 100644 --- a/src/hooks/usePlayground.ts +++ b/src/hooks/usePlayground.ts @@ -13,9 +13,12 @@ export interface UserInputRequest { metadata?: Record; } -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'; +} diff --git a/src/hooks/useSpeechAudioCapture.ts b/src/hooks/useSpeechAudioCapture.ts new file mode 100644 index 0000000..1b6d7ac --- /dev/null +++ b/src/hooks/useSpeechAudioCapture.ts @@ -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; + stop: () => void; +} + +const _RECORDING_CHUNK_MS = 250; +const _MAX_RECONNECT_ATTEMPTS = 3; + +export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi { + const [status, setStatus] = useState('idle'); + const [interimText, setInterimText] = useState(''); + + const cbRef = useRef(callbacks); + cbRef.current = callbacks; + + const wsRef = useRef(null); + const recorderRef = useRef(null); + const streamRef = useRef(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 }; +} diff --git a/src/hooks/useTtsPlayback.ts b/src/hooks/useTtsPlayback.ts new file mode 100644 index 0000000..ecb3edd --- /dev/null +++ b/src/hooks/useTtsPlayback.ts @@ -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(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 }; +} diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index ebaf1c5..512ee41 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -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; } diff --git a/src/index.css b/src/index.css index f4923b5..082c1dc 100644 --- a/src/index.css +++ b/src/index.css @@ -20,4 +20,13 @@ html, body { margin: 0; padding: 0; font-family: var(--font-family, "DM Sans", sans-serif); -} \ No newline at end of file +} + +tr[data-highlighted="true"] { + animation: rowHighlight 2s ease-out; +} + +@keyframes rowHighlight { + 0% { background: rgba(25, 118, 210, 0.25); } + 100% { background: transparent; } +} \ No newline at end of file diff --git a/src/locales/de.ts b/src/locales/de.ts index 1f0c637..87d282d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 6d9bbe0..8989b10 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 83f8b06..9c396fa 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 80a10e1..56deae2 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -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> = { 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 = ({ 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; } diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index dc9fb03..febcce3 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -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 = { automation: , - chatplayground: , - codeeditor: , teamsbot: , }; @@ -26,16 +24,6 @@ const FEATURE_DESCRIPTIONS: Record> = { 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.', diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 36eb49f..4798e5f 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -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,27 +23,36 @@ interface UserFile { fileName: string; mimeType?: string; fileSize?: number; + folderId?: string | null; + featureInstanceId?: string; [key: string]: any; } export const FilesPage: React.FC = () => { const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); - - // Data hook + const [selectedFolderId, setSelectedFolderId] = useState(null); + + const { + leftWidth, isDragging, handleMouseDown, containerRef, + } = useResizablePanels({ + storageKey: 'filesPage-panelWidth', + defaultLeftWidth: 22, + minLeftWidth: 15, + maxLeftWidth: 40, + }); + const { data: files, attributes, permissions, - pagination, loading, error, refetch, fetchFileById, 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(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [treeSelectedIds, setTreeSelectedIds] = useState>(new Set()); + const [highlightedFileId, setHighlightedFileId] = useState(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) => { 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) => { 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, 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 (
- {/* Hidden file input */} {

Dateiverwaltung

- - {canCreate && ( - - )}
-
- {loading && (!files || files.length === 0) ? ( -
-
- Lade Dateien... -
- ) : !files || files.length === 0 ? ( -
- -

Keine Dateien vorhanden

-

- Laden Sie eine Datei hoch, um loszulegen. -

+ {/* Split-view container */} +
} + style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} + > + {/* Left panel: FolderTree */} +
+ { + 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} + /> +
+ + {/* Resizable divider */} +
{ (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 */} +
+ {/* Toolbar above table */} +
+ {canCreate && ( - )}
- ) : ( - deletingFiles.has(row.id), - }] : []), - ]} - customActions={[ - { - id: 'download', - icon: , - onClick: handleDownload, - title: 'Herunterladen', - loading: (row: UserFile) => downloadingFiles.has(row.id), - }, - { - id: 'preview', - icon: , - 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 */} +
+ {loading && (!files || files.length === 0) ? ( +
+
+ Lade Dateien... +
+ ) : filteredFiles.length === 0 ? ( +
+ +

+ {selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'} +

+

+ {selectedFolderId + ? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.' + : 'Laden Sie eine Datei hoch, um loszulegen.'} +

+ {canCreate && ( + + )} +
+ ) : ( + 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: , + onClick: handleDownload, + title: 'Herunterladen', + loading: (row: UserFile) => downloadingFiles.has(row.id), + }, + { + id: 'preview', + icon: , + 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" + /> + )} +
+
{/* Edit Modal */} @@ -331,12 +495,7 @@ export const FilesPage: React.FC = () => {
e.stopPropagation()}>

Datei bearbeiten

- +
{formAttributes.length === 0 ? ( diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 493b5eb..083fa0d 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -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; diff --git a/src/pages/views/codeeditor/CodeEditor.module.css b/src/pages/views/codeeditor/CodeEditor.module.css deleted file mode 100644 index 934aaf5..0000000 --- a/src/pages/views/codeeditor/CodeEditor.module.css +++ /dev/null @@ -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; } diff --git a/src/pages/views/codeeditor/CodeEditorPage.tsx b/src/pages/views/codeeditor/CodeEditorPage.tsx deleted file mode 100644 index 05c2245..0000000 --- a/src/pages/views/codeeditor/CodeEditorPage.tsx +++ /dev/null @@ -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(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 ( -
-
- {/* Left: File List */} -
- -
- -
- - {/* Center + Right */} -
- {/* Center: Chat */} -
-
- - - {agentProgress && isProcessing && ( -
- - - Round {agentProgress.round} | {agentProgress.totalToolCalls} tools |{' '} - {agentProgress.costCHF.toFixed(4)} CHF - -
- )} -
- -
-
- - -
- -