added monaco editor
This commit is contained in:
parent
9e792bc74f
commit
9513e44b0c
43 changed files with 590 additions and 5507 deletions
65
package-lock.json
generated
65
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
19
src/App.tsx
19
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() {
|
|||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="gdpr" element={<GDPRPage />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* WORKFLOWS ROUTES (deprecated - redirect to /) */}
|
||||
{/* Workflows are accessed via feature routes: */}
|
||||
{/* /mandates/:mandateId/chatplayground/:id/workflows */}
|
||||
{/* /mandates/:mandateId/automation/:id/definitions */}
|
||||
{/* ============================================== */}
|
||||
<Route path="workflows">
|
||||
<Route path="playground" element={<PlaygroundPage />} />
|
||||
<Route path="list" element={<Navigate to="/" replace />} />
|
||||
<Route path="automations" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* BASISDATEN ROUTES (global) */}
|
||||
{/* ============================================== */}
|
||||
|
|
@ -162,16 +149,12 @@ function App() {
|
|||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||
|
||||
{/* Chat Playground Feature Views */}
|
||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
||||
|
||||
{/* Automation Feature Views */}
|
||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||
|
||||
{/* Code Editor Feature Views */}
|
||||
{/* Workspace Editor */}
|
||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||
|
||||
{/* Teams Bot Feature Views */}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ export async function fetchWorkflowLogs(
|
|||
|
||||
/**
|
||||
* Fetch unified chat data (messages, logs, stats, documents)
|
||||
* Endpoint: GET /api/chatplayground/{instanceId}/workflows/{workflowId}/chatData
|
||||
* Endpoint: GET /api/automations/{instanceId}/workflows/{workflowId}/chatData
|
||||
* Query params: afterTimestamp (optional) - fetch only data created after this time
|
||||
*/
|
||||
export async function fetchChatData(
|
||||
|
|
@ -248,7 +248,7 @@ export async function fetchChatData(
|
|||
): Promise<ChatDataResponse> {
|
||||
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
|
||||
const requestConfig = {
|
||||
url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/chatData`,
|
||||
url: `/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
|
||||
method: 'get' as const,
|
||||
params
|
||||
};
|
||||
|
|
@ -303,7 +303,7 @@ export async function fetchChatData(
|
|||
|
||||
/**
|
||||
* Start a new workflow or continue an existing one
|
||||
* Endpoint: POST /api/chatplayground/{instanceId}/start
|
||||
* Endpoint: POST /api/automations/{instanceId}/start
|
||||
* Query params: workflowId (optional), workflowMode (default: "Dynamic")
|
||||
*/
|
||||
export async function startWorkflowApi(
|
||||
|
|
@ -318,7 +318,6 @@ export async function startWorkflowApi(
|
|||
if (options?.workflowMode) {
|
||||
params.workflowMode = options.workflowMode;
|
||||
} else {
|
||||
// Default to 'Dynamic' if not provided (though it should always be provided)
|
||||
params.workflowMode = 'Dynamic';
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +325,6 @@ export async function startWorkflowApi(
|
|||
params.workflowId = options.workflowId;
|
||||
}
|
||||
|
||||
// Request body uses 'prompt' field (not 'input') according to API spec
|
||||
const requestBody: any = {
|
||||
prompt: workflowData.prompt,
|
||||
...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }),
|
||||
|
|
@ -336,10 +334,10 @@ export async function startWorkflowApi(
|
|||
};
|
||||
|
||||
const requestConfig = {
|
||||
url: `/api/chatplayground/${instanceId}/start`,
|
||||
url: `/api/automations/${instanceId}/start`,
|
||||
method: 'post' as const,
|
||||
data: requestBody,
|
||||
params: params // Always include workflowMode
|
||||
params: params
|
||||
};
|
||||
|
||||
// Log full request details
|
||||
|
|
@ -359,7 +357,7 @@ export async function startWorkflowApi(
|
|||
|
||||
/**
|
||||
* Stop a running workflow
|
||||
* Endpoint: POST /api/chatplayground/{instanceId}/workflows/{workflowId}/stop
|
||||
* Endpoint: POST /api/automations/{instanceId}/workflows/{workflowId}/stop
|
||||
*/
|
||||
export async function stopWorkflowApi(
|
||||
request: ApiRequestFunction,
|
||||
|
|
@ -367,7 +365,7 @@ export async function stopWorkflowApi(
|
|||
workflowId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/stop`,
|
||||
url: `/api/automations/${instanceId}/workflows/${workflowId}/stop`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -596,7 +596,7 @@ export default function FolderTree({
|
|||
e.dataTransfer.setData('application/folder-id', id);
|
||||
}
|
||||
}
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
}, [selectedItemIds, flatList]);
|
||||
|
||||
const allFileIds = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Trigger Button - matches iconButton style from PlaygroundPage */
|
||||
/* Trigger Button */
|
||||
.triggerButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -110,8 +110,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'feature.trustee': <FaBriefcase />,
|
||||
'feature.realestate': <FaBuilding />,
|
||||
'feature.chatworkflow': <FaPlay />,
|
||||
'feature.chatplayground': <FaPlay />,
|
||||
'feature.codeeditor': <FaFileAlt />,
|
||||
'feature.automation': <FaCogs />,
|
||||
'page.feature.chatbot.conversations': <FaComments />,
|
||||
'feature.chatbot': <FaComments />,
|
||||
|
|
@ -119,6 +117,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
|
||||
// Feature pages - Workspace
|
||||
'page.feature.workspace.dashboard': <FaPlay />,
|
||||
'page.feature.workspace.editor': <FaPlay />,
|
||||
'feature.workspace': <FaPlay />,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes';
|
||||
import type { WorkflowMessage, WorkflowLog } from '../../api/workflowApi';
|
||||
|
||||
export const sortMessages = (a: WorkflowMessage, b: WorkflowMessage) => {
|
||||
if (a.publishedAt !== undefined && b.publishedAt !== undefined) {
|
||||
return a.publishedAt - b.publishedAt;
|
||||
}
|
||||
if (a.publishedAt !== undefined) return -1;
|
||||
if (b.publishedAt !== undefined) return 1;
|
||||
if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) {
|
||||
return a.sequenceNr - b.sequenceNr;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const sortLogs = (a: WorkflowLog, b: WorkflowLog) => {
|
||||
if (a.timestamp !== undefined && b.timestamp !== undefined) {
|
||||
return a.timestamp - b.timestamp;
|
||||
}
|
||||
if (a.publishedAt !== undefined && b.publishedAt !== undefined) {
|
||||
return a.publishedAt - b.publishedAt;
|
||||
}
|
||||
if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) {
|
||||
return a.sequenceNr - b.sequenceNr;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const extractFileIdsFromMessage = (message: WorkflowMessage): Set<string> => {
|
||||
const fileIds = new Set<string>();
|
||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
||||
const files = (message as any).files as any[] | undefined;
|
||||
|
||||
if (documents && Array.isArray(documents)) {
|
||||
documents.forEach((doc: MessageDocument) => {
|
||||
if (doc.fileId) fileIds.add(doc.fileId);
|
||||
});
|
||||
}
|
||||
if (files && Array.isArray(files)) {
|
||||
files.forEach((file: any) => {
|
||||
const fileId = file.id || file.fileId;
|
||||
if (fileId) fileIds.add(fileId);
|
||||
});
|
||||
}
|
||||
return fileIds;
|
||||
};
|
||||
|
||||
export const convertFilesToDocuments = (files: any[], messageId: string): MessageDocument[] => {
|
||||
return files.map((file: any) => ({
|
||||
id: file.id || file.fileId || file.file_id,
|
||||
fileId: file.id || file.fileId || file.file_id,
|
||||
fileName: file.fileName || file.name || file.file_name || 'Unknown File',
|
||||
fileSize: file.fileSize || file.size || 0,
|
||||
mimeType: file.mimeType || file.mime_type || 'application/octet-stream',
|
||||
messageId,
|
||||
roundNumber: 0,
|
||||
taskNumber: 0,
|
||||
actionNumber: 0,
|
||||
actionId: ''
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -1,849 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useApiRequest } from '../useApi';
|
||||
import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { MessageDocument } from '../../components/UiComponents/Messages/MessagesTypes';
|
||||
import { usePrompts } from '../usePrompts';
|
||||
import { usePermissions } from '../usePermissions';
|
||||
import { deleteFileFromMessageApi, deleteMessageApi } from '../../api/workflowApi';
|
||||
import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
|
||||
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
|
||||
import { useWorkflows } from './useWorkflows';
|
||||
import { useDashboardLogTree } from './useDashboardLogTree';
|
||||
import { convertFilesToDocuments, sortMessages } from './playgroundUtils';
|
||||
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
|
||||
|
||||
export interface WorkflowFile {
|
||||
id: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
messageId?: string;
|
||||
source?: 'user_uploaded' | 'ai_created';
|
||||
}
|
||||
|
||||
export function useDashboardInputForm(instanceId: string) {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [pendingFiles, setPendingFiles] = useState<WorkflowFile[]>([]);
|
||||
const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false);
|
||||
const [optimisticMessage, setOptimisticMessage] = useState<WorkflowMessage | null>(null);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect)
|
||||
const [deletedDocumentFileIds, setDeletedDocumentFileIds] = useState<Set<string>>(new Set());
|
||||
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(new Set());
|
||||
const [deletingMessages, setDeletingMessages] = useState<Set<string>>(new Set());
|
||||
|
||||
const { checkPermission } = usePermissions();
|
||||
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(true);
|
||||
const [chatWorkflowPermission, setChatWorkflowPermission] = useState<any>(null);
|
||||
const [promptPermission, setPromptPermission] = useState<any>(null);
|
||||
const [filePermission, setFilePermission] = useState<any>(null);
|
||||
|
||||
const { selectedWorkflowId, selectWorkflow: selectWorkflowFromContext, clearWorkflow: clearWorkflowFromContext } = useWorkflowSelection();
|
||||
const {
|
||||
workflowId,
|
||||
workflowStatus,
|
||||
currentRound,
|
||||
isRunning,
|
||||
isStopping,
|
||||
startingWorkflow,
|
||||
messages,
|
||||
dashboardLogs,
|
||||
unifiedContentLogs,
|
||||
latestStats,
|
||||
startWorkflow,
|
||||
stopWorkflow,
|
||||
resetWorkflow,
|
||||
selectWorkflow,
|
||||
setWorkflowStatusOptimistic
|
||||
} = useWorkflowLifecycle(instanceId);
|
||||
|
||||
// Dashboard log tree hook
|
||||
const {
|
||||
tree: dashboardTree,
|
||||
processDashboardLogs,
|
||||
clearDashboard,
|
||||
toggleOperationExpanded,
|
||||
toggleRoundExpanded,
|
||||
updateCurrentRound,
|
||||
getChildOperations
|
||||
} = useDashboardLogTree();
|
||||
|
||||
// Ref to prevent infinite sync loops
|
||||
const isSyncingRef = useRef(false);
|
||||
|
||||
const fileContext = useFileContext();
|
||||
const { request } = useApiRequest();
|
||||
const { prompts, loading: promptsLoading, permissions: promptsPermissions, fetchPromptById } = usePrompts();
|
||||
|
||||
useEffect(() => {
|
||||
if (promptsPermissions) {
|
||||
setPromptPermission(promptsPermissions);
|
||||
}
|
||||
}, [promptsPermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPermissions = async () => {
|
||||
try {
|
||||
// UI permission is already verified by the navigation/routing layer
|
||||
// (FeatureAccess + instance role checked before page is reachable).
|
||||
// We set it to true and load DATA permissions directly.
|
||||
setPlaygroundUIPermission(true);
|
||||
|
||||
const chatWorkflowPerm = await checkPermission('DATA', 'ChatWorkflow');
|
||||
setChatWorkflowPermission(chatWorkflowPerm);
|
||||
const promptPerm = await checkPermission('DATA', 'Prompt');
|
||||
setPromptPermission(promptPerm);
|
||||
const filePerm = await checkPermission('DATA', 'FileItem');
|
||||
setFilePermission(filePerm);
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
checkPermissions();
|
||||
}, [checkPermission]);
|
||||
|
||||
// Sync context -> lifecycle: When context selection changes, update lifecycle
|
||||
useEffect(() => {
|
||||
if (isSyncingRef.current) return;
|
||||
|
||||
if (selectedWorkflowId && selectedWorkflowId !== workflowId) {
|
||||
isSyncingRef.current = true;
|
||||
selectWorkflow(selectedWorkflowId).finally(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
} else if (!selectedWorkflowId && workflowId) {
|
||||
// If context is cleared but lifecycle still has a workflow, reset lifecycle
|
||||
isSyncingRef.current = true;
|
||||
resetWorkflow();
|
||||
isSyncingRef.current = false;
|
||||
}
|
||||
}, [selectedWorkflowId, workflowId, selectWorkflow, resetWorkflow]);
|
||||
|
||||
// Sync lifecycle -> context: When lifecycle workflowId changes, update context
|
||||
useEffect(() => {
|
||||
if (isSyncingRef.current) return;
|
||||
|
||||
if (workflowId && workflowId !== selectedWorkflowId) {
|
||||
isSyncingRef.current = true;
|
||||
selectWorkflowFromContext(workflowId);
|
||||
isSyncingRef.current = false;
|
||||
} else if (!workflowId && selectedWorkflowId) {
|
||||
// If lifecycle is cleared but context still has selection, clear context
|
||||
isSyncingRef.current = true;
|
||||
clearWorkflowFromContext();
|
||||
isSyncingRef.current = false;
|
||||
}
|
||||
}, [workflowId, selectedWorkflowId, selectWorkflowFromContext, clearWorkflowFromContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSetInput = (event: CustomEvent<{ value: string }>) => {
|
||||
const newValue = event.detail.value;
|
||||
if (newValue && typeof newValue === 'string') {
|
||||
setInputValue(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dashboardSetInput', handleSetInput as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('dashboardSetInput', handleSetInput as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { workflows, loading: workflowsLoading, refetch: refetchWorkflows } = useWorkflows(instanceId);
|
||||
|
||||
// Track processed log IDs to avoid reprocessing
|
||||
const processedLogIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastWorkflowIdRef = useRef<string | null>(null);
|
||||
const lastDashboardLogsLengthRef = useRef<number>(0);
|
||||
|
||||
// Clear processed logs when workflow changes
|
||||
useEffect(() => {
|
||||
if (workflowId !== lastWorkflowIdRef.current) {
|
||||
processedLogIdsRef.current.clear();
|
||||
lastWorkflowIdRef.current = workflowId || null;
|
||||
lastDashboardLogsLengthRef.current = 0;
|
||||
if (!workflowId) {
|
||||
clearDashboard(true);
|
||||
}
|
||||
}
|
||||
}, [workflowId, clearDashboard]);
|
||||
|
||||
// Process dashboard logs when they change (only new logs)
|
||||
useEffect(() => {
|
||||
if (!dashboardLogs || dashboardLogs.length === 0) {
|
||||
lastDashboardLogsLengthRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if the array length changed (indicating new logs)
|
||||
if (dashboardLogs.length === lastDashboardLogsLengthRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to only new logs that haven't been processed
|
||||
const newLogs = dashboardLogs.filter(log => {
|
||||
const logId = log.id || `${log.operationId}-${log.timestamp}`;
|
||||
if (processedLogIdsRef.current.has(logId)) {
|
||||
return false;
|
||||
}
|
||||
processedLogIdsRef.current.add(logId);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Only process if there are new logs
|
||||
if (newLogs.length > 0) {
|
||||
// Convert API WorkflowLog format to LogTypes WorkflowLog format
|
||||
const convertedLogs: LogTypesWorkflowLog[] = newLogs.map(log => ({
|
||||
id: log.id || `${log.operationId || 'unknown'}-${log.timestamp || Date.now()}`,
|
||||
workflowId: log.workflowId || '',
|
||||
message: log.message || '',
|
||||
type: log.type,
|
||||
timestamp: log.timestamp || Date.now(),
|
||||
status: log.status,
|
||||
progress: log.progress,
|
||||
performance: log.performance,
|
||||
parentId: log.parentId,
|
||||
operationId: log.operationId
|
||||
}));
|
||||
processDashboardLogs(convertedLogs);
|
||||
}
|
||||
|
||||
lastDashboardLogsLengthRef.current = dashboardLogs.length;
|
||||
}, [dashboardLogs, processDashboardLogs]);
|
||||
|
||||
// Update current round in dashboard tree when it changes
|
||||
useEffect(() => {
|
||||
if (currentRound !== undefined) {
|
||||
updateCurrentRound(currentRound);
|
||||
}
|
||||
}, [currentRound, updateCurrentRound]);
|
||||
|
||||
const workflowFiles = useMemo(() => {
|
||||
const fileMap = new Map<string, WorkflowFile>();
|
||||
const pendingFileIds = new Set(pendingFiles.map(f => f.fileId));
|
||||
|
||||
const addFilesFromMessage = (message: WorkflowMessage, messageId: string) => {
|
||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
||||
const files = (message as any).files as any[] | undefined;
|
||||
|
||||
if (documents && Array.isArray(documents)) {
|
||||
documents.forEach((doc: MessageDocument) => {
|
||||
if (!doc.fileId || doc.fileId.trim() === '') return;
|
||||
if (!fileMap.has(doc.fileId)) {
|
||||
const source = pendingFileIds.has(doc.fileId) ? 'user_uploaded' : 'ai_created';
|
||||
fileMap.set(doc.fileId, {
|
||||
id: doc.id || doc.fileId,
|
||||
fileId: doc.fileId,
|
||||
fileName: doc.fileName || 'Unknown File',
|
||||
fileSize: doc.fileSize || 0,
|
||||
mimeType: doc.mimeType || 'application/octet-stream',
|
||||
messageId: doc.messageId || messageId,
|
||||
source
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (files && Array.isArray(files)) {
|
||||
files.forEach((file: any) => {
|
||||
const fileId = file.id || file.fileId;
|
||||
if (!fileId || fileId.trim() === '') return;
|
||||
if (!fileMap.has(fileId)) {
|
||||
const source = pendingFileIds.has(fileId) ? 'user_uploaded' : 'ai_created';
|
||||
fileMap.set(fileId, {
|
||||
id: fileId,
|
||||
fileId: fileId,
|
||||
fileName: file.fileName || file.name || 'Unknown File',
|
||||
fileSize: file.fileSize || file.size || 0,
|
||||
mimeType: file.mimeType || file.mime_type || 'application/octet-stream',
|
||||
messageId: messageId,
|
||||
source
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
messages.forEach((message: WorkflowMessage) => {
|
||||
addFilesFromMessage(message, message.id);
|
||||
});
|
||||
}
|
||||
|
||||
if (optimisticMessage) {
|
||||
addFilesFromMessage(optimisticMessage, optimisticMessage.id || 'optimistic');
|
||||
}
|
||||
|
||||
return Array.from(fileMap.values());
|
||||
}, [messages, pendingFiles, optimisticMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messages || messages.length === 0) return;
|
||||
if (!optimisticMessage) return;
|
||||
|
||||
// Clear optimistic message when backend's "first" user message arrives via polling.
|
||||
// The backend message contains the normalizedRequest (which differs from the original prompt),
|
||||
// so we match by status="first" instead of content comparison.
|
||||
const hasFirstMessage = messages.some((msg: WorkflowMessage) =>
|
||||
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
|
||||
);
|
||||
|
||||
if (hasFirstMessage) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
}, [messages, optimisticMessage]);
|
||||
|
||||
const displayMessages = useMemo(() => {
|
||||
const processedMessages = (messages || [])
|
||||
// Filter out locally deleted messages
|
||||
.filter((message: WorkflowMessage) => !deletedMessageIds.has(message.id))
|
||||
.map((message: WorkflowMessage) => {
|
||||
const files = (message as any).files as any[] | undefined;
|
||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
||||
|
||||
let processedDocs = documents;
|
||||
if (files && Array.isArray(files) && (!documents || documents.length === 0)) {
|
||||
processedDocs = convertFilesToDocuments(files, message.id);
|
||||
}
|
||||
|
||||
// Filter out locally deleted documents
|
||||
if (processedDocs && deletedDocumentFileIds.size > 0) {
|
||||
processedDocs = processedDocs.filter(doc => !deletedDocumentFileIds.has(doc.fileId));
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
documents: processedDocs
|
||||
};
|
||||
});
|
||||
|
||||
// If optimistic message is still active (backend "first" message not yet polled),
|
||||
// show the optimistic message instead of any backend user messages to avoid duplicates.
|
||||
const allMessages = [...processedMessages];
|
||||
if (optimisticMessage) {
|
||||
// Find backend "first" user message to inherit its timestamp for correct ordering
|
||||
const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) =>
|
||||
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
|
||||
);
|
||||
if (!firstBackendMsg) {
|
||||
// Backend "first" message not yet arrived - show optimistic message
|
||||
allMessages.push({ ...optimisticMessage, documents: (optimisticMessage as any).documents });
|
||||
}
|
||||
// If firstBackendMsg exists, the useEffect above will clear optimistic on next render
|
||||
}
|
||||
|
||||
return allMessages.sort(sortMessages);
|
||||
}, [messages, optimisticMessage, workflowId, deletedDocumentFileIds, deletedMessageIds]);
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
|
||||
const result = await fileContext.handleFileUpload(file, workflowId || undefined);
|
||||
|
||||
if (result.success && result.fileData) {
|
||||
const responseData = result.fileData;
|
||||
const fileData = responseData.file || responseData;
|
||||
const fileId = fileData?.id;
|
||||
|
||||
if (fileId) {
|
||||
const newFile: WorkflowFile = {
|
||||
id: fileId,
|
||||
fileId: fileId,
|
||||
fileName: fileData.fileName || file.name,
|
||||
fileSize: fileData.fileSize || file.size,
|
||||
mimeType: fileData.mimeType || file.type || 'application/octet-stream',
|
||||
source: 'user_uploaded'
|
||||
};
|
||||
|
||||
setPendingFiles(prev => {
|
||||
if (prev.some(f => f.fileId === fileId)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, newFile];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success || false,
|
||||
data: result.fileData || null
|
||||
};
|
||||
}, [workflowId, fileContext]);
|
||||
|
||||
const handleFileAttach = useCallback(async (fileId: string): Promise<void> => {
|
||||
const isInPending = pendingFiles.some(f => f.fileId === fileId);
|
||||
|
||||
if (isInPending) {
|
||||
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
|
||||
} else {
|
||||
let workflowFile: WorkflowFile | null = null;
|
||||
|
||||
const userFile = fileContext.files.find(f => f.id === fileId);
|
||||
if (userFile) {
|
||||
workflowFile = {
|
||||
id: userFile.id,
|
||||
fileId: userFile.id,
|
||||
fileName: userFile.file_name,
|
||||
fileSize: userFile.size || 0,
|
||||
mimeType: userFile.mime_type || 'application/octet-stream',
|
||||
source: 'user_uploaded'
|
||||
};
|
||||
} else {
|
||||
const existingWorkflowFile = workflowFiles.find(f => f.fileId === fileId);
|
||||
if (existingWorkflowFile) {
|
||||
workflowFile = {
|
||||
...existingWorkflowFile,
|
||||
id: existingWorkflowFile.id || existingWorkflowFile.fileId,
|
||||
fileId: existingWorkflowFile.fileId,
|
||||
fileName: existingWorkflowFile.fileName || 'Unknown File',
|
||||
fileSize: existingWorkflowFile.fileSize || 0,
|
||||
mimeType: existingWorkflowFile.mimeType || 'application/octet-stream',
|
||||
source: existingWorkflowFile.source || 'user_uploaded'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowFile) {
|
||||
setPendingFiles(prev => {
|
||||
if (prev.some(f => f.fileId === fileId)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, workflowFile!];
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [pendingFiles, fileContext.files, workflowFiles]);
|
||||
|
||||
const handleFileUploadAndAttach = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
|
||||
return await handleFileUpload(file);
|
||||
}, [handleFileUpload]);
|
||||
|
||||
const handleFileRemove = useCallback(async (file: WorkflowFile) => {
|
||||
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
|
||||
}, []);
|
||||
|
||||
const handleFileDelete = useCallback(async (file: WorkflowFile) => {
|
||||
if (!file.fileId) return;
|
||||
|
||||
// Immediately remove document from UI for instant feedback
|
||||
setDeletedDocumentFileIds(prev => new Set([...prev, file.fileId]));
|
||||
|
||||
if (workflowId && file.messageId) {
|
||||
// Document in a message: only remove the ChatDocument reference, keep the file itself
|
||||
try {
|
||||
await deleteFileFromMessageApi(request, workflowId, file.messageId, file.fileId);
|
||||
} catch (error) {
|
||||
// Restore document in UI on failure
|
||||
setDeletedDocumentFileIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(file.fileId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Standalone file (pending file not yet in a message): delete the actual file
|
||||
const success = await fileContext.handleFileDelete(file.fileId, () => {
|
||||
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
|
||||
});
|
||||
|
||||
if (success) {
|
||||
setPendingFiles(prev => prev.filter(f => f.fileId !== file.fileId));
|
||||
} else {
|
||||
// Restore document in UI on failure
|
||||
setDeletedDocumentFileIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(file.fileId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [workflowId, fileContext, request]);
|
||||
|
||||
// handleFileView is a no-op because ViewActionButton's ContentPreview handles the preview internally
|
||||
const handleFileView = useCallback(async (_file: WorkflowFile) => {
|
||||
// The ViewActionButton component handles the preview via ContentPreview
|
||||
// No additional action needed here
|
||||
}, []);
|
||||
|
||||
const handleFileDownload = useCallback(async (file: WorkflowFile) => {
|
||||
if (!file.fileId) return;
|
||||
await fileContext.handleFileDownload(file.fileId, file.fileName);
|
||||
}, [fileContext]);
|
||||
|
||||
const handleMessageDelete = useCallback(async (messageId: string) => {
|
||||
if (!workflowId || !messageId) return;
|
||||
|
||||
// Immediately remove message from UI for instant feedback
|
||||
setDeletedMessageIds(prev => new Set([...prev, messageId]));
|
||||
setDeletingMessages(prev => new Set([...prev, messageId]));
|
||||
|
||||
try {
|
||||
await deleteMessageApi(request, workflowId, messageId);
|
||||
} catch (error: any) {
|
||||
// Restore message in UI on failure
|
||||
setDeletedMessageIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(messageId);
|
||||
return next;
|
||||
});
|
||||
console.error('Failed to delete message:', error);
|
||||
} finally {
|
||||
setDeletingMessages(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(messageId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [workflowId, request]);
|
||||
|
||||
const onInputChange = useCallback((value: string) => {
|
||||
setInputValue(value);
|
||||
}, []);
|
||||
|
||||
// Separate stop handler - only stops the workflow without sending new input
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!workflowId) return { success: false, error: 'No workflow to stop' };
|
||||
|
||||
try {
|
||||
const result = await stopWorkflow();
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || 'Failed to stop workflow' };
|
||||
}
|
||||
}, [workflowId, stopWorkflow]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmedInput = inputValue.trim();
|
||||
|
||||
// If running and no new input, just stop
|
||||
if (isRunning && workflowId && !trimmedInput) {
|
||||
try {
|
||||
await stopWorkflow();
|
||||
} catch (error) {
|
||||
// Ignore stop errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If running with new input, stop first then continue with new input
|
||||
if (isRunning && workflowId && trimmedInput) {
|
||||
try {
|
||||
// Stop the current workflow
|
||||
await stopWorkflow();
|
||||
// Continue below to send new input
|
||||
} catch (error) {
|
||||
// Ignore stop errors, try to continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
// No input and not running = nothing to do
|
||||
if (!trimmedInput || startingWorkflow) {
|
||||
return;
|
||||
}
|
||||
if (!trimmedInput || startingWorkflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filesToSend = pendingFiles.filter(file => file.fileId);
|
||||
const fileIdsToSend = filesToSend.map(f => f.fileId).filter((id): id is string => !!id);
|
||||
const sentFileIdsSet = new Set(fileIdsToSend);
|
||||
|
||||
// Optimistically render user message immediately
|
||||
const optimisticMsg: WorkflowMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
workflowId: workflowId || '',
|
||||
message: trimmedInput,
|
||||
role: 'user',
|
||||
publishedAt: Date.now(),
|
||||
documents: filesToSend.map(file => ({
|
||||
id: file.id || file.fileId,
|
||||
fileId: file.fileId,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
mimeType: file.mimeType,
|
||||
messageId: `optimistic-${Date.now()}`,
|
||||
roundNumber: 0,
|
||||
taskNumber: 0,
|
||||
actionNumber: 0,
|
||||
actionId: ''
|
||||
}))
|
||||
};
|
||||
setOptimisticMessage(optimisticMsg);
|
||||
|
||||
// Optimistically update workflow status to 'running' immediately
|
||||
if (setWorkflowStatusOptimistic) {
|
||||
setWorkflowStatusOptimistic('running');
|
||||
}
|
||||
|
||||
setPendingFiles(prev => prev.filter(file =>
|
||||
!file.fileId || !sentFileIdsSet.has(file.fileId)
|
||||
));
|
||||
|
||||
if (!chatWorkflowPermission || chatWorkflowPermission.create === 'n') {
|
||||
setOptimisticMessage(null);
|
||||
if (setWorkflowStatusOptimistic) {
|
||||
setWorkflowStatusOptimistic('idle');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMode = workflowMode || 'Dynamic';
|
||||
const apiWorkflowMode: 'Dynamic' | 'Automation' = selectedMode;
|
||||
|
||||
const workflowOptions: { workflowId?: string; workflowMode: 'Dynamic' | 'Automation' } = {
|
||||
workflowMode: apiWorkflowMode
|
||||
};
|
||||
|
||||
if (workflowId) {
|
||||
workflowOptions.workflowId = workflowId;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
prompt: trimmedInput,
|
||||
listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined,
|
||||
userLanguage: 'en',
|
||||
allowedProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider filter (multiselect)
|
||||
};
|
||||
|
||||
// Debug: Log provider selection
|
||||
console.log('🤖 Provider selection:', { selectedProviders, sentProviders: requestBody.allowedProviders });
|
||||
|
||||
const result = await startWorkflow(requestBody, workflowOptions);
|
||||
|
||||
if (result.success) {
|
||||
setInputValue('');
|
||||
|
||||
const wasNewWorkflow = !workflowId;
|
||||
if (wasNewWorkflow && result.data) {
|
||||
const workflow = result.data as Workflow;
|
||||
|
||||
// Dispatch event first to trigger refetch in useWorkflows
|
||||
window.dispatchEvent(new CustomEvent('workflowCreated', {
|
||||
detail: { workflow }
|
||||
}));
|
||||
|
||||
// Refetch workflows list to ensure dropdown is updated
|
||||
await refetchWorkflows();
|
||||
|
||||
// Update context first (this will trigger the sync effect to update lifecycle)
|
||||
selectWorkflowFromContext(workflow.id);
|
||||
|
||||
// Also directly update lifecycle to ensure immediate state update
|
||||
await selectWorkflow(workflow.id);
|
||||
} else if (workflowId) {
|
||||
// For resumed workflows, ensure context is synced and update lifecycle
|
||||
selectWorkflowFromContext(workflowId);
|
||||
await selectWorkflow(workflowId);
|
||||
}
|
||||
} else {
|
||||
setOptimisticMessage(null);
|
||||
if (setWorkflowStatusOptimistic) {
|
||||
setWorkflowStatusOptimistic('idle');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setOptimisticMessage(null);
|
||||
if (setWorkflowStatusOptimistic) {
|
||||
setWorkflowStatusOptimistic('idle');
|
||||
}
|
||||
}
|
||||
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProviders, setWorkflowStatusOptimistic]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkflowCleared = () => {
|
||||
// Reset all workflow-related state
|
||||
setPendingFiles([]);
|
||||
setOptimisticMessage(null);
|
||||
// Reset workflow lifecycle state
|
||||
resetWorkflow();
|
||||
// NOTE: Do NOT call clearWorkflowFromContext() here — this handler is
|
||||
// triggered BY clearWorkflow() which already set the context to null.
|
||||
// Calling it again would dispatch another 'workflowCleared' event → infinite recursion.
|
||||
};
|
||||
|
||||
window.addEventListener('workflowCleared', handleWorkflowCleared);
|
||||
return () => {
|
||||
window.removeEventListener('workflowCleared', handleWorkflowCleared);
|
||||
};
|
||||
}, [resetWorkflow]);
|
||||
|
||||
const handleWorkflowSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
|
||||
if (item === null) {
|
||||
clearWorkflowFromContext();
|
||||
resetWorkflow();
|
||||
setPendingFiles([]);
|
||||
setOptimisticMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowIdToSelect = typeof item.id === 'string' ? item.id : String(item.id);
|
||||
selectWorkflowFromContext(workflowIdToSelect);
|
||||
|
||||
if (selectWorkflow) {
|
||||
await selectWorkflow(workflowIdToSelect);
|
||||
}
|
||||
}, [selectWorkflow, resetWorkflow, selectWorkflowFromContext, clearWorkflowFromContext]);
|
||||
|
||||
const handlePromptSelect = useCallback(async (item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
|
||||
if (item === null) {
|
||||
setSelectedPromptId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const promptId = typeof item.id === 'string' ? item.id : String(item.id);
|
||||
|
||||
if (!promptPermission || promptPermission.read === 'n') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = await fetchPromptById(promptId);
|
||||
if (prompt && prompt.content) {
|
||||
setSelectedPromptId(promptId);
|
||||
setInputValue(prompt.content);
|
||||
}
|
||||
} catch (error: any) {
|
||||
}
|
||||
}, [fetchPromptById, promptPermission]);
|
||||
|
||||
const handleWorkflowModeSelect = useCallback((item: { id: string | number; label: string; value: any; metadata?: Record<string, any> } | null) => {
|
||||
if (item === null) {
|
||||
setWorkflowMode(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const modeValue = item.value || item.id;
|
||||
const modeString = typeof modeValue === 'string' ? modeValue : String(modeValue);
|
||||
|
||||
if (modeString === 'Dynamic' || modeString === 'Automation') {
|
||||
const mode = modeString as 'Dynamic' | 'Automation';
|
||||
setWorkflowMode(mode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const workflowItems = useMemo(() => {
|
||||
console.log('🔄 useDashboardInputForm: Computing workflowItems from workflows:', workflows);
|
||||
|
||||
if (!workflows || !Array.isArray(workflows)) {
|
||||
console.warn('⚠️ useDashboardInputForm: workflows is not an array:', workflows);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (workflows.length === 0) {
|
||||
console.log('ℹ️ useDashboardInputForm: workflows array is empty');
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = workflows.map(workflow => ({
|
||||
id: workflow.id,
|
||||
label: workflow.name || workflow.id,
|
||||
value: workflow,
|
||||
metadata: {
|
||||
status: workflow.status,
|
||||
workflowMode: workflow.workflowMode
|
||||
}
|
||||
}));
|
||||
|
||||
console.log(`✅ useDashboardInputForm: Created ${items.length} workflow items:`, items);
|
||||
return items;
|
||||
}, [workflows]);
|
||||
|
||||
const promptItems = useMemo(() => {
|
||||
if (!promptPermission || promptPermission.view === false || promptPermission.read === 'n') {
|
||||
return [];
|
||||
}
|
||||
return prompts.map(prompt => ({
|
||||
id: prompt.id,
|
||||
label: prompt.name || prompt.id,
|
||||
value: prompt,
|
||||
metadata: {
|
||||
content: prompt.content
|
||||
}
|
||||
}));
|
||||
}, [prompts, promptPermission]);
|
||||
|
||||
const workflowModeItems = useMemo(() => [
|
||||
{
|
||||
id: 'Automation',
|
||||
label: 'Automation',
|
||||
value: 'Automation' as const,
|
||||
metadata: {
|
||||
description: 'Automated workflow processing'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Dynamic',
|
||||
label: 'Dynamic',
|
||||
value: 'Dynamic' as const,
|
||||
metadata: {
|
||||
description: 'Iterative dynamic-style processing'
|
||||
}
|
||||
}
|
||||
], []);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
isSubmitting: startingWorkflow || isStopping,
|
||||
isStopping,
|
||||
workflowId: workflowId || undefined,
|
||||
workflowStatus,
|
||||
currentRound,
|
||||
isRunning,
|
||||
messages: displayMessages || [],
|
||||
logs: unifiedContentLogs || [], // Unified content logs (without operationId)
|
||||
dashboardTree, // Dashboard log tree (logs with operationId)
|
||||
onToggleOperationExpanded: toggleOperationExpanded,
|
||||
onToggleRoundExpanded: toggleRoundExpanded,
|
||||
getChildOperations,
|
||||
workflowItems,
|
||||
selectedWorkflowId: workflowId || selectedWorkflowId || null,
|
||||
onWorkflowSelect: handleWorkflowSelect,
|
||||
workflowsLoading,
|
||||
promptItems,
|
||||
selectedPromptId,
|
||||
onPromptSelect: handlePromptSelect,
|
||||
promptsLoading,
|
||||
promptPermission,
|
||||
workflowModeItems,
|
||||
selectedWorkflowMode: workflowMode,
|
||||
onWorkflowModeSelect: handleWorkflowModeSelect,
|
||||
playgroundUIPermission,
|
||||
chatWorkflowPermission,
|
||||
filePermission,
|
||||
workflowFiles,
|
||||
pendingFiles,
|
||||
handleFileUpload,
|
||||
handleFileDelete,
|
||||
handleFileRemove,
|
||||
handleFileView,
|
||||
uploadingFile: fileContext.uploadingFile,
|
||||
deletingFiles: fileContext.deletingFiles,
|
||||
previewingFiles: fileContext.previewingFiles,
|
||||
downloadingFiles: fileContext.downloadingFiles,
|
||||
handleFileDownload,
|
||||
handleMessageDelete,
|
||||
deletingMessages,
|
||||
isFileAttachmentPopupOpen,
|
||||
setIsFileAttachmentPopupOpen,
|
||||
allUserFiles: fileContext.files || [],
|
||||
handleFileAttach,
|
||||
handleFileUploadAndAttach,
|
||||
latestStats,
|
||||
// AI Provider selection (multiselect)
|
||||
selectedProviders,
|
||||
onProvidersChange: setSelectedProviders
|
||||
};
|
||||
}
|
||||
|
||||
export function createDashboardHook(instanceId: string) {
|
||||
return () => useDashboardInputForm(instanceId);
|
||||
}
|
||||
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes';
|
||||
|
||||
interface OperationData {
|
||||
logs: Map<string, WorkflowLog>;
|
||||
parentId: string | null;
|
||||
expanded: boolean;
|
||||
latestProgress: number | null;
|
||||
latestStatus: string | null;
|
||||
operationName: string | null; // Stable name from first log
|
||||
latestMessage: string | null; // Latest status message that updates
|
||||
roundNumber: number | null; // Track which round this operation belongs to
|
||||
}
|
||||
|
||||
interface RoundData {
|
||||
operations: Map<string, OperationData>;
|
||||
rootOperations: string[];
|
||||
expanded: boolean;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface DashboardLogTree {
|
||||
operations: Map<string, OperationData>;
|
||||
rootOperations: string[];
|
||||
logExpandedStates: Map<string, boolean>;
|
||||
currentRound: number | null;
|
||||
rounds: Map<number, RoundData>;
|
||||
}
|
||||
|
||||
export function useDashboardLogTree() {
|
||||
const [tree, setTree] = useState<DashboardLogTree>({
|
||||
operations: new Map(),
|
||||
rootOperations: [],
|
||||
logExpandedStates: new Map(),
|
||||
currentRound: null,
|
||||
rounds: new Map()
|
||||
});
|
||||
|
||||
const treeRef = useRef<DashboardLogTree>(tree);
|
||||
treeRef.current = tree;
|
||||
|
||||
const generateLogId = useCallback((log: WorkflowLog): string => {
|
||||
if (log.id) {
|
||||
return log.id;
|
||||
}
|
||||
return `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}, []);
|
||||
|
||||
const processDashboardLogs = useCallback((logs: WorkflowLog[]) => {
|
||||
setTree(prevTree => {
|
||||
const newTree: DashboardLogTree = {
|
||||
operations: new Map(prevTree.operations),
|
||||
rootOperations: [...prevTree.rootOperations],
|
||||
logExpandedStates: new Map(prevTree.logExpandedStates),
|
||||
currentRound: prevTree.currentRound,
|
||||
rounds: new Map(prevTree.rounds)
|
||||
};
|
||||
|
||||
// Process each log
|
||||
logs.forEach(log => {
|
||||
if (!log.operationId) {
|
||||
return; // Skip logs without operationId
|
||||
}
|
||||
|
||||
const operationId = log.operationId;
|
||||
const logId = generateLogId(log);
|
||||
const logRoundNumber = (log as any).roundNumber as number | null | undefined;
|
||||
|
||||
// Update current round tracking
|
||||
if (logRoundNumber !== null && logRoundNumber !== undefined) {
|
||||
if (newTree.currentRound === null || logRoundNumber > newTree.currentRound) {
|
||||
newTree.currentRound = logRoundNumber;
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create operation
|
||||
const existingOperation = newTree.operations.get(operationId);
|
||||
|
||||
// Create new logs Map (copy existing logs if updating)
|
||||
const logsMap = existingOperation
|
||||
? new Map(existingOperation.logs)
|
||||
: new Map();
|
||||
|
||||
// Store log (Map ensures uniqueness by logId)
|
||||
logsMap.set(logId, log);
|
||||
|
||||
// Determine stable operation name (only set once, never change)
|
||||
// Always use formatted operationId as the stable name - don't use log messages
|
||||
// Log messages are status updates and should go in latestMessage, not operationName
|
||||
let operationName = existingOperation?.operationName || null;
|
||||
if (operationName === null) {
|
||||
// Remove UUIDs and timestamps from operationId before formatting
|
||||
// UUID pattern: 8-4-4-4-12 hex digits (e.g., "1e6d7b14-4f30-40e2-b7a6-748b63b6a7f5")
|
||||
// Also remove standalone long hex strings that might be timestamps or IDs
|
||||
let cleanedId = operationId
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') // Remove UUIDs
|
||||
.replace(/\b[0-9a-f]{32,}\b/gi, '') // Remove long hex strings (timestamps/IDs)
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Format by splitting on dashes/underscores and capitalizing
|
||||
// This creates a stable, readable name like "Workflow Planning" from "workflow-planning"
|
||||
const formattedName = cleanedId
|
||||
.split(/[-_\s]+/)
|
||||
.filter(word => word.length > 0) // Remove empty strings
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
operationName = formattedName || operationId.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '').trim();
|
||||
}
|
||||
|
||||
// Update latest message (for status tag) - this updates with each poll
|
||||
const latestMessage = log.message || existingOperation?.latestMessage || null;
|
||||
|
||||
// Update parentId if not set yet (from first log entry)
|
||||
const parentId = existingOperation?.parentId !== null && existingOperation?.parentId !== undefined
|
||||
? existingOperation.parentId
|
||||
: (log.parentId !== undefined && log.parentId !== null ? log.parentId : null);
|
||||
|
||||
// Update latest progress (use latest value)
|
||||
const latestProgress = log.progress !== undefined && log.progress !== null
|
||||
? log.progress
|
||||
: existingOperation?.latestProgress ?? null;
|
||||
|
||||
// Update latest status (use latest value)
|
||||
const latestStatus = log.status !== undefined && log.status !== null
|
||||
? log.status
|
||||
: existingOperation?.latestStatus ?? null;
|
||||
|
||||
// Get round number for this operation (from log or existing)
|
||||
const roundNumber = logRoundNumber !== null && logRoundNumber !== undefined
|
||||
? logRoundNumber
|
||||
: existingOperation?.roundNumber ?? null;
|
||||
|
||||
// Create new operation object to ensure React detects the change
|
||||
const operation: OperationData = {
|
||||
logs: logsMap,
|
||||
parentId,
|
||||
expanded: existingOperation?.expanded ?? false,
|
||||
latestProgress,
|
||||
latestStatus,
|
||||
operationName,
|
||||
latestMessage,
|
||||
roundNumber
|
||||
};
|
||||
|
||||
newTree.operations.set(operationId, operation);
|
||||
|
||||
// Add operation to its round
|
||||
if (roundNumber !== null) {
|
||||
if (!newTree.rounds.has(roundNumber)) {
|
||||
newTree.rounds.set(roundNumber, {
|
||||
operations: new Map(),
|
||||
rootOperations: [],
|
||||
expanded: true, // New rounds start expanded
|
||||
isCompleted: false
|
||||
});
|
||||
}
|
||||
const round = newTree.rounds.get(roundNumber)!;
|
||||
round.operations.set(operationId, operation);
|
||||
}
|
||||
});
|
||||
|
||||
// Rebuild root operations list per round
|
||||
newTree.rounds.forEach((round, roundNumber) => {
|
||||
const rootOpsSet = new Set<string>();
|
||||
round.operations.forEach((op, opId) => {
|
||||
if (op.parentId === null) {
|
||||
rootOpsSet.add(opId);
|
||||
} else {
|
||||
// Check if parent is in a different round - then this is a root in THIS round
|
||||
const parentOp = newTree.operations.get(op.parentId);
|
||||
if (!parentOp || parentOp.roundNumber !== roundNumber) {
|
||||
rootOpsSet.add(opId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by timestamp
|
||||
round.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => {
|
||||
const opA = round.operations.get(opIdA);
|
||||
const opB = round.operations.get(opIdB);
|
||||
if (!opA || !opB) return 0;
|
||||
|
||||
const logsA = Array.from(opA.logs.values());
|
||||
const logsB = Array.from(opB.logs.values());
|
||||
|
||||
if (logsA.length === 0 && logsB.length === 0) return 0;
|
||||
if (logsA.length === 0) return 1;
|
||||
if (logsB.length === 0) return -1;
|
||||
|
||||
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
|
||||
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
|
||||
|
||||
return earliestA - earliestB;
|
||||
});
|
||||
|
||||
// Update completion status
|
||||
const allOpsCompleted = Array.from(round.operations.values()).every(op =>
|
||||
op.latestStatus === 'completed' || op.latestStatus === 'success'
|
||||
);
|
||||
round.isCompleted = allOpsCompleted;
|
||||
|
||||
// Auto-collapse completed rounds (except current)
|
||||
if (round.isCompleted && roundNumber !== newTree.currentRound) {
|
||||
round.expanded = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Rebuild global root operations list (operations without parentId)
|
||||
const rootOpsSet = new Set<string>();
|
||||
newTree.operations.forEach((op, opId) => {
|
||||
if (op.parentId === null) {
|
||||
rootOpsSet.add(opId);
|
||||
}
|
||||
});
|
||||
// Sort by timestamp of earliest log entry (chronological order)
|
||||
newTree.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => {
|
||||
const opA = newTree.operations.get(opIdA);
|
||||
const opB = newTree.operations.get(opIdB);
|
||||
if (!opA || !opB) return 0;
|
||||
|
||||
const logsA = Array.from(opA.logs.values());
|
||||
const logsB = Array.from(opB.logs.values());
|
||||
|
||||
if (logsA.length === 0 && logsB.length === 0) return 0;
|
||||
if (logsA.length === 0) return 1;
|
||||
if (logsB.length === 0) return -1;
|
||||
|
||||
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
|
||||
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
|
||||
|
||||
return earliestA - earliestB;
|
||||
});
|
||||
|
||||
return newTree;
|
||||
});
|
||||
}, [generateLogId]);
|
||||
|
||||
const clearDashboard = useCallback((resetRound: boolean = false) => {
|
||||
setTree({
|
||||
operations: new Map(),
|
||||
rootOperations: [],
|
||||
logExpandedStates: new Map(),
|
||||
currentRound: resetRound ? null : treeRef.current.currentRound,
|
||||
rounds: new Map()
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleOperationExpanded = useCallback((operationId: string) => {
|
||||
setTree(prevTree => {
|
||||
const operation = prevTree.operations.get(operationId);
|
||||
if (!operation) {
|
||||
return prevTree;
|
||||
}
|
||||
|
||||
const newTree: DashboardLogTree = {
|
||||
...prevTree,
|
||||
operations: new Map(prevTree.operations)
|
||||
};
|
||||
|
||||
const updatedOperation = {
|
||||
...operation,
|
||||
expanded: !operation.expanded
|
||||
};
|
||||
|
||||
newTree.operations.set(operationId, updatedOperation);
|
||||
|
||||
return newTree;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateCurrentRound = useCallback((round: number | null) => {
|
||||
setTree(prevTree => {
|
||||
// Only update current round, keep all rounds data
|
||||
// Auto-collapse previous rounds when new round starts
|
||||
if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) {
|
||||
const newRounds = new Map(prevTree.rounds);
|
||||
|
||||
// Collapse the old current round
|
||||
const oldRound = newRounds.get(prevTree.currentRound);
|
||||
if (oldRound) {
|
||||
newRounds.set(prevTree.currentRound, {
|
||||
...oldRound,
|
||||
expanded: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...prevTree,
|
||||
currentRound: round,
|
||||
rounds: newRounds
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prevTree,
|
||||
currentRound: round
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleRoundExpanded = useCallback((roundNumber: number) => {
|
||||
setTree(prevTree => {
|
||||
const round = prevTree.rounds.get(roundNumber);
|
||||
if (!round) {
|
||||
return prevTree;
|
||||
}
|
||||
|
||||
const newRounds = new Map(prevTree.rounds);
|
||||
newRounds.set(roundNumber, {
|
||||
...round,
|
||||
expanded: !round.expanded
|
||||
});
|
||||
|
||||
return {
|
||||
...prevTree,
|
||||
rounds: newRounds
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getChildOperations = useCallback((parentId: string | null): string[] => {
|
||||
const currentTree = treeRef.current;
|
||||
const childOps = Array.from(currentTree.operations.entries())
|
||||
.filter(([_, op]) => op.parentId === parentId)
|
||||
.map(([opId, op]) => ({ opId, op }));
|
||||
|
||||
// Sort by timestamp of earliest log entry (chronological order)
|
||||
return childOps.sort((a, b) => {
|
||||
const logsA = Array.from(a.op.logs.values());
|
||||
const logsB = Array.from(b.op.logs.values());
|
||||
|
||||
if (logsA.length === 0 && logsB.length === 0) return 0;
|
||||
if (logsA.length === 0) return 1; // Put operations without logs at the end
|
||||
if (logsB.length === 0) return -1;
|
||||
|
||||
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
|
||||
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
|
||||
|
||||
return earliestA - earliestB; // Ascending order (oldest first)
|
||||
}).map(({ opId }) => opId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
tree,
|
||||
processDashboardLogs,
|
||||
clearDashboard,
|
||||
toggleOperationExpanded,
|
||||
toggleRoundExpanded,
|
||||
updateCurrentRound,
|
||||
getChildOperations
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,558 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useApiRequest } from '../useApi';
|
||||
import {
|
||||
type Workflow,
|
||||
type WorkflowMessage,
|
||||
type WorkflowLog,
|
||||
type StartWorkflowRequest,
|
||||
fetchWorkflow as fetchWorkflowApi,
|
||||
fetchChatData
|
||||
} from '../../api/workflowApi';
|
||||
import { useWorkflowOperations } from './useWorkflowOperations';
|
||||
import { sortMessages, sortLogs } from './playgroundUtils';
|
||||
import { useWorkflowPolling } from './useWorkflowPolling';
|
||||
import { getWorkflowApiBaseUrl } from '../useWorkflows';
|
||||
|
||||
interface UnifiedChatDataItem {
|
||||
type: 'message' | 'log';
|
||||
item: WorkflowMessage | WorkflowLog;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* WORKFLOW LIFECYCLE STATE MACHINE
|
||||
* =============================================================================
|
||||
*
|
||||
* WORKFLOW STATUS (from Backend):
|
||||
* • idle - No workflow
|
||||
* • running - Workflow is processing
|
||||
* • completed - Round finished (Backend processed "last" message)
|
||||
* • stopped - User stopped the workflow
|
||||
* • failed - Error occurred
|
||||
*
|
||||
* UI FLAG:
|
||||
* • hasRenderedLastMessage: boolean
|
||||
* - true: "last" message was rendered in UI
|
||||
* - false: "last" message not yet in UI
|
||||
*
|
||||
* POLLING LOGIC:
|
||||
* POLL ACTIVE when:
|
||||
* status === 'running'
|
||||
* OR (status === 'completed' AND !hasRenderedLastMessage)
|
||||
*
|
||||
* POLL STOPS when:
|
||||
* status === 'stopped'
|
||||
* OR status === 'failed'
|
||||
* OR hasRenderedLastMessage === true
|
||||
*
|
||||
* TRANSITIONS:
|
||||
* [Send Button] (from any status):
|
||||
* → hasRenderedLastMessage = false (new round starts)
|
||||
* → afterTimestamp = now
|
||||
* → Start polling
|
||||
*
|
||||
* [Load Workflow]:
|
||||
* → Load all data
|
||||
* → Check if last message has status="last"
|
||||
* → If yes: hasRenderedLastMessage = true, no polling
|
||||
* → If no AND status=running: Start polling
|
||||
*
|
||||
* [Message with status="last" rendered]:
|
||||
* → hasRenderedLastMessage = true
|
||||
* → Stop polling
|
||||
*
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export function useWorkflowLifecycle(instanceId: string) {
|
||||
const apiBaseUrl = useMemo(() => getWorkflowApiBaseUrl(instanceId, 'chatplayground'), [instanceId]);
|
||||
|
||||
// === STATE ===
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
|
||||
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
|
||||
const [messages, setMessages] = useState<WorkflowMessage[]>([]);
|
||||
const [logs, setLogs] = useState<WorkflowLog[]>([]);
|
||||
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
|
||||
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
|
||||
const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null);
|
||||
|
||||
// === REFS FOR SYNC ACCESS ===
|
||||
const statusRef = useRef<string>('idle');
|
||||
const lastRenderedTimestampRef = useRef<number | null>(null);
|
||||
|
||||
// === KEY STATE MACHINE FLAG ===
|
||||
// This flag tracks if the UI has rendered a message with status="last"
|
||||
// Polling continues until this is true (even if backend status is "completed")
|
||||
const hasRenderedLastMessageRef = useRef<boolean>(false);
|
||||
const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState<boolean>(false);
|
||||
|
||||
// Flag to prevent useEffect from stopping polling during active workflow start
|
||||
const isStartingWorkflowRef = useRef<boolean>(false);
|
||||
|
||||
// === HOOKS ===
|
||||
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
|
||||
const { request } = useApiRequest();
|
||||
const pollingController = useWorkflowPolling();
|
||||
const pollingControllerRef = useRef(pollingController);
|
||||
pollingControllerRef.current = pollingController;
|
||||
|
||||
// === HELPER: Update workflow status ===
|
||||
const updateWorkflowStatus = useCallback((newStatus: string) => {
|
||||
statusRef.current = newStatus;
|
||||
setWorkflowStatus(newStatus);
|
||||
console.log('📍 Status updated to:', newStatus);
|
||||
}, []);
|
||||
|
||||
// === HELPER: Convert backend log format to frontend format ===
|
||||
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
|
||||
return {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId || workflowId || '',
|
||||
message: log.message || '',
|
||||
type: log.type || 'info',
|
||||
timestamp: log.timestamp || log.createdAt || Date.now(),
|
||||
status: log.status || 'running',
|
||||
progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined,
|
||||
performance: log.performance,
|
||||
operationId: log.operationId || null,
|
||||
parentId: log.parentId || null
|
||||
};
|
||||
}, [workflowId]);
|
||||
|
||||
// === CORE: Process unified chat data ===
|
||||
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; workflowCost: number }) => {
|
||||
console.log('🔄 Processing chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
workflowCost: chatData.workflowCost ?? 0
|
||||
});
|
||||
|
||||
const timeline: UnifiedChatDataItem[] = [];
|
||||
|
||||
(chatData.messages || []).forEach((message: WorkflowMessage) => {
|
||||
timeline.push({
|
||||
type: 'message',
|
||||
item: message,
|
||||
createdAt: message.publishedAt || message.timestamp || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
(chatData.logs || []).forEach((log: any) => {
|
||||
timeline.push({
|
||||
type: 'log',
|
||||
item: log,
|
||||
createdAt: log.timestamp || log.createdAt || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Update lastRenderedTimestamp
|
||||
if (timeline.length > 0) {
|
||||
lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt;
|
||||
}
|
||||
|
||||
// === CHECK FOR "LAST" MESSAGE ===
|
||||
// This is the key state machine logic: detect when a "last" message arrives
|
||||
let foundLastMessage = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'message') {
|
||||
const message = item.item as WorkflowMessage;
|
||||
if ((message as any).status === 'last') {
|
||||
foundLastMessage = true;
|
||||
console.log('🏁 Found "last" message:', message.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === STATE MACHINE: Handle "last" message ===
|
||||
if (foundLastMessage && !hasRenderedLastMessageRef.current) {
|
||||
console.log('🛑 "last" message detected - stopping polling');
|
||||
hasRenderedLastMessageRef.current = true;
|
||||
setHasRenderedLastMessage(true);
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}
|
||||
|
||||
// === UPDATE MESSAGES STATE ===
|
||||
setMessages(prevMessages => {
|
||||
const newMessages: WorkflowMessage[] = [...prevMessages];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'message') {
|
||||
const message = item.item as WorkflowMessage;
|
||||
if (!message || !message.id) return;
|
||||
|
||||
const existingIndex = newMessages.findIndex(m => m.id === message.id);
|
||||
if (existingIndex >= 0) {
|
||||
newMessages[existingIndex] = message;
|
||||
hasChanges = true;
|
||||
} else {
|
||||
newMessages.push(message);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges || timeline.some(item => item.type === 'message')) {
|
||||
return [...newMessages].sort(sortMessages);
|
||||
}
|
||||
return prevMessages;
|
||||
});
|
||||
|
||||
// === UPDATE DASHBOARD LOGS (with operationId) ===
|
||||
setDashboardLogs(prevLogs => {
|
||||
const newLogs: WorkflowLog[] = [...prevLogs];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||
if (frontendLog.operationId) {
|
||||
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
|
||||
newLogs[existingIndex] = frontendLog;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
newLogs.push(frontendLog);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
|
||||
});
|
||||
|
||||
// === UPDATE UNIFIED CONTENT LOGS (without operationId) ===
|
||||
setUnifiedContentLogs(prevLogs => {
|
||||
const newLogs: WorkflowLog[] = [...prevLogs];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||
if (!frontendLog.operationId) {
|
||||
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
|
||||
newLogs[existingIndex] = frontendLog;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
newLogs.push(frontendLog);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
|
||||
});
|
||||
|
||||
// === UPDATE COMBINED LOGS ===
|
||||
setLogs(prevLogs => {
|
||||
const allLogs: WorkflowLog[] = [...prevLogs];
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
allLogs[existingIndex] = frontendLog;
|
||||
} else {
|
||||
allLogs.push(frontendLog);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [...allLogs].sort(sortLogs);
|
||||
});
|
||||
|
||||
// === UPDATE COST from billing transactions (single source of truth) ===
|
||||
const cost = chatData.workflowCost ?? 0;
|
||||
setLatestStats(cost > 0 ? { priceCHF: cost } : null);
|
||||
}, [convertLogToFrontendFormat]);
|
||||
|
||||
// === POLLING FUNCTION ===
|
||||
const pollWorkflowData = useCallback(async (id: string) => {
|
||||
try {
|
||||
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
|
||||
|
||||
// Fetch workflow status
|
||||
const workflowData = await fetchWorkflowApi(request, id, apiBaseUrl).catch(() => null);
|
||||
|
||||
if (workflowData) {
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
if (round !== undefined) setCurrentRound(round);
|
||||
|
||||
// === STATE MACHINE: Check if polling should stop based on status ===
|
||||
if (status === 'stopped' || status === 'failed') {
|
||||
console.log(`🛑 Workflow ${status} - stopping polling immediately`);
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch chat data
|
||||
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
|
||||
|
||||
console.log('📊 Polled chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
workflowCost: chatData.workflowCost ?? 0,
|
||||
afterTimestamp
|
||||
});
|
||||
|
||||
// Process data (this will detect "last" message and stop polling if found)
|
||||
processUnifiedChatData(chatData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Polling error:', error);
|
||||
}
|
||||
}, [request, instanceId, apiBaseUrl, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// === POLLING CONTROL EFFECT ===
|
||||
useEffect(() => {
|
||||
if (!workflowId) {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if we're actively starting a workflow - handleStartWorkflow manages polling
|
||||
if (isStartingWorkflowRef.current) {
|
||||
console.log('📍 Polling decision: Skipping - workflow start in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// === STATE MACHINE: Determine if polling should be active ===
|
||||
// Use ref for immediate value (state may be stale)
|
||||
const hasLastMessage = hasRenderedLastMessageRef.current;
|
||||
|
||||
const shouldPoll =
|
||||
workflowStatus === 'running' ||
|
||||
(workflowStatus === 'completed' && !hasLastMessage);
|
||||
|
||||
const shouldStopImmediately =
|
||||
workflowStatus === 'stopped' ||
|
||||
workflowStatus === 'failed' ||
|
||||
hasLastMessage;
|
||||
|
||||
console.log('📍 Polling decision:', {
|
||||
workflowStatus,
|
||||
hasRenderedLastMessage: hasLastMessage,
|
||||
shouldPoll,
|
||||
shouldStopImmediately
|
||||
});
|
||||
|
||||
if (shouldPoll) {
|
||||
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
|
||||
} else if (shouldStopImmediately) {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
};
|
||||
}, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]);
|
||||
|
||||
// === START WORKFLOW (Send Button) ===
|
||||
const handleStartWorkflow = useCallback(async (
|
||||
workflowData: StartWorkflowRequest,
|
||||
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
|
||||
) => {
|
||||
try {
|
||||
// Set flag to prevent useEffect from interfering during start
|
||||
isStartingWorkflowRef.current = true;
|
||||
|
||||
const result = await startWorkflow(instanceId, workflowData, options);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const workflow = result.data as Workflow;
|
||||
|
||||
// === STATE MACHINE: New round starts ===
|
||||
console.log('🚀 Starting workflow:', workflow.id);
|
||||
|
||||
// Reset state for new round - MUST update refs BEFORE state
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
|
||||
// Set afterTimestamp to NOW - only poll for new data
|
||||
lastRenderedTimestampRef.current = Date.now();
|
||||
|
||||
// Start polling immediately (before state updates trigger useEffect)
|
||||
pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData);
|
||||
|
||||
// Now update state (will trigger re-renders)
|
||||
setWorkflowId(workflow.id);
|
||||
setHasRenderedLastMessage(false);
|
||||
updateWorkflowStatus(workflow.status || 'running');
|
||||
|
||||
// Clear the starting flag after a short delay to allow React to settle
|
||||
setTimeout(() => {
|
||||
isStartingWorkflowRef.current = false;
|
||||
}, 100);
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
isStartingWorkflowRef.current = false;
|
||||
return { success: false, error: result.error || 'Failed to start workflow' };
|
||||
}
|
||||
} catch (error: any) {
|
||||
isStartingWorkflowRef.current = false;
|
||||
return { success: false, error: error.message || 'Failed to start workflow' };
|
||||
}
|
||||
}, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]);
|
||||
|
||||
// === STOP WORKFLOW ===
|
||||
const handleStopWorkflow = useCallback(async () => {
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'No workflow to stop' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await stopWorkflow(instanceId, workflowId);
|
||||
|
||||
if (result.success) {
|
||||
updateWorkflowStatus('stopped');
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Failed to stop workflow' };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message || 'Failed to stop workflow' };
|
||||
}
|
||||
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
|
||||
|
||||
// === RESET WORKFLOW ===
|
||||
const resetWorkflow = useCallback(() => {
|
||||
console.log('🔄 Resetting workflow state');
|
||||
|
||||
setWorkflowId(null);
|
||||
updateWorkflowStatus('idle');
|
||||
setCurrentRound(undefined);
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setDashboardLogs([]);
|
||||
setUnifiedContentLogs([]);
|
||||
setLatestStats(null);
|
||||
|
||||
lastRenderedTimestampRef.current = null;
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
// === SELECT/LOAD WORKFLOW ===
|
||||
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
|
||||
try {
|
||||
console.log('📥 Loading workflow:', workflowIdToSelect);
|
||||
|
||||
setWorkflowId(workflowIdToSelect);
|
||||
lastRenderedTimestampRef.current = null;
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
setLatestStats(null);
|
||||
|
||||
// Fetch workflow data
|
||||
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null);
|
||||
|
||||
if (!workflowData) {
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setDashboardLogs([]);
|
||||
setUnifiedContentLogs([]);
|
||||
setLatestStats(null);
|
||||
updateWorkflowStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
if (round !== undefined) setCurrentRound(round);
|
||||
|
||||
// Fetch all chat data (no afterTimestamp = get everything)
|
||||
try {
|
||||
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
|
||||
console.log('📥 Loaded chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
workflowCost: chatData.workflowCost ?? 0
|
||||
});
|
||||
|
||||
// === STATE MACHINE: Check if last message has status="last" ===
|
||||
const allMessages = chatData.messages || [];
|
||||
const sortedMessages = [...allMessages].sort((a, b) => {
|
||||
const aTime = a.publishedAt || a.timestamp || 0;
|
||||
const bTime = b.publishedAt || b.timestamp || 0;
|
||||
return bTime - aTime; // Sort descending (newest first)
|
||||
});
|
||||
|
||||
const lastMessage = sortedMessages[0];
|
||||
const lastMessageStatus = lastMessage ? (lastMessage as any).status : null;
|
||||
|
||||
console.log('📍 Last message status:', lastMessageStatus);
|
||||
|
||||
if (lastMessageStatus === 'last') {
|
||||
// Round is complete - don't start polling
|
||||
hasRenderedLastMessageRef.current = true;
|
||||
setHasRenderedLastMessage(true);
|
||||
console.log('✅ Workflow round complete - no polling needed');
|
||||
} else if (status === 'running') {
|
||||
// Workflow is running - polling will start via useEffect
|
||||
console.log('🔄 Workflow is running - polling will start');
|
||||
}
|
||||
|
||||
// Process the data
|
||||
processUnifiedChatData(chatData);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch chat data:', error);
|
||||
updateWorkflowStatus('idle');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error selecting workflow:', error);
|
||||
}
|
||||
}, [request, instanceId, apiBaseUrl, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES ===
|
||||
const setWorkflowStatusOptimistic = useCallback((status: string) => {
|
||||
updateWorkflowStatus(status);
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
// === COMPUTED VALUES ===
|
||||
const isRunning = workflowStatus === 'running';
|
||||
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
workflowStatus,
|
||||
currentRound,
|
||||
isRunning,
|
||||
isStopping,
|
||||
startingWorkflow,
|
||||
messages,
|
||||
logs,
|
||||
dashboardLogs,
|
||||
unifiedContentLogs,
|
||||
latestStats,
|
||||
hasRenderedLastMessage,
|
||||
startWorkflow: handleStartWorkflow,
|
||||
stopWorkflow: handleStopWorkflow,
|
||||
resetWorkflow,
|
||||
selectWorkflow,
|
||||
setWorkflowStatusOptimistic
|
||||
};
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Re-export from consolidated hook
|
||||
export { useWorkflowOperations } from '../useWorkflows';
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface PollingState {
|
||||
activeWorkflowId: string | null;
|
||||
isPolling: boolean;
|
||||
isPollInProgress: boolean;
|
||||
isPaused: boolean;
|
||||
currentInterval: number;
|
||||
failureCount: number;
|
||||
rateLimitFailureCount: number;
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
const BASE_INTERVAL = 5000; // 5 seconds
|
||||
const MAX_INTERVAL = 10000; // 10 seconds
|
||||
const BACKOFF_MULTIPLIER = 1.5;
|
||||
const RATE_LIMIT_BACKOFF_MULTIPLIER = 2.0;
|
||||
const MAX_RATE_LIMIT_FAILURES = 5;
|
||||
|
||||
export type PollCallback = (workflowId: string) => Promise<void>;
|
||||
|
||||
export function useWorkflowPolling() {
|
||||
const stateRef = useRef<PollingState>({
|
||||
activeWorkflowId: null,
|
||||
isPolling: false,
|
||||
isPollInProgress: false,
|
||||
isPaused: false,
|
||||
currentInterval: BASE_INTERVAL,
|
||||
failureCount: 0,
|
||||
rateLimitFailureCount: 0,
|
||||
timeoutId: null
|
||||
});
|
||||
|
||||
const pollCallbackRef = useRef<PollCallback | null>(null);
|
||||
|
||||
const calculateInterval = useCallback((isRateLimit: boolean = false): number => {
|
||||
const state = stateRef.current;
|
||||
const multiplier = isRateLimit ? RATE_LIMIT_BACKOFF_MULTIPLIER : BACKOFF_MULTIPLIER;
|
||||
const newInterval = Math.min(
|
||||
BASE_INTERVAL * Math.pow(multiplier, state.failureCount),
|
||||
MAX_INTERVAL
|
||||
);
|
||||
return Math.floor(newInterval);
|
||||
}, []);
|
||||
|
||||
const scheduleNextPoll = useCallback((interval: number) => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (state.timeoutId) {
|
||||
clearTimeout(state.timeoutId);
|
||||
state.timeoutId = null;
|
||||
}
|
||||
|
||||
// Don't schedule if not polling or paused
|
||||
if (!state.isPolling || state.isPaused || !state.activeWorkflowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
state.timeoutId = setTimeout(() => {
|
||||
state.timeoutId = null;
|
||||
doPolling();
|
||||
}, interval);
|
||||
}, []);
|
||||
|
||||
const doPolling = useCallback(async () => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Prevent concurrent polls
|
||||
if (state.isPollInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate workflow is still active
|
||||
if (!state.activeWorkflowId || !state.isPolling || state.isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowId = state.activeWorkflowId;
|
||||
state.isPollInProgress = true;
|
||||
|
||||
try {
|
||||
if (pollCallbackRef.current) {
|
||||
await pollCallbackRef.current(workflowId);
|
||||
}
|
||||
|
||||
// Success - reset failure counts and interval
|
||||
state.failureCount = 0;
|
||||
state.rateLimitFailureCount = 0;
|
||||
state.currentInterval = BASE_INTERVAL;
|
||||
|
||||
// Schedule next poll
|
||||
scheduleNextPoll(state.currentInterval);
|
||||
} catch (error: any) {
|
||||
// Handle errors
|
||||
const isRateLimit = error?.status === 429 || error?.response?.status === 429;
|
||||
|
||||
if (isRateLimit) {
|
||||
state.rateLimitFailureCount++;
|
||||
|
||||
// Stop polling after too many rate limit errors
|
||||
if (state.rateLimitFailureCount >= MAX_RATE_LIMIT_FAILURES) {
|
||||
console.error('Too many rate limit errors, stopping polling');
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
state.rateLimitFailureCount = 0; // Reset rate limit count on non-rate-limit errors
|
||||
}
|
||||
|
||||
state.failureCount++;
|
||||
const nextInterval = calculateInterval(isRateLimit);
|
||||
state.currentInterval = nextInterval;
|
||||
|
||||
console.warn(`Polling error (attempt ${state.failureCount}):`, error);
|
||||
|
||||
// Schedule next poll with backoff
|
||||
scheduleNextPoll(nextInterval);
|
||||
} finally {
|
||||
state.isPollInProgress = false;
|
||||
}
|
||||
}, [scheduleNextPoll, calculateInterval]);
|
||||
|
||||
const startPolling = useCallback((workflowId: string, callback: PollCallback) => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Stop any existing polling
|
||||
if (state.isPolling) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
// Validate workflow ID
|
||||
if (!workflowId || typeof workflowId !== 'string') {
|
||||
console.error('Invalid workflow ID for polling:', workflowId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up polling state
|
||||
state.activeWorkflowId = workflowId;
|
||||
state.isPolling = true;
|
||||
state.isPaused = false;
|
||||
state.failureCount = 0;
|
||||
state.rateLimitFailureCount = 0;
|
||||
state.currentInterval = BASE_INTERVAL;
|
||||
pollCallbackRef.current = callback;
|
||||
|
||||
// Execute immediate first poll (no delay)
|
||||
doPolling();
|
||||
}, [doPolling]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Clear timeout
|
||||
if (state.timeoutId) {
|
||||
clearTimeout(state.timeoutId);
|
||||
state.timeoutId = null;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
state.isPolling = false;
|
||||
state.isPollInProgress = false;
|
||||
state.activeWorkflowId = null;
|
||||
state.failureCount = 0;
|
||||
state.rateLimitFailureCount = 0;
|
||||
state.currentInterval = BASE_INTERVAL;
|
||||
state.isPaused = false;
|
||||
pollCallbackRef.current = null;
|
||||
}, []);
|
||||
|
||||
const pausePolling = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
state.isPaused = true;
|
||||
}, []);
|
||||
|
||||
const resumePolling = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
if (state.isPolling && state.isPaused) {
|
||||
state.isPaused = false;
|
||||
// Resume polling immediately
|
||||
if (!state.isPollInProgress) {
|
||||
scheduleNextPoll(0);
|
||||
}
|
||||
}
|
||||
}, [scheduleNextPoll]);
|
||||
|
||||
const isPolling = useCallback((): boolean => {
|
||||
return stateRef.current.isPolling && !stateRef.current.isPaused;
|
||||
}, []);
|
||||
|
||||
const getActiveWorkflowId = useCallback((): string | null => {
|
||||
return stateRef.current.activeWorkflowId;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startPolling,
|
||||
stopPolling,
|
||||
pausePolling,
|
||||
resumePolling,
|
||||
isPolling,
|
||||
getActiveWorkflowId
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useApiRequest } from '../useApi';
|
||||
import { useWorkflowSelection } from '../../contexts/WorkflowSelectionContext';
|
||||
import { fetchWorkflows as fetchWorkflowsApi, type Workflow } from '../../api/workflowApi';
|
||||
import { getWorkflowApiBaseUrl } from '../useWorkflows';
|
||||
|
||||
export function useWorkflows(instanceId?: string, featureCode: string = 'chatplayground') {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [isRefetching, setIsRefetching] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { request } = useApiRequest<null, Workflow[]>();
|
||||
const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection();
|
||||
|
||||
const apiBaseUrl = useMemo(
|
||||
() => getWorkflowApiBaseUrl(instanceId, featureCode),
|
||||
[instanceId, featureCode]
|
||||
);
|
||||
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
if (!apiBaseUrl) {
|
||||
console.warn('⚠️ useWorkflows: No apiBaseUrl available (missing instanceId), skipping fetch');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('🔄 useWorkflows: Fetching workflows from API...', { apiBaseUrl });
|
||||
const workflowList = await fetchWorkflowsApi(request, undefined, apiBaseUrl);
|
||||
console.log('✅ useWorkflows: Fetched workflows:', workflowList);
|
||||
|
||||
if (Array.isArray(workflowList)) {
|
||||
setWorkflows(workflowList);
|
||||
console.log(`✅ useWorkflows: Set ${workflowList.length} workflows in state`);
|
||||
} else {
|
||||
console.warn('⚠️ useWorkflows: API returned non-array data:', workflowList);
|
||||
setWorkflows([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ useWorkflows: Error fetching workflows:', error);
|
||||
setError(error.message || 'Failed to fetch workflows');
|
||||
setWorkflows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request, apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflows();
|
||||
}, [fetchWorkflows]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkflowDeleted = (event: CustomEvent<{ workflowIds: string[] }>) => {
|
||||
const deletedIds = event.detail.workflowIds;
|
||||
fetchWorkflows();
|
||||
if (selectedWorkflowId && deletedIds.includes(selectedWorkflowId)) {
|
||||
clearWorkflow();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkflowCreated = () => {
|
||||
// Immediately refetch workflows list to include the newly created workflow
|
||||
fetchWorkflows();
|
||||
};
|
||||
|
||||
window.addEventListener('workflowDeleted', handleWorkflowDeleted as EventListener);
|
||||
window.addEventListener('workflowCreated', handleWorkflowCreated as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('workflowDeleted', handleWorkflowDeleted as EventListener);
|
||||
window.removeEventListener('workflowCreated', handleWorkflowCreated as EventListener);
|
||||
};
|
||||
}, [fetchWorkflows, selectedWorkflowId, clearWorkflow]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setIsRefetching(true);
|
||||
try {
|
||||
await fetchWorkflows();
|
||||
} finally {
|
||||
setIsRefetching(false);
|
||||
}
|
||||
}, [fetchWorkflows]);
|
||||
|
||||
return {
|
||||
workflows,
|
||||
loading,
|
||||
isRefetching,
|
||||
error,
|
||||
refetch
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -13,9 +13,12 @@ export interface UserInputRequest {
|
|||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type { WorkflowFile } from './playground/useDashboardInputForm';
|
||||
|
||||
export { useWorkflows } from './playground/useWorkflows';
|
||||
export { useWorkflowOperations } from './playground/useWorkflowOperations';
|
||||
export { useWorkflowLifecycle } from './playground/useWorkflowLifecycle';
|
||||
export { useDashboardInputForm, createDashboardHook } from './playground/useDashboardInputForm';
|
||||
export interface WorkflowFile {
|
||||
id: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
messageId?: string;
|
||||
source?: 'user_uploaded' | 'ai_created';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -27,17 +27,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
|
|||
// RealEstate Views
|
||||
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||
|
||||
// Chat Playground Views (reusing existing workflow pages)
|
||||
import { PlaygroundPage, WorkflowsPage } from './workflows';
|
||||
|
||||
// Automation Views
|
||||
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
|
||||
|
||||
// CodeEditor Views
|
||||
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
|
||||
|
||||
// Workspace Views
|
||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||
|
||||
// Teamsbot Views
|
||||
|
|
@ -128,21 +123,14 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
dashboard: RealEstatePekView,
|
||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||
},
|
||||
chatplayground: {
|
||||
playground: PlaygroundPage,
|
||||
workflows: WorkflowsPage,
|
||||
},
|
||||
automation: {
|
||||
definitions: AutomationDefinitionsView,
|
||||
templates: AutomationTemplatesView,
|
||||
logs: AutomationLogsView,
|
||||
},
|
||||
codeeditor: {
|
||||
editor: CodeEditorPage,
|
||||
workflows: CodeEditorWorkflowsPage,
|
||||
},
|
||||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
editor: WorkspaceEditorPage,
|
||||
settings: WorkspaceSettingsPage,
|
||||
},
|
||||
teamsbot: {
|
||||
|
|
@ -208,8 +196,8 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
}
|
||||
|
||||
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
||||
// other workspace views (e.g. settings) use the standard FeatureViewPage rendering.
|
||||
if (featureCode === 'workspace' && view !== 'settings') {
|
||||
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
|
||||
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaCogs, FaComments, FaFileAlt, FaHeadset } from 'react-icons/fa';
|
||||
import { FaCogs, FaHeadset } from 'react-icons/fa';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useStore } from '../hooks/useStore';
|
||||
import type { StoreFeature } from '../api/storeApi';
|
||||
|
|
@ -15,8 +15,6 @@ import styles from './Store.module.css';
|
|||
|
||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||
automation: <FaCogs />,
|
||||
chatplayground: <FaComments />,
|
||||
codeeditor: <FaFileAlt />,
|
||||
teamsbot: <FaHeadset />,
|
||||
};
|
||||
|
||||
|
|
@ -26,16 +24,6 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
|||
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
||||
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
||||
},
|
||||
chatplayground: {
|
||||
de: 'Teste und experimentiere mit AI-Chat-Modellen in einer interaktiven Umgebung.',
|
||||
en: 'Test and experiment with AI chat models in an interactive environment.',
|
||||
fr: 'Testez et experimentez avec des modeles de chat IA dans un environnement interactif.',
|
||||
},
|
||||
codeeditor: {
|
||||
de: 'AI-gestuetzter Editor fuer Text-Dateien mit Cursor-artigem Chat und Diff-Preview.',
|
||||
en: 'AI-powered editor for text files with Cursor-style chat and diff preview.',
|
||||
fr: 'Editeur de fichiers texte assiste par IA avec chat et apercu des modifications.',
|
||||
},
|
||||
teamsbot: {
|
||||
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
/**
|
||||
* CodeEditorPage
|
||||
*
|
||||
* Main page for the CodeEditor feature.
|
||||
* Three-panel layout: FileList (left) | Chat (center) | DiffPreview (right)
|
||||
* Files are dragged from FileList into the prompt textarea as @fileName references.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useCodeEditor } from './useCodeEditor';
|
||||
import { FileListPanel } from './FileListPanel';
|
||||
import { DiffPreviewPanel } from './DiffPreviewPanel';
|
||||
import { useResizablePanels } from '../../../hooks/useResizablePanels';
|
||||
import { Messages } from '../../../components/UiComponents';
|
||||
import { FaPaperPlane, FaStop, FaRobot, FaEdit } from 'react-icons/fa';
|
||||
import styles from './CodeEditor.module.css';
|
||||
|
||||
export const CodeEditorPage: React.FC = () => {
|
||||
const { instance } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [mode, setMode] = useState<'simple' | 'agent'>('simple');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const {
|
||||
messages,
|
||||
pendingEdits,
|
||||
acceptEdit,
|
||||
rejectEdit,
|
||||
isProcessing,
|
||||
sendMessage,
|
||||
stopProcessing,
|
||||
files,
|
||||
agentProgress,
|
||||
} = useCodeEditor(instanceId);
|
||||
|
||||
const {
|
||||
leftWidth: fileListWidth,
|
||||
handleMouseDown: fileListMouseDown,
|
||||
containerRef: outerContainerRef,
|
||||
isDragging: isDraggingLeft,
|
||||
} = useResizablePanels({
|
||||
storageKey: 'codeeditor-filelist-width',
|
||||
defaultLeftWidth: 20,
|
||||
minLeftWidth: 10,
|
||||
maxLeftWidth: 40,
|
||||
});
|
||||
|
||||
const {
|
||||
leftWidth: chatWidth,
|
||||
handleMouseDown: chatMouseDown,
|
||||
containerRef: innerContainerRef,
|
||||
isDragging: isDraggingRight,
|
||||
} = useResizablePanels({
|
||||
storageKey: 'codeeditor-chat-width',
|
||||
defaultLeftWidth: 60,
|
||||
minLeftWidth: 30,
|
||||
maxLeftWidth: 80,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed || isProcessing) return;
|
||||
sendMessage(trimmed, mode);
|
||||
setInputValue('');
|
||||
}, [inputValue, isProcessing, sendMessage, mode]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [handleSubmit]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('application/x-codeeditor-file')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const fileDataStr = e.dataTransfer.getData('application/x-codeeditor-file');
|
||||
if (!fileDataStr) return;
|
||||
|
||||
try {
|
||||
const fileData = JSON.parse(fileDataStr);
|
||||
const tag = `@${fileData.fileName}`;
|
||||
const textarea = inputRef.current;
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart || inputValue.length;
|
||||
const before = inputValue.slice(0, pos);
|
||||
const after = inputValue.slice(pos);
|
||||
const spaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n') ? ' ' : '';
|
||||
const spaceAfter = after.length > 0 && !after.startsWith(' ') && !after.startsWith('\n') ? ' ' : '';
|
||||
const newValue = `${before}${spaceBefore}${tag}${spaceAfter}${after}`;
|
||||
setInputValue(newValue);
|
||||
requestAnimationFrame(() => {
|
||||
const newPos = pos + spaceBefore.length + tag.length + spaceAfter.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
});
|
||||
} else {
|
||||
setInputValue(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + tag + ' ');
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed drop data
|
||||
}
|
||||
}, [inputValue]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={`${styles.panels} ${isDraggingLeft || isDraggingRight ? styles.dragging : ''}`}
|
||||
ref={outerContainerRef}
|
||||
>
|
||||
{/* Left: File List */}
|
||||
<div className={styles.filePanel} style={{ width: `${fileListWidth}%` }}>
|
||||
<FileListPanel files={files} />
|
||||
</div>
|
||||
|
||||
<div className={styles.divider} onMouseDown={fileListMouseDown} />
|
||||
|
||||
{/* Center + Right */}
|
||||
<div className={styles.mainArea} style={{ width: `${100 - fileListWidth}%` }} ref={innerContainerRef}>
|
||||
{/* Center: Chat */}
|
||||
<div className={styles.chatPanel} style={{ width: `${chatWidth}%` }}>
|
||||
<div className={styles.messagesArea}>
|
||||
<Messages messages={messages} />
|
||||
|
||||
{agentProgress && isProcessing && (
|
||||
<div className={styles.agentProgress}>
|
||||
<FaRobot />
|
||||
<span>
|
||||
Round {agentProgress.round} | {agentProgress.totalToolCalls} tools |{' '}
|
||||
{agentProgress.costCHF.toFixed(4)} CHF
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.inputArea}>
|
||||
<div className={styles.modeToggle}>
|
||||
<button
|
||||
className={`${styles.modeButton} ${mode === 'simple' ? styles.modeActive : ''}`}
|
||||
onClick={() => setMode('simple')}
|
||||
disabled={isProcessing}
|
||||
title="Single AI call -- drag files into prompt as @references"
|
||||
>
|
||||
<FaEdit /> Simple
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.modeButton} ${mode === 'agent' ? styles.modeActive : ''}`}
|
||||
onClick={() => setMode('agent')}
|
||||
disabled={isProcessing}
|
||||
title="AI agent with tools (reads files autonomously, multi-step)"
|
||||
>
|
||||
<FaRobot /> Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={`${styles.input} ${isDragOver ? styles.inputDropTarget : ''}`}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
placeholder={mode === 'agent'
|
||||
? "Describe a complex task (e.g. 'Document all Python files')..."
|
||||
: "Drag files here and describe changes (e.g. 'In @config.yaml change the port to 8080')..."
|
||||
}
|
||||
rows={3}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<div className={styles.inputActions}>
|
||||
<span className={styles.fileCount}>
|
||||
{mode === 'simple'
|
||||
? `Drag files from the list into your prompt`
|
||||
: `Agent mode: AI reads files autonomously`
|
||||
}
|
||||
</span>
|
||||
{isProcessing ? (
|
||||
<button className={styles.stopButton} onClick={stopProcessing}>
|
||||
<FaStop /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={styles.sendButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
<FaPaperPlane /> Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider} onMouseDown={chatMouseDown} />
|
||||
|
||||
{/* Right: Diff Preview */}
|
||||
<div className={styles.diffPanel} style={{ width: `${100 - chatWidth}%` }}>
|
||||
<DiffPreviewPanel
|
||||
edits={pendingEdits}
|
||||
onAccept={acceptEdit}
|
||||
onReject={rejectEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/**
|
||||
* CodeEditorWorkflowsPage
|
||||
*
|
||||
* Lists CodeEditor workflows for the current feature instance.
|
||||
* Uses the codeeditor-specific API endpoint instead of the generic /api/workflows/.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import api from '../../../api';
|
||||
import styles from './CodeEditor.module.css';
|
||||
|
||||
interface WorkflowItem {
|
||||
id: string;
|
||||
label?: string;
|
||||
status?: string;
|
||||
workflowMode?: string;
|
||||
startedAt?: number;
|
||||
lastActivity?: number;
|
||||
}
|
||||
|
||||
export const CodeEditorWorkflowsPage: React.FC = () => {
|
||||
const { instance } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadWorkflows = useCallback(() => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
api.get(`/api/codeeditor/${instanceId}/workflows`)
|
||||
.then(res => {
|
||||
const items = res.data?.items || res.data?.workflows || [];
|
||||
setWorkflows(Array.isArray(items) ? items : []);
|
||||
})
|
||||
.catch(err => console.error('Failed to load workflows:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { loadWorkflows(); }, [loadWorkflows]);
|
||||
|
||||
return (
|
||||
<div className={styles.workflowsPage}>
|
||||
<div className={styles.workflowsHeader}>
|
||||
<h3>CodeEditor Workflows</h3>
|
||||
<button className={styles.refreshButton} onClick={loadWorkflows} disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workflows.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
{loading ? 'Loading workflows...' : 'No workflows yet. Start a conversation in the Editor view.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.workflowTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workflows.map(wf => (
|
||||
<tr key={wf.id}>
|
||||
<td>{wf.label || wf.id.slice(0, 8)}</td>
|
||||
<td>
|
||||
<span className={`${styles.statusBadge} ${styles[`status_${wf.status || 'unknown'}`]}`}>
|
||||
{wf.status || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{wf.startedAt ? new Date(wf.startedAt * 1000).toLocaleString() : '-'}</td>
|
||||
<td>{wf.lastActivity ? new Date(wf.lastActivity * 1000).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* DiffPreviewPanel
|
||||
*
|
||||
* Shows file edit proposals as side-by-side text diffs.
|
||||
* Each edit has Accept/Reject buttons.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaCheck, FaTimes } from 'react-icons/fa';
|
||||
import styles from './CodeEditor.module.css';
|
||||
|
||||
export interface FileEditProposal {
|
||||
id: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
oldContent: string | null;
|
||||
newContent: string;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
}
|
||||
|
||||
interface DiffPreviewPanelProps {
|
||||
edits: FileEditProposal[];
|
||||
onAccept: (editId: string) => void;
|
||||
onReject: (editId: string) => void;
|
||||
}
|
||||
|
||||
export const DiffPreviewPanel: React.FC<DiffPreviewPanelProps> = ({ edits, onAccept, onReject }) => {
|
||||
const pendingEdits = edits.filter(e => e.status === 'pending');
|
||||
const resolvedEdits = edits.filter(e => e.status !== 'pending');
|
||||
|
||||
return (
|
||||
<div className={styles.diffPreview}>
|
||||
<div className={styles.panelHeader}>
|
||||
<h3>Changes ({pendingEdits.length} pending)</h3>
|
||||
</div>
|
||||
<div className={styles.diffItems}>
|
||||
{edits.length === 0 ? (
|
||||
<div className={styles.emptyState}>No changes proposed yet</div>
|
||||
) : (
|
||||
<>
|
||||
{pendingEdits.map((edit) => (
|
||||
<DiffCard key={edit.id} edit={edit} onAccept={onAccept} onReject={onReject} />
|
||||
))}
|
||||
{resolvedEdits.map((edit) => (
|
||||
<DiffCard key={edit.id} edit={edit} onAccept={onAccept} onReject={onReject} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DiffCard: React.FC<{
|
||||
edit: FileEditProposal;
|
||||
onAccept: (id: string) => void;
|
||||
onReject: (id: string) => void;
|
||||
}> = ({ edit, onAccept, onReject }) => {
|
||||
const isPending = edit.status === 'pending';
|
||||
|
||||
return (
|
||||
<div className={`${styles.diffCard} ${styles[`diffCard_${edit.status}`]}`}>
|
||||
<div className={styles.diffCardHeader}>
|
||||
<span className={styles.diffFileName}>{edit.fileName}</span>
|
||||
<span className={`${styles.diffStatus} ${styles[`diffStatus_${edit.status}`]}`}>
|
||||
{edit.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.diffContent}>
|
||||
{edit.oldContent && (
|
||||
<div className={styles.diffOld}>
|
||||
<div className={styles.diffLabel}>Old</div>
|
||||
<pre>{edit.oldContent}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.diffNew}>
|
||||
<div className={styles.diffLabel}>New</div>
|
||||
<pre>{edit.newContent}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<div className={styles.diffActions}>
|
||||
<button className={styles.acceptButton} onClick={() => onAccept(edit.id)}>
|
||||
<FaCheck /> Accept
|
||||
</button>
|
||||
<button className={styles.rejectButton} onClick={() => onReject(edit.id)}>
|
||||
<FaTimes /> Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/**
|
||||
* FileListPanel
|
||||
*
|
||||
* Lists text files grouped by date, draggable into the prompt textarea.
|
||||
* Drag a file into the chat input to insert an @fileName reference.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { FaFile, FaGripVertical } from 'react-icons/fa';
|
||||
import styles from './CodeEditor.module.css';
|
||||
|
||||
export interface FileInfo {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
modifiedAt?: number;
|
||||
}
|
||||
|
||||
interface FileListPanelProps {
|
||||
files: FileInfo[];
|
||||
}
|
||||
|
||||
interface DateGroup {
|
||||
label: string;
|
||||
files: FileInfo[];
|
||||
}
|
||||
|
||||
export const FileListPanel: React.FC<FileListPanelProps> = ({ files }) => {
|
||||
const groups = useMemo(() => _groupByDate(files), [files]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, file: FileInfo) => {
|
||||
e.dataTransfer.setData('application/x-codeeditor-file', JSON.stringify({
|
||||
fileId: file.fileId,
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
e.dataTransfer.setData('text/plain', `@${file.fileName}`);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.fileList}>
|
||||
<div className={styles.panelHeader}>
|
||||
<h3>Files ({files.length})</h3>
|
||||
<span className={styles.dragHint}>drag into prompt</span>
|
||||
</div>
|
||||
<div className={styles.fileItems}>
|
||||
{files.length === 0 ? (
|
||||
<div className={styles.emptyState}>No text files uploaded yet</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div key={group.label} className={styles.dateGroup}>
|
||||
<div className={styles.dateGroupHeader}>{group.label}</div>
|
||||
{group.files.map((file) => (
|
||||
<div
|
||||
key={file.fileId}
|
||||
className={styles.fileItem}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, file)}
|
||||
>
|
||||
<FaGripVertical className={styles.dragHandle} />
|
||||
<FaFile className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{file.fileName}</span>
|
||||
<span className={styles.fileSize}>{_formatSize(file.sizeBytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function _groupByDate(files: FileInfo[]): DateGroup[] {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
||||
const yesterdayStart = todayStart - 86400;
|
||||
|
||||
const today: FileInfo[] = [];
|
||||
const yesterday: FileInfo[] = [];
|
||||
const older: FileInfo[] = [];
|
||||
|
||||
const sorted = [...files].sort((a, b) => (b.modifiedAt || 0) - (a.modifiedAt || 0));
|
||||
|
||||
for (const file of sorted) {
|
||||
const ts = file.modifiedAt || 0;
|
||||
if (ts >= todayStart) {
|
||||
today.push(file);
|
||||
} else if (ts >= yesterdayStart) {
|
||||
yesterday.push(file);
|
||||
} else {
|
||||
older.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: DateGroup[] = [];
|
||||
if (today.length > 0) groups.push({ label: 'Today', files: today });
|
||||
if (yesterday.length > 0) groups.push({ label: 'Yesterday', files: yesterday });
|
||||
if (older.length > 0) groups.push({ label: 'Older', files: older });
|
||||
if (groups.length === 0 && files.length > 0) groups.push({ label: 'All files', files: sorted });
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function _formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { CodeEditorPage } from './CodeEditorPage';
|
||||
export { CodeEditorWorkflowsPage } from './CodeEditorWorkflowsPage';
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
/**
|
||||
* useCodeEditor Hook
|
||||
*
|
||||
* Manages SSE connection, message state, edit proposals, and agent progress.
|
||||
* File references are extracted from @fileName tags in the prompt text.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../../../utils/csrfUtils';
|
||||
import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||
import type { FileInfo } from './FileListPanel';
|
||||
import type { FileEditProposal } from './DiffPreviewPanel';
|
||||
|
||||
export interface AgentProgress {
|
||||
round: number;
|
||||
totalAiCalls: number;
|
||||
totalToolCalls: number;
|
||||
costCHF: number;
|
||||
}
|
||||
|
||||
interface UseCodeEditorReturn {
|
||||
messages: Message[];
|
||||
pendingEdits: FileEditProposal[];
|
||||
acceptEdit: (editId: string) => void;
|
||||
rejectEdit: (editId: string) => void;
|
||||
isProcessing: boolean;
|
||||
sendMessage: (prompt: string, mode?: 'simple' | 'agent') => void;
|
||||
stopProcessing: () => void;
|
||||
files: FileInfo[];
|
||||
agentProgress: AgentProgress | null;
|
||||
}
|
||||
|
||||
export function useCodeEditor(instanceId: string): UseCodeEditorReturn {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
_loadFiles(instanceId, setFiles);
|
||||
}, [instanceId]);
|
||||
|
||||
const sendMessage = useCallback((prompt: string, mode: 'simple' | 'agent' = 'simple') => {
|
||||
if (!instanceId || isProcessing) return;
|
||||
|
||||
const referencedFileIds = _extractFileRefs(prompt, files);
|
||||
|
||||
setIsProcessing(true);
|
||||
setAgentProgress(null);
|
||||
setMessages(prev => [...prev, {
|
||||
id: `user-${Date.now()}`,
|
||||
workflowId: workflowId || '',
|
||||
role: 'user',
|
||||
message: prompt,
|
||||
publishedAt: Date.now() / 1000,
|
||||
}]);
|
||||
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (workflowId) params.set('workflowId', workflowId);
|
||||
params.set('mode', mode);
|
||||
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const url = `${baseURL}/api/codeeditor/${instanceId}/start/stream?${params.toString()}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
if (!getCSRFToken()) {
|
||||
generateAndStoreCSRFToken();
|
||||
}
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
prompt: prompt,
|
||||
listFileId: referencedFileIds,
|
||||
}),
|
||||
credentials: 'include',
|
||||
signal: abortRef.current.signal,
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(jsonStr);
|
||||
_handleSseEvent(event, setMessages, setPendingEdits, setWorkflowId, setAgentProgress);
|
||||
|
||||
if (event.type === 'complete' || event.type === 'error' || event.type === 'stopped') {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
} catch {
|
||||
// skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsProcessing(false);
|
||||
}).catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('CodeEditor SSE error:', err);
|
||||
setMessages(prev => [...prev, {
|
||||
id: `error-${Date.now()}`,
|
||||
workflowId: '',
|
||||
role: 'system',
|
||||
message: `Connection error: ${err.message}`,
|
||||
publishedAt: Date.now() / 1000,
|
||||
}]);
|
||||
setIsProcessing(false);
|
||||
});
|
||||
}, [instanceId, isProcessing, workflowId, files]);
|
||||
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
if (!instanceId || !workflowId) return;
|
||||
api.post(`/api/codeeditor/${instanceId}/${workflowId}/stop`).catch(console.error);
|
||||
setIsProcessing(false);
|
||||
}, [instanceId, workflowId]);
|
||||
|
||||
const acceptEdit = useCallback((editId: string) => {
|
||||
const edit = pendingEdits.find(e => e.id === editId);
|
||||
if (!edit || !instanceId || !workflowId) return;
|
||||
|
||||
api.post(`/api/codeeditor/${instanceId}/${workflowId}/apply`, {
|
||||
fileId: edit.fileId,
|
||||
fileName: edit.fileName,
|
||||
newContent: edit.newContent,
|
||||
}).then(() => {
|
||||
setPendingEdits(prev => prev.map(e =>
|
||||
e.id === editId ? { ...e, status: 'accepted' as const } : e
|
||||
));
|
||||
_loadFiles(instanceId, setFiles);
|
||||
}).catch(console.error);
|
||||
}, [pendingEdits, instanceId, workflowId]);
|
||||
|
||||
const rejectEdit = useCallback((editId: string) => {
|
||||
setPendingEdits(prev => prev.map(e =>
|
||||
e.id === editId ? { ...e, status: 'rejected' as const } : e
|
||||
));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
pendingEdits,
|
||||
acceptEdit,
|
||||
rejectEdit,
|
||||
isProcessing,
|
||||
sendMessage,
|
||||
stopProcessing,
|
||||
files,
|
||||
agentProgress,
|
||||
};
|
||||
}
|
||||
|
||||
function _loadFiles(instanceId: string, setFiles: React.Dispatch<React.SetStateAction<FileInfo[]>>) {
|
||||
api.get(`/api/codeeditor/${instanceId}/files`)
|
||||
.then(res => setFiles(res.data.files || []))
|
||||
.catch(err => console.error('Failed to load files:', err));
|
||||
}
|
||||
|
||||
function _extractFileRefs(prompt: string, files: FileInfo[]): string[] {
|
||||
const atPattern = /@([\w.\-]+)/g;
|
||||
const matchedIds: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = atPattern.exec(prompt)) !== null) {
|
||||
const refName = match[1];
|
||||
const file = files.find(f => f.fileName === refName || f.fileName.toLowerCase() === refName.toLowerCase());
|
||||
if (file && !matchedIds.includes(file.fileId)) {
|
||||
matchedIds.push(file.fileId);
|
||||
}
|
||||
}
|
||||
|
||||
return matchedIds;
|
||||
}
|
||||
|
||||
function _handleSseEvent(
|
||||
event: any,
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
|
||||
setPendingEdits: React.Dispatch<React.SetStateAction<FileEditProposal[]>>,
|
||||
setWorkflowId: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
setAgentProgress: React.Dispatch<React.SetStateAction<AgentProgress | null>>
|
||||
) {
|
||||
if (event.type === 'message' && event.item) {
|
||||
const item = event.item;
|
||||
setMessages(prev => [...prev, {
|
||||
id: item.id || `msg-${Date.now()}-${Math.random()}`,
|
||||
workflowId: item.workflowId || '',
|
||||
role: item.role || 'assistant',
|
||||
message: item.content || '',
|
||||
publishedAt: item.createdAt || Date.now() / 1000,
|
||||
documents: item.documents,
|
||||
}]);
|
||||
} else if (event.type === 'file_edit_proposal' && event.item) {
|
||||
setPendingEdits(prev => [...prev, event.item]);
|
||||
} else if (event.type === 'status') {
|
||||
setMessages(prev => {
|
||||
const lastIsStatus = prev.length > 0 && prev[prev.length - 1].role === 'status';
|
||||
const statusMsg: Message = {
|
||||
id: `status-${Date.now()}`,
|
||||
workflowId: '',
|
||||
role: 'status',
|
||||
message: event.label || '',
|
||||
publishedAt: Date.now() / 1000,
|
||||
};
|
||||
return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg];
|
||||
});
|
||||
} else if (event.type === 'agent_progress' && event.item) {
|
||||
setAgentProgress(event.item);
|
||||
} else if (event.type === 'agent_summary' && event.item) {
|
||||
const s = event.item;
|
||||
setMessages(prev => [...prev, {
|
||||
id: `summary-${Date.now()}`,
|
||||
workflowId: '',
|
||||
role: 'system',
|
||||
message: `Agent completed: ${s.rounds} rounds, ${s.totalToolCalls} tool calls, ${s.costCHF} CHF, ${s.processingTime}s`,
|
||||
publishedAt: Date.now() / 1000,
|
||||
}]);
|
||||
setAgentProgress(null);
|
||||
} else if (event.type === 'complete' && event.workflowId) {
|
||||
setWorkflowId(event.workflowId);
|
||||
}
|
||||
}
|
||||
|
|
@ -444,7 +444,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
const connectionRef = getConnectionReference(msftConnection);
|
||||
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
|
||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||
await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
|
||||
await api.post(`/api/automations/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
|
||||
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
|
||||
} catch (err: any) {
|
||||
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
isPollingRef.current = true;
|
||||
try {
|
||||
const chatDataRes = await api.get(
|
||||
`/api/chatplayground/${instanceId}/${workflowId}/chatData`,
|
||||
`/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
|
||||
{
|
||||
params: latestTimestampRef.current
|
||||
? { afterTimestamp: latestTimestampRef.current }
|
||||
|
|
@ -307,7 +307,7 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
const template = buildTemplate(fileIds);
|
||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||
const response = await api.post(
|
||||
`/api/chatplayground/${instanceId}/start`,
|
||||
`/api/automations/${instanceId}/start`,
|
||||
{ prompt },
|
||||
{ params: { workflowMode: 'Automation' } }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface ChatStreamProps {
|
|||
pendingEdits: FileEditProposal[];
|
||||
onAcceptEdit: (editId: string) => void;
|
||||
onRejectEdit: (editId: string) => void;
|
||||
onOpenEditor?: () => void;
|
||||
}
|
||||
|
||||
export const ChatStream: React.FC<ChatStreamProps> = ({
|
||||
|
|
@ -28,6 +29,7 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
pendingEdits,
|
||||
onAcceptEdit,
|
||||
onRejectEdit,
|
||||
onOpenEditor,
|
||||
}) => {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -138,10 +140,9 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* File edit proposals */}
|
||||
{pendingEdits.filter(e => e.status === 'pending').map((edit) => (
|
||||
{/* File edit proposals -- compact notification cards */}
|
||||
{pendingEdits.filter(e => e.status === 'pending').length > 0 && (
|
||||
<div
|
||||
key={edit.id}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: 12,
|
||||
|
|
@ -152,49 +153,54 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
maxWidth: '85%',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: '#ff9800' }}>✎</span>
|
||||
File Edit Proposal: {edit.fileName}
|
||||
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
|
||||
</div>
|
||||
<pre style={{
|
||||
fontSize: 12,
|
||||
maxHeight: 160,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
background: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
borderRadius: 4,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{edit.newContent?.slice(0, 800)}
|
||||
{(edit.newContent?.length || 0) > 800 && '\n...'}
|
||||
</pre>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
||||
{pendingEdits.filter(e => e.status === 'pending').map(edit => (
|
||||
<div key={edit.id} style={{ fontSize: 12, color: '#555', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ff9800', flexShrink: 0 }} />
|
||||
{edit.fileName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{onOpenEditor && (
|
||||
<button
|
||||
onClick={onOpenEditor}
|
||||
style={{
|
||||
padding: '5px 14px', borderRadius: 4, border: 'none',
|
||||
background: 'var(--primary-color, #1976d2)', color: '#fff',
|
||||
cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Im Editor pruefen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onAcceptEdit(edit.id)}
|
||||
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onAcceptEdit(e.id))}
|
||||
style={{
|
||||
padding: '4px 14px', borderRadius: 4, border: 'none',
|
||||
padding: '5px 14px', borderRadius: 4, border: 'none',
|
||||
background: 'var(--success-color, #4caf50)', color: '#fff',
|
||||
cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
Alle annehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRejectEdit(edit.id)}
|
||||
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onRejectEdit(e.id))}
|
||||
style={{
|
||||
padding: '4px 14px', borderRadius: 4,
|
||||
padding: '5px 14px', borderRadius: 4,
|
||||
border: '1px solid var(--border-color, #ccc)',
|
||||
background: '#fff', cursor: 'pointer', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
Alle ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Agent progress */}
|
||||
{isProcessing && agentProgress && (
|
||||
|
|
|
|||
278
src/pages/views/workspace/WorkspaceEditorPage.tsx
Normal file
278
src/pages/views/workspace/WorkspaceEditorPage.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* WorkspaceEditorPage -- Diff editor for reviewing AI agent file edit proposals.
|
||||
*
|
||||
* Full-page layout with:
|
||||
* - Header: back-to-dashboard, accept-all / reject-all
|
||||
* - Tab bar: one tab per pending edit
|
||||
* - Center: Monaco DiffEditor (original vs. modified)
|
||||
* - Footer: status bar with counts and file metadata
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import type { editor as monacoEditor } from 'monaco-editor';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
|
||||
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
|
||||
|
||||
function _getMonacoLanguage(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const langMap: Record<string, string> = {
|
||||
js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
|
||||
py: 'python', json: 'json', html: 'html', css: 'css', md: 'markdown',
|
||||
xml: 'xml', yaml: 'yaml', yml: 'yaml', sh: 'shell', sql: 'sql',
|
||||
txt: 'plaintext', csv: 'plaintext', log: 'plaintext',
|
||||
};
|
||||
return langMap[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
function _formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export const WorkspaceEditorPage: React.FC = () => {
|
||||
const instanceId = useInstanceId() || '';
|
||||
const navigate = useNavigate();
|
||||
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
|
||||
mandateId: string; featureCode: string; instanceId: string;
|
||||
}>();
|
||||
const editor = useWorkspaceEditor(instanceId);
|
||||
|
||||
const activeEdit = useMemo(
|
||||
() => editor.edits.find(e => e.id === editor.activeEditId) || null,
|
||||
[editor.edits, editor.activeEditId],
|
||||
);
|
||||
|
||||
const pendingEdits = useMemo(
|
||||
() => editor.edits.filter(e => e.status === 'pending'),
|
||||
[editor.edits],
|
||||
);
|
||||
|
||||
const _goBack = () => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/dashboard`);
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||
Keine Workspace-Instanz ausgewaehlt.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', height: '100%',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 16px', borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #f8f9fa)', flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button onClick={_goBack} style={_btnStyle} title="Zurueck zum Dashboard">
|
||||
<FaArrowLeft size={14} />
|
||||
</button>
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>
|
||||
File Edit Review
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: '#888' }}>
|
||||
{editor.pendingCount} pending
|
||||
</span>
|
||||
<button onClick={editor.refresh} style={_btnStyle} title="Aktualisieren">
|
||||
<FaSync size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={editor.acceptAll}
|
||||
disabled={editor.pendingCount === 0}
|
||||
style={{ ..._actionBtnStyle, background: 'var(--success-color, #4caf50)', color: '#fff' }}
|
||||
>
|
||||
<FaCheckDouble size={12} /> Accept All
|
||||
</button>
|
||||
<button
|
||||
onClick={editor.rejectAll}
|
||||
disabled={editor.pendingCount === 0}
|
||||
style={{ ..._actionBtnStyle, background: 'transparent', border: '1px solid var(--border-color, #ccc)' }}
|
||||
>
|
||||
<FaBan size={12} /> Reject All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
{pendingEdits.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', overflowX: 'auto', flexShrink: 0,
|
||||
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #f8f9fa)',
|
||||
}}>
|
||||
{pendingEdits.map(edit => (
|
||||
<_EditorTab
|
||||
key={edit.id}
|
||||
edit={edit}
|
||||
isActive={edit.id === editor.activeEditId}
|
||||
onClick={() => editor.setActiveEditId(edit.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{editor.isLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888' }}>
|
||||
Lade Aenderungsvorschlaege...
|
||||
</div>
|
||||
) : pendingEdits.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888', gap: 12 }}>
|
||||
<span style={{ fontSize: 48, opacity: 0.3 }}>✓</span>
|
||||
<span style={{ fontSize: 16 }}>Keine offenen Aenderungsvorschlaege</span>
|
||||
<button onClick={_goBack} style={{ ..._actionBtnStyle, marginTop: 8 }}>
|
||||
Zurueck zum Dashboard
|
||||
</button>
|
||||
</div>
|
||||
) : activeEdit ? (
|
||||
<_SafeDiffEditor
|
||||
key={activeEdit.id}
|
||||
original={activeEdit.oldContent}
|
||||
modified={activeEdit.newContent}
|
||||
language={_getMonacoLanguage(activeEdit.fileName)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer / action bar for active edit */}
|
||||
{activeEdit && activeEdit.status === 'pending' && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 16px', borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #f8f9fa)', flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
|
||||
<span>{activeEdit.fileName}</span>
|
||||
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
|
||||
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => editor.acceptEdit(activeEdit.id)}
|
||||
style={{ ..._actionBtnStyle, background: 'var(--success-color, #4caf50)', color: '#fff' }}
|
||||
>
|
||||
<FaCheck size={12} /> Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.rejectEdit(activeEdit.id)}
|
||||
style={{ ..._actionBtnStyle, border: '1px solid var(--error-color, #f44336)', color: 'var(--error-color, #f44336)' }}
|
||||
>
|
||||
<FaTimes size={12} /> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe DiffEditor wrapper -- prevents "TextModel got disposed" errors
|
||||
// by tracking the editor ref and skipping disposal when already torn down.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _SafeDiffEditor: React.FC<{
|
||||
original: string;
|
||||
modified: string;
|
||||
language: string;
|
||||
}> = ({ original, modified, language }) => {
|
||||
const editorRef = useRef<monacoEditor.IDiffEditor | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setReady(true);
|
||||
return () => {
|
||||
if (editorRef.current) {
|
||||
try {
|
||||
editorRef.current.dispose();
|
||||
} catch { /* already disposed */ }
|
||||
editorRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<DiffEditor
|
||||
original={original}
|
||||
modified={modified}
|
||||
language={language}
|
||||
theme="vs-dark"
|
||||
onMount={(diffEditor) => { editorRef.current = diffEditor; }}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
originalEditable: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _EditorTab: React.FC<{
|
||||
edit: EditorFileEdit;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ edit, isActive, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
border: 'none',
|
||||
borderBottom: isActive ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
|
||||
background: isActive ? 'var(--bg-primary, #fff)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
color: isActive ? 'var(--text-primary, #333)' : 'var(--text-secondary, #888)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: edit.status === 'pending' ? '#ff9800'
|
||||
: edit.status === 'accepted' ? '#4caf50' : '#f44336',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
{edit.fileName}
|
||||
</button>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _btnStyle: React.CSSProperties = {
|
||||
padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border-color, #ddd)',
|
||||
background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||
};
|
||||
|
||||
const _actionBtnStyle: React.CSSProperties = {
|
||||
padding: '5px 14px', borderRadius: 4, border: 'none',
|
||||
cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
};
|
||||
|
||||
export default WorkspaceEditorPage;
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useFileOperations } from '../../../hooks/useFiles';
|
||||
import { useWorkspace } from './useWorkspace';
|
||||
|
|
@ -69,6 +70,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
const instanceId = persistentInstanceId || instance?.id || '';
|
||||
const workspace = useWorkspace(instanceId);
|
||||
const fileOps = useFileOperations();
|
||||
const navigate = useNavigate();
|
||||
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
|
||||
mandateId: string; featureCode: string; instanceId: string;
|
||||
}>();
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const _leftResize = _useResizable(280, 200, 450);
|
||||
|
|
@ -386,6 +391,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
pendingEdits={workspace.pendingEdits}
|
||||
onAcceptEdit={workspace.acceptEdit}
|
||||
onRejectEdit={workspace.rejectEdit}
|
||||
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
|
||||
/>
|
||||
<WorkspaceInput
|
||||
instanceId={instanceId}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@ export interface FileEditProposal {
|
|||
id: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
newContent: string;
|
||||
mimeType?: string;
|
||||
oldContent?: string;
|
||||
newContent?: string;
|
||||
oldSize?: number;
|
||||
newSize?: number;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
}
|
||||
|
||||
|
|
@ -232,8 +236,17 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
setAgentProgress(null);
|
||||
},
|
||||
onFileEditProposal: (event) => {
|
||||
if (event.item) {
|
||||
setPendingEdits(prev => [...prev, event.item]);
|
||||
const data = event.item || event.data || {};
|
||||
if (data.id) {
|
||||
setPendingEdits(prev => [...prev, {
|
||||
id: data.id,
|
||||
fileId: data.fileId || '',
|
||||
fileName: data.fileName || '',
|
||||
mimeType: data.mimeType || '',
|
||||
oldSize: data.oldSize || 0,
|
||||
newSize: data.newSize || 0,
|
||||
status: 'pending' as const,
|
||||
}]);
|
||||
}
|
||||
},
|
||||
onFileVersion: (event) => {
|
||||
|
|
@ -359,21 +372,38 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
|
||||
const acceptEdit = useCallback(
|
||||
(editId: string) => {
|
||||
const edit = pendingEdits.find(e => e.id === editId);
|
||||
if (!edit || !instanceId || !workflowId) return;
|
||||
if (!instanceId) return;
|
||||
setPendingEdits(prev =>
|
||||
prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)),
|
||||
);
|
||||
refreshFiles();
|
||||
api.post(`/api/workspace/${instanceId}/edit/${editId}/accept`)
|
||||
.then(() => refreshFiles())
|
||||
.catch(err => {
|
||||
console.error('Failed to accept edit:', err);
|
||||
setPendingEdits(prev =>
|
||||
prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)),
|
||||
);
|
||||
});
|
||||
},
|
||||
[pendingEdits, instanceId, workflowId, refreshFiles],
|
||||
[instanceId, refreshFiles],
|
||||
);
|
||||
|
||||
const rejectEdit = useCallback((editId: string) => {
|
||||
setPendingEdits(prev =>
|
||||
prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)),
|
||||
);
|
||||
}, []);
|
||||
const rejectEdit = useCallback(
|
||||
(editId: string) => {
|
||||
if (!instanceId) return;
|
||||
setPendingEdits(prev =>
|
||||
prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)),
|
||||
);
|
||||
api.post(`/api/workspace/${instanceId}/edit/${editId}/reject`)
|
||||
.catch(err => {
|
||||
console.error('Failed to reject edit:', err);
|
||||
setPendingEdits(prev =>
|
||||
prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)),
|
||||
);
|
||||
});
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
|
|
|||
127
src/pages/views/workspace/useWorkspaceEditor.ts
Normal file
127
src/pages/views/workspace/useWorkspaceEditor.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* useWorkspaceEditor Hook
|
||||
*
|
||||
* State management for the workspace editor page.
|
||||
* Loads pending file edit proposals from the API,
|
||||
* provides accept/reject actions, and tracks the active tab.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
|
||||
export interface EditorFileEdit {
|
||||
id: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceEditorReturn {
|
||||
edits: EditorFileEdit[];
|
||||
activeEditId: string | null;
|
||||
isLoading: boolean;
|
||||
setActiveEditId: (id: string | null) => void;
|
||||
acceptEdit: (editId: string) => Promise<void>;
|
||||
rejectEdit: (editId: string) => Promise<void>;
|
||||
acceptAll: () => Promise<void>;
|
||||
rejectAll: () => Promise<void>;
|
||||
refresh: () => void;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export function useWorkspaceEditor(instanceId: string): UseWorkspaceEditorReturn {
|
||||
const [edits, setEdits] = useState<EditorFileEdit[]>([]);
|
||||
const [activeEditId, setActiveEditId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!instanceId) return;
|
||||
setIsLoading(true);
|
||||
api.get(`/api/workspace/${instanceId}/pending-edits`)
|
||||
.then(res => {
|
||||
const loadedEdits: EditorFileEdit[] = (res.data.edits || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
fileId: e.fileId || '',
|
||||
fileName: e.fileName || '',
|
||||
mimeType: e.mimeType || '',
|
||||
oldContent: e.oldContent || '',
|
||||
newContent: e.newContent || '',
|
||||
status: e.status || 'pending',
|
||||
workflowId: e.workflowId || '',
|
||||
}));
|
||||
setEdits(loadedEdits);
|
||||
if (loadedEdits.length > 0 && !activeEditId) {
|
||||
setActiveEditId(loadedEdits[0].id);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to load pending edits:', err))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [instanceId, activeEditId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const acceptEdit = useCallback(async (editId: string) => {
|
||||
if (!instanceId) return;
|
||||
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)));
|
||||
try {
|
||||
await api.post(`/api/workspace/${instanceId}/edit/${editId}/accept`);
|
||||
} catch (err) {
|
||||
console.error('Failed to accept edit:', err);
|
||||
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)));
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
const rejectEdit = useCallback(async (editId: string) => {
|
||||
if (!instanceId) return;
|
||||
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)));
|
||||
try {
|
||||
await api.post(`/api/workspace/${instanceId}/edit/${editId}/reject`);
|
||||
} catch (err) {
|
||||
console.error('Failed to reject edit:', err);
|
||||
setEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)));
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
const acceptAll = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setEdits(prev => prev.map(e => (e.status === 'pending' ? { ...e, status: 'accepted' as const } : e)));
|
||||
try {
|
||||
await api.post(`/api/workspace/${instanceId}/edit/accept-all`);
|
||||
} catch (err) {
|
||||
console.error('Failed to accept all edits:', err);
|
||||
refresh();
|
||||
}
|
||||
}, [instanceId, refresh]);
|
||||
|
||||
const rejectAll = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setEdits(prev => prev.map(e => (e.status === 'pending' ? { ...e, status: 'rejected' as const } : e)));
|
||||
try {
|
||||
await api.post(`/api/workspace/${instanceId}/edit/reject-all`);
|
||||
} catch (err) {
|
||||
console.error('Failed to reject all edits:', err);
|
||||
refresh();
|
||||
}
|
||||
}, [instanceId, refresh]);
|
||||
|
||||
const pendingCount = edits.filter(e => e.status === 'pending').length;
|
||||
|
||||
return {
|
||||
edits,
|
||||
activeEditId,
|
||||
isLoading,
|
||||
setActiveEditId,
|
||||
acceptEdit,
|
||||
rejectEdit,
|
||||
acceptAll,
|
||||
rejectAll,
|
||||
refresh,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,593 +0,0 @@
|
|||
/**
|
||||
* PlaygroundPage Styles
|
||||
*
|
||||
* Resizable two-column layout for Chat Playground.
|
||||
* Uses existing Nyla CSS variables and design patterns.
|
||||
*/
|
||||
|
||||
/* Main container */
|
||||
.playgroundContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.pageHeader {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.headerTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.headerStatItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.headerControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Main content area with resizable columns */
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Left panel - Chat/Messages */
|
||||
.leftPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
/* Resizable divider between panels */
|
||||
.resizeDivider {
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.resizeDivider:hover,
|
||||
.resizeDivider.dragging {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.dividerHandle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 40px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--text-secondary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.resizeDivider:hover .dividerHandle,
|
||||
.resizeDivider.dragging .dividerHandle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Right panel - Dashboard */
|
||||
.rightPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 200px;
|
||||
background: var(--surface-color);
|
||||
border-left: 1px solid var(--border-color);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panelContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Content section */
|
||||
.contentSection {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.contentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Messages container */
|
||||
.messagesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.emptyDescription {
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Footer / Input area */
|
||||
.inputFooter {
|
||||
flex-shrink: 0;
|
||||
padding: 1rem;
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.inputRow {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.selectors {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.textareaWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputTextarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.inputTextarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.inputTextarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.inputControls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fileButtons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--danger-color, #e53e3e);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.stopButton:hover:not(:disabled) {
|
||||
background: #c53030;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Select/Dropdown */
|
||||
.selector {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.selectDropdown {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.selectDropdown:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
/* Pending files */
|
||||
.pendingFiles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.pendingFile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pendingFileName {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removeFileButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.removeFileButton:hover {
|
||||
background: var(--danger-color, #e53e3e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Dragging state - prevent text selection */
|
||||
.mainContent.dragging {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.mainContent {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.leftPanel,
|
||||
.rightPanel {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.resizeDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-radius: 0 0 8px 8px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loadingSpinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--primary-color, #f25843);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Drag & Drop Styles */
|
||||
.dragOver {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dragOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(var(--primary-rgb, 242, 88, 67), 0.1);
|
||||
border: 2px dashed var(--primary-color, #f25843);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dragOverlayContent {
|
||||
text-align: center;
|
||||
color: var(--primary-color, #f25843);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dragOverFooter {
|
||||
border-color: var(--primary-color, #f25843);
|
||||
background: rgba(var(--primary-rgb, 242, 88, 67), 0.05);
|
||||
}
|
||||
|
||||
/* Prompts Row */
|
||||
.promptsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.promptsSelect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.promptDropdown {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.promptDropdown:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.promptDropdown:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Voice Recording Button */
|
||||
.iconButton.recording {
|
||||
background: var(--danger-color, #e53e3e);
|
||||
border-color: var(--danger-color, #e53e3e);
|
||||
color: white;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.iconButton.recording:hover {
|
||||
background: #c53030;
|
||||
border-color: #c53030;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(229, 62, 62, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(229, 62, 62, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,811 +0,0 @@
|
|||
/**
|
||||
* PlaygroundPage (Chat Playground)
|
||||
*
|
||||
* Global page for workflow execution and chat interaction.
|
||||
* Features a resizable two-column layout with chat on the left and dashboard on the right.
|
||||
* Includes: Drag & Drop file upload, Prompts selection, Voice input
|
||||
*/
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDashboardInputForm } from '../../hooks/usePlayground';
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||
import { usePrompts } from '../../hooks/usePrompts';
|
||||
import { useCurrentInstance } from '../../hooks/useCurrentInstance';
|
||||
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents';
|
||||
import { ProviderMultiSelect } from '../../components/ProviderSelector';
|
||||
import type { Message } from '../../components/UiComponents/Messages/MessagesTypes';
|
||||
import api from '../../api';
|
||||
import styles from './PlaygroundPage.module.css';
|
||||
|
||||
export const PlaygroundPage: React.FC = () => {
|
||||
// Read workflowId from URL query parameters
|
||||
const [searchParams] = useSearchParams();
|
||||
const urlWorkflowId = searchParams.get('workflowId');
|
||||
|
||||
// Get feature instance context
|
||||
const { instance } = useCurrentInstance();
|
||||
const instanceId = instance?.id || '';
|
||||
|
||||
// Main hook for input form and data
|
||||
const hookData = useDashboardInputForm(instanceId);
|
||||
const {
|
||||
inputValue,
|
||||
onInputChange,
|
||||
isRunning,
|
||||
isStopping,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
isSubmitting,
|
||||
workflowStatus,
|
||||
messages,
|
||||
dashboardTree,
|
||||
onToggleOperationExpanded,
|
||||
onToggleRoundExpanded,
|
||||
currentRound,
|
||||
workflowId,
|
||||
onWorkflowSelect,
|
||||
workflowItems,
|
||||
pendingFiles,
|
||||
handleFileRemove,
|
||||
handleFileDelete,
|
||||
handleFileView,
|
||||
handleFileDownload,
|
||||
latestStats,
|
||||
playgroundUIPermission,
|
||||
deletingFiles,
|
||||
previewingFiles,
|
||||
downloadingFiles,
|
||||
handleMessageDelete,
|
||||
deletingMessages,
|
||||
selectedProviders,
|
||||
onProvidersChange,
|
||||
} = hookData;
|
||||
|
||||
const { prompts, refetch: refetchPrompts } = usePrompts();
|
||||
const { showError, showSuccess } = useToast();
|
||||
|
||||
// Resizable panels hook
|
||||
const {
|
||||
leftWidth,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
containerRef,
|
||||
} = useResizablePanels({
|
||||
storageKey: 'playground-panel-width',
|
||||
defaultLeftWidth: 70,
|
||||
minLeftWidth: 40,
|
||||
maxLeftWidth: 85,
|
||||
});
|
||||
|
||||
// File input ref for hidden file input
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Drag & Drop state
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
// Voice recording state
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||
|
||||
// Voice language selection (defaults to user profile language)
|
||||
const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage();
|
||||
|
||||
// Prompts dropdown state
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||
|
||||
// Load prompts on mount
|
||||
useEffect(() => {
|
||||
refetchPrompts();
|
||||
}, []);
|
||||
|
||||
// Load workflow from URL parameter
|
||||
const urlWorkflowLoadedRef = useRef(false);
|
||||
|
||||
// Debug: Log URL parameter status
|
||||
useEffect(() => {
|
||||
console.log('🔍 PlaygroundPage URL debug:', {
|
||||
urlWorkflowId,
|
||||
currentWorkflowId: workflowId,
|
||||
hasOnWorkflowSelect: !!onWorkflowSelect,
|
||||
alreadyLoaded: urlWorkflowLoadedRef.current,
|
||||
fullUrl: window.location.href
|
||||
});
|
||||
}, [urlWorkflowId, workflowId, onWorkflowSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only load once on mount, and only if we have a URL workflowId
|
||||
if (urlWorkflowId && !urlWorkflowLoadedRef.current && onWorkflowSelect) {
|
||||
urlWorkflowLoadedRef.current = true;
|
||||
console.log('🔗 Loading workflow from URL:', urlWorkflowId);
|
||||
// Small delay to ensure hooks are initialized
|
||||
setTimeout(() => {
|
||||
onWorkflowSelect({ id: urlWorkflowId, label: '', value: urlWorkflowId });
|
||||
}, 100);
|
||||
}
|
||||
}, [urlWorkflowId, onWorkflowSelect]);
|
||||
|
||||
// Handle prompt selection
|
||||
const handlePromptSelect = (promptId: string) => {
|
||||
setSelectedPromptId(promptId);
|
||||
if (promptId) {
|
||||
const prompt = prompts?.find((p: any) => p.id === promptId);
|
||||
if (prompt && prompt.content) {
|
||||
// Append prompt content to input
|
||||
const currentText = inputValue || '';
|
||||
const newText = currentText ? `${currentText}\n\n${prompt.content}` : prompt.content;
|
||||
onInputChange(newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Drag & Drop handlers
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0 && hookData.handleFileUpload) {
|
||||
for (const file of Array.from(files)) {
|
||||
await hookData.handleFileUpload(file);
|
||||
}
|
||||
}
|
||||
}, [hookData.handleFileUpload]);
|
||||
|
||||
// Voice recording handlers
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// Find supported MIME type
|
||||
const mimeTypes = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
];
|
||||
let mimeType = '';
|
||||
for (const type of mimeTypes) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
mimeType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||
const chunks: Blob[] = [];
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
// Stop all tracks
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
// Process recording
|
||||
if (chunks.length > 0) {
|
||||
const audioBlob = new Blob(chunks, { type: mimeType || 'audio/webm' });
|
||||
await processVoiceRecording(audioBlob);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setMediaRecorder(recorder);
|
||||
setIsRecording(true);
|
||||
} catch (error: any) {
|
||||
console.error('Error starting recording:', error);
|
||||
showError('Mikrofonzugriff verweigert', 'Bitte erlauben Sie den Mikrofonzugriff in Ihren Browser-Einstellungen.');
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
setIsRecording(false);
|
||||
setMediaRecorder(null);
|
||||
}
|
||||
};
|
||||
|
||||
const processVoiceRecording = async (audioBlob: Blob) => {
|
||||
try {
|
||||
// Create FormData for speech-to-text API
|
||||
const formData = new FormData();
|
||||
formData.append('audioFile', audioBlob, 'voice_recording.webm');
|
||||
formData.append('language', voiceLanguage);
|
||||
|
||||
// Call speech-to-text API (Google Cloud)
|
||||
const response = await api.post('/voice-google/speech-to-text', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data?.text) {
|
||||
const transcribedText = response.data.text.trim();
|
||||
// Append transcribed text to input
|
||||
const currentText = inputValue || '';
|
||||
const newText = currentText ? `${currentText} ${transcribedText}` : transcribedText;
|
||||
onInputChange(newText);
|
||||
showSuccess('Transkription erfolgreich', 'Text wurde hinzugefügt.');
|
||||
} else {
|
||||
showError('Transkription fehlgeschlagen', response.data?.error || 'Unbekannter Fehler');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error processing voice recording:', error);
|
||||
showError('Transkription fehlgeschlagen', error.message || 'Fehler bei der Sprachverarbeitung');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceClick = () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
// Simple wrapper for workflow selection
|
||||
const handleWorkflowChange = (id: string | null) => {
|
||||
if (!id) {
|
||||
onWorkflowSelect(null);
|
||||
} else {
|
||||
const item = workflowItems?.find((w: any) => w.id === id);
|
||||
if (item) {
|
||||
onWorkflowSelect(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file upload click
|
||||
const handleFileClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// Handle file change
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && hookData.handleFileUpload) {
|
||||
for (const file of Array.from(files)) {
|
||||
await hookData.handleFileUpload(file);
|
||||
}
|
||||
}
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Render messages using the Messages component with document support
|
||||
const renderMessages = () => {
|
||||
if (!messages || messages.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<FaComment className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Nachrichten</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Starten Sie einen neuen Workflow oder wählen Sie einen bestehenden aus.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Messages
|
||||
messages={messages as any as Message[]}
|
||||
variant="chat"
|
||||
showDocuments={true}
|
||||
showMetadata={false}
|
||||
onFileDelete={handleFileDelete}
|
||||
onFileRemove={handleFileRemove}
|
||||
onFileView={handleFileView}
|
||||
onFileDownload={handleFileDownload}
|
||||
deletingFiles={deletingFiles}
|
||||
previewingFiles={previewingFiles}
|
||||
downloadingFiles={downloadingFiles}
|
||||
workflowId={workflowId}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
deletingMessages={deletingMessages}
|
||||
emptyMessage="Keine Nachrichten"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render dashboard tree with rounds
|
||||
const renderDashboard = () => {
|
||||
// Check if we have rounds data
|
||||
const hasRounds = dashboardTree && dashboardTree.rounds && dashboardTree.rounds.size > 0;
|
||||
const hasOperations = dashboardTree && dashboardTree.rootOperations.length > 0;
|
||||
|
||||
if (!hasRounds && !hasOperations) {
|
||||
return (
|
||||
<div className={styles.emptyState} style={{ padding: '2rem' }}>
|
||||
<FaTasks className={styles.emptyIcon} style={{ fontSize: '2rem' }} />
|
||||
<p style={{ fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
||||
Keine aktiven Operationen
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderOperation = (operationId: string, depth: number = 0, roundOperations?: Map<string, any>) => {
|
||||
const operation = roundOperations?.get(operationId) || dashboardTree.operations.get(operationId);
|
||||
if (!operation) return null;
|
||||
|
||||
const childOps = Array.from(dashboardTree.operations.entries())
|
||||
.filter(([_, op]) => op.parentId === operationId)
|
||||
.map(([id]) => id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={operationId}
|
||||
style={{
|
||||
paddingLeft: `${depth * 1}rem`,
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem',
|
||||
borderBottom: depth === 0 ? '1px solid var(--border-color)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => onToggleOperationExpanded(operationId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
cursor: childOps.length > 0 ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{childOps.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--text-secondary)',
|
||||
transform: operation.expanded ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.15s',
|
||||
}}>
|
||||
▶
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1,
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: depth === 0 ? 500 : 400,
|
||||
}}>
|
||||
{operation.operationName || operationId.slice(0, 20)}
|
||||
</span>
|
||||
{operation.latestProgress !== null && operation.latestProgress < 1 && (
|
||||
<span style={{
|
||||
fontSize: '0.6875rem',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{Math.round(operation.latestProgress * 100)}%
|
||||
</span>
|
||||
)}
|
||||
{operation.latestStatus && (
|
||||
<span style={{
|
||||
fontSize: '0.6875rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
background: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
|
||||
? 'var(--success-bg, #dcfce7)'
|
||||
: operation.latestStatus === 'running'
|
||||
? 'var(--info-bg, #dbeafe)'
|
||||
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
|
||||
? 'var(--danger-bg, #fee2e2)'
|
||||
: 'var(--bg-secondary)',
|
||||
color: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
|
||||
? 'var(--success-color, #16a34a)'
|
||||
: operation.latestStatus === 'running'
|
||||
? 'var(--info-color, #2563eb)'
|
||||
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
|
||||
? 'var(--danger-color, #dc2626)'
|
||||
: 'var(--text-secondary)',
|
||||
}}>
|
||||
{operation.latestStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{operation.expanded && childOps.length > 0 && (
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
{childOps.map(childId => renderOperation(childId, depth + 1, roundOperations))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// If we have rounds, render them
|
||||
if (hasRounds) {
|
||||
const sortedRounds = Array.from(dashboardTree.rounds.entries()).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sortedRounds.map(([roundNumber, round]) => (
|
||||
<div key={`round-${roundNumber}`} style={{ marginBottom: '0.5rem' }}>
|
||||
{/* Round Header */}
|
||||
<div
|
||||
onClick={() => onToggleRoundExpanded(roundNumber)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: roundNumber === currentRound
|
||||
? 'var(--primary-bg, #eff6ff)'
|
||||
: 'var(--bg-secondary)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: round.expanded ? '0.5rem' : '0',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--text-secondary)',
|
||||
transform: round.expanded ? 'rotate(90deg)' : 'none',
|
||||
transition: 'transform 0.15s',
|
||||
}}>
|
||||
▶
|
||||
</span>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
Runde {roundNumber}
|
||||
</span>
|
||||
{round.isCompleted && (
|
||||
<span style={{
|
||||
fontSize: '0.6875rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--success-bg, #dcfce7)',
|
||||
color: 'var(--success-color, #16a34a)',
|
||||
}}>
|
||||
abgeschlossen
|
||||
</span>
|
||||
)}
|
||||
{roundNumber === currentRound && !round.isCompleted && (
|
||||
<span style={{
|
||||
fontSize: '0.6875rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--info-bg, #dbeafe)',
|
||||
color: 'var(--info-color, #2563eb)',
|
||||
}}>
|
||||
aktiv
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Round Operations */}
|
||||
{round.expanded && (
|
||||
<div style={{
|
||||
paddingLeft: '0.5rem',
|
||||
borderLeft: '2px solid var(--border-color)',
|
||||
marginLeft: '0.5rem',
|
||||
}}>
|
||||
{round.rootOperations.map(opId => renderOperation(opId, 0, round.operations))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: render without rounds (for backward compatibility)
|
||||
return (
|
||||
<div>
|
||||
{dashboardTree.rootOperations.map(opId => renderOperation(opId))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Permission check - also show while loading
|
||||
if (playgroundUIPermission === false) {
|
||||
return (
|
||||
<div className={styles.playgroundContainer}>
|
||||
<div className={styles.emptyState}>
|
||||
<h3 className={styles.emptyTitle}>Kein Zugriff</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Sie haben keine Berechtigung für den Chat Playground.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state while permission is being checked (undefined)
|
||||
if (playgroundUIPermission === undefined) {
|
||||
return (
|
||||
<div className={styles.playgroundContainer}>
|
||||
<div className={styles.emptyState}>
|
||||
<p>Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.playgroundContainer}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Page Header */}
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.headerTitleRow}>
|
||||
<h1 className={styles.pageTitle}>Chat Playground</h1>
|
||||
{latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
|
||||
<div className={styles.headerStats}>
|
||||
<span className={styles.headerStatItem} title="Kosten">
|
||||
CHF {latestStats.priceCHF.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
|
||||
</div>
|
||||
<div className={styles.headerControls}>
|
||||
<select
|
||||
className={styles.selectDropdown}
|
||||
value={workflowId || ''}
|
||||
onChange={(e) => handleWorkflowChange(e.target.value || null)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<option value="">Neuer Workflow</option>
|
||||
{workflowItems?.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label || item.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${styles.mainContent} ${isDragging ? styles.dragging : ''} ${isDragOver ? styles.dragOver : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
<div className={styles.dragOverlay}>
|
||||
<div className={styles.dragOverlayContent}>
|
||||
<FaFile style={{ fontSize: '3rem', marginBottom: '1rem' }} />
|
||||
<p>Dateien hier ablegen</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Left Panel - Chat Messages */}
|
||||
<div
|
||||
className={styles.leftPanel}
|
||||
style={{ width: `${leftWidth}%` }}
|
||||
>
|
||||
<div className={styles.contentSection}>
|
||||
<div className={styles.contentHeader}>
|
||||
<h3 className={styles.panelTitle}>
|
||||
<FaComment style={{ marginRight: '0.5rem' }} />
|
||||
Nachrichten
|
||||
</h3>
|
||||
</div>
|
||||
<div className={styles.contentArea}>
|
||||
{renderMessages()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resize Divider */}
|
||||
<div
|
||||
className={`${styles.resizeDivider} ${isDragging ? styles.dragging : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className={styles.dividerHandle} />
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Dashboard */}
|
||||
<div
|
||||
className={styles.rightPanel}
|
||||
style={{ width: `${100 - leftWidth}%` }}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<h3 className={styles.panelTitle}>
|
||||
<FaTasks style={{ marginRight: '0.5rem' }} />
|
||||
Dashboard
|
||||
</h3>
|
||||
</div>
|
||||
<div className={styles.panelContent}>
|
||||
{renderDashboard()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Footer */}
|
||||
<div
|
||||
className={`${styles.inputFooter} ${isDragOver ? styles.dragOverFooter : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Prompts Selection Row */}
|
||||
<div className={styles.promptsRow}>
|
||||
<div className={styles.promptsSelect}>
|
||||
<FaFileAlt style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginRight: '0.5rem' }} />
|
||||
<select
|
||||
className={styles.promptDropdown}
|
||||
value={selectedPromptId}
|
||||
onChange={(e) => handlePromptSelect(e.target.value)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<option value="">Prompt-Vorlage wählen...</option>
|
||||
{prompts?.map((prompt: any) => (
|
||||
<option key={prompt.id} value={prompt.id}>
|
||||
{prompt.name || prompt.content?.substring(0, 50) + '...'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending files */}
|
||||
{pendingFiles && pendingFiles.length > 0 && (
|
||||
<div className={styles.pendingFiles}>
|
||||
{pendingFiles.map((file: any) => (
|
||||
<div key={file.fileId} className={styles.pendingFile}>
|
||||
<FaFile style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }} />
|
||||
<span className={styles.pendingFileName}>{file.fileName}</span>
|
||||
<button
|
||||
className={styles.removeFileButton}
|
||||
onClick={() => handleFileRemove(file)}
|
||||
title="Entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
<div className={styles.inputRow}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.textareaWrapper}>
|
||||
<textarea
|
||||
className={styles.inputTextarea}
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
placeholder={
|
||||
isRunning
|
||||
? "Workflow läuft. Neue Eingabe zum Unterbrechen und Neustarten..."
|
||||
: workflowStatus === 'completed'
|
||||
? "Workflow abgeschlossen. Neue Eingabe zum Fortsetzen..."
|
||||
: workflowStatus === 'failed'
|
||||
? "Workflow fehlgeschlagen. Neue Eingabe zum Wiederholen..."
|
||||
: workflowStatus === 'stopped'
|
||||
? "Workflow gestoppt. Neue Eingabe zum Fortfahren..."
|
||||
: !workflowId
|
||||
? "Geben Sie einen Prompt ein, um zu starten..."
|
||||
: "Geben Sie Ihre Nachricht ein oder ziehen Sie Dateien hierher..."
|
||||
}
|
||||
disabled={false}
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.inputControls}>
|
||||
<div className={styles.fileButtons}>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
onClick={handleFileClick}
|
||||
disabled={false}
|
||||
title="Datei anhängen"
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<ProviderMultiSelect
|
||||
selectedProviders={selectedProviders}
|
||||
onChange={onProvidersChange}
|
||||
showLabel={false}
|
||||
excludeByDefault={['privatellm']}
|
||||
/>
|
||||
<VoiceLanguageSelect
|
||||
value={voiceLanguage}
|
||||
onChange={setVoiceLanguage}
|
||||
disabled={isRecording}
|
||||
compact={true}
|
||||
title="Sprache für Spracherkennung"
|
||||
/>
|
||||
<button
|
||||
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
|
||||
onClick={handleVoiceClick}
|
||||
disabled={false}
|
||||
title={isRecording ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
|
||||
>
|
||||
{isRecording ? <FaSquare /> : <FaMicrophone />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.actionButtons}>
|
||||
{/* Stop button - only visible when running */}
|
||||
{isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.stopButton}
|
||||
onClick={handleStop}
|
||||
disabled={isStopping}
|
||||
title="Workflow stoppen"
|
||||
>
|
||||
<FaStop />
|
||||
{isStopping ? 'Stoppt...' : 'Stop'}
|
||||
</button>
|
||||
)}
|
||||
{/* Send button - always visible with dynamic text */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim() || isSubmitting}
|
||||
>
|
||||
<FaPaperPlane />
|
||||
{isSubmitting
|
||||
? 'Senden...'
|
||||
: isRunning
|
||||
? 'Neue Eingabe'
|
||||
: !workflowId
|
||||
? 'Starten'
|
||||
: 'Senden'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaygroundPage;
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
/* WorkflowPages.module.css - Shared styles for workflow pages */
|
||||
|
||||
.page {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #1a1a2e);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #1a1a2e);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
/* Loading, Error, Empty states */
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.tableContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
background: var(--color-surface-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--color-surface-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-surface-secondary, #f3f4f6);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.badge.running,
|
||||
.badge.active {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info, #2563eb);
|
||||
}
|
||||
|
||||
.badge.completed {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.badge.error,
|
||||
.badge.failed {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.badge.stopped,
|
||||
.badge.pending {
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.deleteButton,
|
||||
.executeButton,
|
||||
.submitButton,
|
||||
.stopButton,
|
||||
.toggleButton {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error, #dc2626);
|
||||
border-color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.deleteButton:hover:not(:disabled) {
|
||||
background: var(--color-error, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.executeButton {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info, #2563eb);
|
||||
border-color: var(--color-info, #2563eb);
|
||||
}
|
||||
|
||||
.executeButton:hover:not(:disabled) {
|
||||
background: var(--color-info, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
background: var(--color-primary, #4f46e5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submitButton:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark, #4338ca);
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
background: var(--color-error, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stopButton:hover:not(:disabled) {
|
||||
background: var(--color-error-dark, #b91c1c);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
background: var(--color-surface-secondary, #f3f4f6);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.toggleButton.active {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
border-color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.select {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface, #ffffff);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.inputForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #4f46e5);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light, rgba(79, 70, 229, 0.1));
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Messages display */
|
||||
.messagesContainer {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-secondary, #f9fafb);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.messageRole {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
color: var(--color-text-primary, #1a1a2e);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Log display */
|
||||
.logContainer {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface-secondary, #f9fafb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logStatus {
|
||||
font-weight: 600;
|
||||
color: var(--color-info, #2563eb);
|
||||
}
|
||||
|
||||
.logMessage {
|
||||
color: var(--color-text-primary, #1a1a2e);
|
||||
}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
/**
|
||||
* WorkflowsPage
|
||||
*
|
||||
* Page for viewing and managing workflows using FormGeneratorTable.
|
||||
* Follows the pattern established in AdminUsersPage.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useUserWorkflows, useWorkflowOperations, getWorkflowApiBaseUrl } from '../../hooks/useWorkflows';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaList, FaPlay } from 'react-icons/fa';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../hooks/useCurrentInstance';
|
||||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name?: string;
|
||||
status: string;
|
||||
workflowMode?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const WorkflowsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { instanceId, featureCode } = useCurrentInstance();
|
||||
const workflowOptions = instanceId && featureCode ? { instanceId, featureCode } : undefined;
|
||||
const apiBaseUrl = getWorkflowApiBaseUrl(instanceId, featureCode);
|
||||
const apiEndpoint = apiBaseUrl ? `${apiBaseUrl}/workflows` : '';
|
||||
|
||||
// Data hook - pass instance context when in feature route
|
||||
const {
|
||||
data: workflows,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
fetchWorkflowById,
|
||||
updateOptimistically,
|
||||
} = useUserWorkflows(workflowOptions);
|
||||
|
||||
// Operations hook - pass instance context when in feature route
|
||||
const {
|
||||
handleWorkflowDelete,
|
||||
handleWorkflowDeleteMultiple,
|
||||
handleWorkflowUpdate,
|
||||
handleInlineUpdate,
|
||||
deletingWorkflows,
|
||||
} = useWorkflowOperations(workflowOptions);
|
||||
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
|
||||
|
||||
// Initial fetch on mount
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, []);
|
||||
|
||||
// Generate columns from attributes
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle edit click - fetch full workflow data
|
||||
const handleEditClick = async (workflow: Workflow) => {
|
||||
const fullWorkflow = await fetchWorkflowById(workflow.id);
|
||||
if (fullWorkflow) {
|
||||
setEditingWorkflow(fullWorkflow as Workflow);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle continue workflow - navigate to playground within same feature instance
|
||||
// Uses relative navigation since WorkflowsPage is rendered under same instance route as playground
|
||||
const handleContinueWorkflow = (workflow: Workflow) => {
|
||||
// Navigate relatively to playground (sibling route under same instance)
|
||||
navigate(`../playground?workflowId=${workflow.id}`);
|
||||
};
|
||||
|
||||
// Handle edit submit
|
||||
const handleEditSubmit = async (data: Partial<Workflow>) => {
|
||||
if (!editingWorkflow) return;
|
||||
const result = await handleWorkflowUpdate(editingWorkflow.id, data);
|
||||
if (result.success) {
|
||||
setEditingWorkflow(null);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete single workflow (confirmation handled by DeleteActionButton)
|
||||
const handleDelete = async (workflow: Workflow) => {
|
||||
const success = await handleWorkflowDelete(workflow.id);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete multiple workflows (confirmation handled by FormGenerator)
|
||||
const handleDeleteMultiple = async (workflowsToDelete: Workflow[]) => {
|
||||
const ids = workflowsToDelete.map(w => w.id);
|
||||
const success = await handleWorkflowDeleteMultiple(ids);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// Form attributes for edit modal - filter out non-editable fields
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt'];
|
||||
return (attributes || [])
|
||||
.filter(attr => !excludedFields.includes(attr.name));
|
||||
}, [attributes]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Workflows: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Workflows</h1>
|
||||
<p className={styles.pageSubtitle}>Übersicht aller Workflows</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => refetch()}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!workflows || workflows.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Workflows...</span>
|
||||
</div>
|
||||
) : !workflows || workflows.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaList className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Workflows vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Starten Sie einen neuen Workflow im Chat Playground.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={workflows}
|
||||
columns={columns}
|
||||
apiEndpoint={apiEndpoint}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: 'Bearbeiten',
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: 'Löschen',
|
||||
loading: (row: Workflow) => deletingWorkflows.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'continue',
|
||||
icon: <FaPlay />,
|
||||
onClick: handleContinueWorkflow,
|
||||
title: 'Workflow fortsetzen',
|
||||
}
|
||||
]}
|
||||
onDelete={handleDelete}
|
||||
onDeleteMultiple={handleDeleteMultiple}
|
||||
hookData={{
|
||||
refetch,
|
||||
permissions,
|
||||
pagination,
|
||||
handleDelete: handleWorkflowDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically,
|
||||
}}
|
||||
emptyMessage="Keine Workflows gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingWorkflow && (
|
||||
<div className={styles.modalOverlay} onClick={() => setEditingWorkflow(null)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Workflow bearbeiten</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setEditingWorkflow(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingWorkflow}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingWorkflow(null)}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowsPage;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { PlaygroundPage } from './PlaygroundPage';
|
||||
export { WorkflowsPage } from './WorkflowsPage';
|
||||
|
||||
|
|
@ -241,24 +241,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
||||
]
|
||||
},
|
||||
chatplayground: {
|
||||
code: 'chatplayground',
|
||||
label: { de: 'Chat Playground', en: 'Chat Playground' },
|
||||
icon: 'message',
|
||||
views: [
|
||||
{ code: 'playground', label: { de: 'Playground', en: 'Playground' }, path: 'playground' },
|
||||
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
||||
]
|
||||
},
|
||||
codeeditor: {
|
||||
code: 'codeeditor',
|
||||
label: { de: 'Code Editor', en: 'Code Editor' },
|
||||
icon: 'description',
|
||||
views: [
|
||||
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
|
||||
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
||||
]
|
||||
},
|
||||
teamsbot: {
|
||||
code: 'teamsbot',
|
||||
label: { de: 'Teams Bot', en: 'Teams Bot' },
|
||||
|
|
@ -307,6 +289,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
icon: 'psychology',
|
||||
views: [
|
||||
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
|
||||
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' },
|
||||
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
* Shared SSE Client Utility
|
||||
*
|
||||
* Generic fetch-based SSE streaming for POST requests with JSON body.
|
||||
* Extracted from useCodeEditor.ts and chatbotApi.ts to provide a single
|
||||
* reusable SSE implementation across all workspace features.
|
||||
* Reusable SSE implementation across all workspace features.
|
||||
*/
|
||||
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './csrfUtils';
|
||||
|
|
@ -19,6 +18,8 @@ export interface SseEventHandlers {
|
|||
onStatus?: (event: SseEvent) => void;
|
||||
onFileEditProposal?: (event: SseEvent) => void;
|
||||
onFileVersion?: (event: SseEvent) => void;
|
||||
onFileEditRejected?: (event: SseEvent) => void;
|
||||
onFileUpdated?: (event: SseEvent) => void;
|
||||
onToolCall?: (event: SseEvent) => void;
|
||||
onToolResult?: (event: SseEvent) => void;
|
||||
onAgentProgress?: (event: SseEvent) => void;
|
||||
|
|
@ -50,6 +51,10 @@ const _EVENT_ROUTER: Record<string, keyof SseEventHandlers> = {
|
|||
fileEditProposal: 'onFileEditProposal',
|
||||
file_version: 'onFileVersion',
|
||||
fileVersion: 'onFileVersion',
|
||||
file_edit_rejected: 'onFileEditRejected',
|
||||
fileEditRejected: 'onFileEditRejected',
|
||||
file_updated: 'onFileUpdated',
|
||||
fileUpdated: 'onFileUpdated',
|
||||
toolCall: 'onToolCall',
|
||||
toolResult: 'onToolResult',
|
||||
agent_progress: 'onAgentProgress',
|
||||
|
|
|
|||
Loading…
Reference in a new issue