commit
197bc51632
84 changed files with 7946 additions and 6397 deletions
65
package-lock.json
generated
65
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@azure/msal-react": "^3.0.12",
|
"@azure/msal-react": "^3.0.12",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
|
|
@ -1043,6 +1044,27 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|
@ -1642,6 +1664,13 @@
|
||||||
"@types/react": "^19.0.0"
|
"@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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|
@ -2809,6 +2838,15 @@
|
||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||||
|
|
@ -4397,6 +4435,18 @@
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -5354,6 +5404,16 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/motion": {
|
||||||
"version": "12.23.9",
|
"version": "12.23.9",
|
||||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.9.tgz",
|
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.9.tgz",
|
||||||
|
|
@ -6613,6 +6673,11 @@
|
||||||
"node": ">= 10.x"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
"@azure/msal-browser": "^4.12.0",
|
||||||
"@azure/msal-react": "^3.0.12",
|
"@azure/msal-react": "^3.0.12",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
"axios": "^1.8.3",
|
"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 { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PlaygroundPage } from './pages/workflows';
|
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin } from './pages/billing';
|
import { BillingDataView, BillingAdmin } from './pages/billing';
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -100,18 +99,6 @@ function App() {
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="gdpr" element={<GDPRPage />} />
|
<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) */}
|
{/* BASISDATEN ROUTES (global) */}
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
|
|
@ -162,16 +149,12 @@ function App() {
|
||||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<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 */}
|
{/* Automation Feature Views */}
|
||||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||||
|
|
||||||
{/* Code Editor Feature Views */}
|
{/* Workspace Editor */}
|
||||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ export interface CreditAddRequest {
|
||||||
export interface CheckoutCreateRequest {
|
export interface CheckoutCreateRequest {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
returnUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutCreateResponse {
|
export interface CheckoutCreateResponse {
|
||||||
|
|
|
||||||
|
|
@ -176,21 +176,116 @@ export async function deleteFiles(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
fileIds: string[]
|
fileIds: string[]
|
||||||
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
|
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
|
||||||
const results = await Promise.allSettled(
|
const uniqueIds = [...new Set(fileIds.filter(Boolean))];
|
||||||
fileIds.map(fileId =>
|
if (uniqueIds.length === 0) return [];
|
||||||
request({
|
await request({
|
||||||
url: `/api/files/${fileId}`,
|
url: '/api/files/batch-delete',
|
||||||
method: 'delete'
|
method: 'post',
|
||||||
}).then(() => ({ success: true, fileId }))
|
data: { fileIds: uniqueIds }
|
||||||
.catch((error) => ({ success: false, fileId, error }))
|
});
|
||||||
)
|
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
||||||
);
|
|
||||||
|
|
||||||
return results.map((result, index) => {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
return result.value;
|
|
||||||
}
|
}
|
||||||
return { success: false, fileId: fileIds[index], error: result.reason };
|
|
||||||
|
export async function deleteFolders(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderIds: string[],
|
||||||
|
recursiveFolders: boolean = true
|
||||||
|
): Promise<{ deletedFiles: number; deletedFolders: number }> {
|
||||||
|
const uniqueIds = [...new Set(folderIds.filter(Boolean))];
|
||||||
|
if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 };
|
||||||
|
return await request({
|
||||||
|
url: '/api/files/batch-delete',
|
||||||
|
method: 'post',
|
||||||
|
data: { folderIds: uniqueIds, recursiveFolders }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FOLDER API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FolderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
mandateId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFolders(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
parentId?: string | null
|
||||||
|
): Promise<FolderInfo[]> {
|
||||||
|
const params: any = {};
|
||||||
|
if (parentId !== undefined && parentId !== null) {
|
||||||
|
params.parentId = parentId;
|
||||||
|
}
|
||||||
|
const data = await request({
|
||||||
|
url: '/api/files/folders',
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
name: string,
|
||||||
|
parentId?: string | null
|
||||||
|
): Promise<FolderInfo> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/files/folders',
|
||||||
|
method: 'post',
|
||||||
|
data: { name, parentId: parentId || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
name: string
|
||||||
|
): Promise<any> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: { name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolderApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
recursive: boolean = false
|
||||||
|
): Promise<any> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { recursive },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFolder(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
targetParentId: string | null
|
||||||
|
): Promise<any> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}/move`,
|
||||||
|
method: 'post',
|
||||||
|
data: { targetParentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFile(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
fileId: string,
|
||||||
|
targetFolderId: string | null
|
||||||
|
): Promise<any> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/${fileId}/move`,
|
||||||
|
method: 'post',
|
||||||
|
data: { targetFolderId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ export interface StartWorkflowResponse extends Workflow {
|
||||||
export interface ChatDataResponse {
|
export interface ChatDataResponse {
|
||||||
messages: WorkflowMessage[];
|
messages: WorkflowMessage[];
|
||||||
logs: WorkflowLog[];
|
logs: WorkflowLog[];
|
||||||
stats: WorkflowStats[];
|
|
||||||
documents: WorkflowDocument[];
|
documents: WorkflowDocument[];
|
||||||
|
workflowCost: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for the request function passed to API functions
|
// Type for the request function passed to API functions
|
||||||
|
|
@ -237,7 +237,7 @@ export async function fetchWorkflowLogs(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch unified chat data (messages, logs, stats, documents)
|
* 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
|
* Query params: afterTimestamp (optional) - fetch only data created after this time
|
||||||
*/
|
*/
|
||||||
export async function fetchChatData(
|
export async function fetchChatData(
|
||||||
|
|
@ -248,7 +248,7 @@ export async function fetchChatData(
|
||||||
): Promise<ChatDataResponse> {
|
): Promise<ChatDataResponse> {
|
||||||
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
|
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
|
||||||
const requestConfig = {
|
const requestConfig = {
|
||||||
url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/chatData`,
|
url: `/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
|
||||||
method: 'get' as const,
|
method: 'get' as const,
|
||||||
params
|
params
|
||||||
};
|
};
|
||||||
|
|
@ -259,35 +259,25 @@ export async function fetchChatData(
|
||||||
|
|
||||||
console.log('📥 fetchChatData response:', data);
|
console.log('📥 fetchChatData response:', data);
|
||||||
|
|
||||||
// Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] }
|
const workflowCost: number = data.workflowCost ?? 0;
|
||||||
|
|
||||||
if (data.items && Array.isArray(data.items)) {
|
if (data.items && Array.isArray(data.items)) {
|
||||||
const messages: WorkflowMessage[] = [];
|
const messages: WorkflowMessage[] = [];
|
||||||
const logs: WorkflowLog[] = [];
|
const logs: WorkflowLog[] = [];
|
||||||
const stats: WorkflowStats[] = [];
|
|
||||||
const documents: WorkflowDocument[] = [];
|
const documents: WorkflowDocument[] = [];
|
||||||
|
|
||||||
data.items.forEach((item: any) => {
|
data.items.forEach((item: any) => {
|
||||||
if (item.type === 'message') {
|
if (item.type === 'message') {
|
||||||
// Handle both formats: item.item or direct item data
|
|
||||||
const messageData = item.item || item;
|
const messageData = item.item || item;
|
||||||
if (messageData && (messageData.id || messageData.message)) {
|
if (messageData && (messageData.id || messageData.message)) {
|
||||||
messages.push(messageData);
|
messages.push(messageData);
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Invalid message item:', item);
|
|
||||||
}
|
}
|
||||||
} else if (item.type === 'log') {
|
} else if (item.type === 'log') {
|
||||||
const logData = item.item || item;
|
const logData = item.item || item;
|
||||||
if (logData) {
|
if (logData) {
|
||||||
logs.push(logData);
|
logs.push(logData);
|
||||||
}
|
}
|
||||||
} else if (item.type === 'stat') {
|
} else if (item.type === 'document') {
|
||||||
const statData = item.item || item;
|
|
||||||
if (statData) {
|
|
||||||
stats.push(statData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Documents might be in items or separate
|
|
||||||
if (item.type === 'document') {
|
|
||||||
const docData = item.item || item;
|
const docData = item.item || item;
|
||||||
if (docData) {
|
if (docData) {
|
||||||
documents.push(docData);
|
documents.push(docData);
|
||||||
|
|
@ -295,33 +285,25 @@ export async function fetchChatData(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📦 Extracted from items:', {
|
|
||||||
messages: messages.length,
|
|
||||||
logs: logs.length,
|
|
||||||
stats: stats.length,
|
|
||||||
documents: documents.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
logs,
|
logs,
|
||||||
stats,
|
documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []),
|
||||||
documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : [])
|
workflowCost
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to direct format: { messages: [], logs: [], stats: [] }
|
|
||||||
return {
|
return {
|
||||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||||
logs: Array.isArray(data.logs) ? data.logs : [],
|
logs: Array.isArray(data.logs) ? data.logs : [],
|
||||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
documents: Array.isArray(data.documents) ? data.documents : [],
|
||||||
documents: Array.isArray(data.documents) ? data.documents : []
|
workflowCost
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new workflow or continue an existing one
|
* 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")
|
* Query params: workflowId (optional), workflowMode (default: "Dynamic")
|
||||||
*/
|
*/
|
||||||
export async function startWorkflowApi(
|
export async function startWorkflowApi(
|
||||||
|
|
@ -336,7 +318,6 @@ export async function startWorkflowApi(
|
||||||
if (options?.workflowMode) {
|
if (options?.workflowMode) {
|
||||||
params.workflowMode = options.workflowMode;
|
params.workflowMode = options.workflowMode;
|
||||||
} else {
|
} else {
|
||||||
// Default to 'Dynamic' if not provided (though it should always be provided)
|
|
||||||
params.workflowMode = 'Dynamic';
|
params.workflowMode = 'Dynamic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,7 +325,6 @@ export async function startWorkflowApi(
|
||||||
params.workflowId = options.workflowId;
|
params.workflowId = options.workflowId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request body uses 'prompt' field (not 'input') according to API spec
|
|
||||||
const requestBody: any = {
|
const requestBody: any = {
|
||||||
prompt: workflowData.prompt,
|
prompt: workflowData.prompt,
|
||||||
...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }),
|
...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }),
|
||||||
|
|
@ -354,10 +334,10 @@ export async function startWorkflowApi(
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestConfig = {
|
const requestConfig = {
|
||||||
url: `/api/chatplayground/${instanceId}/start`,
|
url: `/api/automations/${instanceId}/start`,
|
||||||
method: 'post' as const,
|
method: 'post' as const,
|
||||||
data: requestBody,
|
data: requestBody,
|
||||||
params: params // Always include workflowMode
|
params: params
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log full request details
|
// Log full request details
|
||||||
|
|
@ -377,7 +357,7 @@ export async function startWorkflowApi(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop a running workflow
|
* Stop a running workflow
|
||||||
* Endpoint: POST /api/chatplayground/{instanceId}/workflows/{workflowId}/stop
|
* Endpoint: POST /api/automations/{instanceId}/workflows/{workflowId}/stop
|
||||||
*/
|
*/
|
||||||
export async function stopWorkflowApi(
|
export async function stopWorkflowApi(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
|
|
@ -385,7 +365,7 @@ export async function stopWorkflowApi(
|
||||||
workflowId: string
|
workflowId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await request({
|
await request({
|
||||||
url: `/api/chatplayground/${instanceId}/workflows/${workflowId}/stop`,
|
url: `/api/automations/${instanceId}/workflows/${workflowId}/stop`,
|
||||||
method: 'post'
|
method: 'post'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
src/components/FolderTree/FolderTree.module.css
Normal file
157
src/components/FolderTree/FolderTree.module.css
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
.folderTree {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode:hover {
|
||||||
|
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode.selected {
|
||||||
|
background: var(--color-bg-selected, rgba(25, 118, 210, 0.08));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode.multiSelected {
|
||||||
|
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
|
||||||
|
box-shadow: inset 3px 0 0 var(--color-primary, #1976d2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode.multiSelected:hover {
|
||||||
|
background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20));
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode.dropTarget {
|
||||||
|
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
|
||||||
|
outline: 2px dashed var(--color-primary, #1976d2);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.empty {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-secondary, #888);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folderName {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renameInput {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--color-primary, #1976d2);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode:hover .actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-secondary, #888);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn:hover {
|
||||||
|
background: var(--color-bg-hover, rgba(0, 0, 0, 0.08));
|
||||||
|
color: var(--color-text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn.danger:hover {
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootLabel {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File nodes inside the tree */
|
||||||
|
.fileNode {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileNode:hover {
|
||||||
|
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileSize {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-secondary, #999);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rootActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
741
src/components/FolderTree/FolderTree.tsx
Normal file
741
src/components/FolderTree/FolderTree.tsx
Normal file
|
|
@ -0,0 +1,741 @@
|
||||||
|
/**
|
||||||
|
* FolderTree – Shared recursive folder/file tree component.
|
||||||
|
*
|
||||||
|
* Used on the Files page and in the Workspace chat.
|
||||||
|
* Supports:
|
||||||
|
* - Alphabetical sorting per level (folders first, then files)
|
||||||
|
* - Multi-selection (CTRL+click, SHIFT+click) with visual highlight
|
||||||
|
* - Batch drag-and-drop for selected items
|
||||||
|
* - Inline CRUD icons for folders
|
||||||
|
* - showFiles mode renders files inline under their parent folder
|
||||||
|
* - Drag-out: sets application/tree-items on dataTransfer for external drop targets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
||||||
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
|
/* ── Public types ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface FolderNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
children?: FolderNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileNode {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
folderId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeItem {
|
||||||
|
id: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderTreeProps {
|
||||||
|
folders: FolderNode[];
|
||||||
|
files?: FileNode[];
|
||||||
|
showFiles?: boolean;
|
||||||
|
selectedFolderId: string | null;
|
||||||
|
onSelect: (folderId: string | null) => void;
|
||||||
|
onFileSelect?: (fileId: string) => void;
|
||||||
|
selectedItemIds?: Set<string>;
|
||||||
|
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||||
|
expandedIds?: Set<string>;
|
||||||
|
onToggleExpand?: (id: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||||
|
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
|
||||||
|
onDeleteFolder?: (folderId: string) => Promise<void>;
|
||||||
|
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
|
||||||
|
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
|
||||||
|
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
||||||
|
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||||
|
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
|
||||||
|
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||||
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
|
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _buildTree(folders: FolderNode[]): FolderNode[] {
|
||||||
|
const map = new Map<string, FolderNode>();
|
||||||
|
const roots: FolderNode[] = [];
|
||||||
|
for (const f of folders) map.set(f.id, { ...f, children: [] });
|
||||||
|
for (const f of folders) {
|
||||||
|
const node = map.get(f.id)!;
|
||||||
|
if (f.parentId && map.has(f.parentId)) {
|
||||||
|
map.get(f.parentId)!.children!.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const _sortLevel = (nodes: FolderNode[]) => {
|
||||||
|
nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.children && n.children.length > 0) _sortLevel(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_sortLevel(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _groupFilesByFolder(files: FileNode[]): Map<string, FileNode[]> {
|
||||||
|
const map = new Map<string, FileNode[]>();
|
||||||
|
for (const f of files) {
|
||||||
|
const key = f.folderId || '';
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(f);
|
||||||
|
}
|
||||||
|
for (const [, arr] of map) {
|
||||||
|
arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _computeFlatList(
|
||||||
|
tree: FolderNode[],
|
||||||
|
expandedIds: Set<string>,
|
||||||
|
showFiles: boolean,
|
||||||
|
filesByFolder: Map<string, FileNode[]>,
|
||||||
|
): TreeItem[] {
|
||||||
|
const result: TreeItem[] = [];
|
||||||
|
const _walk = (nodes: FolderNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
result.push({ id: node.id, type: 'folder', name: node.name });
|
||||||
|
if (expandedIds.has(node.id)) {
|
||||||
|
if (node.children) _walk(node.children);
|
||||||
|
if (showFiles) {
|
||||||
|
for (const f of (filesByFolder.get(node.id) || [])) {
|
||||||
|
result.push({ id: f.id, type: 'file', name: f.fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_walk(tree);
|
||||||
|
if (showFiles) {
|
||||||
|
for (const f of (filesByFolder.get('') || [])) {
|
||||||
|
result.push({ id: f.id, type: 'file', name: f.fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fileIcon(mime?: string): string {
|
||||||
|
if (!mime) return '\uD83D\uDCC4';
|
||||||
|
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||||
|
if (mime.includes('pdf')) return '\uD83D\uDCD5';
|
||||||
|
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
|
||||||
|
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
|
||||||
|
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
|
||||||
|
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
|
||||||
|
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
|
||||||
|
if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
|
||||||
|
if (mime.startsWith('video/')) return '\uD83C\uDFA5';
|
||||||
|
return '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selection context threaded through the tree ──────────────────────── */
|
||||||
|
|
||||||
|
interface SelectionCtx {
|
||||||
|
selectedItemIds: Set<string>;
|
||||||
|
selectedFileIds: string[];
|
||||||
|
selectedFolderIds: string[];
|
||||||
|
onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void;
|
||||||
|
onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void;
|
||||||
|
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
|
||||||
|
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||||
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const isSelected = sel.selectedItemIds.has(file.id);
|
||||||
|
const multiSelected = sel.selectedItemIds.size > 1;
|
||||||
|
|
||||||
|
const _handleRename = useCallback(async () => {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
|
||||||
|
await sel.onRenameFile(file.id, trimmed);
|
||||||
|
}
|
||||||
|
setRenaming(false);
|
||||||
|
}, [renameValue, file.id, file.fileName, sel.onRenameFile]);
|
||||||
|
|
||||||
|
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
|
||||||
|
await sel.onDeleteFiles(sel.selectedFileIds);
|
||||||
|
}
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
|
||||||
|
await sel.onDeleteFolders(sel.selectedFolderIds);
|
||||||
|
}
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sel.onDeleteFile) await sel.onDeleteFile(file.id);
|
||||||
|
}, [file.id, sel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.treeNode,
|
||||||
|
styles.fileNode,
|
||||||
|
isSelected ? styles.multiSelected : '',
|
||||||
|
dragging ? styles.dragging : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
sel.onItemDragStart(e, file.id, 'file', file.fileName);
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDragging(false)}
|
||||||
|
>
|
||||||
|
<span className={styles.fileIcon}>{_fileIcon(file.mimeType)}</span>
|
||||||
|
{renaming ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className={styles.renameInput}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={_handleRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') _handleRename();
|
||||||
|
if (e.key === 'Escape') setRenaming(false);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={styles.folderName}>{file.fileName}</span>
|
||||||
|
)}
|
||||||
|
{!renaming && file.fileSize != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(file.fileSize / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!renaming && (
|
||||||
|
<span className={styles.actions}>
|
||||||
|
{sel.onRenameFile && !multiSelected && (
|
||||||
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||||
|
<FaPen />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{multiSelected && isSelected ? (
|
||||||
|
<>
|
||||||
|
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||||
|
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||||
|
<FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tree node (folder) ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: FolderNode;
|
||||||
|
depth: number;
|
||||||
|
selectedFolderId: string | null;
|
||||||
|
expandedIds: Set<string>;
|
||||||
|
showFiles: boolean;
|
||||||
|
filesByFolder: Map<string, FileNode[]>;
|
||||||
|
sel: SelectionCtx;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||||
|
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
|
||||||
|
onDeleteFolder?: (folderId: string) => Promise<void>;
|
||||||
|
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
|
||||||
|
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
|
||||||
|
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
||||||
|
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||||
|
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _TreeNode({
|
||||||
|
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
||||||
|
onToggle, onSelect,
|
||||||
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
|
onDownloadFolder,
|
||||||
|
}: TreeNodeProps) {
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState(node.name);
|
||||||
|
const [dropOver, setDropOver] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isExpanded = expandedIds.has(node.id);
|
||||||
|
const isNavSelected = selectedFolderId === node.id;
|
||||||
|
const isMultiSelected = sel.selectedItemIds.has(node.id);
|
||||||
|
const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : [];
|
||||||
|
const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renaming && inputRef.current) inputRef.current.focus();
|
||||||
|
}, [renaming]);
|
||||||
|
|
||||||
|
const _handleRename = useCallback(async () => {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (trimmed && trimmed !== node.name && onRenameFolder) {
|
||||||
|
await onRenameFolder(node.id, trimmed);
|
||||||
|
}
|
||||||
|
setRenaming(false);
|
||||||
|
}, [renameValue, node.id, node.name, onRenameFolder]);
|
||||||
|
|
||||||
|
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!onCreateFolder) return;
|
||||||
|
const name = prompt('Neuer Ordnername:');
|
||||||
|
if (name?.trim()) {
|
||||||
|
await onCreateFolder(name.trim(), node.id);
|
||||||
|
if (!expandedIds.has(node.id)) onToggle(node.id);
|
||||||
|
}
|
||||||
|
}, [onCreateFolder, node.id, expandedIds, onToggle]);
|
||||||
|
|
||||||
|
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onDeleteFolder) await onDeleteFolder(node.id);
|
||||||
|
}, [onDeleteFolder, node.id]);
|
||||||
|
|
||||||
|
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
|
||||||
|
await sel.onDeleteFolders(sel.selectedFolderIds);
|
||||||
|
}
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
|
||||||
|
await sel.onDeleteFiles(sel.selectedFileIds);
|
||||||
|
}
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDropOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleDragLeave = useCallback(() => setDropOver(false), []);
|
||||||
|
|
||||||
|
const _handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDropOver(false);
|
||||||
|
|
||||||
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
|
if (treeItemsJson) {
|
||||||
|
const items: TreeItem[] = JSON.parse(treeItemsJson);
|
||||||
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
|
const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id);
|
||||||
|
|
||||||
|
if (folderIds.length > 0 && onMoveFolders) {
|
||||||
|
await onMoveFolders(folderIds, node.id);
|
||||||
|
} else if (onMoveFolder) {
|
||||||
|
for (const fId of folderIds) await onMoveFolder(fId, node.id);
|
||||||
|
}
|
||||||
|
if (fileIds.length > 0 && onMoveFiles) {
|
||||||
|
await onMoveFiles(fileIds, node.id);
|
||||||
|
} else if (fileIds.length > 0 && onMoveFile) {
|
||||||
|
for (const fId of fileIds) await onMoveFile(fId, node.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderId = e.dataTransfer.getData('application/folder-id');
|
||||||
|
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
|
||||||
|
const fileId = e.dataTransfer.getData('application/file-id');
|
||||||
|
if (folderId && folderId !== node.id && onMoveFolder) {
|
||||||
|
await onMoveFolder(folderId, node.id);
|
||||||
|
} else if (fileIdsJson && onMoveFiles) {
|
||||||
|
await onMoveFiles(JSON.parse(fileIdsJson), node.id);
|
||||||
|
} else if (fileId && onMoveFile) {
|
||||||
|
await onMoveFile(fileId, node.id);
|
||||||
|
}
|
||||||
|
}, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
|
||||||
|
|
||||||
|
const nodeClasses = [
|
||||||
|
styles.treeNode,
|
||||||
|
isNavSelected && !isMultiSelected ? styles.selected : '',
|
||||||
|
isMultiSelected ? styles.multiSelected : '',
|
||||||
|
dropOver ? styles.dropTarget : '',
|
||||||
|
dragging ? styles.dragging : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={nodeClasses}
|
||||||
|
onClick={(e) => sel.onItemClick(node.id, 'folder', e)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
sel.onItemDragStart(e, node.id, 'folder', node.name);
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDragging(false)}
|
||||||
|
onDragOver={_handleDragOver}
|
||||||
|
onDragLeave={_handleDragLeave}
|
||||||
|
onDrop={_handleDrop}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''} ${!hasChildren ? styles.empty : ''}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderIcon}>
|
||||||
|
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
|
||||||
|
</span>
|
||||||
|
{renaming ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.renameInput}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={_handleRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') _handleRename();
|
||||||
|
if (e.key === 'Escape') setRenaming(false);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={styles.folderName}>{node.name}</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.actions}>
|
||||||
|
{onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||||
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title="Ordner herunterladen (ZIP)">
|
||||||
|
<FaDownload />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||||
|
<button className={styles.actionBtn} onClick={_handleAdd} title="Neuer Unterordner">
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||||
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title="Umbenennen">
|
||||||
|
<FaPen />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
|
||||||
|
<>
|
||||||
|
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||||
|
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||||
|
<FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : onDeleteFolder && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded && hasChildren && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{node.children!.map((child) => (
|
||||||
|
<_TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
showFiles={showFiles}
|
||||||
|
filesByFolder={filesByFolder}
|
||||||
|
sel={sel}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onCreateFolder={onCreateFolder}
|
||||||
|
onRenameFolder={onRenameFolder}
|
||||||
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
onMoveFolder={onMoveFolder}
|
||||||
|
onMoveFolders={onMoveFolders}
|
||||||
|
onMoveFile={onMoveFile}
|
||||||
|
onMoveFiles={onMoveFiles}
|
||||||
|
onDownloadFolder={onDownloadFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{folderFiles.map((file) => (
|
||||||
|
<_FileItem key={file.id} file={file} sel={sel} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Root component ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default function FolderTree({
|
||||||
|
folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect,
|
||||||
|
selectedItemIds: externalSelectedIds, onSelectionChange,
|
||||||
|
expandedIds: externalExpandedIds, onToggleExpand,
|
||||||
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
|
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||||
|
}: FolderTreeProps) {
|
||||||
|
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [rootDropOver, setRootDropOver] = useState(false);
|
||||||
|
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const lastClickedIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
||||||
|
|
||||||
|
const tree = useMemo(() => _buildTree(folders), [folders]);
|
||||||
|
const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]);
|
||||||
|
const rootFiles = showFiles ? (filesByFolder.get('') || []) : [];
|
||||||
|
|
||||||
|
const selectedItemIds = externalSelectedIds ?? internalSelectedIds;
|
||||||
|
|
||||||
|
const flatList = useMemo(
|
||||||
|
() => _computeFlatList(tree, expandedIds, showFiles, filesByFolder),
|
||||||
|
[tree, expandedIds, showFiles, filesByFolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _handleToggle = useCallback((id: string) => {
|
||||||
|
if (onToggleExpand) {
|
||||||
|
onToggleExpand(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInternalExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _setSelection = useCallback((ids: Set<string>) => {
|
||||||
|
if (onSelectionChange) {
|
||||||
|
onSelectionChange(ids);
|
||||||
|
} else {
|
||||||
|
setInternalSelectedIds(ids);
|
||||||
|
}
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const next = new Set(selectedItemIds);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
_setSelection(next);
|
||||||
|
lastClickedIdRef.current = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey && lastClickedIdRef.current) {
|
||||||
|
const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current);
|
||||||
|
const currIdx = flatList.findIndex(i => i.id === id);
|
||||||
|
if (lastIdx >= 0 && currIdx >= 0) {
|
||||||
|
const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
|
||||||
|
const next = new Set(selectedItemIds);
|
||||||
|
for (let i = from; i <= to; i++) next.add(flatList[i].id);
|
||||||
|
_setSelection(next);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSelection(new Set([id]));
|
||||||
|
lastClickedIdRef.current = id;
|
||||||
|
if (type === 'folder') onSelect(id);
|
||||||
|
if (type === 'file') onFileSelect?.(id);
|
||||||
|
}, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]);
|
||||||
|
|
||||||
|
const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => {
|
||||||
|
const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1;
|
||||||
|
|
||||||
|
if (isInSelection) {
|
||||||
|
const items: TreeItem[] = [];
|
||||||
|
for (const selId of selectedItemIds) {
|
||||||
|
const item = flatList.find(i => i.id === selId);
|
||||||
|
if (item) items.push(item);
|
||||||
|
}
|
||||||
|
e.dataTransfer.setData('application/tree-items', JSON.stringify(items));
|
||||||
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
|
if (fileIds.length > 0) {
|
||||||
|
e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }]));
|
||||||
|
if (type === 'file') {
|
||||||
|
e.dataTransfer.setData('application/file-id', id);
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.setData('application/folder-id', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
}, [selectedItemIds, flatList]);
|
||||||
|
|
||||||
|
const allFileIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id);
|
||||||
|
return ids;
|
||||||
|
}, [filesByFolder]);
|
||||||
|
|
||||||
|
const allFolderIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
|
||||||
|
_collect(tree);
|
||||||
|
return ids;
|
||||||
|
}, [tree]);
|
||||||
|
|
||||||
|
const sel: SelectionCtx = useMemo(() => {
|
||||||
|
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
|
||||||
|
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
|
||||||
|
return {
|
||||||
|
selectedItemIds,
|
||||||
|
selectedFileIds: selFileIds,
|
||||||
|
selectedFolderIds: selFolderIds,
|
||||||
|
onItemClick: _handleItemClick,
|
||||||
|
onItemDragStart: _handleItemDragStart,
|
||||||
|
onRenameFile,
|
||||||
|
onDeleteFile,
|
||||||
|
onDeleteFiles,
|
||||||
|
onDeleteFolders,
|
||||||
|
};
|
||||||
|
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]);
|
||||||
|
|
||||||
|
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setRootDropOver(false);
|
||||||
|
|
||||||
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
|
if (treeItemsJson) {
|
||||||
|
const items: TreeItem[] = JSON.parse(treeItemsJson);
|
||||||
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
||||||
|
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
|
||||||
|
if (folderIds.length > 0 && onMoveFolders) {
|
||||||
|
await onMoveFolders(folderIds, null);
|
||||||
|
} else if (onMoveFolder) {
|
||||||
|
for (const fId of folderIds) await onMoveFolder(fId, null);
|
||||||
|
}
|
||||||
|
if (fileIds.length > 0 && onMoveFiles) {
|
||||||
|
await onMoveFiles(fileIds, null);
|
||||||
|
} else if (fileIds.length > 0 && onMoveFile) {
|
||||||
|
for (const fId of fileIds) await onMoveFile(fId, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderId = e.dataTransfer.getData('application/folder-id');
|
||||||
|
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
|
||||||
|
const fileId = e.dataTransfer.getData('application/file-id');
|
||||||
|
if (folderId && onMoveFolder) {
|
||||||
|
await onMoveFolder(folderId, null);
|
||||||
|
} else if (fileIdsJson && onMoveFiles) {
|
||||||
|
await onMoveFiles(JSON.parse(fileIdsJson), null);
|
||||||
|
} else if (fileId && onMoveFile) {
|
||||||
|
await onMoveFile(fileId, null);
|
||||||
|
}
|
||||||
|
}, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
|
||||||
|
|
||||||
|
const rootClasses = [
|
||||||
|
styles.treeNode,
|
||||||
|
selectedFolderId === null ? styles.selected : '',
|
||||||
|
rootDropOver ? styles.dropTarget : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.folderTree}>
|
||||||
|
<div
|
||||||
|
className={rootClasses}
|
||||||
|
onClick={() => { onSelect(null); _setSelection(new Set()); }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }}
|
||||||
|
onDragLeave={() => setRootDropOver(false)}
|
||||||
|
onDrop={_handleRootDrop}
|
||||||
|
>
|
||||||
|
<span className={styles.folderIcon}><FaGlobe /></span>
|
||||||
|
<span className={`${styles.folderName} ${styles.rootLabel}`}>(Global)</span>
|
||||||
|
<span className={styles.rootActions}>
|
||||||
|
{onRefresh && (
|
||||||
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title="Aktualisieren">
|
||||||
|
<FaSyncAlt />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onCreateFolder && (
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const name = prompt('Neuer Ordnername:');
|
||||||
|
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
||||||
|
}}
|
||||||
|
title="Neuer Ordner"
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.children}>
|
||||||
|
{tree.map((node) => (
|
||||||
|
<_TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={1}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
showFiles={showFiles}
|
||||||
|
filesByFolder={filesByFolder}
|
||||||
|
sel={sel}
|
||||||
|
onToggle={_handleToggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onCreateFolder={onCreateFolder}
|
||||||
|
onRenameFolder={onRenameFolder}
|
||||||
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
onMoveFolder={onMoveFolder}
|
||||||
|
onMoveFolders={onMoveFolders}
|
||||||
|
onMoveFile={onMoveFile}
|
||||||
|
onMoveFiles={onMoveFiles}
|
||||||
|
onDownloadFolder={onDownloadFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rootFiles.map((file) => (
|
||||||
|
<_FileItem key={file.id} file={file} sel={sel} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -171,6 +171,8 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
||||||
groupDefaultExpanded?: boolean;
|
groupDefaultExpanded?: boolean;
|
||||||
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
||||||
|
rowDraggable?: boolean;
|
||||||
|
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormGeneratorTable<T extends Record<string, any>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
@ -208,7 +210,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
groupRenderer: _groupRenderer,
|
groupRenderer: _groupRenderer,
|
||||||
groupRowData,
|
groupRowData,
|
||||||
groupDefaultExpanded = true,
|
groupDefaultExpanded = true,
|
||||||
groupActions
|
groupActions,
|
||||||
|
rowDraggable = false,
|
||||||
|
onRowDragStart,
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
||||||
|
|
@ -282,7 +286,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Track if we've loaded from localStorage for this storage key
|
// Track if we've loaded from localStorage for this storage key
|
||||||
const loadedStorageKeyRef = useRef<string | null>(null);
|
const loadedStorageKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Check if backend pagination is supported (hookData has refetch that accepts params)
|
// Check if backend pagination is supported (hookData has refetch that accepts params).
|
||||||
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
|
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
|
||||||
|
|
||||||
// Debounce search term for backend calls
|
// Debounce search term for backend calls
|
||||||
|
|
@ -1971,6 +1975,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
key={`${groupKey}-row-${rowIndex}`}
|
key={`${groupKey}-row-${rowIndex}`}
|
||||||
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||||
onClick={() => onRowClick?.(row, globalIndex)}
|
onClick={() => onRowClick?.(row, globalIndex)}
|
||||||
|
draggable={rowDraggable}
|
||||||
|
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
|
||||||
{...Object.fromEntries(
|
{...Object.fromEntries(
|
||||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||||
)}
|
)}
|
||||||
|
|
@ -2084,6 +2090,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
key={index}
|
key={index}
|
||||||
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||||
onClick={() => onRowClick?.(row, index)}
|
onClick={() => onRowClick?.(row, index)}
|
||||||
|
draggable={rowDraggable}
|
||||||
|
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
|
||||||
{...Object.fromEntries(
|
{...Object.fromEntries(
|
||||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,12 @@ export const UserSection: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialen für Avatar
|
// Initialen für Avatar
|
||||||
const initials = user.fullName
|
const initials = (() => {
|
||||||
? user.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
const name = user.fullName || user.username || '';
|
||||||
: user.username.slice(0, 2).toUpperCase();
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toLocaleUpperCase();
|
||||||
|
return [...name.trim()].slice(0, 2).join('').toLocaleUpperCase() || '?';
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.userSection}>
|
<div className={styles.userSection}>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trigger Button - matches iconButton style from PlaygroundPage */
|
/* Trigger Button */
|
||||||
.triggerButton {
|
.triggerButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* ProviderSelector Component
|
* ProviderSelector Component
|
||||||
*
|
*
|
||||||
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
|
* 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:
|
* Features:
|
||||||
* - Dropdown für Einzelauswahl
|
* - Dropdown für Einzelauswahl
|
||||||
|
|
|
||||||
|
|
@ -2,114 +2,37 @@ import React, { useMemo } from 'react';
|
||||||
import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes';
|
import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes';
|
||||||
import styles from './WorkflowStatus.module.css';
|
import styles from './WorkflowStatus.module.css';
|
||||||
|
|
||||||
// Helper function to extract workflow status and round from log message
|
const _STATUS_MAP: Record<string, WorkflowStatusType> = {
|
||||||
|
success: 'completed',
|
||||||
|
completed: 'completed',
|
||||||
|
started: 'started',
|
||||||
|
running: 'started',
|
||||||
|
resumed: 'resumed',
|
||||||
|
stopped: 'stopped',
|
||||||
|
failed: 'failed',
|
||||||
|
error: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round: number | null; timestamp: number } => {
|
const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round: number | null; timestamp: number } => {
|
||||||
// First, check for completion messages with success status (these take priority)
|
if (!logs.length) return { status: null, round: null, timestamp: 0 };
|
||||||
const completionMessages = logs.filter(log => {
|
|
||||||
const message = (log.message || '').toLowerCase();
|
const sorted = [...logs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||||
|
|
||||||
|
for (const log of sorted) {
|
||||||
const logStatus = (log.status || '').toLowerCase();
|
const logStatus = (log.status || '').toLowerCase();
|
||||||
return (message.includes('fast path completed') ||
|
const mapped = _STATUS_MAP[logStatus];
|
||||||
message.includes('completed successfully')) &&
|
if (mapped) {
|
||||||
logStatus === 'success';
|
const roundMatch = (log.message || '').match(/\(?round\s+(\d+)\)?/i);
|
||||||
});
|
return { status: mapped, round: roundMatch ? parseInt(roundMatch[1], 10) : null, timestamp: log.timestamp || 0 };
|
||||||
|
|
||||||
// If we have completion messages, use the latest one
|
|
||||||
if (completionMessages.length > 0) {
|
|
||||||
const latestCompletion = completionMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
|
||||||
|
|
||||||
// Try to extract round from completion message
|
|
||||||
let round: number | null = null;
|
|
||||||
const message = (latestCompletion.message || '').toLowerCase();
|
|
||||||
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
|
|
||||||
if (roundMatch) {
|
|
||||||
round = parseInt(roundMatch[1], 10);
|
|
||||||
} else {
|
|
||||||
// If no round in completion message, get round from latest workflow status message
|
|
||||||
const statusMessages = logs.filter(log => {
|
|
||||||
const msg = (log.message || '').toLowerCase();
|
|
||||||
return msg.includes('workflow started') || msg.includes('workflow resumed');
|
|
||||||
});
|
|
||||||
if (statusMessages.length > 0) {
|
|
||||||
const latestWorkflowStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
|
||||||
const workflowMessage = (latestWorkflowStatus.message || '').toLowerCase();
|
|
||||||
const workflowRoundMatch = workflowMessage.match(/\(?round\s+(\d+)\)?/i);
|
|
||||||
if (workflowRoundMatch) {
|
|
||||||
round = parseInt(workflowRoundMatch[1], 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'completed',
|
|
||||||
round,
|
|
||||||
timestamp: latestCompletion.timestamp || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no completion messages, look for workflow started/resumed/stopped messages
|
|
||||||
const statusMessages = logs.filter(log => {
|
|
||||||
const message = (log.message || '').toLowerCase();
|
|
||||||
return message.includes('workflow started') ||
|
|
||||||
message.includes('workflow resumed') ||
|
|
||||||
message.includes('workflow stopped') ||
|
|
||||||
message.includes('workflow failed') ||
|
|
||||||
message.includes('workflow completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (statusMessages.length === 0) {
|
|
||||||
return { status: null, round: null, timestamp: 0 };
|
return { status: null, round: null, timestamp: 0 };
|
||||||
}
|
|
||||||
|
|
||||||
// Get the latest status message
|
|
||||||
const latestStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
|
||||||
const message = (latestStatus.message || '').toLowerCase();
|
|
||||||
|
|
||||||
let status: WorkflowStatusType = null;
|
|
||||||
if (message.includes('started')) {
|
|
||||||
status = 'started';
|
|
||||||
} else if (message.includes('resumed')) {
|
|
||||||
status = 'resumed';
|
|
||||||
} else if (message.includes('stopped')) {
|
|
||||||
status = 'stopped';
|
|
||||||
} else if (message.includes('failed')) {
|
|
||||||
status = 'failed';
|
|
||||||
} else if (message.includes('completed')) {
|
|
||||||
status = 'completed';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract round number from message (e.g., "round 4", "round 2", or "(round 4)")
|
|
||||||
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
|
|
||||||
const round = roundMatch ? parseInt(roundMatch[1], 10) : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
round,
|
|
||||||
timestamp: latestStatus.timestamp || 0
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format bytes to KB or MB
|
const _formatCurrency = (amount?: number): string => {
|
||||||
const formatBytes = (bytes?: number): string => {
|
if (amount === undefined || amount === null) return '-';
|
||||||
if (bytes === undefined || bytes === null) return '-';
|
return `${amount.toFixed(2)} CHF`;
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const kb = bytes / 1024;
|
|
||||||
if (kb < 1024) {
|
|
||||||
return `${kb.toFixed(2)} KB`;
|
|
||||||
}
|
|
||||||
const mb = kb / 1024;
|
|
||||||
return `${mb.toFixed(2)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to format price
|
|
||||||
const formatPrice = (price?: number): string => {
|
|
||||||
if (price === undefined || price === null) return '-';
|
|
||||||
return `$${price.toFixed(2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to format processing time
|
|
||||||
const formatProcessingTime = (time?: number): string => {
|
|
||||||
if (time === undefined || time === null) return '-';
|
|
||||||
return `${time.toFixed(2)}s`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
||||||
|
|
@ -122,40 +45,10 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
// Use workflow status and round from API response, fallback to extracting from logs
|
// Use workflow status and round from API response, fallback to extracting from logs
|
||||||
const workflowStatus = useMemo(() => {
|
const workflowStatus = useMemo(() => {
|
||||||
// If we have status from API, use it
|
|
||||||
if (workflowStatusFromApi) {
|
if (workflowStatusFromApi) {
|
||||||
let status: WorkflowStatusType = null;
|
const mapped = _STATUS_MAP[workflowStatusFromApi.toLowerCase()] || null;
|
||||||
const statusLower = workflowStatusFromApi.toLowerCase();
|
return { status: mapped, round: currentRoundFromApi || null, timestamp: Date.now() / 1000 };
|
||||||
|
|
||||||
if (statusLower === 'completed') {
|
|
||||||
status = 'completed';
|
|
||||||
} else if (statusLower === 'running') {
|
|
||||||
// Check if it's started or resumed from logs
|
|
||||||
const startedResumedLogs = logs.filter(log => {
|
|
||||||
const message = (log.message || '').toLowerCase();
|
|
||||||
return message.includes('workflow started') || message.includes('workflow resumed');
|
|
||||||
});
|
|
||||||
if (startedResumedLogs.length > 0) {
|
|
||||||
const latest = startedResumedLogs.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
|
||||||
const message = (latest.message || '').toLowerCase();
|
|
||||||
status = message.includes('resumed') ? 'resumed' : 'started';
|
|
||||||
} else {
|
|
||||||
status = 'started';
|
|
||||||
}
|
}
|
||||||
} else if (statusLower === 'stopped') {
|
|
||||||
status = 'stopped';
|
|
||||||
} else if (statusLower === 'failed') {
|
|
||||||
status = 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
round: currentRoundFromApi || null,
|
|
||||||
timestamp: Date.now() / 1000 // Use current time since we don't have timestamp from API
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to extracting from logs
|
|
||||||
return extractWorkflowStatus(logs);
|
return extractWorkflowStatus(logs);
|
||||||
}, [workflowStatusFromApi, currentRoundFromApi, logs]);
|
}, [workflowStatusFromApi, currentRoundFromApi, logs]);
|
||||||
|
|
||||||
|
|
@ -185,33 +78,13 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Display */}
|
{/* Cost Display */}
|
||||||
{latestStats && (
|
{latestStats && latestStats.priceCHF !== undefined && (
|
||||||
<div className={styles.statsContainer}>
|
<div className={styles.statsContainer}>
|
||||||
{latestStats.priceUsd !== undefined && (
|
|
||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}>
|
||||||
<span className={styles.statLabel}>Price:</span>
|
<span className={styles.statLabel}>Cost:</span>
|
||||||
<span className={styles.statValue}>{formatPrice(latestStats.priceUsd)}</span>
|
<span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{latestStats.processingTime !== undefined && (
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<span className={styles.statLabel}>Time:</span>
|
|
||||||
<span className={styles.statValue}>{formatProcessingTime(latestStats.processingTime)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestStats.bytesSent !== undefined && (
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<span className={styles.statLabel}>Sent:</span>
|
|
||||||
<span className={styles.statValue}>{formatBytes(latestStats.bytesSent)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestStats.bytesReceived !== undefined && (
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<span className={styles.statLabel}>Received:</span>
|
|
||||||
<span className={styles.statValue}>{formatBytes(latestStats.bytesReceived)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,10 @@ export interface WorkflowStatusProps {
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Latest statistics from the workflow (price, processing time, bytes sent/received)
|
* Latest cost from billing transactions (single source of truth)
|
||||||
*/
|
*/
|
||||||
latestStats?: {
|
latestStats?: {
|
||||||
priceUsd?: number;
|
priceCHF?: number;
|
||||||
processingTime?: number;
|
|
||||||
bytesSent?: number;
|
|
||||||
bytesReceived?: number;
|
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,12 +110,15 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.trustee': <FaBriefcase />,
|
'feature.trustee': <FaBriefcase />,
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.chatplayground': <FaPlay />,
|
|
||||||
'feature.codeeditor': <FaFileAlt />,
|
|
||||||
'feature.automation': <FaCogs />,
|
'feature.automation': <FaCogs />,
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
'page.feature.chatbot.conversations': <FaComments />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
||||||
|
// Feature pages - Workspace
|
||||||
|
'page.feature.workspace.dashboard': <FaPlay />,
|
||||||
|
'page.feature.workspace.editor': <FaPlay />,
|
||||||
|
'feature.workspace': <FaPlay />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import React, { createContext, useContext, useCallback } from 'react';
|
import React, { createContext, useContext, useCallback, useState, useEffect } from 'react';
|
||||||
|
import api from '../api';
|
||||||
import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles';
|
import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles';
|
||||||
|
import type { FolderInfo } from '../api/fileApi';
|
||||||
|
|
||||||
|
export type { FolderInfo };
|
||||||
|
|
||||||
interface FileContextType {
|
interface FileContextType {
|
||||||
files: UserFile[];
|
files: UserFile[];
|
||||||
|
|
@ -14,6 +18,19 @@ interface FileContextType {
|
||||||
deletingFiles: Set<string>;
|
deletingFiles: Set<string>;
|
||||||
previewingFiles: Set<string>;
|
previewingFiles: Set<string>;
|
||||||
downloadingFiles: Set<string>;
|
downloadingFiles: Set<string>;
|
||||||
|
folders: FolderInfo[];
|
||||||
|
foldersLoading: boolean;
|
||||||
|
refreshFolders: () => Promise<void>;
|
||||||
|
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
|
||||||
|
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
|
||||||
|
handleDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
|
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
|
||||||
|
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
||||||
|
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||||
|
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
|
||||||
|
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
|
||||||
|
expandedFolderIds: Set<string>;
|
||||||
|
toggleFolderExpanded: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||||
|
|
@ -31,45 +48,120 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
downloadingFiles
|
downloadingFiles
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
// Centralized file upload that updates the shared state
|
useEffect(() => { refetchFiles(); }, []);
|
||||||
|
|
||||||
|
// ── Folder expanded state (persisted in localStorage) ───────────────────
|
||||||
|
const _STORAGE_KEY = 'folderTree-expandedIds';
|
||||||
|
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(_STORAGE_KEY);
|
||||||
|
return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>();
|
||||||
|
} catch { return new Set<string>(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFolderExpanded = useCallback((id: string) => {
|
||||||
|
setExpandedFolderIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
try { localStorage.setItem(_STORAGE_KEY, JSON.stringify([...next])); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Folder state (single source of truth) ──────────────────────────────
|
||||||
|
const [folders, setFolders] = useState<FolderInfo[]>([]);
|
||||||
|
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||||
|
|
||||||
|
const refreshFolders = useCallback(async () => {
|
||||||
|
setFoldersLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/files/folders');
|
||||||
|
const data = Array.isArray(response.data) ? response.data : [];
|
||||||
|
setFolders(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load folders:', err);
|
||||||
|
} finally {
|
||||||
|
setFoldersLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refreshFolders(); }, [refreshFolders]);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
|
||||||
|
await api.post('/api/files/folders', { name, parentId: parentId || null });
|
||||||
|
await refreshFolders();
|
||||||
|
}, [refreshFolders]);
|
||||||
|
|
||||||
|
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
|
||||||
|
await api.put(`/api/files/folders/${folderId}`, { name: newName });
|
||||||
|
await refreshFolders();
|
||||||
|
}, [refreshFolders]);
|
||||||
|
|
||||||
|
const handleDeleteFolder = useCallback(async (folderId: string) => {
|
||||||
|
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
|
||||||
|
await refreshFolders();
|
||||||
|
await refetchFiles();
|
||||||
|
}, [refreshFolders, refetchFiles]);
|
||||||
|
|
||||||
|
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
|
||||||
|
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
|
||||||
|
await refreshFolders();
|
||||||
|
}, [refreshFolders]);
|
||||||
|
|
||||||
|
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
||||||
|
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
|
||||||
|
await refetchFiles();
|
||||||
|
}, [refetchFiles]);
|
||||||
|
|
||||||
|
const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
||||||
|
await api.post('/api/files/batch-move', { fileIds, targetFolderId });
|
||||||
|
await refetchFiles();
|
||||||
|
}, [refetchFiles]);
|
||||||
|
|
||||||
|
const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
|
||||||
|
await api.post('/api/files/batch-move', { folderIds, targetParentId });
|
||||||
|
await refreshFolders();
|
||||||
|
}, [refreshFolders]);
|
||||||
|
|
||||||
|
const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/files/folders/${folderId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(response.data);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `${folderName}.zip`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to download folder:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── File operations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
|
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
|
||||||
const result = await hookHandleFileUpload(file, workflowId);
|
const result = await hookHandleFileUpload(file, workflowId);
|
||||||
|
|
||||||
if (result.success && result.fileData) {
|
if (result.success && result.fileData) {
|
||||||
// The API response structure: { message, file: FileInfo, ... }
|
|
||||||
// The file data is nested in the 'file' property
|
|
||||||
const responseData = result.fileData;
|
|
||||||
const fileData = responseData.file || responseData; // Support both nested and direct structure
|
|
||||||
|
|
||||||
if (!fileData || !fileData.id) {
|
|
||||||
console.error('File upload response missing file data:', responseData);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// File will be added via refetch
|
|
||||||
// Refetch to ensure we have the latest data (this will update all consumers)
|
|
||||||
await refetchFiles();
|
await refetchFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [hookHandleFileUpload, refetchFiles]);
|
}, [hookHandleFileUpload, refetchFiles]);
|
||||||
|
|
||||||
// Centralized file delete that updates the shared state
|
|
||||||
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
|
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
|
||||||
const success = await hookHandleFileDelete(fileId, () => {
|
const success = await hookHandleFileDelete(fileId, () => {
|
||||||
removeFileOptimistically(fileId);
|
removeFileOptimistically(fileId);
|
||||||
onOptimisticDelete?.();
|
onOptimisticDelete?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Refetch to ensure we have the latest data
|
|
||||||
await refetchFiles();
|
await refetchFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]);
|
}, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]);
|
||||||
|
|
||||||
// Expose refetch function
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
await refetchFiles();
|
await refetchFiles();
|
||||||
}, [refetchFiles]);
|
}, [refetchFiles]);
|
||||||
|
|
@ -86,12 +178,24 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
|
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
|
||||||
handleFileDownload: async (fileId: string, fileName: string) => {
|
handleFileDownload: async (fileId: string, fileName: string) => {
|
||||||
await handleFileDownload(fileId, fileName);
|
await handleFileDownload(fileId, fileName);
|
||||||
// Return void (ignore boolean return value)
|
|
||||||
},
|
},
|
||||||
uploadingFile,
|
uploadingFile,
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
downloadingFiles
|
downloadingFiles,
|
||||||
|
folders,
|
||||||
|
foldersLoading,
|
||||||
|
refreshFolders,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFile,
|
||||||
|
handleMoveFiles,
|
||||||
|
handleMoveFolders,
|
||||||
|
handleDownloadFolder,
|
||||||
|
expandedFolderIds,
|
||||||
|
toggleFolderExpanded,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -106,4 +210,3 @@ export function useFileContext() {
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,614 +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' | 'stat';
|
|
||||||
item: WorkflowMessage | WorkflowLog | any;
|
|
||||||
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<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null);
|
|
||||||
|
|
||||||
// === REFS FOR SYNC ACCESS ===
|
|
||||||
const statusRef = useRef<string>('idle');
|
|
||||||
const lastRenderedTimestampRef = useRef<number | null>(null);
|
|
||||||
const processedStatIdsRef = useRef<Set<string>>(new Set());
|
|
||||||
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
|
|
||||||
|
|
||||||
// === 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[]; stats: any[] }) => {
|
|
||||||
console.log('🔄 Processing chat data:', {
|
|
||||||
messages: chatData.messages?.length || 0,
|
|
||||||
logs: chatData.logs?.length || 0,
|
|
||||||
stats: chatData.stats?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build unified timeline
|
|
||||||
const timeline: UnifiedChatDataItem[] = [];
|
|
||||||
|
|
||||||
// Add messages
|
|
||||||
(chatData.messages || []).forEach((message: WorkflowMessage) => {
|
|
||||||
timeline.push({
|
|
||||||
type: 'message',
|
|
||||||
item: message,
|
|
||||||
createdAt: message.publishedAt || message.timestamp || Date.now()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add logs
|
|
||||||
(chatData.logs || []).forEach((log: any) => {
|
|
||||||
timeline.push({
|
|
||||||
type: 'log',
|
|
||||||
item: log,
|
|
||||||
createdAt: log.timestamp || log.createdAt || Date.now()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add stats
|
|
||||||
const rawStats = chatData.stats || [];
|
|
||||||
rawStats.forEach((stat: any) => {
|
|
||||||
timeline.push({
|
|
||||||
type: 'stat',
|
|
||||||
item: stat,
|
|
||||||
createdAt: stat._createdAt || stat.createdAt || Date.now()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort chronologically
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// === PROCESS STATS ===
|
|
||||||
const statsItems = timeline.filter(item => item.type === 'stat');
|
|
||||||
|
|
||||||
if (statsItems.length > 0) {
|
|
||||||
let hasNewStats = false;
|
|
||||||
|
|
||||||
statsItems.forEach(statItem => {
|
|
||||||
const statData = statItem.item;
|
|
||||||
const statId = statData?.id;
|
|
||||||
|
|
||||||
if (statId && processedStatIdsRef.current.has(statId)) {
|
|
||||||
return; // Skip already processed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statData) {
|
|
||||||
hasNewStats = true;
|
|
||||||
if (statId) {
|
|
||||||
processedStatIdsRef.current.add(statId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate stats
|
|
||||||
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
|
|
||||||
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
|
|
||||||
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
|
|
||||||
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
|
|
||||||
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasNewStats) {
|
|
||||||
setLatestStats({
|
|
||||||
priceUsd: cumulativeStatsRef.current.priceUsd,
|
|
||||||
processingTime: cumulativeStatsRef.current.processingTime,
|
|
||||||
bytesSent: cumulativeStatsRef.current.bytesSent,
|
|
||||||
bytesReceived: cumulativeStatsRef.current.bytesReceived
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [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,
|
|
||||||
stats: chatData.stats?.length || 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);
|
|
||||||
|
|
||||||
// Reset refs
|
|
||||||
lastRenderedTimestampRef.current = null;
|
|
||||||
processedStatIdsRef.current.clear();
|
|
||||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
|
||||||
hasRenderedLastMessageRef.current = false;
|
|
||||||
setHasRenderedLastMessage(false);
|
|
||||||
|
|
||||||
pollingControllerRef.current.stopPolling();
|
|
||||||
}, [updateWorkflowStatus]);
|
|
||||||
|
|
||||||
// === SELECT/LOAD WORKFLOW ===
|
|
||||||
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
|
|
||||||
try {
|
|
||||||
console.log('📥 Loading workflow:', workflowIdToSelect);
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
setWorkflowId(workflowIdToSelect);
|
|
||||||
lastRenderedTimestampRef.current = null;
|
|
||||||
processedStatIdsRef.current.clear();
|
|
||||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
|
||||||
hasRenderedLastMessageRef.current = false;
|
|
||||||
setHasRenderedLastMessage(false);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
stats: chatData.stats?.length || 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -15,8 +15,7 @@ import {
|
||||||
type CoachingContext, type CoachingSession, type CoachingMessage,
|
type CoachingContext, type CoachingSession, type CoachingMessage,
|
||||||
type CoachingTask, type CoachingScore, type SSEEvent,
|
type CoachingTask, type CoachingScore, type SSEEvent,
|
||||||
} from '../api/commcoachApi';
|
} from '../api/commcoachApi';
|
||||||
|
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
|
||||||
export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error';
|
|
||||||
|
|
||||||
export interface CommcoachHookReturn {
|
export interface CommcoachHookReturn {
|
||||||
contexts: CoachingContext[];
|
contexts: CoachingContext[];
|
||||||
|
|
@ -49,8 +48,11 @@ export interface CommcoachHookReturn {
|
||||||
cancelSession: () => Promise<void>;
|
cancelSession: () => Promise<void>;
|
||||||
|
|
||||||
stopTts: () => void;
|
stopTts: () => void;
|
||||||
|
pauseTts: () => void;
|
||||||
resumeTts: () => void;
|
resumeTts: () => void;
|
||||||
hasAudioToResume: () => boolean;
|
hasAudioToResume: () => boolean;
|
||||||
|
ttsIsPlaying: boolean;
|
||||||
|
ttsIsPaused: boolean;
|
||||||
|
|
||||||
onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>;
|
onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>;
|
||||||
|
|
||||||
|
|
@ -90,12 +92,21 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null);
|
const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null);
|
||||||
const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null);
|
const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []);
|
const ttsPlayback = useTtsPlayback({
|
||||||
|
onPlaying: () => { (window as any).__dlog?.('TTS-PLAYING'); onTtsEventRef.current?.('playing'); },
|
||||||
|
onEnded: () => { (window as any).__dlog?.('TTS-ENDED'); onTtsEventRef.current?.('ended'); },
|
||||||
|
onPaused: () => { (window as any).__dlog?.('TTS-PAUSED'); onTtsEventRef.current?.('paused'); },
|
||||||
|
onError: () => { (window as any).__dlog?.('TTS-ERROR'); onTtsEventRef.current?.('error'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => { isMountedRef.current = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshContexts = useCallback(async () => {
|
const refreshContexts = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -111,54 +122,21 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
}, [request, instanceId]);
|
}, [request, instanceId]);
|
||||||
|
|
||||||
const _emitTts = useCallback((event: TtsEvent) => {
|
|
||||||
(window as any).__dlog?.(`TTS-${event.toUpperCase()}`);
|
|
||||||
onTtsEventRef.current?.(event);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _playTtsAudio = useCallback((audioB64: string) => {
|
|
||||||
if (!audioB64 || !isMountedRef.current) return;
|
|
||||||
if (currentAudioRef.current) {
|
|
||||||
currentAudioRef.current.pause();
|
|
||||||
currentAudioRef.current = null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const audio = new Audio(`data:audio/mp3;base64,${audioB64}`);
|
|
||||||
currentAudioRef.current = audio;
|
|
||||||
audio.onended = () => {
|
|
||||||
currentAudioRef.current = null;
|
|
||||||
_emitTts('ended');
|
|
||||||
};
|
|
||||||
audio.play().then(() => {
|
|
||||||
_emitTts('playing');
|
|
||||||
}).catch(() => {
|
|
||||||
_emitTts('error');
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
_emitTts('error');
|
|
||||||
}
|
|
||||||
}, [_emitTts]);
|
|
||||||
|
|
||||||
const stopTts = useCallback(() => {
|
const stopTts = useCallback(() => {
|
||||||
if (currentAudioRef.current) {
|
ttsPlayback.stop();
|
||||||
currentAudioRef.current.pause();
|
}, [ttsPlayback]);
|
||||||
_emitTts('paused');
|
|
||||||
}
|
const pauseTts = useCallback(() => {
|
||||||
}, [_emitTts]);
|
ttsPlayback.pause();
|
||||||
|
}, [ttsPlayback]);
|
||||||
|
|
||||||
const resumeTts = useCallback(() => {
|
const resumeTts = useCallback(() => {
|
||||||
if (currentAudioRef.current && currentAudioRef.current.paused) {
|
ttsPlayback.resume();
|
||||||
currentAudioRef.current.play().then(() => {
|
}, [ttsPlayback]);
|
||||||
_emitTts('playing');
|
|
||||||
}).catch(() => {
|
|
||||||
_emitTts('error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [_emitTts]);
|
|
||||||
|
|
||||||
const hasAudioToResume = useCallback(() => {
|
const hasAudioToResume = useCallback(() => {
|
||||||
return !!(currentAudioRef.current && currentAudioRef.current.paused && currentAudioRef.current.currentTime > 0);
|
return ttsPlayback.isPaused;
|
||||||
}, []);
|
}, [ttsPlayback]);
|
||||||
|
|
||||||
const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => {
|
const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -196,7 +174,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setMessages(eventData.messages);
|
setMessages(eventData.messages);
|
||||||
}
|
}
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
}
|
}
|
||||||
if (eventType === 'complete') setIsStreaming(false);
|
if (eventType === 'complete') setIsStreaming(false);
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +188,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts');
|
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts');
|
||||||
}
|
}
|
||||||
}, [request, instanceId, _playTtsAudio]);
|
}, [request, instanceId, ttsPlayback.play]);
|
||||||
|
|
||||||
const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => {
|
const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -298,7 +276,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
return [...prev, msg];
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'status' && eventData) {
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
setStreamingStatus(eventData.label || null);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
|
|
@ -333,7 +311,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setActionLoading(null);
|
if (isMountedRef.current) setActionLoading(null);
|
||||||
}
|
}
|
||||||
}, [instanceId, selectedContextId, _playTtsAudio]);
|
}, [instanceId, selectedContextId, ttsPlayback.play]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (content: string) => {
|
const sendMessage = useCallback(async (content: string) => {
|
||||||
const normalizedContent = content.trim();
|
const normalizedContent = content.trim();
|
||||||
|
|
@ -343,10 +321,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
abortControllerRef.current = ac;
|
abortControllerRef.current = ac;
|
||||||
|
|
||||||
if (currentAudioRef.current) {
|
ttsPlayback.stop();
|
||||||
currentAudioRef.current.pause();
|
|
||||||
currentAudioRef.current = null;
|
|
||||||
}
|
|
||||||
await _unlockAudioForTts();
|
await _unlockAudioForTts();
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
@ -396,7 +371,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
});
|
});
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
setError(null);
|
setError(null);
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'status' && eventData) {
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
setStreamingStatus(eventData.label || null);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
|
|
@ -433,14 +408,11 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [instanceId, session, _playTtsAudio]);
|
}, [instanceId, session, ttsPlayback.play]);
|
||||||
|
|
||||||
const sendAudio = useCallback(async (audioBlob: Blob) => {
|
const sendAudio = useCallback(async (audioBlob: Blob) => {
|
||||||
if (!instanceId || !session) return;
|
if (!instanceId || !session) return;
|
||||||
if (currentAudioRef.current) {
|
ttsPlayback.stop();
|
||||||
currentAudioRef.current.pause();
|
|
||||||
currentAudioRef.current = null;
|
|
||||||
}
|
|
||||||
await _unlockAudioForTts();
|
await _unlockAudioForTts();
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
@ -474,7 +446,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
});
|
});
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
setError(null);
|
setError(null);
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
|
|
@ -585,8 +557,10 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
error, inputValue, setInputValue,
|
error, inputValue, setInputValue,
|
||||||
selectContext, createContext, archiveContext,
|
selectContext, createContext, archiveContext,
|
||||||
startSession: startSessionCb,
|
startSession: startSessionCb,
|
||||||
sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb,
|
sendMessage, sendAudio,
|
||||||
stopTts, resumeTts, hasAudioToResume,
|
completeSession: completeSessionCb, cancelSession: cancelSessionCb,
|
||||||
|
stopTts, pauseTts, resumeTts, hasAudioToResume,
|
||||||
|
ttsIsPlaying: ttsPlayback.isPlaying, ttsIsPaused: ttsPlayback.isPaused,
|
||||||
onTtsEventRef,
|
onTtsEventRef,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
toggleTaskStatus, addTask, removeTask,
|
toggleTaskStatus, addTask, removeTask,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import {
|
||||||
fetchFileById as fetchFileByIdApi,
|
fetchFileById as fetchFileByIdApi,
|
||||||
updateFile as updateFileApi,
|
updateFile as updateFileApi,
|
||||||
deleteFile as deleteFileApi,
|
deleteFile as deleteFileApi,
|
||||||
deleteFiles as deleteFilesApi
|
deleteFiles as deleteFilesApi,
|
||||||
|
type FolderInfo,
|
||||||
} from '../api/fileApi';
|
} from '../api/fileApi';
|
||||||
|
|
||||||
// File interfaces - exactly matching backend FileItem model
|
// File interfaces - exactly matching backend FileItem model
|
||||||
|
|
@ -479,7 +480,7 @@ export function useFileOperations() {
|
||||||
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
||||||
* - Upload should now work correctly
|
* - Upload should now work correctly
|
||||||
*/
|
*/
|
||||||
const handleFileUpload = async (file: globalThis.File, workflowId?: string) => {
|
const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadingFile(true);
|
setUploadingFile(true);
|
||||||
|
|
||||||
|
|
@ -500,6 +501,9 @@ export function useFileOperations() {
|
||||||
if (workflowId) {
|
if (workflowId) {
|
||||||
formData.append('workflowId', workflowId);
|
formData.append('workflowId', workflowId);
|
||||||
}
|
}
|
||||||
|
if (featureInstanceId) {
|
||||||
|
formData.append('featureInstanceId', featureInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
// FormData is now correctly configured for backend
|
// FormData is now correctly configured for backend
|
||||||
|
|
||||||
|
|
@ -966,3 +970,86 @@ export function useFileOperations() {
|
||||||
isLoading
|
isLoading
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder management hook ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useFolders() {
|
||||||
|
const [folders, setFolders] = useState<FolderInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/files/folders');
|
||||||
|
const data = Array.isArray(response.data) ? response.data : [];
|
||||||
|
setFolders(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load folders:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.post('/api/files/folders', { name, parentId: parentId || null });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/api/files/folders/${folderId}`, { name: newName });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleDeleteFolder = useCallback(async (folderId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Move failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Move failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
loading,
|
||||||
|
refresh,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,12 @@ export interface UserInputRequest {
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { WorkflowFile } from './playground/useDashboardInputForm';
|
export interface WorkflowFile {
|
||||||
|
id: string;
|
||||||
export { useWorkflows } from './playground/useWorkflows';
|
fileId: string;
|
||||||
export { useWorkflowOperations } from './playground/useWorkflowOperations';
|
fileName: string;
|
||||||
export { useWorkflowLifecycle } from './playground/useWorkflowLifecycle';
|
fileSize: number;
|
||||||
export { useDashboardInputForm, createDashboardHook } from './playground/useDashboardInputForm';
|
mimeType: string;
|
||||||
|
messageId?: string;
|
||||||
|
source?: 'user_uploaded' | 'ai_created';
|
||||||
|
}
|
||||||
|
|
|
||||||
198
src/hooks/useSpeechAudioCapture.ts
Normal file
198
src/hooks/useSpeechAudioCapture.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/**
|
||||||
|
* useVoiceStream — single hook for mic capture + STT streaming.
|
||||||
|
*
|
||||||
|
* Starts MediaRecorder, opens a WebSocket to the generic STT endpoint,
|
||||||
|
* sends audio chunks, and receives interim/final transcripts from
|
||||||
|
* Google Streaming Recognition on the backend.
|
||||||
|
*
|
||||||
|
* No client-side VAD, no segmentation, no recorder restarts.
|
||||||
|
* Google handles silence detection and endpoint natively.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export type VoiceStreamStatus = 'idle' | 'connecting' | 'listening' | 'error';
|
||||||
|
|
||||||
|
export interface VoiceStreamCallbacks {
|
||||||
|
onInterim?: (text: string) => void;
|
||||||
|
onFinal?: (text: string) => void;
|
||||||
|
onStatusChange?: (status: VoiceStreamStatus) => void;
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceStreamApi {
|
||||||
|
status: VoiceStreamStatus;
|
||||||
|
interimText: string;
|
||||||
|
start: (language?: string) => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _RECORDING_CHUNK_MS = 250;
|
||||||
|
const _MAX_RECONNECT_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi {
|
||||||
|
const [status, setStatus] = useState<VoiceStreamStatus>('idle');
|
||||||
|
const [interimText, setInterimText] = useState('');
|
||||||
|
|
||||||
|
const cbRef = useRef(callbacks);
|
||||||
|
cbRef.current = callbacks;
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const languageRef = useRef('de-DE');
|
||||||
|
const stoppingRef = useRef(false);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
|
||||||
|
const _setStatus = useCallback((next: VoiceStreamStatus) => {
|
||||||
|
setStatus(next);
|
||||||
|
cbRef.current.onStatusChange?.(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _pickMimeType = useCallback((): string => {
|
||||||
|
for (const mime of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']) {
|
||||||
|
try { if (MediaRecorder.isTypeSupported(mime)) return mime; } catch { /* skip */ }
|
||||||
|
}
|
||||||
|
throw new Error('No supported audio MIME type for MediaRecorder');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _closeWs = useCallback(() => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!ws) return;
|
||||||
|
wsRef.current = null;
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'close' }));
|
||||||
|
}
|
||||||
|
ws.close();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _stopRecorder = useCallback(() => {
|
||||||
|
const recorder = recorderRef.current;
|
||||||
|
if (recorder && recorder.state !== 'inactive') {
|
||||||
|
try { recorder.stop(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
recorderRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _releaseDevices = useCallback(() => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(t => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
stoppingRef.current = true;
|
||||||
|
_stopRecorder();
|
||||||
|
_closeWs();
|
||||||
|
_releaseDevices();
|
||||||
|
setInterimText('');
|
||||||
|
_setStatus('idle');
|
||||||
|
stoppingRef.current = false;
|
||||||
|
}, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]);
|
||||||
|
|
||||||
|
const start = useCallback(async (language?: string) => {
|
||||||
|
if (status === 'listening' || status === 'connecting') return;
|
||||||
|
stoppingRef.current = false;
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
languageRef.current = language || 'de-DE';
|
||||||
|
_setStatus('connecting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!streamRef.current) {
|
||||||
|
streamRef.current = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResp = await api.post('/voice-google/stt/token');
|
||||||
|
const wsToken: string = tokenResp.data.wsToken;
|
||||||
|
|
||||||
|
const baseURL = api.defaults.baseURL || window.location.origin;
|
||||||
|
const wsBase = baseURL.replace(/^http/i, 'ws');
|
||||||
|
const wsUrl = `${wsBase}/voice-google/stt/stream?wsToken=${encodeURIComponent(wsToken)}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (stoppingRef.current) { ws.close(); return; }
|
||||||
|
ws.send(JSON.stringify({ type: 'open', language: languageRef.current }));
|
||||||
|
|
||||||
|
const mimeType = _pickMimeType();
|
||||||
|
const recorder = new MediaRecorder(streamRef.current!, { mimeType });
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event: BlobEvent) => {
|
||||||
|
if (!event.data || event.data.size === 0) return;
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
const b64 = dataUrl.split(',')[1];
|
||||||
|
if (b64) ws.send(JSON.stringify({ type: 'audio', chunk: b64 }));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start(_RECORDING_CHUNK_MS);
|
||||||
|
_setStatus('listening');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'interim' && msg.text) {
|
||||||
|
setInterimText(msg.text);
|
||||||
|
cbRef.current.onInterim?.(msg.text);
|
||||||
|
} else if (msg.type === 'final' && msg.text) {
|
||||||
|
setInterimText('');
|
||||||
|
cbRef.current.onFinal?.(msg.text);
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error'));
|
||||||
|
} else if (msg.type === 'reconnect_required') {
|
||||||
|
if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) {
|
||||||
|
reconnectAttemptsRef.current++;
|
||||||
|
_closeWs();
|
||||||
|
start(languageRef.current).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (!stoppingRef.current) {
|
||||||
|
cbRef.current.onError?.(new Error('WebSocket connection error'));
|
||||||
|
_setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!stoppingRef.current) {
|
||||||
|
_setStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
cbRef.current.onError?.(err);
|
||||||
|
_setStatus('error');
|
||||||
|
_releaseDevices();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stoppingRef.current = true;
|
||||||
|
_stopRecorder();
|
||||||
|
_closeWs();
|
||||||
|
_releaseDevices();
|
||||||
|
};
|
||||||
|
}, [_stopRecorder, _closeWs, _releaseDevices]);
|
||||||
|
|
||||||
|
return { status, interimText, start, stop };
|
||||||
|
}
|
||||||
79
src/hooks/useTtsPlayback.ts
Normal file
79
src/hooks/useTtsPlayback.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* useTtsPlayback — central hook for TTS audio playback.
|
||||||
|
*
|
||||||
|
* Plays base64-encoded audio (MP3), manages current playback state,
|
||||||
|
* emits lifecycle events. Used by all features (CommCoach, Workspace, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type TtsEvent = 'playing' | 'paused' | 'ended' | 'error';
|
||||||
|
|
||||||
|
export interface TtsPlaybackCallbacks {
|
||||||
|
onPlaying?: () => void;
|
||||||
|
onPaused?: () => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TtsPlaybackApi {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
play: (base64Audio: string, format?: string) => void;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const cbRef = useRef(callbacks);
|
||||||
|
cbRef.current = callbacks;
|
||||||
|
|
||||||
|
const _emit = useCallback((event: TtsEvent) => {
|
||||||
|
if (event === 'playing') { setIsPlaying(true); setIsPaused(false); cbRef.current?.onPlaying?.(); }
|
||||||
|
else if (event === 'paused') { setIsPaused(true); cbRef.current?.onPaused?.(); }
|
||||||
|
else if (event === 'ended') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onEnded?.(); }
|
||||||
|
else if (event === 'error') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onError?.(); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const play = useCallback((base64Audio: string, format?: string) => {
|
||||||
|
if (!base64Audio) return;
|
||||||
|
stop();
|
||||||
|
try {
|
||||||
|
const mimeType = format === 'wav' ? 'audio/wav' : 'audio/mp3';
|
||||||
|
const audio = new Audio(`data:${mimeType};base64,${base64Audio}`);
|
||||||
|
audioRef.current = audio;
|
||||||
|
audio.onended = () => { audioRef.current = null; _emit('ended'); };
|
||||||
|
audio.onpause = () => { if (audioRef.current === audio && audio.currentTime < audio.duration) _emit('paused'); };
|
||||||
|
audio.play().then(() => _emit('playing')).catch(() => _emit('error'));
|
||||||
|
} catch {
|
||||||
|
_emit('error');
|
||||||
|
}
|
||||||
|
}, [stop, _emit]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (audioRef.current && !audioRef.current.paused) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (audioRef.current && audioRef.current.paused) {
|
||||||
|
audioRef.current.play().then(() => _emit('playing')).catch(() => _emit('error'));
|
||||||
|
}
|
||||||
|
}, [_emit]);
|
||||||
|
|
||||||
|
return { isPlaying, isPaused, play, pause, resume, stop };
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,6 @@ export interface PaginationParams {
|
||||||
/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */
|
/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */
|
||||||
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
|
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
|
||||||
if (!instanceId || !featureCode) return undefined;
|
if (!instanceId || !featureCode) return undefined;
|
||||||
if (featureCode === 'chatplayground') return `/api/chatplayground/${instanceId}`;
|
|
||||||
if (featureCode === 'automation') return `/api/automations/${instanceId}`;
|
if (featureCode === 'automation') return `/api/automations/${instanceId}`;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -276,12 +275,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
|
||||||
} else if (attr.type === 'textarea') {
|
} else if (attr.type === 'textarea') {
|
||||||
fieldType = 'textarea';
|
fieldType = 'textarea';
|
||||||
} else if (attr.type === 'text') {
|
} else if (attr.type === 'text') {
|
||||||
// Check if it should be textarea based on name
|
fieldType = (attr as any).multiline === true ? 'textarea' : 'string';
|
||||||
if (attr.name.toLowerCase().includes('description') || attr.name.toLowerCase().includes('note')) {
|
|
||||||
fieldType = 'textarea';
|
|
||||||
} else {
|
|
||||||
fieldType = 'string';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
|
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
|
||||||
// If needed, they should be handled via type casting: (attr as any).type === 'boolean'
|
// If needed, they should be handled via type casting: (attr as any).type === 'boolean'
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,12 @@ html, body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: var(--font-family, "DM Sans", sans-serif);
|
font-family: var(--font-family, "DM Sans", sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tr[data-highlighted="true"] {
|
||||||
|
animation: rowHighlight 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rowHighlight {
|
||||||
|
0% { background: rgba(25, 118, 210, 0.25); }
|
||||||
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,8 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
--mobile-topbar-height: 0px;
|
||||||
/* Let child components handle their own scrolling for sticky headers */
|
/* Let child components handle their own scrolling for sticky headers */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
|
|
@ -228,6 +230,10 @@
|
||||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
--mobile-topbar-height: 57px;
|
||||||
|
}
|
||||||
|
|
||||||
.mobileBackdrop {
|
.mobileBackdrop {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
|
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// INNER LAYOUT (mit Zugriff auf Store)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => {
|
||||||
className={styles.mobileLogo}
|
className={styles.mobileLogo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
||||||
|
|
||||||
|
<div style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : 'contents' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -718,11 +718,6 @@ export default {
|
||||||
'warning.duplicate_file.title': 'Datei bereits vorhanden',
|
'warning.duplicate_file.title': 'Datei bereits vorhanden',
|
||||||
'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.',
|
'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 Page
|
||||||
'automations.title': 'Automatisierungen',
|
'automations.title': 'Automatisierungen',
|
||||||
'automations.description': 'Workflow-Automatisierungen verwalten',
|
'automations.description': 'Workflow-Automatisierungen verwalten',
|
||||||
|
|
|
||||||
|
|
@ -718,11 +718,6 @@ export default {
|
||||||
'warning.duplicate_file.title': 'File Already Exists',
|
'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.',
|
'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 Page
|
||||||
'automations.title': 'Automations',
|
'automations.title': 'Automations',
|
||||||
'automations.description': 'Manage workflow automations',
|
'automations.description': 'Manage workflow automations',
|
||||||
|
|
|
||||||
|
|
@ -718,11 +718,6 @@ export default {
|
||||||
'warning.duplicate_file.title': 'Fichier Déjà Existant',
|
'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é.',
|
'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 Page
|
||||||
'automations.title': 'Automatisations',
|
'automations.title': 'Automatisations',
|
||||||
'automations.description': 'Gérer les automatisations de workflow',
|
'automations.description': 'Gérer les automatisations de workflow',
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,13 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
|
||||||
// RealEstate Views
|
// RealEstate Views
|
||||||
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
// Chat Playground Views (reusing existing workflow pages)
|
|
||||||
import { PlaygroundPage, WorkflowsPage } from './workflows';
|
|
||||||
|
|
||||||
// Automation Views
|
// Automation Views
|
||||||
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
|
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
|
||||||
|
|
||||||
// CodeEditor Views
|
// Workspace Views
|
||||||
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
|
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||||
|
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||||
|
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||||
|
|
||||||
// Teamsbot Views
|
// Teamsbot Views
|
||||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
|
|
@ -124,18 +123,15 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
dashboard: RealEstatePekView,
|
dashboard: RealEstatePekView,
|
||||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
},
|
},
|
||||||
chatplayground: {
|
|
||||||
playground: PlaygroundPage,
|
|
||||||
workflows: WorkflowsPage,
|
|
||||||
},
|
|
||||||
automation: {
|
automation: {
|
||||||
definitions: AutomationDefinitionsView,
|
definitions: AutomationDefinitionsView,
|
||||||
templates: AutomationTemplatesView,
|
templates: AutomationTemplatesView,
|
||||||
logs: AutomationLogsView,
|
logs: AutomationLogsView,
|
||||||
},
|
},
|
||||||
codeeditor: {
|
workspace: {
|
||||||
editor: CodeEditorPage,
|
dashboard: WorkspacePage,
|
||||||
workflows: CodeEditorWorkflowsPage,
|
editor: WorkspaceEditorPage,
|
||||||
|
settings: WorkspaceSettingsPage,
|
||||||
},
|
},
|
||||||
teamsbot: {
|
teamsbot: {
|
||||||
dashboard: TeamsbotDashboardView,
|
dashboard: TeamsbotDashboardView,
|
||||||
|
|
@ -199,6 +195,12 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return <AccessDenied />;
|
return <AccessDenied />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
||||||
|
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
|
||||||
|
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// View-Komponente finden
|
// View-Komponente finden
|
||||||
const featureViews = VIEW_COMPONENTS[featureCode];
|
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||||
if (!featureViews) {
|
if (!featureViews) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
|
|
||||||
font-family: "DM Sans", sans-serif;
|
font-family: "DM Sans", sans-serif;
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports not (min-height: 100dvh) {
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 3rem;
|
padding: 2.5rem 2rem;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,39 +23,28 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoText {
|
.logo {
|
||||||
|
|
||||||
font-size: 35px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
letter-spacing: -0.5px;
|
width: 100%;
|
||||||
font-weight: 200;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoPower {
|
.logoImage {
|
||||||
color: var(--color-text);
|
height: 44px;
|
||||||
}
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
.logoOn {
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo img {
|
|
||||||
height: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginBox {
|
.loginBox {
|
||||||
|
|
||||||
|
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
width: 25%;
|
width: min(100%, 460px);
|
||||||
height: auto;
|
height: auto;
|
||||||
|
margin-top: 2rem;
|
||||||
margin-top: 5%;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
|
|
@ -297,3 +291,41 @@ button:disabled {
|
||||||
.passwordResetLink .textButton:hover {
|
.passwordResetLink .textButton:hover {
|
||||||
color: var(--color-secondary);
|
color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImage {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registerLink {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.button {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,11 @@ function Login() {
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<div className={styles.logoText}>
|
<img
|
||||||
<span className={styles.logoPower}>Power</span>
|
src="/logos/poweron-logo.png"
|
||||||
<span className={styles.logoOn}>On</span>
|
alt="PowerOn"
|
||||||
</div>
|
className={styles.logoImage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
|
|
||||||
font-family: "DM Sans", sans-serif;
|
font-family: "DM Sans", sans-serif;
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports not (min-height: 100dvh) {
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 3rem;
|
padding: 2.5rem 2rem;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,39 +23,28 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoText {
|
.logo {
|
||||||
|
|
||||||
font-size: 35px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
letter-spacing: -0.5px;
|
width: 100%;
|
||||||
font-weight: 200;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoPower {
|
.logoImage {
|
||||||
color: var(--color-text);
|
height: 44px;
|
||||||
}
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
.logoOn {
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo img {
|
|
||||||
height: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginBox {
|
.loginBox {
|
||||||
|
|
||||||
|
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
width: 25%;
|
width: min(100%, 460px);
|
||||||
height: auto;
|
height: auto;
|
||||||
|
margin-top: 2rem;
|
||||||
margin-top: 5%;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
|
|
@ -240,3 +234,41 @@ button:disabled {
|
||||||
.infoMessage p {
|
.infoMessage p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImage {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registerLink {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.button {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,11 @@ function PasswordResetRequest() {
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<div className={styles.logoText}>
|
<img
|
||||||
<span className={styles.logoPower}>Power</span>
|
src="/logos/poweron-logo.png"
|
||||||
<span className={styles.logoOn}>On</span>
|
alt="PowerOn"
|
||||||
</div>
|
className={styles.logoImage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
|
|
||||||
font-family: "DM Sans", sans-serif;
|
font-family: "DM Sans", sans-serif;
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports not (min-height: 100dvh) {
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 3rem;
|
padding: 2.5rem 2rem;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,39 +23,28 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoText {
|
.logo {
|
||||||
|
|
||||||
font-size: 35px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
letter-spacing: -0.5px;
|
width: 100%;
|
||||||
font-weight: 200;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoPower {
|
.logoImage {
|
||||||
color: var(--color-text);
|
height: 44px;
|
||||||
}
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
.logoOn {
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo img {
|
|
||||||
height: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginBox {
|
.loginBox {
|
||||||
|
|
||||||
|
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
width: 25%;
|
width: min(100%, 460px);
|
||||||
height: auto;
|
height: auto;
|
||||||
|
margin-top: 2rem;
|
||||||
margin-top: 5%;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
|
|
@ -273,3 +267,41 @@ button:disabled {
|
||||||
.infoMessage p {
|
.infoMessage p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImage {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registerLink {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.button {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,10 +137,11 @@ function Register() {
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<div className={styles.logoText}>
|
<img
|
||||||
<span className={styles.logoPower}>Power</span>
|
src="/logos/poweron-logo.png"
|
||||||
<span className={styles.logoOn}>On</span>
|
alt="PowerOn"
|
||||||
</div>
|
className={styles.logoImage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
|
|
||||||
font-family: "DM Sans", sans-serif;
|
font-family: "DM Sans", sans-serif;
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports not (min-height: 100dvh) {
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 3rem;
|
padding: 2.5rem 2rem;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,39 +23,28 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoText {
|
.logo {
|
||||||
|
|
||||||
font-size: 35px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
letter-spacing: -0.5px;
|
width: 100%;
|
||||||
font-weight: 200;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoPower {
|
.logoImage {
|
||||||
color: var(--color-text);
|
height: 44px;
|
||||||
}
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
.logoOn {
|
|
||||||
color: var(--color-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo img {
|
|
||||||
height: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginBox {
|
.loginBox {
|
||||||
|
|
||||||
|
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
width: 25%;
|
width: min(100%, 460px);
|
||||||
height: auto;
|
height: auto;
|
||||||
|
margin-top: 2rem;
|
||||||
margin-top: 5%;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
|
|
@ -234,3 +228,41 @@ button:disabled {
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImage {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registerLink {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.mainContent {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBox {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.button {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,11 @@ function Reset() {
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<div className={styles.logoText}>
|
<img
|
||||||
<span className={styles.logoPower}>Power</span>
|
src="/logos/poweron-logo.png"
|
||||||
<span className={styles.logoOn}>On</span>
|
alt="PowerOn"
|
||||||
</div>
|
className={styles.logoImage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
|
|
@ -135,10 +136,11 @@ function Reset() {
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<div className={styles.logoText}>
|
<img
|
||||||
<span className={styles.logoPower}>Power</span>
|
src="/logos/poweron-logo.png"
|
||||||
<span className={styles.logoOn}>On</span>
|
alt="PowerOn"
|
||||||
</div>
|
className={styles.logoImage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature } from '../api/storeApi';
|
||||||
|
|
@ -15,8 +15,6 @@ import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
automation: <FaCogs />,
|
automation: <FaCogs />,
|
||||||
chatplayground: <FaComments />,
|
|
||||||
codeeditor: <FaFileAlt />,
|
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -26,16 +24,6 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
||||||
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
||||||
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
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: {
|
teamsbot: {
|
||||||
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,34 @@
|
||||||
border-color: var(--text-secondary);
|
border-color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.googleButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #4285f4;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.googleButton:hover {
|
||||||
|
background: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.googleButton:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.googleButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Filter Section Styles */
|
/* Filter Section Styles */
|
||||||
.filterSection {
|
.filterSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.googleButton}
|
||||||
onClick={handleCreateGoogle}
|
onClick={handleCreateGoogle}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
|
|
@ -255,7 +255,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.googleButton}
|
||||||
onClick={handleCreateGoogle}
|
onClick={handleCreateGoogle}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* FilesPage
|
* FilesPage
|
||||||
*
|
*
|
||||||
* Page for file management using FormGeneratorTable.
|
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right.
|
||||||
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
|
* Uses useResizablePanels for the divider.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import api from '../../api';
|
||||||
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
||||||
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye } from 'react-icons/fa';
|
import FolderTree from '../../components/FolderTree/FolderTree';
|
||||||
|
import type { FileNode } from '../../components/FolderTree/FolderTree';
|
||||||
|
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||||
|
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -18,19 +23,29 @@ interface UserFile {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
|
folderId?: string | null;
|
||||||
|
featureInstanceId?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilesPage: React.FC = () => {
|
export const FilesPage: React.FC = () => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
leftWidth, isDragging, handleMouseDown, containerRef,
|
||||||
|
} = useResizablePanels({
|
||||||
|
storageKey: 'filesPage-panelWidth',
|
||||||
|
defaultLeftWidth: 22,
|
||||||
|
minLeftWidth: 15,
|
||||||
|
maxLeftWidth: 40,
|
||||||
|
});
|
||||||
|
|
||||||
// Data hook
|
|
||||||
const {
|
const {
|
||||||
data: files,
|
data: files,
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
|
@ -38,7 +53,6 @@ export const FilesPage: React.FC = () => {
|
||||||
updateFileOptimistically,
|
updateFileOptimistically,
|
||||||
} = useUserFiles();
|
} = useUserFiles();
|
||||||
|
|
||||||
// Operations hook
|
|
||||||
const {
|
const {
|
||||||
handleFileDownload,
|
handleFileDownload,
|
||||||
handleFileDelete,
|
handleFileDelete,
|
||||||
|
|
@ -53,16 +67,62 @@ export const FilesPage: React.FC = () => {
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
|
const {
|
||||||
|
folders,
|
||||||
|
refreshFolders,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFolders,
|
||||||
|
handleMoveFile,
|
||||||
|
handleMoveFiles: contextMoveFiles,
|
||||||
|
handleDownloadFolder,
|
||||||
|
expandedFolderIds,
|
||||||
|
toggleFolderExpanded,
|
||||||
|
} = useFileContext();
|
||||||
|
|
||||||
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
||||||
|
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Initial fetch
|
useEffect(() => { refetch(); }, []);
|
||||||
useEffect(() => {
|
|
||||||
refetch();
|
const treeFileNodes: FileNode[] = useMemo(() => {
|
||||||
}, []);
|
if (!files) return [];
|
||||||
|
return files.map((f: UserFile) => ({
|
||||||
|
id: f.id,
|
||||||
|
fileName: f.fileName,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
fileSize: f.fileSize,
|
||||||
|
folderId: f.folderId ?? null,
|
||||||
|
}));
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const _handleTreeFileSelect = useCallback((fileId: string) => {
|
||||||
|
const file = files?.find((f: UserFile) => f.id === fileId);
|
||||||
|
if (file) {
|
||||||
|
setSelectedFolderId(file.folderId ?? null);
|
||||||
|
setHighlightedFileId(fileId);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const row = document.querySelector('tr[data-highlighted="true"]');
|
||||||
|
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
setTimeout(() => setHighlightedFileId(null), 2500);
|
||||||
|
}
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const filteredFiles = useMemo(() => {
|
||||||
|
if (!files) return [];
|
||||||
|
if (selectedFolderId === null) {
|
||||||
|
return files.filter((f: UserFile) => !f.folderId);
|
||||||
|
}
|
||||||
|
return files.filter((f: UserFile) => f.folderId === selectedFolderId);
|
||||||
|
}, [files, selectedFolderId]);
|
||||||
|
|
||||||
// Generate columns from attributes - hide internal fields
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const hiddenColumns = ['id', 'mandateId', 'featureInstanceId', 'fileHash'];
|
const hiddenColumns = ['id', 'mandateId', 'fileHash', 'folderId'];
|
||||||
|
|
||||||
const cols = (attributes || [])
|
const cols = (attributes || [])
|
||||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||||
|
|
@ -76,9 +136,10 @@ export const FilesPage: React.FC = () => {
|
||||||
width: attr.width || 150,
|
width: attr.width || 150,
|
||||||
minWidth: attr.minWidth || 100,
|
minWidth: attr.minWidth || 100,
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
fkSource: (attr as any).fkSource,
|
||||||
|
fkDisplayField: (attr as any).fkDisplayField,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add _createdBy column with FK resolution to show username
|
|
||||||
cols.push({
|
cols.push({
|
||||||
key: '_createdBy',
|
key: '_createdBy',
|
||||||
label: 'Created By',
|
label: 'Created By',
|
||||||
|
|
@ -94,20 +155,15 @@ export const FilesPage: React.FC = () => {
|
||||||
return cols;
|
return cols;
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
// Check permissions
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
const canUpdate = permissions?.update !== 'n';
|
const canUpdate = permissions?.update !== 'n';
|
||||||
const canDelete = permissions?.delete !== 'n';
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
// Handle edit click
|
|
||||||
const handleEditClick = async (file: UserFile) => {
|
const handleEditClick = async (file: UserFile) => {
|
||||||
const fullFile = await fetchFileById(file.id);
|
const fullFile = await fetchFileById(file.id);
|
||||||
if (fullFile) {
|
if (fullFile) setEditingFile(fullFile as UserFile);
|
||||||
setEditingFile(fullFile as UserFile);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle edit submit
|
|
||||||
const handleEditSubmit = async (data: Partial<UserFile>) => {
|
const handleEditSubmit = async (data: Partial<UserFile>) => {
|
||||||
if (!editingFile) return;
|
if (!editingFile) return;
|
||||||
const result = await handleFileUpdate(editingFile.id, {
|
const result = await handleFileUpdate(editingFile.id, {
|
||||||
|
|
@ -119,29 +175,21 @@ export const FilesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete single file (confirmation handled by DeleteActionButton)
|
|
||||||
const handleDelete = async (file: UserFile) => {
|
const handleDelete = async (file: UserFile) => {
|
||||||
const success = await handleFileDelete(file.id);
|
const success = await handleFileDelete(file.id);
|
||||||
if (success) {
|
if (success) refetch();
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete multiple files (confirmation handled by FormGenerator)
|
|
||||||
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
|
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
|
||||||
const ids = filesToDelete.map(f => f.id);
|
const ids = filesToDelete.map(f => f.id);
|
||||||
const success = await handleFileDeleteMultiple(ids);
|
const success = await handleFileDeleteMultiple(ids);
|
||||||
if (success) {
|
if (success) refetch();
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle download
|
|
||||||
const handleDownload = async (file: UserFile) => {
|
const handleDownload = async (file: UserFile) => {
|
||||||
await handleFileDownload(file.id, file.fileName);
|
await handleFileDownload(file.id, file.fileName);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle preview
|
|
||||||
const handlePreview = async (file: UserFile) => {
|
const handlePreview = async (file: UserFile) => {
|
||||||
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
|
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
|
||||||
if (result.success && result.previewUrl) {
|
if (result.success && result.previewUrl) {
|
||||||
|
|
@ -149,36 +197,19 @@ export const FilesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle upload click
|
const handleUploadClick = () => { fileInputRef.current?.click(); };
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle file selection
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFiles = e.target.files;
|
const selectedFiles = e.target.files;
|
||||||
if (selectedFiles && selectedFiles.length > 0) {
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const file of Array.from(selectedFiles)) {
|
for (const file of Array.from(selectedFiles)) {
|
||||||
const result = await handleFileUpload(file);
|
const result = await handleFileUpload(file);
|
||||||
if (result?.success) {
|
if (result?.success) successCount++; else errorCount++;
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
errorCount++;
|
|
||||||
}
|
}
|
||||||
}
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
|
||||||
// Reset input first
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh table to show new files
|
|
||||||
await refetch();
|
await refetch();
|
||||||
|
|
||||||
// Show feedback
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
showSuccess(
|
showSuccess(
|
||||||
'Upload erfolgreich',
|
'Upload erfolgreich',
|
||||||
|
|
@ -190,11 +221,75 @@ export const FilesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form attributes for edit modal
|
const _handleNewFolder = useCallback(async () => {
|
||||||
|
const name = prompt('Neuer Ordnername:');
|
||||||
|
if (name?.trim()) {
|
||||||
|
await handleCreateFolder(name.trim(), selectedFolderId);
|
||||||
|
}
|
||||||
|
}, [handleCreateFolder, selectedFolderId]);
|
||||||
|
|
||||||
|
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
||||||
|
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||||
|
if (isInSelection && selectedFiles.length > 1) {
|
||||||
|
const ids = selectedFiles.map(f => f.id);
|
||||||
|
e.dataTransfer.setData('application/file-ids', JSON.stringify(ids));
|
||||||
|
} else {
|
||||||
|
e.dataTransfer.setData('application/file-id', row.id);
|
||||||
|
}
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}, [selectedFiles]);
|
||||||
|
|
||||||
|
const _handleMoveFilePage = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
||||||
|
await handleMoveFile(fileId, targetFolderId);
|
||||||
|
await refetch();
|
||||||
|
}, [handleMoveFile, refetch]);
|
||||||
|
|
||||||
|
const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
||||||
|
await contextMoveFiles(fileIds, targetFolderId);
|
||||||
|
await refetch();
|
||||||
|
}, [contextMoveFiles, refetch]);
|
||||||
|
|
||||||
|
const _handleRenameFile = useCallback(async (fileId: string, newName: string) => {
|
||||||
|
await handleFileUpdate(fileId, { fileName: newName });
|
||||||
|
await refetch();
|
||||||
|
}, [handleFileUpdate, refetch]);
|
||||||
|
|
||||||
|
const _handleDeleteTreeFile = useCallback(async (fileId: string) => {
|
||||||
|
await handleFileDelete(fileId);
|
||||||
|
await refetch();
|
||||||
|
}, [handleFileDelete, refetch]);
|
||||||
|
|
||||||
|
const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => {
|
||||||
|
await handleFileDeleteMultiple(fileIds);
|
||||||
|
await refetch();
|
||||||
|
}, [handleFileDeleteMultiple, refetch]);
|
||||||
|
|
||||||
|
const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => {
|
||||||
|
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
|
||||||
|
await refreshFolders();
|
||||||
|
await refetch();
|
||||||
|
}, [refreshFolders, refetch]);
|
||||||
|
|
||||||
|
const _handleTreeRefresh = useCallback(async () => {
|
||||||
|
await refetch();
|
||||||
|
await refreshFolders();
|
||||||
|
}, [refetch, refreshFolders]);
|
||||||
|
|
||||||
|
const _tableRefetch = useCallback(async (params?: any) => {
|
||||||
|
const nextParams = { ...(params || {}) };
|
||||||
|
const nextFilters = { ...(nextParams.filters || {}) };
|
||||||
|
nextFilters.folderId = selectedFolderId;
|
||||||
|
nextParams.filters = nextFilters;
|
||||||
|
await refetch(nextParams);
|
||||||
|
}, [refetch, selectedFolderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_tableRefetch({ page: 1, pageSize: 25 });
|
||||||
|
}, [selectedFolderId, _tableRefetch]);
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
||||||
return (attributes || [])
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
.filter(attr => !excludedFields.includes(attr.name));
|
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -213,7 +308,6 @@ export const FilesPage: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={styles.adminPage}>
|
||||||
{/* Hidden file input */}
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -228,51 +322,116 @@ export const FilesPage: React.FC = () => {
|
||||||
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
|
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}>
|
||||||
className={styles.secondaryButton}
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
{canCreate && (
|
</div>
|
||||||
<button
|
</div>
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleUploadClick}
|
{/* Split-view container */}
|
||||||
disabled={uploadingFile}
|
<div
|
||||||
|
ref={containerRef as React.RefObject<HTMLDivElement>}
|
||||||
|
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
|
||||||
>
|
>
|
||||||
|
{/* Left panel: FolderTree */}
|
||||||
|
<div style={{
|
||||||
|
width: `${leftWidth}%`,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'auto',
|
||||||
|
borderRight: '1px solid var(--color-border, #e0e0e0)',
|
||||||
|
padding: '8px 4px',
|
||||||
|
}}>
|
||||||
|
<FolderTree
|
||||||
|
folders={folders}
|
||||||
|
files={treeFileNodes}
|
||||||
|
showFiles={true}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
onSelect={setSelectedFolderId}
|
||||||
|
onFileSelect={_handleTreeFileSelect}
|
||||||
|
selectedItemIds={treeSelectedIds}
|
||||||
|
onSelectionChange={setTreeSelectedIds}
|
||||||
|
expandedIds={expandedFolderIds}
|
||||||
|
onToggleExpand={toggleFolderExpanded}
|
||||||
|
onRefresh={_handleTreeRefresh}
|
||||||
|
onCreateFolder={handleCreateFolder}
|
||||||
|
onRenameFolder={handleRenameFolder}
|
||||||
|
onDeleteFolder={async (folderId) => {
|
||||||
|
await handleDeleteFolder(folderId);
|
||||||
|
if (selectedFolderId === folderId) setSelectedFolderId(null);
|
||||||
|
await refetch();
|
||||||
|
}}
|
||||||
|
onMoveFolder={handleMoveFolder}
|
||||||
|
onMoveFolders={handleMoveFolders}
|
||||||
|
onMoveFile={_handleMoveFilePage}
|
||||||
|
onMoveFiles={_handleMoveFiles}
|
||||||
|
onRenameFile={_handleRenameFile}
|
||||||
|
onDeleteFile={_handleDeleteTreeFile}
|
||||||
|
onDeleteFiles={_handleDeleteTreeFiles}
|
||||||
|
onDeleteFolders={_handleDeleteTreeFolders}
|
||||||
|
onDownloadFolder={handleDownloadFolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizable divider */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
background: isDragging ? 'var(--color-primary, #1976d2)' : 'transparent',
|
||||||
|
transition: isDragging ? 'none' : 'background 0.15s',
|
||||||
|
flexShrink: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }}
|
||||||
|
onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right panel: File table */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Toolbar above table */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 8, padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e0e0e0)',
|
||||||
|
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<button className={styles.secondaryButton} onClick={_handleNewFolder}>
|
||||||
|
<FaFolderPlus /> Neuer Ordner
|
||||||
|
</button>
|
||||||
|
{canCreate && (
|
||||||
|
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||||
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
|
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
{/* Table content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{loading && (!files || files.length === 0) ? (
|
{loading && (!files || files.length === 0) ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>Lade Dateien...</span>
|
<span>Lade Dateien...</span>
|
||||||
</div>
|
</div>
|
||||||
) : !files || files.length === 0 ? (
|
) : filteredFiles.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaFolder className={styles.emptyIcon} />
|
<FaFolder className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>Keine Dateien vorhanden</h3>
|
<h3 className={styles.emptyTitle}>
|
||||||
|
{selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'}
|
||||||
|
</h3>
|
||||||
<p className={styles.emptyDescription}>
|
<p className={styles.emptyDescription}>
|
||||||
Laden Sie eine Datei hoch, um loszulegen.
|
{selectedFolderId
|
||||||
|
? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.'
|
||||||
|
: 'Laden Sie eine Datei hoch, um loszulegen.'}
|
||||||
</p>
|
</p>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button
|
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||||
className={styles.primaryButton}
|
<FaUpload /> Datei hochladen
|
||||||
onClick={handleUploadClick}
|
|
||||||
disabled={uploadingFile}
|
|
||||||
>
|
|
||||||
<FaUpload /> Erste Datei hochladen
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={files}
|
data={filteredFiles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/files/list"
|
apiEndpoint="/api/files/list"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
@ -282,6 +441,12 @@ export const FilesPage: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
||||||
|
rowDraggable={true}
|
||||||
|
onRowDragStart={_onRowDragStart}
|
||||||
|
getRowDataAttributes={(row: UserFile) =>
|
||||||
|
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
||||||
|
}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
...(canUpdate ? [{
|
...(canUpdate ? [{
|
||||||
type: 'edit' as const,
|
type: 'edit' as const,
|
||||||
|
|
@ -313,9 +478,8 @@ export const FilesPage: React.FC = () => {
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
onDeleteMultiple={handleDeleteMultiple}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch: _tableRefetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically: updateFileOptimistically,
|
updateOptimistically: updateFileOptimistically,
|
||||||
|
|
@ -324,6 +488,8 @@ export const FilesPage: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingFile && (
|
{editingFile && (
|
||||||
|
|
@ -331,12 +497,7 @@ export const FilesPage: React.FC = () => {
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
|
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
|
||||||
<button
|
<button className={styles.modalClose} onClick={() => setEditingFile(null)}>✕</button>
|
||||||
className={styles.modalClose}
|
|
||||||
onClick={() => setEditingFile(null)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributes.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||||
import { useAdminMandates } from '../../hooks/useMandates';
|
import { useAdminMandates } from '../../hooks/useMandates';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
const _formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CHF'
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MANDATE SELECTOR
|
// MANDATE SELECTOR
|
||||||
|
|
@ -195,18 +199,18 @@ interface CreditAdderProps {
|
||||||
settings: BillingSettings | null;
|
settings: BillingSettings | null;
|
||||||
accounts: AccountSummary[];
|
accounts: AccountSummary[];
|
||||||
users: MandateUserSummary[];
|
users: MandateUserSummary[];
|
||||||
onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>;
|
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onCreateCheckout }) => {
|
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||||
const [amount, setAmount] = useState<number>(10);
|
const [amount, setAmount] = useState<string>('');
|
||||||
|
const [description, setDescription] = useState<string>('Manuelles Aufladen durch Admin');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
|
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
|
||||||
|
|
||||||
// Map accounts by userId for balance lookup
|
|
||||||
const accountsByUserId = accounts
|
const accountsByUserId = accounts
|
||||||
.filter(acc => acc.accountType === 'USER')
|
.filter(acc => acc.accountType === 'USER')
|
||||||
.reduce((map, acc) => {
|
.reduce((map, acc) => {
|
||||||
|
|
@ -214,9 +218,10 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
return map;
|
return map;
|
||||||
}, {} as Record<string, AccountSummary>);
|
}, {} as Record<string, AccountSummary>);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (amount <= 0) {
|
const numAmount = parseFloat(amount);
|
||||||
|
if (!numAmount || numAmount <= 0) {
|
||||||
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
|
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -225,24 +230,19 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount);
|
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
||||||
window.location.href = redirectUrl;
|
setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
|
||||||
|
setAmount('');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
|
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
|
||||||
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('de-CH', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'CHF'
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminSection}>
|
<div className={styles.adminSection}>
|
||||||
<h3>Guthaben aufladen</h3>
|
<h3>Guthaben manuell aufladen</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||||
|
|
@ -250,7 +250,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={_handleSubmit}>
|
||||||
{isPrepayUser && (
|
{isPrepayUser && (
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
|
|
@ -264,7 +264,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
<option value="">-- Benutzer wählen --</option>
|
<option value="">-- Benutzer wählen --</option>
|
||||||
{users.map((user) => {
|
{users.map((user) => {
|
||||||
const account = accountsByUserId[user.id];
|
const account = accountsByUserId[user.id];
|
||||||
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)';
|
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
|
||||||
return (
|
return (
|
||||||
<option key={user.id} value={user.id}>
|
<option key={user.id} value={user.id}>
|
||||||
{user.displayName || user.username || user.id}{balanceInfo}
|
{user.displayName || user.username || user.id}{balanceInfo}
|
||||||
|
|
@ -279,27 +279,35 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>Betrag (CHF)</label>
|
<label>Betrag (CHF)</label>
|
||||||
<select
|
<input
|
||||||
className={styles.select}
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(Number(e.target.value))}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="z.B. 50"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
</div>
|
||||||
<option key={preset} value={preset}>
|
<div className={styles.formGroup}>
|
||||||
{preset} CHF
|
<label>Beschreibung</label>
|
||||||
</option>
|
<input
|
||||||
))}
|
type="text"
|
||||||
</select>
|
className={styles.input}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Beschreibung der Gutschrift"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
disabled={saving || (isPrepayUser && !selectedUserId)}
|
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
||||||
>
|
>
|
||||||
{saving ? 'Weiterleitung zu Stripe...' : 'Mit Stripe aufladen'}
|
{saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -370,18 +378,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const BillingAdmin: React.FC = () => {
|
export const BillingAdmin: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||||
const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
|
const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
|
||||||
|
|
||||||
const successParam = searchParams.get('success');
|
|
||||||
const canceledParam = searchParams.get('canceled');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (successParam === 'true' && selectedMandateId) {
|
|
||||||
loadAccounts();
|
|
||||||
}
|
|
||||||
}, [successParam, selectedMandateId, loadAccounts]);
|
|
||||||
|
|
||||||
const handleMandateSelect = (mandateId: string) => {
|
const handleMandateSelect = (mandateId: string) => {
|
||||||
setSelectedMandateId(mandateId || null);
|
setSelectedMandateId(mandateId || null);
|
||||||
|
|
@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => {
|
||||||
await saveSettings(settingsUpdate);
|
await saveSettings(settingsUpdate);
|
||||||
}, [selectedMandateId, saveSettings]);
|
}, [selectedMandateId, saveSettings]);
|
||||||
|
|
||||||
const handleCreateCheckout = useCallback(async (userId: string | undefined, amount: number) => {
|
const _handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => {
|
||||||
if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt');
|
if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt');
|
||||||
const result = await createCheckout({ userId, amount });
|
const result = await addCredit({ userId, amount, description });
|
||||||
if (!result) throw new Error('Checkout konnte nicht erstellt werden');
|
if (!result) throw new Error('Gutschrift konnte nicht erstellt werden');
|
||||||
|
await loadAccounts();
|
||||||
return result;
|
return result;
|
||||||
}, [selectedMandateId, createCheckout]);
|
}, [selectedMandateId, addCredit, loadAccounts]);
|
||||||
|
|
||||||
const clearStripeParams = useCallback(() => {
|
|
||||||
searchParams.delete('success');
|
|
||||||
searchParams.delete('canceled');
|
|
||||||
searchParams.delete('session_id');
|
|
||||||
setSearchParams(searchParams, { replace: true });
|
|
||||||
}, [searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
|
|
@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {
|
||||||
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
|
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{successParam === 'true' && (
|
|
||||||
<div className={styles.successMessage} style={{ marginBottom: '1rem' }}>
|
|
||||||
Zahlung erfolgreich. Guthaben wird gutgeschrieben.
|
|
||||||
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{canceledParam === 'true' && (
|
|
||||||
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
|
|
||||||
Zahlung abgebrochen.
|
|
||||||
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<MandateSelector
|
<MandateSelector
|
||||||
selectedMandateId={selectedMandateId}
|
selectedMandateId={selectedMandateId}
|
||||||
|
|
@ -445,7 +424,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
settings={settings}
|
settings={settings}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
users={users}
|
users={users}
|
||||||
onCreateCheckout={handleCreateCheckout}
|
onAddCredit={_handleAddCredit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountsOverview
|
<AccountsOverview
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,19 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
||||||
import { UserTransaction } from '../../api/billingApi';
|
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
|
||||||
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
|
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER: Currency formatter
|
// HELPER: Currency formatter
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -47,9 +52,14 @@ interface ViewStatistics {
|
||||||
|
|
||||||
interface BalanceCardProps {
|
interface BalanceCardProps {
|
||||||
balance: BillingBalance;
|
balance: BillingBalance;
|
||||||
|
onCheckout?: (mandateId: string, amount: number) => void;
|
||||||
|
checkoutLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]);
|
||||||
|
const [showCheckout, setShowCheckout] = useState(false);
|
||||||
|
|
||||||
const _getBillingModelLabel = (model: string) => {
|
const _getBillingModelLabel = (model: string) => {
|
||||||
switch (model) {
|
switch (model) {
|
||||||
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
||||||
|
|
@ -60,6 +70,10 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canTopUp = balance.billingModel === 'PREPAY_USER'
|
||||||
|
|| balance.billingModel === 'PREPAY_MANDATE'
|
||||||
|
|| balance.billingModel === 'CREDIT_POSTPAY';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
|
|
@ -74,6 +88,47 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
||||||
Niedriges Guthaben
|
Niedriges Guthaben
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canTopUp && onCheckout && (
|
||||||
|
<div style={{ marginTop: '12px' }}>
|
||||||
|
{!showCheckout ? (
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
|
||||||
|
onClick={() => setShowCheckout(true)}
|
||||||
|
>
|
||||||
|
Budget laden mit Kreditkarte
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={selectedAmount}
|
||||||
|
onChange={(e) => setSelectedAmount(Number(e.target.value))}
|
||||||
|
style={{ flex: 1, fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
||||||
|
<option key={preset} value={preset}>{preset} CHF</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
|
||||||
|
disabled={checkoutLoading}
|
||||||
|
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
|
||||||
|
>
|
||||||
|
{checkoutLoading ? 'Laden...' : 'Zahlen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.buttonSecondary || ''}`}
|
||||||
|
style={{ fontSize: '13px', padding: '6px 12px' }}
|
||||||
|
onClick={() => setShowCheckout(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
||||||
|
|
||||||
export const BillingDataView: React.FC = () => {
|
export const BillingDataView: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||||
|
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
// Scope filter: 'personal' | 'all' | mandateId
|
// Scope filter: 'personal' | 'all' | mandateId
|
||||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
||||||
|
|
@ -273,8 +332,90 @@ export const BillingDataView: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
balances,
|
balances,
|
||||||
loading: dashboardLoading,
|
loading: dashboardLoading,
|
||||||
|
refetch: refetchBalances,
|
||||||
} = useBilling();
|
} = useBilling();
|
||||||
|
|
||||||
|
const successParam = searchParams.get('success');
|
||||||
|
const canceledParam = searchParams.get('canceled');
|
||||||
|
const sessionIdParam = searchParams.get('session_id');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const _confirmCheckoutIfNeeded = async () => {
|
||||||
|
if (successParam !== 'true') {
|
||||||
|
if (canceledParam === 'true' && !cancelled) {
|
||||||
|
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionIdParam) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
|
||||||
|
}
|
||||||
|
refetchBalances();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
|
||||||
|
if (!cancelled) {
|
||||||
|
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err?.response?.data?.detail;
|
||||||
|
if (!cancelled) {
|
||||||
|
setCheckoutMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestaetigt werden.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refetchBalances();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_confirmCheckoutIfNeeded();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [successParam, canceledParam, sessionIdParam, refetchBalances]);
|
||||||
|
|
||||||
|
const _clearStripeParams = useCallback(() => {
|
||||||
|
searchParams.delete('success');
|
||||||
|
searchParams.delete('canceled');
|
||||||
|
searchParams.delete('session_id');
|
||||||
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
setCheckoutMessage(null);
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
const _handleCheckout = useCallback(async (mandateId: string, amount: number) => {
|
||||||
|
setCheckoutLoading(true);
|
||||||
|
setCheckoutMessage(null);
|
||||||
|
try {
|
||||||
|
const currentUser = getUserDataCache();
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
currentUrl.searchParams.delete('success');
|
||||||
|
currentUrl.searchParams.delete('canceled');
|
||||||
|
currentUrl.searchParams.delete('session_id');
|
||||||
|
currentUrl.hash = '';
|
||||||
|
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
||||||
|
const result = await createCheckoutSession(request, mandateId, {
|
||||||
|
userId: currentUser?.id,
|
||||||
|
amount,
|
||||||
|
returnUrl,
|
||||||
|
});
|
||||||
|
if (result?.redirectUrl) {
|
||||||
|
window.location.href = result.redirectUrl;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' });
|
||||||
|
setCheckoutLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
// All user balances (for admin overview cards)
|
// All user balances (for admin overview cards)
|
||||||
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
||||||
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
||||||
|
|
@ -475,6 +616,15 @@ export const BillingDataView: React.FC = () => {
|
||||||
|
|
||||||
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
|
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
|
{checkoutMessage && (
|
||||||
|
<div className={checkoutMessage.type === 'success' ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '1rem' }}>
|
||||||
|
{checkoutMessage.text}
|
||||||
|
{(successParam || canceledParam) && (
|
||||||
|
<button type="button" onClick={_clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', color: 'inherit' }}>Schliessen</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* Tab: Übersicht (My Overview) */}
|
{/* Tab: Übersicht (My Overview) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
|
|
@ -502,7 +652,12 @@ export const BillingDataView: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.balanceGrid}>
|
<div className={styles.balanceGrid}>
|
||||||
{filteredBalances.map((balance) => (
|
{filteredBalances.map((balance) => (
|
||||||
<BalanceCard key={balance.mandateId} balance={balance} />
|
<BalanceCard
|
||||||
|
key={balance.mandateId}
|
||||||
|
balance={balance}
|
||||||
|
onCheckout={_handleCheckout}
|
||||||
|
checkoutLoading={checkoutLoading}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useCommcoach, type TtsEvent } from '../../../hooks/useCommcoach';
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
|
@ -46,7 +47,9 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
const sendMessageRef = useRef(coach.sendMessage);
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
sendMessageRef.current = coach.sendMessage;
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
|
||||||
const voice = useVoiceController((text) => sendMessageRef.current(text));
|
const voice = useVoiceController({
|
||||||
|
onFinalText: (text) => sendMessageRef.current(text),
|
||||||
|
});
|
||||||
|
|
||||||
// #region agent log
|
// #region agent log
|
||||||
const debugLogsRef = useRef<string[]>([]);
|
const debugLogsRef = useRef<string[]>([]);
|
||||||
|
|
@ -116,13 +119,13 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}, [activeTab, coach.session?.id, voice]);
|
}, [activeTab, coach.session?.id, voice]);
|
||||||
|
|
||||||
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
||||||
|
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||||||
voice.cancelPendingSpeech();
|
|
||||||
await coach.sendMessage(coach.inputValue);
|
await coach.sendMessage(coach.inputValue);
|
||||||
}, [coach, voice]);
|
}, [coach]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||||
|
|
@ -335,7 +338,10 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
<span className={styles.sessionLabel}>Session aktiv</span>
|
<span className={styles.sessionLabel}>Session aktiv</span>
|
||||||
<div className={styles.sessionActions}>
|
<div className={styles.sessionActions}>
|
||||||
{voice.state === 'botSpeaking' && (
|
{voice.state === 'botSpeaking' && (
|
||||||
|
<>
|
||||||
|
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
|
||||||
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
||||||
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>
|
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,15 @@
|
||||||
* States: idle | listening | botSpeaking | interrupted
|
* States: idle | listening | botSpeaking | interrupted
|
||||||
* Muted: orthogonal boolean flag (independent of main state)
|
* Muted: orthogonal boolean flag (independent of main state)
|
||||||
*
|
*
|
||||||
* Recognition is STOPPED during botSpeaking or when muted=true.
|
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
||||||
* Recognition is STARTED when entering listening/interrupted AND muted=false.
|
* Google Streaming STT handles silence detection natively.
|
||||||
* Each start() creates a fresh results session (processedIndex resets to 0).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
|
|
||||||
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||||
|
|
||||||
const SILENCE_TIMEOUT_MS = 1000;
|
|
||||||
const REC_AUTORESTART_DELAY_MS = 300;
|
|
||||||
|
|
||||||
export interface VoiceControllerApi {
|
export interface VoiceControllerApi {
|
||||||
state: VoiceState;
|
state: VoiceState;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
|
|
@ -26,28 +23,25 @@ export interface VoiceControllerApi {
|
||||||
ttsPaused: () => void;
|
ttsPaused: () => void;
|
||||||
ttsEnded: () => void;
|
ttsEnded: () => void;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
cancelPendingSpeech: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVoiceController(onMessage: (text: string) => void): VoiceControllerApi {
|
export interface VoiceControllerCallbacks {
|
||||||
|
onFinalText?: (text: string) => void | Promise<void>;
|
||||||
|
onInterimText?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||||
const [state, setState] = useState<VoiceState>('idle');
|
const [state, setState] = useState<VoiceState>('idle');
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [liveTranscript, setLiveTranscript] = useState('');
|
|
||||||
const stateRef = useRef<VoiceState>('idle');
|
const stateRef = useRef<VoiceState>('idle');
|
||||||
const mutedRef = useRef(false);
|
const mutedRef = useRef(false);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const cbRef = useRef(callbacks);
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
cbRef.current = callbacks;
|
||||||
const transcriptPartsRef = useRef<string[]>([]);
|
|
||||||
const processedIndexRef = useRef(0);
|
|
||||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const onMessageRef = useRef(onMessage);
|
|
||||||
onMessageRef.current = onMessage;
|
|
||||||
|
|
||||||
const _dlog = useCallback((tag: string, info?: string) => {
|
const _dlog = useCallback((tag: string, info?: string) => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
||||||
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
|
(window as any).__dlog?.(`[${ts}] ${tag}${info ? ' ' + info : ''}`);
|
||||||
(window as any).__dlog?.(entry);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _setState = useCallback((next: VoiceState) => {
|
const _setState = useCallback((next: VoiceState) => {
|
||||||
|
|
@ -64,183 +58,51 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
|
||||||
_dlog('MUTED', String(next));
|
_dlog('MUTED', String(next));
|
||||||
}, [_dlog]);
|
}, [_dlog]);
|
||||||
|
|
||||||
const _cancelSilenceTimer = useCallback(() => {
|
const voiceStream = useVoiceStream({
|
||||||
if (silenceTimerRef.current) {
|
onFinal: (text) => {
|
||||||
clearTimeout(silenceTimerRef.current);
|
cbRef.current.onFinalText?.(text);
|
||||||
silenceTimerRef.current = null;
|
},
|
||||||
}
|
onInterim: (text) => {
|
||||||
}, []);
|
cbRef.current.onInterimText?.(text);
|
||||||
|
},
|
||||||
const _finalizeTranscript = useCallback(() => {
|
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
||||||
const full = transcriptPartsRef.current.join(' ').trim();
|
});
|
||||||
_dlog('SEND', `"${full.substring(0, 80)}"`);
|
|
||||||
if (full) onMessageRef.current(full);
|
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
setLiveTranscript('');
|
|
||||||
}, [_dlog]);
|
|
||||||
|
|
||||||
const _resetSilenceTimer = useCallback(() => {
|
|
||||||
_cancelSilenceTimer();
|
|
||||||
silenceTimerRef.current = setTimeout(() => {
|
|
||||||
_finalizeTranscript();
|
|
||||||
}, SILENCE_TIMEOUT_MS);
|
|
||||||
}, [_cancelSilenceTimer, _finalizeTranscript]);
|
|
||||||
|
|
||||||
const _startRecognition = useCallback(() => {
|
|
||||||
if (mutedRef.current) return;
|
|
||||||
const rec = recognitionRef.current;
|
|
||||||
if (!rec) return;
|
|
||||||
try {
|
|
||||||
rec.start();
|
|
||||||
_dlog('REC-START', 'ok');
|
|
||||||
} catch {
|
|
||||||
_dlog('REC-START', 'failed');
|
|
||||||
}
|
|
||||||
}, [_dlog]);
|
|
||||||
|
|
||||||
const _stopRecognition = useCallback(() => {
|
|
||||||
const rec = recognitionRef.current;
|
|
||||||
if (!rec) return;
|
|
||||||
try {
|
|
||||||
rec.stop();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _createRecognition = useCallback(() => {
|
|
||||||
const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
|
||||||
if (!SpeechRecognitionApi) return;
|
|
||||||
|
|
||||||
const recognition = new SpeechRecognitionApi();
|
|
||||||
recognition.continuous = true;
|
|
||||||
recognition.interimResults = true;
|
|
||||||
recognition.lang = 'de-DE';
|
|
||||||
|
|
||||||
recognition.onspeechstart = () => {
|
|
||||||
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
|
|
||||||
_resetSilenceTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
|
||||||
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
|
|
||||||
const interimParts: string[] = [];
|
|
||||||
for (let i = processedIndexRef.current; i < event.results.length; i++) {
|
|
||||||
const r = event.results[i];
|
|
||||||
if (r.isFinal) {
|
|
||||||
const text = r[0].transcript.trim();
|
|
||||||
if (text) transcriptPartsRef.current.push(text);
|
|
||||||
processedIndexRef.current = i + 1;
|
|
||||||
} else {
|
|
||||||
const text = r[0].transcript.trim();
|
|
||||||
if (text) interimParts.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const currentInterim = interimParts.join(' ');
|
|
||||||
const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
|
|
||||||
setLiveTranscript(preview);
|
|
||||||
if (preview) _resetSilenceTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onspeechend = () => {
|
|
||||||
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
|
|
||||||
_resetSilenceTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onend = () => {
|
|
||||||
_dlog('REC-END', `state=${stateRef.current} muted=${mutedRef.current}`);
|
|
||||||
if (recognitionRef.current !== recognition) return;
|
|
||||||
const cur = stateRef.current;
|
|
||||||
if (cur === 'botSpeaking' || cur === 'idle' || mutedRef.current) return;
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (recognitionRef.current !== recognition) return;
|
|
||||||
if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
|
|
||||||
if (mutedRef.current) return;
|
|
||||||
try {
|
|
||||||
recognition.start();
|
|
||||||
_dlog('REC-AUTOSTART', 'ok');
|
|
||||||
} catch {
|
|
||||||
_dlog('REC-AUTOSTART', 'failed');
|
|
||||||
}
|
|
||||||
}, REC_AUTORESTART_DELAY_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onerror = (event: any) => {
|
|
||||||
_dlog('REC-ERR', event.error);
|
|
||||||
if (event.error === 'no-speech' || event.error === 'aborted') return;
|
|
||||||
console.warn('SpeechRecognition error:', event.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
recognitionRef.current = recognition;
|
|
||||||
_startRecognition();
|
|
||||||
}, [_dlog, _resetSilenceTimer, _startRecognition]);
|
|
||||||
|
|
||||||
const activate = useCallback(async () => {
|
const activate = useCallback(async () => {
|
||||||
if (stateRef.current !== 'idle') return;
|
if (stateRef.current !== 'idle') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!streamRef.current) {
|
await voiceStream.start('de-DE');
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: { echoCancellation: true, noiseSuppression: true },
|
|
||||||
});
|
|
||||||
streamRef.current = stream;
|
|
||||||
}
|
|
||||||
_createRecognition();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Mic access failed:', err);
|
_dlog('MIC-ERR', String(err));
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
}
|
}
|
||||||
}, [_setState, _createRecognition]);
|
}, [_setState, voiceStream, _dlog]);
|
||||||
|
|
||||||
const deactivate = useCallback(() => {
|
const deactivate = useCallback(() => {
|
||||||
_cancelSilenceTimer();
|
voiceStream.stop();
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
if (recognitionRef.current) {
|
}, [_setState, voiceStream]);
|
||||||
try { recognitionRef.current.stop(); } catch { /* ignore */ }
|
|
||||||
recognitionRef.current = null;
|
|
||||||
}
|
|
||||||
if (streamRef.current) {
|
|
||||||
streamRef.current.getTracks().forEach(t => t.stop());
|
|
||||||
streamRef.current = null;
|
|
||||||
}
|
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
}, [_setState, _cancelSilenceTimer]);
|
|
||||||
|
|
||||||
const ttsPlaying = useCallback(() => {
|
const ttsPlaying = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur === 'idle') return;
|
if (cur === 'idle') return;
|
||||||
_cancelSilenceTimer();
|
voiceStream.stop();
|
||||||
_finalizeTranscript();
|
|
||||||
_stopRecognition();
|
|
||||||
_setState('botSpeaking');
|
_setState('botSpeaking');
|
||||||
}, [_setState, _cancelSilenceTimer, _finalizeTranscript, _stopRecognition]);
|
}, [_setState, voiceStream]);
|
||||||
|
|
||||||
const ttsPaused = useCallback(() => {
|
const ttsPaused = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
if (stateRef.current !== 'botSpeaking') return;
|
||||||
if (cur !== 'botSpeaking') return;
|
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
_setState('interrupted');
|
_setState('interrupted');
|
||||||
_startRecognition();
|
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, _startRecognition]);
|
}, [_setState, voiceStream, _dlog]);
|
||||||
|
|
||||||
const ttsEnded = useCallback(() => {
|
const ttsEnded = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
_startRecognition();
|
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, _startRecognition]);
|
}, [_setState, voiceStream, _dlog]);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
|
|
@ -248,45 +110,23 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
|
||||||
if (mutedRef.current) {
|
if (mutedRef.current) {
|
||||||
_setMuted(false);
|
_setMuted(false);
|
||||||
if (cur === 'listening' || cur === 'interrupted') {
|
if (cur === 'listening' || cur === 'interrupted') {
|
||||||
_startRecognition();
|
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_setMuted(true);
|
_setMuted(true);
|
||||||
_stopRecognition();
|
voiceStream.stop();
|
||||||
}
|
}
|
||||||
}, [_setMuted, _startRecognition, _stopRecognition]);
|
}, [_setMuted, voiceStream, _dlog]);
|
||||||
|
|
||||||
const cancelPendingSpeech = useCallback(() => {
|
|
||||||
_cancelSilenceTimer();
|
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
setLiveTranscript('');
|
|
||||||
_dlog('CANCEL-SPEECH', 'pending speech cleared for text input');
|
|
||||||
}, [_cancelSilenceTimer, _dlog]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
|
||||||
if (recognitionRef.current) {
|
|
||||||
try { recognitionRef.current.stop(); } catch { /* ignore */ }
|
|
||||||
recognitionRef.current = null;
|
|
||||||
}
|
|
||||||
if (streamRef.current) {
|
|
||||||
streamRef.current.getTracks().forEach(t => t.stop());
|
|
||||||
streamRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
muted,
|
muted,
|
||||||
liveTranscript,
|
liveTranscript: voiceStream.interimText,
|
||||||
activate,
|
activate,
|
||||||
deactivate,
|
deactivate,
|
||||||
ttsPlaying,
|
ttsPlaying,
|
||||||
ttsPaused,
|
ttsPaused,
|
||||||
ttsEnded,
|
ttsEnded,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
cancelPendingSpeech,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* testing the connection, and removing the integration.
|
* testing the connection, and removing the integration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
|
@ -35,6 +35,19 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message?: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message?: string } | null>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importDone, setImportDone] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
|
||||||
|
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importDone) return;
|
||||||
|
const t = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [importDone]);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -62,8 +75,21 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
return () => { mountedRef.current = false; };
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
const _loadImportStatus = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
|
||||||
|
if (mountedRef.current) setImportStatus(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingConfig?.configured) _loadImportStatus();
|
||||||
|
}, [existingConfig, _loadImportStatus]);
|
||||||
|
|
||||||
const _getSelectedConnector = (): AccountingConnectorInfo | undefined => {
|
const _getSelectedConnector = (): AccountingConnectorInfo | undefined => {
|
||||||
return connectors.find(c => c.connectorType === selectedType);
|
return connectors.find(c => c.connectorType === selectedType);
|
||||||
};
|
};
|
||||||
|
|
@ -291,6 +317,109 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Import Accounting Data */}
|
||||||
|
{existingConfig?.configured && (
|
||||||
|
<div className={styles.setupStep}>
|
||||||
|
<div className={styles.stepNumber}>4</div>
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
<h4>Buchhaltungsdaten importieren</h4>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
|
||||||
|
Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen.
|
||||||
|
Diese Daten stehen anschliessend im AI Workspace fuer Analysen zur Verfuegung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>Von (optional)</label>
|
||||||
|
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>Bis (optional)</label>
|
||||||
|
<input type="date" className={styles.folderSelect} value={dateTo} onChange={e => setDateTo(e.target.value)} style={{ width: '160px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'YTD', from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
|
||||||
|
{
|
||||||
|
label: 'Letztes Jahr',
|
||||||
|
from: `${new Date().getFullYear() - 1}-01-01`,
|
||||||
|
to: `${new Date().getFullYear() - 1}-12-31`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Letzter Monat',
|
||||||
|
from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(),
|
||||||
|
to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(),
|
||||||
|
},
|
||||||
|
].map(s => (
|
||||||
|
<button
|
||||||
|
key={s.label}
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
style={{ fontSize: '0.75rem', padding: '0.25rem 0.6rem', minWidth: 0 }}
|
||||||
|
onClick={() => { setDateFrom(s.from); setDateTo(s.to); }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
disabled={importing}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setImporting(true);
|
||||||
|
setImportResult(null);
|
||||||
|
try {
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
if (dateFrom) body.dateFrom = dateFrom;
|
||||||
|
if (dateTo) body.dateTo = dateTo;
|
||||||
|
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setImportResult(res.data);
|
||||||
|
if (res.data.errors?.length) {
|
||||||
|
showError('Import teilweise fehlgeschlagen', res.data.errors.join('; '));
|
||||||
|
} else {
|
||||||
|
showSuccess('Import abgeschlossen',
|
||||||
|
`${res.data.accounts || 0} Konten, ${res.data.journalEntries || 0} Buchungen, ` +
|
||||||
|
`${res.data.contacts || 0} Kontakte, ${res.data.accountBalances || 0} Salden importiert.`);
|
||||||
|
}
|
||||||
|
_loadImportStatus();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showError('Import fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler');
|
||||||
|
} finally {
|
||||||
|
setImportDone(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importing ? 'Importiere...' : 'Daten jetzt einlesen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{importResult && !importResult.errors?.length && (
|
||||||
|
<div className={styles.successMessage} style={{ marginTop: '0.75rem' }}>
|
||||||
|
Import abgeschlossen in {importResult.durationSeconds}s:
|
||||||
|
{' '}{importResult.accounts} Konten, {importResult.journalEntries} Buchungen ({importResult.journalLines} Zeilen),
|
||||||
|
{' '}{importResult.contacts} Kontakte, {importResult.accountBalances} Salden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importStatus && (importStatus.accounts > 0 || importStatus.journalEntries > 0) && (
|
||||||
|
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<strong>Aktueller Datenbestand:</strong>{' '}
|
||||||
|
{importStatus.accounts} Konten, {importStatus.journalEntries} Buchungen,
|
||||||
|
{' '}{importStatus.journalLines} Zeilen, {importStatus.contacts} Kontakte,
|
||||||
|
{' '}{importStatus.accountBalances} Salden
|
||||||
|
{importStatus.lastSyncAt && (
|
||||||
|
<> · Letzter Import: {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -444,7 +444,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
const connectionRef = getConnectionReference(msftConnection);
|
const connectionRef = getConnectionReference(msftConnection);
|
||||||
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
|
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
|
||||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
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.');
|
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
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;
|
isPollingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const chatDataRes = await api.get(
|
const chatDataRes = await api.get(
|
||||||
`/api/chatplayground/${instanceId}/${workflowId}/chatData`,
|
`/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
|
||||||
{
|
{
|
||||||
params: latestTimestampRef.current
|
params: latestTimestampRef.current
|
||||||
? { afterTimestamp: latestTimestampRef.current }
|
? { afterTimestamp: latestTimestampRef.current }
|
||||||
|
|
@ -307,7 +307,7 @@ export const TrusteeScanUploadView: React.FC = () => {
|
||||||
const template = buildTemplate(fileIds);
|
const template = buildTemplate(fileIds);
|
||||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`/api/chatplayground/${instanceId}/start`,
|
`/api/automations/${instanceId}/start`,
|
||||||
{ prompt },
|
{ prompt },
|
||||||
{ params: { workflowMode: 'Automation' } }
|
{ params: { workflowMode: 'Automation' } }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
520
src/pages/views/workspace/ChatStream.tsx
Normal file
520
src/pages/views/workspace/ChatStream.tsx
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
/**
|
||||||
|
* ChatStream -- SSE-driven message display for the workspace.
|
||||||
|
*
|
||||||
|
* Renders messages with full Markdown (GFM tables, code blocks with syntax
|
||||||
|
* highlighting), agent progress indicators, and file edit proposals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import api from '../../../api';
|
||||||
|
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
||||||
|
|
||||||
|
interface ChatStreamProps {
|
||||||
|
messages: Message[];
|
||||||
|
agentProgress: AgentProgress | null;
|
||||||
|
isProcessing: boolean;
|
||||||
|
pendingEdits: FileEditProposal[];
|
||||||
|
onAcceptEdit: (editId: string) => void;
|
||||||
|
onRejectEdit: (editId: string) => void;
|
||||||
|
onOpenEditor?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatStream: React.FC<ChatStreamProps> = ({
|
||||||
|
messages,
|
||||||
|
agentProgress,
|
||||||
|
isProcessing,
|
||||||
|
pendingEdits,
|
||||||
|
onAcceptEdit,
|
||||||
|
onRejectEdit,
|
||||||
|
onOpenEditor,
|
||||||
|
}) => {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, agentProgress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '16px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: '85%',
|
||||||
|
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
background: _getBubbleBackground(msg.role || 'assistant'),
|
||||||
|
border: msg.role === 'user'
|
||||||
|
? 'none'
|
||||||
|
: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
fontSize: msg.role === 'status' ? 12 : 14,
|
||||||
|
color: msg.role === 'status' ? '#795548' : 'inherit',
|
||||||
|
fontStyle: msg.role === 'status' ? 'italic' : 'normal',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
|
||||||
|
)}
|
||||||
|
{msg.role === 'status' ? (
|
||||||
|
<span>{msg.message}</span>
|
||||||
|
) : (
|
||||||
|
<div className="workspace-markdown">
|
||||||
|
{msg.message && (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code: _CodeBlock,
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div style={{ overflowX: 'auto', margin: '8px 0' }}>
|
||||||
|
<table style={{
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
width: '100%',
|
||||||
|
fontSize: 13,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th style={{
|
||||||
|
borderBottom: '2px solid #ddd',
|
||||||
|
padding: '6px 10px',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: '#f8f9fa',
|
||||||
|
fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td style={{
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
padding: '5px 10px',
|
||||||
|
fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#1976d2' }}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.message}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)}
|
||||||
|
{msg.documents && msg.documents.length > 0 && (
|
||||||
|
<div style={{ marginTop: msg.message ? 8 : 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{msg.documents.map((doc) => (
|
||||||
|
<_FileCard key={doc.id || doc.fileId} doc={doc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(msg as any)._audioUrl && (
|
||||||
|
<_AudioPlayer
|
||||||
|
url={(msg as any)._audioUrl}
|
||||||
|
language={(msg as any)._audioLang}
|
||||||
|
charCount={(msg as any)._audioCharCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* File edit proposals -- compact notification cards */}
|
||||||
|
{pendingEdits.filter(e => e.status === 'pending').length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--warning-color, #ff9800)',
|
||||||
|
background: 'var(--edit-bg, #fff8e1)',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
maxWidth: '85%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: '#ff9800' }}>✎</span>
|
||||||
|
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
|
||||||
|
</div>
|
||||||
|
<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={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onAcceptEdit(e.id))}
|
||||||
|
style={{
|
||||||
|
padding: '5px 14px', borderRadius: 4, border: 'none',
|
||||||
|
background: 'var(--success-color, #4caf50)', color: '#fff',
|
||||||
|
cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alle annehmen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onRejectEdit(e.id))}
|
||||||
|
style={{
|
||||||
|
padding: '5px 14px', borderRadius: 4,
|
||||||
|
border: '1px solid var(--border-color, #ccc)',
|
||||||
|
background: '#fff', cursor: 'pointer', fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alle ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent progress */}
|
||||||
|
{isProcessing && agentProgress && (
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '8px 14px', borderRadius: 8, fontSize: 12,
|
||||||
|
background: 'var(--progress-bg, #e8f5e9)',
|
||||||
|
border: '1px solid var(--progress-border, #c8e6c9)',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
display: 'flex', gap: 12, alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>{agentProgress.totalToolCalls} tools</span>
|
||||||
|
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProcessing && !agentProgress && (
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '8px 14px', borderRadius: 8, fontSize: 12,
|
||||||
|
color: '#666', alignSelf: 'flex-start', fontStyle: 'italic',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
}}>
|
||||||
|
<span className="workspace-spinner" style={{
|
||||||
|
display: 'inline-block', width: 12, height: 12,
|
||||||
|
border: '2px solid #ccc', borderTopColor: '#1976d2',
|
||||||
|
borderRadius: '50%', animation: 'workspace-spin 0.8s linear infinite',
|
||||||
|
}} />
|
||||||
|
Processing...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes workspace-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.workspace-markdown p { margin: 4px 0; }
|
||||||
|
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
|
||||||
|
.workspace-markdown blockquote {
|
||||||
|
margin: 8px 0; padding: 4px 12px;
|
||||||
|
border-left: 3px solid #ddd; color: #666;
|
||||||
|
}
|
||||||
|
.workspace-markdown h1, .workspace-markdown h2, .workspace-markdown h3 {
|
||||||
|
margin: 8px 0 4px; line-height: 1.3;
|
||||||
|
}
|
||||||
|
.workspace-markdown img { max-width: 100%; border-radius: 4px; }
|
||||||
|
.workspace-markdown hr { border: none; border-top: 1px solid #e0e0e0; margin: 8px 0; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _getBubbleBackground(role: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'user': return 'var(--primary-light, #e3f2fd)';
|
||||||
|
case 'status': return 'var(--status-bg, #fff3e0)';
|
||||||
|
case 'system': return 'var(--system-bg, #f5f5f5)';
|
||||||
|
default: return 'var(--assistant-bg, #ffffff)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _FileCard({ doc }: { doc: MessageDocument }) {
|
||||||
|
const _handleDownload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/files/${doc.fileId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = doc.fileName || 'download';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed:', err);
|
||||||
|
}
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
||||||
|
const icon = _getFileIcon(ext);
|
||||||
|
const sizeLabel = doc.fileSize
|
||||||
|
? doc.fileSize > 1024 * 1024
|
||||||
|
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
: `${(doc.fileSize / 1024).toFixed(1)} KB`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={_handleDownload}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
background: 'var(--file-card-bg, #f8f9fa)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
maxWidth: 340,
|
||||||
|
}}
|
||||||
|
title={`Download ${doc.fileName}`}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#e8f0fe')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 22 }}>{icon}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 13, fontWeight: 600, overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{doc.fileName}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#888' }}>
|
||||||
|
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 14, color: '#1976d2' }} title="Download">⬇</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getFileIcon(ext: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA',
|
||||||
|
doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
|
||||||
|
md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB',
|
||||||
|
html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC',
|
||||||
|
py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD',
|
||||||
|
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
|
||||||
|
gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F',
|
||||||
|
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6',
|
||||||
|
pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA',
|
||||||
|
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5',
|
||||||
|
mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC',
|
||||||
|
eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7',
|
||||||
|
};
|
||||||
|
return map[ext] || '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _AudioPlayer({ url, language }: { url: string; language?: string; charCount?: number }) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
if (audio.duration) setProgress(audio.currentTime / audio.duration);
|
||||||
|
});
|
||||||
|
audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); });
|
||||||
|
audio.addEventListener('pause', () => setPlaying(false));
|
||||||
|
audio.addEventListener('play', () => setPlaying(true));
|
||||||
|
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.src = '';
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const _togglePlay = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (playing) { audio.pause(); } else { audio.play().catch(() => {}); }
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
const _stop = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
setPlaying(false);
|
||||||
|
setProgress(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _formatTime = (s: number) => {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = Math.floor(s % 60);
|
||||||
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '8px 12px', borderRadius: 8,
|
||||||
|
background: 'var(--audio-player-bg, #f0f4f8)',
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
maxWidth: 360, marginTop: 6,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={_togglePlay}
|
||||||
|
style={{
|
||||||
|
width: 32, height: 32, borderRadius: '50%', border: 'none',
|
||||||
|
background: 'var(--primary-color, #1976d2)', color: '#fff',
|
||||||
|
cursor: 'pointer', fontSize: 14,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title={playing ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{playing ? '\u275A\u275A' : '\u25B6'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
height: 4, borderRadius: 2,
|
||||||
|
background: 'var(--border-color, #ddd)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%', borderRadius: 2,
|
||||||
|
background: 'var(--primary-color, #1976d2)',
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
transition: 'width 0.2s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
|
||||||
|
<span style={{ fontSize: 10, color: '#888' }}>
|
||||||
|
{duration > 0 ? _formatTime(progress * duration) : '0:00'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#888' }}>
|
||||||
|
{duration > 0 ? _formatTime(duration) : '--:--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={_stop}
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
|
||||||
|
background: 'transparent', color: '#888',
|
||||||
|
cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
■
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{language && (
|
||||||
|
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
|
||||||
|
{language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _CodeBlock({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
const isInline = !match && !String(children).includes('\n');
|
||||||
|
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
background: '#f0f0f0',
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: '0.9em',
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', margin: '8px 0' }}>
|
||||||
|
{match && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, right: 0,
|
||||||
|
padding: '2px 8px', fontSize: 10, color: '#888',
|
||||||
|
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
||||||
|
}}>
|
||||||
|
{match[1]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre style={{
|
||||||
|
background: '#1e1e1e',
|
||||||
|
color: '#d4d4d4',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: 0,
|
||||||
|
}}>
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
438
src/pages/views/workspace/ConversationList.tsx
Normal file
438
src/pages/views/workspace/ConversationList.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
/**
|
||||||
|
* ConversationList -- Shows all workspace workflows/conversations.
|
||||||
|
*
|
||||||
|
* Features: filter, rename (double-click), delete, archive, create new,
|
||||||
|
* pagination (20 per page), last-activity display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
|
const _PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
startedAt?: number;
|
||||||
|
lastActivity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationListProps {
|
||||||
|
instanceId: string;
|
||||||
|
activeWorkflowId: string | null;
|
||||||
|
onSelect: (workflowId: string) => void;
|
||||||
|
onCreateNew?: () => void;
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
|
instanceId,
|
||||||
|
activeWorkflowId,
|
||||||
|
onSelect,
|
||||||
|
onCreateNew,
|
||||||
|
refreshTrigger,
|
||||||
|
}) => {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'active' | 'archived'>('active');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const _loadConversations = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } })
|
||||||
|
.then(res => {
|
||||||
|
const items = (res.data.workflows || res.data || [])
|
||||||
|
.map((w: any) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.name || w.label || 'Untitled',
|
||||||
|
status: w.status || 'unknown',
|
||||||
|
startedAt: w.startedAt || w.createdAt,
|
||||||
|
lastActivity: w.lastActivity || w.updatedAt || w.startedAt,
|
||||||
|
}))
|
||||||
|
.sort((a: Conversation, b: Conversation) =>
|
||||||
|
(b.lastActivity || 0) - (a.lastActivity || 0),
|
||||||
|
);
|
||||||
|
setConversations(items);
|
||||||
|
})
|
||||||
|
.catch(() => setConversations([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadConversations();
|
||||||
|
}, [_loadConversations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger) _loadConversations();
|
||||||
|
}, [refreshTrigger, _loadConversations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) {
|
||||||
|
_loadConversations();
|
||||||
|
}
|
||||||
|
}, [activeWorkflowId, conversations, _loadConversations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingId && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
|
const _formatTime = (ts?: number): string => {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
if (diffDays === 1) return 'Gestern';
|
||||||
|
if (diffDays < 7) return `vor ${diffDays}d`;
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const _formatDate = (ts?: number): string => {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const _startEditing = (conv: Conversation) => {
|
||||||
|
setEditingId(conv.id);
|
||||||
|
setEditName(conv.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _commitRename = (convId: string) => {
|
||||||
|
const trimmed = editName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setEditingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConversations(prev =>
|
||||||
|
prev.map(c => c.id === convId ? { ...c, name: trimmed } : c),
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed })
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
_commitRename(convId);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleDelete = (convId: string) => {
|
||||||
|
setConversations(prev => prev.filter(c => c.id !== convId));
|
||||||
|
if (activeWorkflowId === convId) onSelect('');
|
||||||
|
api.delete(`/api/workspace/${instanceId}/workflows/${convId}`)
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleArchive = (convId: string) => {
|
||||||
|
setConversations(prev => prev.map(c =>
|
||||||
|
c.id === convId ? { ...c, status: 'archived' } : c,
|
||||||
|
));
|
||||||
|
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' })
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleReactivate = (convId: string) => {
|
||||||
|
setConversations(prev => prev.map(c =>
|
||||||
|
c.id === convId ? { ...c, status: 'active' } : c,
|
||||||
|
));
|
||||||
|
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' })
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleCreateNew = () => {
|
||||||
|
if (onCreateNew) onCreateNew();
|
||||||
|
};
|
||||||
|
|
||||||
|
const _filtered = (items: Conversation[], query: string): Conversation[] => {
|
||||||
|
if (!query.trim()) return items;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return items.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _byStatus = viewMode === 'archived'
|
||||||
|
? conversations.filter(c => c.status === 'archived')
|
||||||
|
: conversations.filter(c => c.status !== 'archived');
|
||||||
|
const filtered = _filtered(_byStatus, filterQuery);
|
||||||
|
const totalPages = Math.ceil(filtered.length / _PAGE_SIZE);
|
||||||
|
const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE);
|
||||||
|
|
||||||
|
const _archivedCount = conversations.filter(c => c.status === 'archived').length;
|
||||||
|
const _activeCount = conversations.filter(c => c.status !== 'archived').length;
|
||||||
|
|
||||||
|
useEffect(() => { setPage(0); }, [filterQuery, viewMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={_handleCreateNew}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#1976d2' }}
|
||||||
|
title="Neuer Chat"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_loadConversations}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
|
||||||
|
>
|
||||||
|
{loading ? '...' : '\u21BB'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div style={{ display: 'flex', marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #ddd' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('active')}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
|
||||||
|
background: viewMode === 'active' ? 'var(--primary-color, #1976d2)' : 'transparent',
|
||||||
|
color: viewMode === 'active' ? '#fff' : '#888',
|
||||||
|
transition: 'background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aktiv {_activeCount > 0 && <span style={{ fontWeight: 400 }}>({_activeCount})</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('archived')}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
|
||||||
|
borderLeft: '1px solid #ddd',
|
||||||
|
background: viewMode === 'archived' ? '#ff9800' : 'transparent',
|
||||||
|
color: viewMode === 'archived' ? '#fff' : '#888',
|
||||||
|
transition: 'background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Archiv {_archivedCount > 0 && <span style={{ fontWeight: 400 }}>({_archivedCount})</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
{filtered.length > 3 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter chats..."
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={e => setFilterQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
|
||||||
|
border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{filtered.length === 0 && !loading && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
{viewMode === 'archived'
|
||||||
|
? 'Keine archivierten Chats.'
|
||||||
|
: 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{paginated.map(conv => {
|
||||||
|
const isActive = conv.id === activeWorkflowId;
|
||||||
|
const isEditing = editingId === conv.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => { if (!isEditing) onSelect(conv.id); }}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
marginBottom: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: isEditing ? 'default' : 'pointer',
|
||||||
|
background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent',
|
||||||
|
border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
|
||||||
|
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
|
||||||
|
if (actions) actions.style.opacity = '1';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||||
|
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
|
||||||
|
if (actions) actions.style.opacity = '0';
|
||||||
|
if (confirmDeleteId === conv.id) setConfirmDeleteId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Name row */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 4 }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={editName}
|
||||||
|
onChange={e => setEditName(e.target.value)}
|
||||||
|
onBlur={() => _commitRename(conv.id)}
|
||||||
|
onKeyDown={e => _handleKeyDown(e, conv.id)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600,
|
||||||
|
padding: '1px 4px', borderRadius: 3,
|
||||||
|
border: '1px solid var(--primary-color, #1976d2)',
|
||||||
|
outline: 'none', background: '#fff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 10, color: '#aaa', flexShrink: 0, marginRight: 6 }}
|
||||||
|
title={_formatDate(conv.lastActivity)}
|
||||||
|
>
|
||||||
|
{_formatTime(conv.lastActivity)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
|
||||||
|
title={conv.name}
|
||||||
|
>
|
||||||
|
{conv.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons (visible on hover) */}
|
||||||
|
{!isEditing && (
|
||||||
|
<span
|
||||||
|
data-actions=""
|
||||||
|
style={{ display: 'flex', gap: 2, opacity: 0, transition: 'opacity 0.15s', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
|
||||||
|
style={_actionBtnStyle}
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
{conv.status === 'archived' ? (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#4caf50' }}
|
||||||
|
title="Reaktivieren"
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
|
||||||
|
style={_actionBtnStyle}
|
||||||
|
title="Archivieren"
|
||||||
|
>
|
||||||
|
📦
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{confirmDeleteId === conv.id ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', gap: 1, background: 'var(--color-secondary, #555)',
|
||||||
|
borderRadius: 12, padding: '1px 2px', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); _handleDelete(conv.id); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||||
|
title="Ja, loeschen"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||||
|
title="Abbrechen"
|
||||||
|
>
|
||||||
|
✗
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
|
||||||
|
title="Loeschen"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 8, fontSize: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
style={{ ..._pageBtnStyle, opacity: page === 0 ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#888' }}>{page + 1} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
style={{ ..._pageBtnStyle, opacity: page >= totalPages - 1 ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _actionBtnStyle: React.CSSProperties = {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#999',
|
||||||
|
padding: '0 2px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _pageBtnStyle: React.CSSProperties = {
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 8px',
|
||||||
|
color: '#666',
|
||||||
|
};
|
||||||
798
src/pages/views/workspace/DataSourcePanel.tsx
Normal file
798
src/pages/views/workspace/DataSourcePanel.tsx
Normal file
|
|
@ -0,0 +1,798 @@
|
||||||
|
/**
|
||||||
|
* DataSourcePanel -- Browse external data sources as a lazy-loading tree.
|
||||||
|
*
|
||||||
|
* Tree structure:
|
||||||
|
* UserConnection (Level 1, loaded on mount)
|
||||||
|
* └─ Service (Level 2, loaded when connection expanded)
|
||||||
|
* └─ Folder / Site / File (Level 3+, loaded when service/folder expanded)
|
||||||
|
*
|
||||||
|
* Each folder node can be added as a DataSource for this workspace instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
|
import type { DataSource, FeatureDataSource } from './useWorkspace';
|
||||||
|
|
||||||
|
/* ─── Types ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
type: 'connection' | 'service' | 'folder' | 'file';
|
||||||
|
expanded: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
children: TreeNode[] | null;
|
||||||
|
connectionId: string;
|
||||||
|
service?: string;
|
||||||
|
path?: string;
|
||||||
|
authority?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureConnectionNode {
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
tableCount: number;
|
||||||
|
expanded: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
tables: FeatureTableNode[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureTableNode {
|
||||||
|
objectKey: string;
|
||||||
|
tableName: string;
|
||||||
|
label: Record<string, string>;
|
||||||
|
fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataSourcePanelProps {
|
||||||
|
instanceId: string;
|
||||||
|
dataSources: DataSource[];
|
||||||
|
featureDataSources: FeatureDataSource[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
onRefreshFeatureDataSources: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Icons ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const _AUTHORITY_ICONS: Record<string, string> = {
|
||||||
|
msft: '\uD83D\uDFE6',
|
||||||
|
google: '\uD83D\uDFE9',
|
||||||
|
'local:ftp': '\uD83D\uDD17',
|
||||||
|
'local:jira': '\uD83D\uDD27',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SERVICE_ICONS: Record<string, string> = {
|
||||||
|
sharepoint: '\uD83D\uDCC1',
|
||||||
|
onedrive: '\u2601\uFE0F',
|
||||||
|
outlook: '\uD83D\uDCE7',
|
||||||
|
teams: '\uD83D\uDCAC',
|
||||||
|
drive: '\uD83D\uDCC2',
|
||||||
|
gmail: '\uD83D\uDCE8',
|
||||||
|
files: '\uD83D\uDCC2',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── Source colors & icons ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const _SOURCE_COLORS: Record<string, string> = {
|
||||||
|
sharepointFolder: '#0078d4',
|
||||||
|
onedriveFolder: '#0078d4',
|
||||||
|
outlookFolder: '#0078d4',
|
||||||
|
googleDriveFolder: '#34a853',
|
||||||
|
gmailFolder: '#ea4335',
|
||||||
|
ftpFolder: '#795548',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _getSourceColor(sourceType: string): string {
|
||||||
|
return _SOURCE_COLORS[sourceType] || '#1976d2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getSourceIcon(sourceType: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
sharepointFolder: '\uD83D\uDCC1',
|
||||||
|
onedriveFolder: '\u2601\uFE0F',
|
||||||
|
outlookFolder: '\uD83D\uDCE7',
|
||||||
|
googleDriveFolder: '\uD83D\uDCC2',
|
||||||
|
gmailFolder: '\uD83D\uDCE8',
|
||||||
|
ftpFolder: '\uD83D\uDD17',
|
||||||
|
};
|
||||||
|
return map[sourceType] || '\uD83D\uDCC1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Component ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
|
instanceId,
|
||||||
|
dataSources,
|
||||||
|
featureDataSources,
|
||||||
|
onRefresh,
|
||||||
|
onRefreshFeatureDataSources,
|
||||||
|
}) => {
|
||||||
|
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||||
|
const [loadingRoot, setLoadingRoot] = useState(false);
|
||||||
|
const [addingPath, setAddingPath] = useState<string | null>(null);
|
||||||
|
const [featureTree, setFeatureTree] = useState<FeatureConnectionNode[]>([]);
|
||||||
|
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||||
|
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
|
||||||
|
|
||||||
|
/* ── Load Level 1: UserConnections ── */
|
||||||
|
const _loadConnections = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoadingRoot(true);
|
||||||
|
api.get(`/api/workspace/${instanceId}/connections`)
|
||||||
|
.then(res => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
const conns = res.data.connections || [];
|
||||||
|
const nodes: TreeNode[] = conns
|
||||||
|
.filter((c: any) => c.status === 'active')
|
||||||
|
.map((c: any) => ({
|
||||||
|
key: `conn-${c.id}`,
|
||||||
|
label: c.externalEmail || c.externalUsername || c.authority,
|
||||||
|
icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
|
||||||
|
type: 'connection' as const,
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
children: null,
|
||||||
|
connectionId: c.id,
|
||||||
|
authority: c.authority,
|
||||||
|
}));
|
||||||
|
setTree(nodes);
|
||||||
|
})
|
||||||
|
.catch(() => { if (mountedRef.current) setTree([]); })
|
||||||
|
.finally(() => { if (mountedRef.current) setLoadingRoot(false); });
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadConnections(); }, [_loadConnections]);
|
||||||
|
|
||||||
|
/* ── Generic tree update helper ── */
|
||||||
|
const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => {
|
||||||
|
setTree(prev => _mapTree(prev, key, updater));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Toggle expand/collapse ── */
|
||||||
|
const _toggleNode = useCallback(async (node: TreeNode) => {
|
||||||
|
if (node.expanded) {
|
||||||
|
_updateNode(node.key, n => ({ ...n, expanded: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children !== null) {
|
||||||
|
_updateNode(node.key, n => ({ ...n, expanded: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateNode(node.key, n => ({ ...n, loading: true, expanded: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
let children: TreeNode[] = [];
|
||||||
|
|
||||||
|
if (node.type === 'connection') {
|
||||||
|
children = await _loadServices(instanceId, node.connectionId);
|
||||||
|
} else if (node.type === 'service' || node.type === 'folder') {
|
||||||
|
children = await _browseService(instanceId, node.connectionId, node.service!, node.path || '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mountedRef.current) {
|
||||||
|
_updateNode(node.key, n => ({ ...n, loading: false, children }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
_updateNode(node.key, n => ({ ...n, loading: false, children: [] }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [instanceId, _updateNode]);
|
||||||
|
|
||||||
|
/* ── Add as DataSource ── */
|
||||||
|
const _addAsDataSource = useCallback(async (node: TreeNode) => {
|
||||||
|
if (!node.service || !node.connectionId) return;
|
||||||
|
setAddingPath(node.key);
|
||||||
|
try {
|
||||||
|
const sourceTypeMap: Record<string, string> = {
|
||||||
|
sharepoint: 'sharepointFolder',
|
||||||
|
onedrive: 'onedriveFolder',
|
||||||
|
outlook: 'outlookFolder',
|
||||||
|
drive: 'googleDriveFolder',
|
||||||
|
gmail: 'gmailFolder',
|
||||||
|
files: 'ftpFolder',
|
||||||
|
};
|
||||||
|
await api.post(`/api/workspace/${instanceId}/datasources`, {
|
||||||
|
connectionId: node.connectionId,
|
||||||
|
sourceType: sourceTypeMap[node.service] || node.service,
|
||||||
|
path: node.path || '/',
|
||||||
|
label: node.label,
|
||||||
|
});
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add data source:', err);
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setAddingPath(null);
|
||||||
|
}
|
||||||
|
}, [instanceId, onRefresh]);
|
||||||
|
|
||||||
|
/* ── Remove DataSource ── */
|
||||||
|
const _removeDatasource = useCallback(async (dsId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove data source:', err);
|
||||||
|
}
|
||||||
|
}, [instanceId, onRefresh]);
|
||||||
|
|
||||||
|
/* ── Check if a path is already added ── */
|
||||||
|
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
|
||||||
|
return dataSources.some(ds =>
|
||||||
|
ds.connectionId === connectionId && ds.path === (path || '/'),
|
||||||
|
);
|
||||||
|
}, [dataSources]);
|
||||||
|
|
||||||
|
/* ── Feature Connections: Load Level 1 ── */
|
||||||
|
const _loadFeatureConnections = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoadingFeatures(true);
|
||||||
|
api.get(`/api/workspace/${instanceId}/feature-connections`)
|
||||||
|
.then(res => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
const conns = res.data.featureConnections || [];
|
||||||
|
setFeatureTree(conns.map((c: any) => ({
|
||||||
|
featureInstanceId: c.featureInstanceId,
|
||||||
|
featureCode: c.featureCode,
|
||||||
|
label: c.label,
|
||||||
|
icon: c.icon || '\uD83D\uDDC3\uFE0F',
|
||||||
|
tableCount: c.tableCount || 0,
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
tables: null,
|
||||||
|
})));
|
||||||
|
})
|
||||||
|
.catch(() => { if (mountedRef.current) setFeatureTree([]); })
|
||||||
|
.finally(() => { if (mountedRef.current) setLoadingFeatures(false); });
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]);
|
||||||
|
|
||||||
|
/* ── Feature Connections: Toggle expand ── */
|
||||||
|
const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => {
|
||||||
|
if (node.expanded) {
|
||||||
|
setFeatureTree(prev => prev.map(n =>
|
||||||
|
n.featureInstanceId === node.featureInstanceId ? { ...n, expanded: false } : n
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.tables !== null) {
|
||||||
|
setFeatureTree(prev => prev.map(n =>
|
||||||
|
n.featureInstanceId === node.featureInstanceId ? { ...n, expanded: true } : n
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeatureTree(prev => prev.map(n =>
|
||||||
|
n.featureInstanceId === node.featureInstanceId ? { ...n, loading: true, expanded: true } : n
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`);
|
||||||
|
const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({
|
||||||
|
objectKey: t.objectKey,
|
||||||
|
tableName: t.tableName,
|
||||||
|
label: t.label || {},
|
||||||
|
fields: t.fields || [],
|
||||||
|
}));
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setFeatureTree(prev => prev.map(n =>
|
||||||
|
n.featureInstanceId === node.featureInstanceId ? { ...n, loading: false, tables } : n
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setFeatureTree(prev => prev.map(n =>
|
||||||
|
n.featureInstanceId === node.featureInstanceId ? { ...n, loading: false, tables: [] } : n
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
/* ── Feature: Add table as FeatureDataSource ── */
|
||||||
|
const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => {
|
||||||
|
const key = `${node.featureInstanceId}-${table.tableName}`;
|
||||||
|
setAddingFeatureKey(key);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
|
||||||
|
featureInstanceId: node.featureInstanceId,
|
||||||
|
featureCode: node.featureCode,
|
||||||
|
tableName: table.tableName,
|
||||||
|
objectKey: table.objectKey,
|
||||||
|
label: table.label?.en || table.label?.de || table.tableName,
|
||||||
|
});
|
||||||
|
onRefreshFeatureDataSources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add feature data source:', err);
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setAddingFeatureKey(null);
|
||||||
|
}
|
||||||
|
}, [instanceId, onRefreshFeatureDataSources]);
|
||||||
|
|
||||||
|
/* ── Feature: Remove FeatureDataSource ── */
|
||||||
|
const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
|
||||||
|
onRefreshFeatureDataSources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove feature data source:', err);
|
||||||
|
}
|
||||||
|
}, [instanceId, onRefreshFeatureDataSources]);
|
||||||
|
|
||||||
|
/* ── Feature: check if table already added ── */
|
||||||
|
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
|
||||||
|
return featureDataSources.some(fds =>
|
||||||
|
fds.featureInstanceId === featureInstanceId && fds.tableName === tableName,
|
||||||
|
);
|
||||||
|
}, [featureDataSources]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8, fontSize: 13 }}>
|
||||||
|
{/* Active DataSources */}
|
||||||
|
{dataSources.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
||||||
|
Active Sources
|
||||||
|
</div>
|
||||||
|
{dataSources.map(ds => {
|
||||||
|
const connColor = _getSourceColor(ds.sourceType);
|
||||||
|
const connNode = tree.find(n => n.connectionId === ds.connectionId);
|
||||||
|
const connLabel = connNode?.label || ds.connectionId;
|
||||||
|
const folder = ds.label || ds.path || ds.id;
|
||||||
|
return (
|
||||||
|
<div key={ds.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
||||||
|
background: `${connColor}18`,
|
||||||
|
borderLeft: `3px solid ${connColor}`,
|
||||||
|
fontSize: 12,
|
||||||
|
}} title={`${connLabel} – ${ds.path || ds.label}`}>
|
||||||
|
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{connLabel} – {folder}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => _removeDatasource(ds.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tree header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
||||||
|
Browse Sources
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={_loadConnections}
|
||||||
|
disabled={loadingRoot}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
|
||||||
|
>
|
||||||
|
{loadingRoot ? '...' : '\u21BB'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree */}
|
||||||
|
{loadingRoot && tree.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
Loading connections...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingRoot && tree.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
No active connections found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tree.map(node => (
|
||||||
|
<_TreeNodeView
|
||||||
|
key={node.key}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
onToggle={_toggleNode}
|
||||||
|
onAdd={_addAsDataSource}
|
||||||
|
isAdded={_isAdded}
|
||||||
|
addingPath={addingPath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Feature Data Section ── */}
|
||||||
|
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
|
||||||
|
|
||||||
|
{/* Active Feature Data Sources */}
|
||||||
|
{featureDataSources.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
||||||
|
Active Feature Sources
|
||||||
|
</div>
|
||||||
|
{featureDataSources.map(fds => {
|
||||||
|
const fdsConnLabel = featureTree.find(n => n.featureInstanceId === fds.featureInstanceId)?.label || fds.label;
|
||||||
|
return (
|
||||||
|
<div key={fds.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
||||||
|
background: '#7b1fa218',
|
||||||
|
borderLeft: '3px solid #7b1fa2',
|
||||||
|
fontSize: 12,
|
||||||
|
}} title={`${fdsConnLabel} - ${fds.tableName}`}>
|
||||||
|
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
|
||||||
|
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{fdsConnLabel} – {fds.tableName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => _removeFeatureDataSource(fds.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
); })}
|
||||||
|
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feature Connections Tree */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
||||||
|
Feature Data
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={_loadFeatureConnections}
|
||||||
|
disabled={loadingFeatures}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#7b1fa2' }}
|
||||||
|
>
|
||||||
|
{loadingFeatures ? '...' : '\u21BB'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingFeatures && featureTree.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
Loading feature instances...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingFeatures && featureTree.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
No feature instances found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{featureTree.map(fNode => (
|
||||||
|
<_FeatureNodeView
|
||||||
|
key={fNode.featureInstanceId}
|
||||||
|
node={fNode}
|
||||||
|
onToggle={_toggleFeatureNode}
|
||||||
|
onAddTable={_addFeatureTable}
|
||||||
|
isTableAdded={_isFeatureTableAdded}
|
||||||
|
addingKey={addingFeatureKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface TreeNodeViewProps {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
onToggle: (node: TreeNode) => void;
|
||||||
|
onAdd: (node: TreeNode) => void;
|
||||||
|
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
|
||||||
|
addingPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
|
||||||
|
node, depth, onToggle, onAdd, isAdded, addingPath,
|
||||||
|
}) => {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const hasChildren = node.type !== 'file';
|
||||||
|
const chevron = hasChildren
|
||||||
|
? (node.expanded ? '\u25BE' : '\u25B8')
|
||||||
|
: '\u00A0\u00A0';
|
||||||
|
const canAdd = node.type === 'folder' || node.type === 'service';
|
||||||
|
const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path);
|
||||||
|
const isAdding = addingPath === node.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={() => { if (hasChildren) onToggle(node); }}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingLeft: depth * 16 + 4,
|
||||||
|
paddingRight: 4,
|
||||||
|
paddingTop: 3,
|
||||||
|
paddingBottom: 3,
|
||||||
|
cursor: hasChildren ? 'pointer' : 'default',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
{node.loading ? _Spinner() : chevron}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
|
||||||
|
<span style={{
|
||||||
|
flex: 1, minWidth: 0, overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: node.type === 'connection' ? 600 : 400,
|
||||||
|
}}>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
{canAdd && hovered && !alreadyAdded && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onAdd(node); }}
|
||||||
|
disabled={isAdding}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1px solid #1976d2', borderRadius: 3,
|
||||||
|
cursor: isAdding ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 10, color: '#1976d2', padding: '1px 5px',
|
||||||
|
opacity: isAdding ? 0.5 : 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title="Add as data source"
|
||||||
|
>
|
||||||
|
{isAdding ? '...' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canAdd && alreadyAdded && (
|
||||||
|
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
|
||||||
|
{'\u2713'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children */}
|
||||||
|
{node.expanded && node.children && node.children.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{node.children.map(child => (
|
||||||
|
<_TreeNodeView
|
||||||
|
key={child.key}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onAdd={onAdd}
|
||||||
|
isAdded={isAdded}
|
||||||
|
addingPath={addingPath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
|
||||||
|
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
|
||||||
|
(empty)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */
|
||||||
|
|
||||||
|
interface FeatureNodeViewProps {
|
||||||
|
node: FeatureConnectionNode;
|
||||||
|
onToggle: (node: FeatureConnectionNode) => void;
|
||||||
|
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
||||||
|
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
|
||||||
|
addingKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
|
||||||
|
node, onToggle, onAddTable, isTableAdded, addingKey,
|
||||||
|
}) => {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const chevron = node.expanded ? '\u25BE' : '\u25B8';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={() => onToggle(node)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
||||||
|
cursor: 'pointer', borderRadius: 3,
|
||||||
|
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
|
||||||
|
transition: 'background 0.1s', userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
{node.loading ? _Spinner() : chevron}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
|
||||||
|
{getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
|
||||||
|
{node.tableCount} tables
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.expanded && node.tables && node.tables.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{node.tables.map(table => (
|
||||||
|
<_FeatureTableRow
|
||||||
|
key={table.objectKey}
|
||||||
|
featureNode={node}
|
||||||
|
table={table}
|
||||||
|
onAdd={onAddTable}
|
||||||
|
isAdded={isTableAdded(node.featureInstanceId, table.tableName)}
|
||||||
|
isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
|
||||||
|
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
|
||||||
|
(no tables)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeatureTableRowProps {
|
||||||
|
featureNode: FeatureConnectionNode;
|
||||||
|
table: FeatureTableNode;
|
||||||
|
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
||||||
|
isAdded: boolean;
|
||||||
|
isAdding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
|
||||||
|
featureNode, table, onAdd, isAdded, isAdding,
|
||||||
|
}) => {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const tableLabel = table.label?.en || table.label?.de || table.tableName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
|
||||||
|
transition: 'background 0.1s', userSelect: 'none',
|
||||||
|
}}
|
||||||
|
title={`${table.tableName}: ${table.fields.join(', ')}`}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
|
||||||
|
{tableLabel}
|
||||||
|
</span>
|
||||||
|
{hovered && !isAdded && (
|
||||||
|
<button
|
||||||
|
onClick={() => onAdd(featureNode, table)}
|
||||||
|
disabled={isAdding}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1px solid #7b1fa2', borderRadius: 3,
|
||||||
|
cursor: isAdding ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
|
||||||
|
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title="Add as feature data source"
|
||||||
|
>
|
||||||
|
{isAdding ? '...' : '+ Add'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdded && (
|
||||||
|
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
|
||||||
|
{'\u2713'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── Spinner (inline) ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _Spinner(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 10, height: 10,
|
||||||
|
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 0.6s linear infinite',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Data fetching ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
|
||||||
|
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
|
||||||
|
const services = res.data.services || [];
|
||||||
|
return services.map((s: any) => ({
|
||||||
|
key: `svc-${connectionId}-${s.service}`,
|
||||||
|
label: s.label || s.service,
|
||||||
|
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
|
||||||
|
type: 'service' as const,
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
children: null,
|
||||||
|
connectionId,
|
||||||
|
service: s.service,
|
||||||
|
path: '/',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _browseService(
|
||||||
|
instanceId: string, connectionId: string, service: string, path: string,
|
||||||
|
): Promise<TreeNode[]> {
|
||||||
|
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
|
||||||
|
params: { service, path },
|
||||||
|
});
|
||||||
|
const items = res.data.items || [];
|
||||||
|
return items.map((entry: any, idx: number) => ({
|
||||||
|
key: `item-${connectionId}-${service}-${entry.path || idx}`,
|
||||||
|
label: entry.name,
|
||||||
|
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
|
||||||
|
type: entry.isFolder ? 'folder' as const : 'file' as const,
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
children: entry.isFolder ? null : [],
|
||||||
|
connectionId,
|
||||||
|
service,
|
||||||
|
path: entry.path,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fileIcon(name: string): string {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
|
||||||
|
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
|
||||||
|
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
|
||||||
|
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
|
||||||
|
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
|
||||||
|
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
|
||||||
|
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
|
||||||
|
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
|
||||||
|
};
|
||||||
|
return map[ext] || '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tree map utility ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
|
||||||
|
return nodes.map(n => {
|
||||||
|
if (n.key === key) return updater(n);
|
||||||
|
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
252
src/pages/views/workspace/FileBrowser.tsx
Normal file
252
src/pages/views/workspace/FileBrowser.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
/**
|
||||||
|
* FileBrowser -- Folder-tree file browser for workspace.
|
||||||
|
*
|
||||||
|
* Uses useFileContext() for folders (shared state with Dateien page).
|
||||||
|
* Uses FolderTree with showFiles=true so folders and files render inline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
import FolderTree from '../../../components/FolderTree/FolderTree';
|
||||||
|
import type { FileNode } from '../../../components/FolderTree/FolderTree';
|
||||||
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
|
import type { WorkspaceFile } from './useWorkspace';
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
instanceId: string;
|
||||||
|
files: WorkspaceFile[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
onFileSelect?: (fileId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
|
instanceId,
|
||||||
|
files,
|
||||||
|
onRefresh,
|
||||||
|
onFileSelect,
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
folders,
|
||||||
|
refreshFolders,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFolders,
|
||||||
|
handleMoveFile,
|
||||||
|
handleMoveFiles: contextMoveFiles,
|
||||||
|
handleFileDelete,
|
||||||
|
handleDownloadFolder,
|
||||||
|
expandedFolderIds,
|
||||||
|
toggleFolderExpanded,
|
||||||
|
} = useFileContext();
|
||||||
|
|
||||||
|
const _folderNodes = useMemo(() =>
|
||||||
|
folders.map(f => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
parentId: f.parentId ?? null,
|
||||||
|
})),
|
||||||
|
[folders],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _fileNodes: FileNode[] = useMemo(() => {
|
||||||
|
let result: WorkspaceFile[] = files;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(f =>
|
||||||
|
f.fileName.toLowerCase().includes(q)
|
||||||
|
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
.sort((a, b) => a.fileName.localeCompare(b.fileName))
|
||||||
|
.map(f => ({
|
||||||
|
id: f.id,
|
||||||
|
fileName: f.fileName,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
fileSize: f.fileSize,
|
||||||
|
folderId: f.folderId ?? null,
|
||||||
|
}));
|
||||||
|
}, [files, searchQuery]);
|
||||||
|
|
||||||
|
const _refreshAll = useCallback(() => {
|
||||||
|
onRefresh();
|
||||||
|
refreshFolders();
|
||||||
|
}, [onRefresh, refreshFolders]);
|
||||||
|
|
||||||
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
|
if (!instanceId || uploading) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('featureInstanceId', instanceId);
|
||||||
|
await api.post('/api/files/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File upload failed:', err);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, uploading, _refreshAll]);
|
||||||
|
|
||||||
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
_uploadFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}, [_uploadFiles]);
|
||||||
|
|
||||||
|
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
_uploadFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}, [_uploadFiles]);
|
||||||
|
|
||||||
|
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
||||||
|
await handleMoveFile(fileId, targetFolderId);
|
||||||
|
onRefresh();
|
||||||
|
}, [handleMoveFile, onRefresh]);
|
||||||
|
|
||||||
|
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
||||||
|
await contextMoveFiles(fileIds, targetFolderId);
|
||||||
|
onRefresh();
|
||||||
|
}, [contextMoveFiles, onRefresh]);
|
||||||
|
|
||||||
|
const _onDeleteFolder = useCallback(async (folderId: string) => {
|
||||||
|
await handleDeleteFolder(folderId);
|
||||||
|
if (selectedFolderId === folderId) setSelectedFolderId(null);
|
||||||
|
onRefresh();
|
||||||
|
}, [handleDeleteFolder, selectedFolderId, onRefresh]);
|
||||||
|
|
||||||
|
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
|
||||||
|
await api.put(`/api/files/${fileId}`, { fileName: newName });
|
||||||
|
onRefresh();
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
const _onDeleteFile = useCallback(async (fileId: string) => {
|
||||||
|
await handleFileDelete(fileId);
|
||||||
|
onRefresh();
|
||||||
|
}, [handleFileDelete, onRefresh]);
|
||||||
|
|
||||||
|
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
|
||||||
|
await api.post('/api/files/batch-delete', { fileIds });
|
||||||
|
onRefresh();
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
|
||||||
|
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
|
||||||
|
refreshFolders();
|
||||||
|
onRefresh();
|
||||||
|
}, [refreshFolders, onRefresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ padding: 8, position: 'relative', display: 'flex', flexDirection: 'column', gap: 4 }}
|
||||||
|
onDragOver={_handleDragOver}
|
||||||
|
onDragLeave={_handleDragLeave}
|
||||||
|
onDrop={_handleDrop}
|
||||||
|
>
|
||||||
|
{isDragOver && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
background: 'rgba(25, 118, 210, 0.08)',
|
||||||
|
border: '2px dashed #1976d2', borderRadius: 8,
|
||||||
|
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 13, fontWeight: 600, color: '#1976d2',
|
||||||
|
}}>
|
||||||
|
Dateien hier ablegen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
|
||||||
|
title="Upload files"
|
||||||
|
>
|
||||||
|
{uploading ? '...' : '+'}
|
||||||
|
</button>
|
||||||
|
<button onClick={_refreshAll} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Dateien suchen..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
|
||||||
|
border: '1px solid #ddd', boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Folder tree with inline files */}
|
||||||
|
<FolderTree
|
||||||
|
folders={_folderNodes}
|
||||||
|
files={_fileNodes}
|
||||||
|
showFiles={true}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
onSelect={setSelectedFolderId}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
expandedIds={expandedFolderIds}
|
||||||
|
onToggleExpand={toggleFolderExpanded}
|
||||||
|
onRefresh={_refreshAll}
|
||||||
|
onCreateFolder={handleCreateFolder}
|
||||||
|
onRenameFolder={handleRenameFolder}
|
||||||
|
onDeleteFolder={_onDeleteFolder}
|
||||||
|
onMoveFolder={handleMoveFolder}
|
||||||
|
onMoveFolders={handleMoveFolders}
|
||||||
|
onMoveFile={_onMoveFile}
|
||||||
|
onMoveFiles={_onMoveFiles}
|
||||||
|
onRenameFile={_onRenameFile}
|
||||||
|
onDeleteFile={_onDeleteFile}
|
||||||
|
onDeleteFiles={_onDeleteFiles}
|
||||||
|
onDeleteFolders={_onDeleteFolders}
|
||||||
|
onDownloadFolder={handleDownloadFolder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{_fileNodes.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
src/pages/views/workspace/FilePreview.tsx
Normal file
153
src/pages/views/workspace/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* FilePreview -- File preview / editor panel in the right sidebar.
|
||||||
|
*
|
||||||
|
* Displays content preview for selected files based on their MIME type:
|
||||||
|
* - Text files: rendered as text with optional editing
|
||||||
|
* - Images: rendered as preview
|
||||||
|
* - PDFs: link to download
|
||||||
|
* - Other: metadata display
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
import type { WorkspaceFile } from './useWorkspace';
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
instanceId: string;
|
||||||
|
fileId: string | null;
|
||||||
|
files: WorkspaceFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, files }) => {
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const file = fileId ? files.find(f => f.id === fileId) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContent(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
if (!file || !instanceId) return;
|
||||||
|
|
||||||
|
const isText = _isTextMime(file.mimeType);
|
||||||
|
const isImage = file.mimeType.startsWith('image/');
|
||||||
|
|
||||||
|
if (isText && file.fileSize < 500_000) {
|
||||||
|
setLoading(true);
|
||||||
|
api.get(`/api/files/${file.id}/download`, { responseType: 'text' })
|
||||||
|
.then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)))
|
||||||
|
.catch(() => setContent(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else if (isImage) {
|
||||||
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
|
setPreviewUrl(`${baseUrl}/api/files/${file.id}/download`);
|
||||||
|
}
|
||||||
|
}, [file, instanceId]);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center', color: '#999', fontSize: 13 }}>
|
||||||
|
Select a file to preview
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{file.fileName}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
||||||
|
<span>{file.mimeType}</span>
|
||||||
|
<span>{_formatFileSize(file.fileSize)}</span>
|
||||||
|
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
||||||
|
</div>
|
||||||
|
{file.description && (
|
||||||
|
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>{file.description}</div>
|
||||||
|
)}
|
||||||
|
{file.tags && file.tags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||||
|
{file.tags.map(tag => (
|
||||||
|
<span key={tag} style={{ fontSize: 10, padding: '1px 6px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content !== null && !loading && (
|
||||||
|
<pre style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
padding: 8,
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: 4,
|
||||||
|
margin: 0,
|
||||||
|
maxHeight: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewUrl && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 8 }}>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.fileName}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 4, objectFit: 'contain' }}
|
||||||
|
onError={() => setPreviewUrl(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && content === null && !previewUrl && (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||||
|
{file.fileSize > 500_000
|
||||||
|
? 'File too large for inline preview'
|
||||||
|
: `No preview available for ${file.mimeType}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _isTextMime(mime: string): boolean {
|
||||||
|
if (mime.startsWith('text/')) return true;
|
||||||
|
const textTypes = [
|
||||||
|
'application/json',
|
||||||
|
'application/xml',
|
||||||
|
'application/javascript',
|
||||||
|
'application/typescript',
|
||||||
|
'application/x-python',
|
||||||
|
'application/x-yaml',
|
||||||
|
'application/yaml',
|
||||||
|
'application/sql',
|
||||||
|
'application/csv',
|
||||||
|
];
|
||||||
|
return textTypes.includes(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatFileSize(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`;
|
||||||
|
}
|
||||||
83
src/pages/views/workspace/ToolActivityLog.tsx
Normal file
83
src/pages/views/workspace/ToolActivityLog.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* ToolActivityLog -- Real-time tool call activity display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ToolActivity } from './useWorkspace';
|
||||||
|
|
||||||
|
interface ToolActivityLogProps {
|
||||||
|
activities: ToolActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
|
||||||
|
if (!activities.length) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||||
|
No tool activity yet
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8 }}>
|
||||||
|
{activities.map(activity => (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
marginBottom: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
border: `1px solid ${
|
||||||
|
activity.status === 'calling'
|
||||||
|
? '#ffc107'
|
||||||
|
: activity.status === 'success'
|
||||||
|
? '#4caf50'
|
||||||
|
: '#f44336'
|
||||||
|
}30`,
|
||||||
|
background: activity.status === 'calling'
|
||||||
|
? '#fff8e1'
|
||||||
|
: activity.status === 'success'
|
||||||
|
? '#e8f5e9'
|
||||||
|
: '#ffebee',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{activity.toolName}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: activity.status === 'calling'
|
||||||
|
? '#ffc107'
|
||||||
|
: activity.status === 'success'
|
||||||
|
? '#4caf50'
|
||||||
|
: '#f44336',
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
{activity.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{activity.args && Object.keys(activity.args).length > 0 && (
|
||||||
|
<div style={{ marginTop: 4, color: '#666', fontSize: 11 }}>
|
||||||
|
{Object.entries(activity.args)
|
||||||
|
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activity.result && (
|
||||||
|
<div style={{ marginTop: 4, color: '#388e3c', fontSize: 11, maxHeight: 60, overflow: 'hidden' }}>
|
||||||
|
{activity.result.slice(0, 200)}
|
||||||
|
{activity.result.length > 200 && '...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activity.error && (
|
||||||
|
<div style={{ marginTop: 4, color: '#c62828', fontSize: 11 }}>
|
||||||
|
{activity.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
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;
|
||||||
712
src/pages/views/workspace/WorkspaceInput.tsx
Normal file
712
src/pages/views/workspace/WorkspaceInput.tsx
Normal file
|
|
@ -0,0 +1,712 @@
|
||||||
|
/**
|
||||||
|
* WorkspaceInput -- Prompt input with @file autocomplete, attachment bar,
|
||||||
|
* voice toggle (generic audio capture hook), and data source selection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||||||
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
|
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
||||||
|
|
||||||
|
const _STT_LANGUAGES = [
|
||||||
|
{ code: 'de-DE', label: 'Deutsch' },
|
||||||
|
{ code: 'en-US', label: 'English (US)' },
|
||||||
|
{ code: 'en-GB', label: 'English (UK)' },
|
||||||
|
{ code: 'fr-FR', label: 'Francais' },
|
||||||
|
{ code: 'it-IT', label: 'Italiano' },
|
||||||
|
{ code: 'es-ES', label: 'Espanol' },
|
||||||
|
{ code: 'pt-BR', label: 'Portugues' },
|
||||||
|
{ code: 'nl-NL', label: 'Nederlands' },
|
||||||
|
{ code: 'pl-PL', label: 'Polski' },
|
||||||
|
{ code: 'ru-RU', label: 'Russkij' },
|
||||||
|
{ code: 'ja-JP', label: 'Japanese' },
|
||||||
|
{ code: 'zh-CN', label: 'Chinese' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PendingFile {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
itemType?: 'file' | 'folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeItemDrop {
|
||||||
|
id: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceInputProps {
|
||||||
|
instanceId: string;
|
||||||
|
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void;
|
||||||
|
isProcessing: boolean;
|
||||||
|
onStop: () => void;
|
||||||
|
files: WorkspaceFile[];
|
||||||
|
dataSources: DataSource[];
|
||||||
|
featureDataSources?: FeatureDataSource[];
|
||||||
|
pendingFiles?: PendingFile[];
|
||||||
|
onRemovePendingFile?: (fileId: string) => void;
|
||||||
|
onFileUploadClick?: () => void;
|
||||||
|
uploading?: boolean;
|
||||||
|
selectedProviders?: string[];
|
||||||
|
onProvidersChange?: (providers: string[]) => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
||||||
|
onPasteAsFile?: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
|
instanceId: _instanceId,
|
||||||
|
onSend,
|
||||||
|
isProcessing,
|
||||||
|
onStop,
|
||||||
|
files,
|
||||||
|
dataSources,
|
||||||
|
featureDataSources = [],
|
||||||
|
pendingFiles = [],
|
||||||
|
onRemovePendingFile,
|
||||||
|
onFileUploadClick,
|
||||||
|
uploading = false,
|
||||||
|
selectedProviders = [],
|
||||||
|
onProvidersChange,
|
||||||
|
isMobile = false,
|
||||||
|
onTreeItemsDrop,
|
||||||
|
onPasteAsFile,
|
||||||
|
}) => {
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||||
|
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||||
|
const [voiceActive, setVoiceActive] = useState(false);
|
||||||
|
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
|
||||||
|
const [showLangPicker, setShowLangPicker] = useState(false);
|
||||||
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||||
|
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||||
|
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const promptBeforeVoiceRef = useRef('');
|
||||||
|
const finalizedTextRef = useRef('');
|
||||||
|
const currentInterimRef = useRef('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
||||||
|
}, [voiceLanguage]);
|
||||||
|
|
||||||
|
const _extractFileRefs = useCallback(
|
||||||
|
(text: string): string[] => {
|
||||||
|
const pattern = /@([\w.\-]+)/g;
|
||||||
|
const matched: string[] = [];
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const ref = match[1];
|
||||||
|
const file = files.find(
|
||||||
|
f => f.fileName === ref || f.fileName.toLowerCase() === ref.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (file && !matched.includes(file.id)) {
|
||||||
|
matched.push(file.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
},
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _handleSend = useCallback(() => {
|
||||||
|
const trimmed = prompt.trim();
|
||||||
|
if (!trimmed || isProcessing) return;
|
||||||
|
const inlineFileIds = _extractFileRefs(trimmed);
|
||||||
|
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
||||||
|
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds);
|
||||||
|
setPrompt('');
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setShowSourcePicker(false);
|
||||||
|
setAttachedFileIds([]);
|
||||||
|
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]);
|
||||||
|
|
||||||
|
const _handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
_handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_handleSend],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setPrompt(value);
|
||||||
|
const cursorPos = e.target.selectionStart;
|
||||||
|
const textBeforeCursor = value.slice(0, cursorPos);
|
||||||
|
const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/);
|
||||||
|
if (atMatch) {
|
||||||
|
setAutocompleteFilter(atMatch[1].toLowerCase());
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
} else {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _insertFileRef = useCallback(
|
||||||
|
(fileName: string) => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const textBefore = prompt.slice(0, cursorPos);
|
||||||
|
const textAfter = prompt.slice(cursorPos);
|
||||||
|
const atStart = textBefore.lastIndexOf('@');
|
||||||
|
const newText = textBefore.slice(0, atStart) + `@${fileName} ` + textAfter;
|
||||||
|
setPrompt(newText);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
textarea.focus();
|
||||||
|
},
|
||||||
|
[prompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _removeAttachedFile = useCallback((fileId: string) => {
|
||||||
|
setAttachedFileIds(prev => prev.filter(id => id !== fileId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _removeAttachedDataSource = useCallback((dsId: string) => {
|
||||||
|
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||||
|
|
||||||
|
const _toggleDataSource = useCallback((dsId: string) => {
|
||||||
|
setAttachedDataSourceIds(prev =>
|
||||||
|
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
|
||||||
|
setAttachedFeatureDataSourceIds(prev =>
|
||||||
|
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _buildPromptFromRefs = useCallback(() => {
|
||||||
|
const parts = [
|
||||||
|
promptBeforeVoiceRef.current,
|
||||||
|
finalizedTextRef.current,
|
||||||
|
currentInterimRef.current,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' ');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const voiceStream = useVoiceStream({
|
||||||
|
onFinal: (text) => {
|
||||||
|
finalizedTextRef.current = finalizedTextRef.current
|
||||||
|
? `${finalizedTextRef.current} ${text}`
|
||||||
|
: text;
|
||||||
|
currentInterimRef.current = '';
|
||||||
|
setPrompt(_buildPromptFromRefs());
|
||||||
|
},
|
||||||
|
onInterim: (text) => {
|
||||||
|
currentInterimRef.current = text;
|
||||||
|
setPrompt(_buildPromptFromRefs());
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.warn('Workspace voice stream error', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const _stopVoiceCapture = useCallback(() => {
|
||||||
|
if (currentInterimRef.current) {
|
||||||
|
finalizedTextRef.current = finalizedTextRef.current
|
||||||
|
? `${finalizedTextRef.current} ${currentInterimRef.current}`
|
||||||
|
: currentInterimRef.current;
|
||||||
|
currentInterimRef.current = '';
|
||||||
|
}
|
||||||
|
setPrompt(_buildPromptFromRefs());
|
||||||
|
voiceStream.stop();
|
||||||
|
setVoiceActive(false);
|
||||||
|
}, [voiceStream, _buildPromptFromRefs]);
|
||||||
|
|
||||||
|
const _toggleVoice = useCallback(async () => {
|
||||||
|
if (voiceActive) {
|
||||||
|
_stopVoiceCapture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
promptBeforeVoiceRef.current = prompt;
|
||||||
|
finalizedTextRef.current = '';
|
||||||
|
currentInterimRef.current = '';
|
||||||
|
try {
|
||||||
|
setVoiceActive(true);
|
||||||
|
await voiceStream.start(voiceLanguage);
|
||||||
|
} catch {
|
||||||
|
setVoiceActive(false);
|
||||||
|
}
|
||||||
|
}, [voiceActive, prompt, voiceStream, voiceLanguage, _stopVoiceCapture]);
|
||||||
|
|
||||||
|
const filteredFiles = showAutocomplete
|
||||||
|
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
|
||||||
|
const _horizontalPadding = isMobile ? 12 : 24;
|
||||||
|
const _controlSize = isMobile ? 38 : 40;
|
||||||
|
|
||||||
|
const _handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!onPasteAsFile) return;
|
||||||
|
const text = e.clipboardData.getData('text/plain');
|
||||||
|
if (text && text.length >= 1000) {
|
||||||
|
e.preventDefault();
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
|
const file = new File([blob], `pasted-text-${Date.now()}.txt`, { type: 'text/plain' });
|
||||||
|
onPasteAsFile(file);
|
||||||
|
}
|
||||||
|
}, [onPasteAsFile]);
|
||||||
|
|
||||||
|
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.types.includes('application/tree-items')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
setTreeDropOver(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||||
|
|
||||||
|
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
|
if (treeItemsJson && onTreeItemsDrop) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setTreeDropOver(false);
|
||||||
|
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
||||||
|
onTreeItemsDrop(items);
|
||||||
|
}
|
||||||
|
}, [onTreeItemsDrop]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
outline: treeDropOver ? '2px dashed #1976d2' : 'none',
|
||||||
|
background: treeDropOver ? 'rgba(25, 118, 210, 0.04)' : undefined,
|
||||||
|
transition: 'background 0.15s, outline 0.15s',
|
||||||
|
}}
|
||||||
|
onDragOver={_handlePromptDragOver}
|
||||||
|
onDragLeave={_handlePromptDragLeave}
|
||||||
|
onDrop={_handlePromptDrop}
|
||||||
|
>
|
||||||
|
{/* Pending uploaded files */}
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
padding: `6px ${_horizontalPadding}px`,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
||||||
|
background: 'var(--bg-secondary, #fafafa)',
|
||||||
|
}}>
|
||||||
|
{pendingFiles.map(pf => (
|
||||||
|
<span
|
||||||
|
key={pf.fileId}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
||||||
|
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
||||||
|
fontWeight: 500,
|
||||||
|
border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
|
||||||
|
{onRemovePendingFile && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRemovePendingFile(pf.fileId)}
|
||||||
|
style={{
|
||||||
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 12, color: '#e65100', padding: 0, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachment bar */}
|
||||||
|
{hasAttachments && (
|
||||||
|
<div style={{
|
||||||
|
padding: `6px ${_horizontalPadding}px`,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}>
|
||||||
|
{attachedFileIds.map(fId => {
|
||||||
|
const file = files.find(f => f.id === fId);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={fId}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📄 {file?.fileName || fId}
|
||||||
|
<button
|
||||||
|
onClick={() => _removeAttachedFile(fId)}
|
||||||
|
style={{
|
||||||
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 12, color: '#1565c0', padding: 0, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attachedDataSourceIds.map(dsId => {
|
||||||
|
const ds = dataSources.find(d => d.id === dsId);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={dsId}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔗 {ds?.label || ds?.path || dsId}
|
||||||
|
<button
|
||||||
|
onClick={() => _removeAttachedDataSource(dsId)}
|
||||||
|
style={{
|
||||||
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 12, color: '#2e7d32', padding: 0, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attachedFeatureDataSourceIds.map(fdsId => {
|
||||||
|
const fds = featureDataSources.find(d => d.id === fdsId);
|
||||||
|
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={fdsId}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
||||||
|
{fds?.label || fdsId} – {fds?.tableName || ''}
|
||||||
|
<button
|
||||||
|
onClick={() => _toggleFeatureDataSource(fdsId)}
|
||||||
|
style={{
|
||||||
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 12, color: '#7b1fa2', padding: 0, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown */}
|
||||||
|
{showAutocomplete && filteredFiles.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: _horizontalPadding,
|
||||||
|
right: _horizontalPadding,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 -2px 8px rgba(0,0,0,0.1)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
{filteredFiles.slice(0, 10).map(f => (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => _insertFileRef(f.fileName)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = '')}
|
||||||
|
>
|
||||||
|
@{f.fileName}
|
||||||
|
<span style={{ color: '#999', marginLeft: 8, fontSize: 11 }}>
|
||||||
|
{f.mimeType} · {(f.fileSize / 1024).toFixed(1)}KB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main input row */}
|
||||||
|
<div style={{
|
||||||
|
padding: `8px ${_horizontalPadding}px 12px`,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: isMobile ? 'stretch' : 'flex-end',
|
||||||
|
flexWrap: isMobile ? 'wrap' : 'nowrap',
|
||||||
|
}}>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={prompt}
|
||||||
|
onChange={_handleChange}
|
||||||
|
onKeyDown={_handleKeyDown}
|
||||||
|
onPaste={_handlePaste}
|
||||||
|
placeholder="Type a message... Use @filename to reference files"
|
||||||
|
disabled={isProcessing}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: isMobile ? 44 : 40,
|
||||||
|
maxHeight: 120,
|
||||||
|
resize: 'vertical',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-color, #ccc)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
flexBasis: isMobile ? '100%' : undefined,
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onFileUploadClick}
|
||||||
|
disabled={uploading || isProcessing}
|
||||||
|
title="Datei anhängen"
|
||||||
|
style={{
|
||||||
|
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
|
||||||
|
background: 'var(--secondary-bg, #f5f5f5)',
|
||||||
|
color: uploading ? '#1976d2' : '#666',
|
||||||
|
cursor: uploading || isProcessing ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: isProcessing ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? '...' : '+'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dataSources.length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSourcePicker(prev => !prev)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
title="Datenquellen anhängen"
|
||||||
|
style={{
|
||||||
|
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
|
||||||
|
background: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
|
||||||
|
color: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#2e7d32' : '#666',
|
||||||
|
cursor: isProcessing ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: isProcessing ? 0.5 : 1,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
{(attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: -4, right: -4,
|
||||||
|
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
|
||||||
|
borderRadius: '50%', width: 16, height: 16,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{attachedDataSourceIds.length + attachedFeatureDataSourceIds.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showSourcePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
|
||||||
|
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||||
|
minWidth: 240, maxHeight: 260, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
Active Sources auswählen
|
||||||
|
</div>
|
||||||
|
{dataSources.map(ds => {
|
||||||
|
const isSelected = attachedDataSourceIds.includes(ds.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ds.id}
|
||||||
|
onClick={() => _toggleDataSource(ds.id)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: isSelected ? '#e8f5e9' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 16, height: 16, borderRadius: 3,
|
||||||
|
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
|
||||||
|
background: isSelected ? '#2e7d32' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{isSelected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{ds.label || ds.path || ds.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{featureDataSources.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
Feature Data Sources
|
||||||
|
</div>
|
||||||
|
{featureDataSources.map(fds => {
|
||||||
|
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fds.id}
|
||||||
|
onClick={() => _toggleFeatureDataSource(fds.id)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: isSelected ? '#f3e5f5' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
width: 16, height: 16, borderRadius: 3,
|
||||||
|
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
|
||||||
|
background: isSelected ? '#7b1fa2' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{isSelected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', fontSize: 13, color: '#7b1fa2', flexShrink: 0 }}>
|
||||||
|
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{fds.label || fds.featureCode} – {fds.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onProvidersChange && (
|
||||||
|
<ProviderMultiSelect
|
||||||
|
selectedProviders={selectedProviders}
|
||||||
|
onChange={onProvidersChange}
|
||||||
|
showLabel={false}
|
||||||
|
excludeByDefault={['privatellm']}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLangPicker(prev => !prev)}
|
||||||
|
title="Sprache waehlen"
|
||||||
|
style={{
|
||||||
|
height: _controlSize, borderRadius: '8px 0 0 8px', border: '1px solid var(--border-color, #ddd)',
|
||||||
|
borderRight: 'none',
|
||||||
|
background: 'var(--secondary-bg, #f5f5f5)',
|
||||||
|
color: '#666', cursor: 'pointer', fontSize: 10, padding: '0 6px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{voiceLanguage.split('-')[0].toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_toggleVoice}
|
||||||
|
title={voiceActive ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
|
||||||
|
style={{
|
||||||
|
width: _controlSize, height: _controlSize, borderRadius: '0 8px 8px 0', border: 'none',
|
||||||
|
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
|
||||||
|
color: voiceActive ? '#fff' : '#666',
|
||||||
|
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{voiceActive ? '■' : '\uD83C\uDFA4'}
|
||||||
|
</button>
|
||||||
|
{showLangPicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||||||
|
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||||
|
maxHeight: 240, overflowY: 'auto', minWidth: 160,
|
||||||
|
}}>
|
||||||
|
{_STT_LANGUAGES.map(lang => (
|
||||||
|
<div
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||||
|
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
|
||||||
|
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
|
||||||
|
>
|
||||||
|
{lang.label} ({lang.code})
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isProcessing ? (
|
||||||
|
<button
|
||||||
|
onClick={onStop}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px', borderRadius: 8, border: 'none',
|
||||||
|
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600,
|
||||||
|
minWidth: isMobile ? 84 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={_handleSend}
|
||||||
|
disabled={!prompt.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px', borderRadius: 8, border: 'none',
|
||||||
|
background: prompt.trim() ? 'var(--primary-color, #1976d2)' : '#ccc',
|
||||||
|
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
|
||||||
|
minWidth: isMobile ? 84 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/pages/views/workspace/WorkspaceKeepAlive.tsx
Normal file
48
src/pages/views/workspace/WorkspaceKeepAlive.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* WorkspaceKeepAlive
|
||||||
|
*
|
||||||
|
* Renders the WorkspacePage permanently at the MainLayout level so it
|
||||||
|
* survives route changes. Visibility is toggled via CSS `display`
|
||||||
|
* instead of mount / unmount, preserving messages, SSE connections,
|
||||||
|
* files, and all other workspace state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { WorkspacePage } from './WorkspacePage';
|
||||||
|
|
||||||
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
|
||||||
|
|
||||||
|
interface WorkspaceKeepAliveProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const cachedInstanceIdRef = useRef<string>('');
|
||||||
|
|
||||||
|
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
|
||||||
|
if (match?.[2]) {
|
||||||
|
cachedInstanceIdRef.current = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceId = cachedInstanceIdRef.current;
|
||||||
|
if (!instanceId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: isVisible ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'var(--mobile-topbar-height, 0px)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkspacePage persistentInstanceId={instanceId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
523
src/pages/views/workspace/WorkspacePage.tsx
Normal file
523
src/pages/views/workspace/WorkspacePage.tsx
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
/**
|
||||||
|
* WorkspacePage -- Unified AI Workspace
|
||||||
|
*
|
||||||
|
* 3-column layout:
|
||||||
|
* Left sidebar: ConversationList, FileBrowser, DataSourcePanel
|
||||||
|
* Center: ChatStream + WorkspaceInput
|
||||||
|
* Right sidebar: FilePreview, ToolActivityLog
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
import { ChatStream } from './ChatStream';
|
||||||
|
import { WorkspaceInput } from './WorkspaceInput';
|
||||||
|
import { ConversationList } from './ConversationList';
|
||||||
|
import { FileBrowser } from './FileBrowser';
|
||||||
|
import { DataSourcePanel } from './DataSourcePanel';
|
||||||
|
import { FilePreview } from './FilePreview';
|
||||||
|
import { ToolActivityLog } from './ToolActivityLog';
|
||||||
|
|
||||||
|
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const dragging = useRef(false);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startW = useRef(0);
|
||||||
|
|
||||||
|
const _onMouseDown = useCallback((e: React.MouseEvent, direction: 1 | -1 = 1) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging.current = true;
|
||||||
|
startX.current = e.clientX;
|
||||||
|
startW.current = width;
|
||||||
|
|
||||||
|
const _onMouseMove = (ev: MouseEvent) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
const delta = (ev.clientX - startX.current) * direction;
|
||||||
|
setWidth(Math.max(minWidth, Math.min(maxWidth, startW.current + delta)));
|
||||||
|
};
|
||||||
|
const _onMouseUp = () => {
|
||||||
|
dragging.current = false;
|
||||||
|
document.removeEventListener('mousemove', _onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', _onMouseUp);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', _onMouseMove);
|
||||||
|
document.addEventListener('mouseup', _onMouseUp);
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}, [width, minWidth, maxWidth]);
|
||||||
|
|
||||||
|
return { width, onMouseDown: _onMouseDown };
|
||||||
|
}
|
||||||
|
type LeftTab = 'conversations' | 'files' | 'datasources';
|
||||||
|
type RightTab = 'activity' | 'preview';
|
||||||
|
|
||||||
|
interface PendingFile {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
itemType?: 'file' | 'folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspacePageProps {
|
||||||
|
persistentInstanceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId }) => {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
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);
|
||||||
|
const _rightResize = _useResizable(320, 200, 500);
|
||||||
|
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
|
||||||
|
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
||||||
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||||
|
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const dragCounterRef = useRef(0);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
||||||
|
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
|
||||||
|
);
|
||||||
|
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||||
|
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const _handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth <= 1024);
|
||||||
|
};
|
||||||
|
_handleResize();
|
||||||
|
window.addEventListener('resize', _handleResize);
|
||||||
|
return () => window.removeEventListener('resize', _handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) {
|
||||||
|
setMobileLeftOpen(false);
|
||||||
|
setMobileRightOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const _uploadAndAttach = useCallback(async (file: File) => {
|
||||||
|
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
||||||
|
if (result.success && result.fileData) {
|
||||||
|
const data = result.fileData.file || result.fileData;
|
||||||
|
if (data?.id) {
|
||||||
|
setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]);
|
||||||
|
}
|
||||||
|
workspace.refreshFiles();
|
||||||
|
}
|
||||||
|
}, [fileOps, workspace, instanceId]);
|
||||||
|
|
||||||
|
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 droppedFiles = e.dataTransfer.files;
|
||||||
|
if (droppedFiles.length > 0) {
|
||||||
|
for (const file of Array.from(droppedFiles)) {
|
||||||
|
await _uploadAndAttach(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [_uploadAndAttach]);
|
||||||
|
|
||||||
|
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
Array.from(e.target.files).forEach(file => _uploadAndAttach(file));
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}, [_uploadAndAttach]);
|
||||||
|
|
||||||
|
const _handleRemovePendingFile = useCallback((fileId: string) => {
|
||||||
|
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => {
|
||||||
|
setPendingFiles(prev => {
|
||||||
|
const existing = new Set(prev.map(f => f.fileId));
|
||||||
|
const toAdd: PendingFile[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!existing.has(item.id)) {
|
||||||
|
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
|
||||||
|
existing.add(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...prev, ...toAdd];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||||
|
No workspace instance selected.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _handleFileSelect = (fileId: string) => {
|
||||||
|
setSelectedFileId(fileId);
|
||||||
|
setRightTab('preview');
|
||||||
|
setRightCollapsed(false);
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileRightOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleConversationSelect = (wfId: string) => {
|
||||||
|
workspace.loadWorkflow(wfId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 0',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: active ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
color: active ? 'var(--primary-color, #1976d2)' : '#888',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _leftPanelBody = (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
|
<button style={tabButtonStyle(leftTab === 'conversations')} onClick={() => setLeftTab('conversations')}>Chats</button>
|
||||||
|
<button style={tabButtonStyle(leftTab === 'files')} onClick={() => setLeftTab('files')}>Files</button>
|
||||||
|
<button style={tabButtonStyle(leftTab === 'datasources')} onClick={() => setLeftTab('datasources')}>Sources</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
{leftTab === 'conversations' && (
|
||||||
|
<ConversationList
|
||||||
|
instanceId={instanceId}
|
||||||
|
activeWorkflowId={workspace.workflowId}
|
||||||
|
onSelect={_handleConversationSelect}
|
||||||
|
onCreateNew={workspace.resetToNew}
|
||||||
|
refreshTrigger={workspace.workflowVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{leftTab === 'files' && (
|
||||||
|
<FileBrowser
|
||||||
|
instanceId={instanceId}
|
||||||
|
files={workspace.files}
|
||||||
|
onRefresh={workspace.refreshFiles}
|
||||||
|
onFileSelect={_handleFileSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{leftTab === 'datasources' && (
|
||||||
|
<DataSourcePanel
|
||||||
|
instanceId={instanceId}
|
||||||
|
dataSources={workspace.dataSources}
|
||||||
|
featureDataSources={workspace.featureDataSources}
|
||||||
|
onRefresh={workspace.refreshDataSources}
|
||||||
|
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const _rightPanelBody = (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button style={tabButtonStyle(rightTab === 'activity')} onClick={() => setRightTab('activity')}>Activity</button>
|
||||||
|
<button style={tabButtonStyle(rightTab === 'preview')} onClick={() => setRightTab('preview')}>Preview</button>
|
||||||
|
</div>
|
||||||
|
{!isMobile && (
|
||||||
|
<button onClick={() => setRightCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>▶</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
{rightTab === 'activity' && (
|
||||||
|
<ToolActivityLog activities={workspace.toolActivities} />
|
||||||
|
)}
|
||||||
|
{rightTab === 'preview' && (
|
||||||
|
<FilePreview
|
||||||
|
instanceId={instanceId}
|
||||||
|
fileId={selectedFileId}
|
||||||
|
files={workspace.files}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
{/* Left sidebar */}
|
||||||
|
{!isMobile && !leftCollapsed && (
|
||||||
|
<aside style={{
|
||||||
|
width: _leftResize.width,
|
||||||
|
minWidth: 200,
|
||||||
|
borderRight: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
|
||||||
|
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>◀</button>
|
||||||
|
</div>
|
||||||
|
{_leftPanelBody}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left resize handle */}
|
||||||
|
{!isMobile && !leftCollapsed && (
|
||||||
|
<div
|
||||||
|
onMouseDown={e => _leftResize.onMouseDown(e, 1)}
|
||||||
|
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && leftCollapsed && (
|
||||||
|
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderRight: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
|
<button onClick={() => setLeftCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>▶</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
|
||||||
|
|
||||||
|
{/* Center - Chat + Input */}
|
||||||
|
<main
|
||||||
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, minHeight: 0, position: 'relative' }}
|
||||||
|
onDragEnter={_handleDragEnter}
|
||||||
|
onDragLeave={_handleDragLeave}
|
||||||
|
onDragOver={_handleDragOver}
|
||||||
|
onDrop={_handleDrop}
|
||||||
|
>
|
||||||
|
{isMobile && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileLeftOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-color, #ddd)',
|
||||||
|
background: '#f7f7f7',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Workspace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRightTab('activity'); setMobileRightOpen(true); }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-color, #ddd)',
|
||||||
|
background: rightTab === 'activity' ? '#e8f3ff' : '#f7f7f7',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRightTab('preview'); setMobileRightOpen(true); }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-color, #ddd)',
|
||||||
|
background: rightTab === 'preview' ? '#e8f3ff' : '#f7f7f7',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isDragOver && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(25, 118, 210, 0.08)',
|
||||||
|
border: '2px dashed #1976d2', borderRadius: 8,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 16, fontWeight: 600, color: '#1976d2',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
Dateien hier ablegen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChatStream
|
||||||
|
messages={workspace.messages}
|
||||||
|
agentProgress={workspace.agentProgress}
|
||||||
|
isProcessing={workspace.isProcessing}
|
||||||
|
pendingEdits={workspace.pendingEdits}
|
||||||
|
onAcceptEdit={workspace.acceptEdit}
|
||||||
|
onRejectEdit={workspace.rejectEdit}
|
||||||
|
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
|
||||||
|
/>
|
||||||
|
<WorkspaceInput
|
||||||
|
instanceId={instanceId}
|
||||||
|
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds) => {
|
||||||
|
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
|
||||||
|
workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds);
|
||||||
|
setPendingFiles([]);
|
||||||
|
}}
|
||||||
|
isProcessing={workspace.isProcessing}
|
||||||
|
onStop={workspace.stopProcessing}
|
||||||
|
files={workspace.files}
|
||||||
|
dataSources={workspace.dataSources}
|
||||||
|
featureDataSources={workspace.featureDataSources}
|
||||||
|
pendingFiles={pendingFiles}
|
||||||
|
onRemovePendingFile={_handleRemovePendingFile}
|
||||||
|
onFileUploadClick={() => fileInputRef.current?.click()}
|
||||||
|
uploading={fileOps.uploadingFile}
|
||||||
|
selectedProviders={selectedProviders}
|
||||||
|
onProvidersChange={setSelectedProviders}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onTreeItemsDrop={_handleTreeItemsDrop}
|
||||||
|
onPasteAsFile={_uploadAndAttach}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right resize handle */}
|
||||||
|
{!isMobile && !rightCollapsed && (
|
||||||
|
<div
|
||||||
|
onMouseDown={e => _rightResize.onMouseDown(e, -1)}
|
||||||
|
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right sidebar */}
|
||||||
|
{!isMobile && !rightCollapsed && (
|
||||||
|
<aside style={{
|
||||||
|
width: _rightResize.width,
|
||||||
|
minWidth: 200,
|
||||||
|
borderLeft: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{_rightPanelBody}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && rightCollapsed && (
|
||||||
|
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderLeft: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
|
<button onClick={() => setRightCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>◀</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && mobileLeftOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.35)',
|
||||||
|
zIndex: 120,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
onClick={() => setMobileLeftOpen(false)}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 460,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
borderRight: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
|
||||||
|
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
||||||
|
</div>
|
||||||
|
{_leftPanelBody}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && mobileRightOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.35)',
|
||||||
|
zIndex: 120,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
onClick={() => setMobileRightOpen(false)}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 460,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
borderLeft: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{rightTab === 'activity' ? 'Activity' : 'Preview'}</span>
|
||||||
|
<button onClick={() => setMobileRightOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
||||||
|
</div>
|
||||||
|
{_rightPanelBody}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspacePage;
|
||||||
173
src/pages/views/workspace/WorkspaceSettings.module.css
Normal file
173
src/pages/views/workspace/WorkspaceSettings.module.css
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
.settings {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #fde8e8;
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select, .input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceRow .select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testBtn, .addBtn, .removeBtn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testBtn:hover:not(:disabled),
|
||||||
|
.addBtn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
.testBtn:disabled,
|
||||||
|
.addBtn:disabled {
|
||||||
|
background: var(--color-medium-gray, #ccc);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid var(--color-error, #d32f2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover { background: #fde8e8; }
|
||||||
|
|
||||||
|
.voiceTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceTable th,
|
||||||
|
.voiceTable td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceTable th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
.saveBtn:disabled {
|
||||||
|
background: var(--color-medium-gray, #ccc);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--primary-color, #1976d2);
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn:hover { text-decoration: underline; }
|
||||||
280
src/pages/views/workspace/WorkspaceSettings.tsx
Normal file
280
src/pages/views/workspace/WorkspaceSettings.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
/**
|
||||||
|
* WorkspaceSettings -- Voice preferences per language.
|
||||||
|
*
|
||||||
|
* Allows the user to configure a preferred voice for each TTS language.
|
||||||
|
* Language detection is automatic; this page lets users override the
|
||||||
|
* default Google Cloud voice for specific languages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import styles from './WorkspaceSettings.module.css';
|
||||||
|
|
||||||
|
interface VoiceMapEntry {
|
||||||
|
language: string;
|
||||||
|
voiceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceSettingsProps {
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceSettings: React.FC<WorkspaceSettingsProps> = ({ instanceId }) => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [languages, setLanguages] = useState<any[]>([]);
|
||||||
|
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||||
|
|
||||||
|
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||||
|
const [addVoices, setAddVoices] = useState<any[]>([]);
|
||||||
|
const [addVoiceName, setAddVoiceName] = useState('');
|
||||||
|
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||||
|
|
||||||
|
const _loadSettings = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [settingsData, languagesData] = await Promise.all([
|
||||||
|
request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }),
|
||||||
|
request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const langList = (languagesData as any)?.languages || [];
|
||||||
|
setLanguages(langList);
|
||||||
|
|
||||||
|
const map: Record<string, any> = (settingsData as any)?.ttsVoiceMap || {};
|
||||||
|
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
|
||||||
|
language: lang,
|
||||||
|
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
|
||||||
|
}));
|
||||||
|
setVoiceMap(entries);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden der Einstellungen');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadSettings(); }, [_loadSettings]);
|
||||||
|
|
||||||
|
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoadingVoices(true);
|
||||||
|
try {
|
||||||
|
const result = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/voice/voices`,
|
||||||
|
method: 'get',
|
||||||
|
params: { language: lang },
|
||||||
|
});
|
||||||
|
setAddVoices((result as any)?.voices || []);
|
||||||
|
setAddVoiceName('');
|
||||||
|
} catch {
|
||||||
|
setAddVoices([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingVoices(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
|
||||||
|
|
||||||
|
const _handleAddEntry = useCallback(() => {
|
||||||
|
if (!addLanguage) return;
|
||||||
|
const exists = voiceMap.some(e => e.language === addLanguage);
|
||||||
|
if (exists) {
|
||||||
|
setVoiceMap(prev => prev.map(e =>
|
||||||
|
e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
|
||||||
|
}
|
||||||
|
setAddVoiceName('');
|
||||||
|
}, [addLanguage, addVoiceName, voiceMap]);
|
||||||
|
|
||||||
|
const _handleRemoveEntry = useCallback((lang: string) => {
|
||||||
|
setVoiceMap(prev => prev.filter(e => e.language !== lang));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleSave = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const mapObj: Record<string, any> = {};
|
||||||
|
voiceMap.forEach(e => {
|
||||||
|
mapObj[e.language] = { voiceName: e.voiceName || '' };
|
||||||
|
});
|
||||||
|
const putResult = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/settings/voice`,
|
||||||
|
method: 'put',
|
||||||
|
data: { ttsVoiceMap: mapObj },
|
||||||
|
});
|
||||||
|
if ((putResult as any)?.error) {
|
||||||
|
setError((putResult as any).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuccess('Einstellungen gespeichert');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
await _loadSettings();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, voiceMap]);
|
||||||
|
|
||||||
|
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setTesting(lang);
|
||||||
|
try {
|
||||||
|
const result: any = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/voice/test`,
|
||||||
|
method: 'post',
|
||||||
|
data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` },
|
||||||
|
});
|
||||||
|
if (result?.success && result?.audio) {
|
||||||
|
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Stimmtest fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const _getLanguageName = useCallback((code: string) => {
|
||||||
|
const found = languages.find((l: any) => (l.code || l) === code);
|
||||||
|
return found?.name || found?.code || code;
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaultLangs = [
|
||||||
|
{ code: 'de-DE', name: 'Deutsch' },
|
||||||
|
{ code: 'en-US', name: 'English (US)' },
|
||||||
|
{ code: 'fr-FR', name: 'Francais' },
|
||||||
|
{ code: 'it-IT', name: 'Italiano' },
|
||||||
|
{ code: 'es-ES', name: 'Espanol' },
|
||||||
|
];
|
||||||
|
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.settings}>
|
||||||
|
<h2 className={styles.heading}>Stimmeneinstellungen</h2>
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Konfigurierte Stimmen pro Sprache</h3>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
|
||||||
|
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{voiceMap.length === 0 ? (
|
||||||
|
<div className={styles.emptyHint}>
|
||||||
|
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className={styles.voiceTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Sprache</th>
|
||||||
|
<th>Stimme</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{voiceMap.map(entry => (
|
||||||
|
<tr key={entry.language}>
|
||||||
|
<td>{_getLanguageName(entry.language)}</td>
|
||||||
|
<td>{entry.voiceName || 'Standard'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className={styles.testBtn}
|
||||||
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
||||||
|
onClick={() => _handleTestVoice(entry.language, entry.voiceName)}
|
||||||
|
disabled={testing === entry.language}
|
||||||
|
>
|
||||||
|
{testing === entry.language ? '...' : 'Test'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className={styles.removeBtn} onClick={() => _handleRemoveEntry(entry.language)}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Stimme hinzufuegen / aendern</h3>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>Sprache</label>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={addLanguage}
|
||||||
|
onChange={e => setAddLanguage(e.target.value)}
|
||||||
|
>
|
||||||
|
{_displayLanguages.map((lang: any) => (
|
||||||
|
<option key={lang.code || lang} value={lang.code || lang}>
|
||||||
|
{lang.name || lang.code || lang}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>Stimme</label>
|
||||||
|
<div className={styles.voiceRow}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={addVoiceName}
|
||||||
|
onChange={e => setAddVoiceName(e.target.value)}
|
||||||
|
disabled={loadingVoices}
|
||||||
|
>
|
||||||
|
<option value="">Standard</option>
|
||||||
|
{addVoices.map((v: any) => (
|
||||||
|
<option key={v.name || v} value={v.name || v}>
|
||||||
|
{v.displayName || v.name || v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className={styles.testBtn}
|
||||||
|
onClick={() => _handleTestVoice(addLanguage, addVoiceName)}
|
||||||
|
disabled={testing !== null}
|
||||||
|
>
|
||||||
|
{testing === addLanguage ? '...' : 'Testen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.addBtn} onClick={_handleAddEntry}>
|
||||||
|
Stimme zuweisen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.saveBtn} onClick={_handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceSettings;
|
||||||
72
src/pages/views/workspace/WorkspaceSettingsPage.tsx
Normal file
72
src/pages/views/workspace/WorkspaceSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* WorkspaceSettingsPage -- Tabbed settings for the AI Workspace.
|
||||||
|
*
|
||||||
|
* First tab: Voice / Language (WorkspaceSettings).
|
||||||
|
* Additional tabs can be added here as needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||||
|
|
||||||
|
type SettingsTab = 'voice';
|
||||||
|
|
||||||
|
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||||
|
{ key: 'voice', label: 'Sprache & Stimme' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WorkspaceSettingsPage: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('voice');
|
||||||
|
|
||||||
|
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%' }}>
|
||||||
|
<nav style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 0,
|
||||||
|
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
background: 'var(--bg-secondary, #fafafa)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: activeTab === tab.key
|
||||||
|
? '2px solid var(--primary-color, #1976d2)'
|
||||||
|
: '2px solid transparent',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: activeTab === tab.key ? 600 : 400,
|
||||||
|
color: activeTab === tab.key
|
||||||
|
? 'var(--primary-color, #1976d2)'
|
||||||
|
: 'var(--text-secondary, #888)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||||
|
{activeTab === 'voice' && (
|
||||||
|
<WorkspaceSettings instanceId={instanceId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceSettingsPage;
|
||||||
588
src/pages/views/workspace/useWorkspace.ts
Normal file
588
src/pages/views/workspace/useWorkspace.ts
Normal file
|
|
@ -0,0 +1,588 @@
|
||||||
|
/**
|
||||||
|
* useWorkspace Hook
|
||||||
|
*
|
||||||
|
* Central state management for the Unified AI Workspace.
|
||||||
|
* Manages SSE streaming, messages, files, folders, data sources,
|
||||||
|
* tool activity, voice, and file previews via the shared sseClient.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { startSseStream, SseEvent } from '../../../utils/sseClient';
|
||||||
|
import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
|
||||||
|
export interface AgentProgress {
|
||||||
|
round: number;
|
||||||
|
maxRounds?: number;
|
||||||
|
totalAiCalls: number;
|
||||||
|
totalToolCalls: number;
|
||||||
|
costCHF: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolActivity {
|
||||||
|
id: string;
|
||||||
|
toolName: string;
|
||||||
|
status: 'calling' | 'success' | 'error';
|
||||||
|
args?: Record<string, any>;
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceFile {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
tags?: string[];
|
||||||
|
folderId?: string;
|
||||||
|
status?: string;
|
||||||
|
description?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
featureInstanceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSource {
|
||||||
|
id: string;
|
||||||
|
connectionId: string;
|
||||||
|
sourceType: string;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureDataSource {
|
||||||
|
id: string;
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
tableName: string;
|
||||||
|
objectKey: string;
|
||||||
|
label: string;
|
||||||
|
mandateId: string;
|
||||||
|
workspaceInstanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditProposal {
|
||||||
|
id: string;
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
oldContent?: string;
|
||||||
|
newContent?: string;
|
||||||
|
oldSize?: number;
|
||||||
|
newSize?: number;
|
||||||
|
status: 'pending' | 'accepted' | 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceAccessEvent {
|
||||||
|
sourceType: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWorkspaceReturn {
|
||||||
|
messages: Message[];
|
||||||
|
isProcessing: boolean;
|
||||||
|
sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[]) => void;
|
||||||
|
stopProcessing: () => void;
|
||||||
|
loadWorkflow: (workflowId: string) => void;
|
||||||
|
resetToNew: () => void;
|
||||||
|
files: WorkspaceFile[];
|
||||||
|
folders: WorkspaceFolder[];
|
||||||
|
dataSources: DataSource[];
|
||||||
|
featureDataSources: FeatureDataSource[];
|
||||||
|
refreshFeatureDataSources: () => void;
|
||||||
|
agentProgress: AgentProgress | null;
|
||||||
|
toolActivities: ToolActivity[];
|
||||||
|
pendingEdits: FileEditProposal[];
|
||||||
|
acceptEdit: (editId: string) => void;
|
||||||
|
rejectEdit: (editId: string) => void;
|
||||||
|
workflowId: string | null;
|
||||||
|
workflowVersion: number;
|
||||||
|
refreshFiles: () => void;
|
||||||
|
refreshFolders: () => void;
|
||||||
|
refreshDataSources: () => void;
|
||||||
|
dataSourceAccesses: DataSourceAccessEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
||||||
|
const [folders, setFolders] = useState<WorkspaceFolder[]>([]);
|
||||||
|
const [dataSources, setDataSources] = useState<DataSource[]>([]);
|
||||||
|
const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]);
|
||||||
|
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
||||||
|
const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]);
|
||||||
|
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
|
||||||
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||||
|
const [workflowVersion, setWorkflowVersion] = useState(0);
|
||||||
|
const [dataSourceAccesses, setDataSourceAccesses] = useState<DataSourceAccessEvent[]>([]);
|
||||||
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const refreshFiles = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/files`)
|
||||||
|
.then(res => setFiles(res.data.files || []))
|
||||||
|
.catch(err => console.error('Failed to load workspace files:', err));
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const refreshFolders = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/folders`)
|
||||||
|
.then(res => setFolders(res.data.folders || []))
|
||||||
|
.catch(err => console.error('Failed to load workspace folders:', err));
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const refreshDataSources = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/datasources`)
|
||||||
|
.then(res => setDataSources(res.data.dataSources || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const refreshFeatureDataSources = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/feature-datasources`)
|
||||||
|
.then(res => setFeatureDataSources(res.data.featureDataSources || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
refreshFiles();
|
||||||
|
refreshFolders();
|
||||||
|
refreshDataSources();
|
||||||
|
refreshFeatureDataSources();
|
||||||
|
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources, refreshFeatureDataSources]);
|
||||||
|
|
||||||
|
const loadWorkflow = useCallback((wfId: string) => {
|
||||||
|
if (!instanceId || !wfId) return;
|
||||||
|
setWorkflowId(wfId);
|
||||||
|
setMessages([]);
|
||||||
|
setToolActivities([]);
|
||||||
|
setPendingEdits([]);
|
||||||
|
setAgentProgress(null);
|
||||||
|
setDataSourceAccesses([]);
|
||||||
|
|
||||||
|
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
|
||||||
|
.then(res => {
|
||||||
|
const msgs = (res.data.messages || []).map((m: any) => ({
|
||||||
|
id: m.id || `loaded-${Math.random()}`,
|
||||||
|
workflowId: wfId,
|
||||||
|
role: m.role || 'assistant',
|
||||||
|
message: m.content || m.message || '',
|
||||||
|
publishedAt: m.createdAt || Date.now() / 1000,
|
||||||
|
}));
|
||||||
|
setMessages(msgs);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
const resetToNew = useCallback(() => {
|
||||||
|
setWorkflowId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setToolActivities([]);
|
||||||
|
setPendingEdits([]);
|
||||||
|
setAgentProgress(null);
|
||||||
|
setDataSourceAccesses([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = []) => {
|
||||||
|
if (!instanceId || isProcessing) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setAgentProgress(null);
|
||||||
|
setToolActivities([]);
|
||||||
|
setDataSourceAccesses([]);
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
workflowId: workflowId || '',
|
||||||
|
role: 'user',
|
||||||
|
message: prompt,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cleanupRef.current) {
|
||||||
|
cleanupRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/workspace/${instanceId}/start/stream`;
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
prompt,
|
||||||
|
fileIds,
|
||||||
|
dataSourceIds,
|
||||||
|
featureDataSourceIds,
|
||||||
|
userLanguage: navigator.language?.slice(0, 2) || 'en',
|
||||||
|
};
|
||||||
|
if (workflowId) {
|
||||||
|
body.workflowId = workflowId;
|
||||||
|
}
|
||||||
|
if (allowedProviders.length > 0) {
|
||||||
|
body.allowedProviders = allowedProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRef.current = startSseStream({
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
handlers: {
|
||||||
|
onMessage: (event) => _handleMessage(event, setMessages),
|
||||||
|
onChunk: (event) => _handleChunk(event, setMessages),
|
||||||
|
onStatus: (event) => _handleStatus(event, setMessages),
|
||||||
|
onToolCall: (event) => _handleToolCall(event, setToolActivities),
|
||||||
|
onToolResult: (event) => _handleToolResult(event, setToolActivities),
|
||||||
|
onAgentProgress: (event) => setAgentProgress(event.item || event.data || null),
|
||||||
|
onAgentSummary: (event) => {
|
||||||
|
const s = event.item || event.data || {};
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `summary-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'system',
|
||||||
|
message: `Agent completed: ${s.rounds || '?'} rounds, ${s.totalToolCalls || 0} tool calls, ${s.costCHF || '?'} CHF`,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setAgentProgress(null);
|
||||||
|
},
|
||||||
|
onFileEditProposal: (event) => {
|
||||||
|
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) => {
|
||||||
|
const data = event.item || event.data || {};
|
||||||
|
if (data.fileId) {
|
||||||
|
setPendingEdits(prev =>
|
||||||
|
prev.map(e =>
|
||||||
|
e.fileId === data.fileId
|
||||||
|
? { ...e, status: 'accepted' as const }
|
||||||
|
: e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
refreshFiles();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFileCreated: (event) => {
|
||||||
|
refreshFiles();
|
||||||
|
const data = event.item || event.data || {};
|
||||||
|
if (data.fileId && data.fileName) {
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `file-${data.fileId}-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'assistant',
|
||||||
|
message: '',
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
documents: [{
|
||||||
|
id: data.fileId,
|
||||||
|
messageId: '',
|
||||||
|
fileId: data.fileId,
|
||||||
|
fileName: data.fileName,
|
||||||
|
mimeType: data.mimeType || 'application/octet-stream',
|
||||||
|
fileSize: data.fileSize || 0,
|
||||||
|
roundNumber: 0,
|
||||||
|
taskNumber: 0,
|
||||||
|
actionNumber: 0,
|
||||||
|
actionId: '',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDataSourceAccess: (event) => {
|
||||||
|
const data = event.item || event.data || {};
|
||||||
|
setDataSourceAccesses(prev => [...prev, {
|
||||||
|
sourceType: data.sourceType || '',
|
||||||
|
label: data.label || '',
|
||||||
|
path: data.path || '',
|
||||||
|
action: data.action || 'access',
|
||||||
|
}]);
|
||||||
|
},
|
||||||
|
onVoiceResponse: (event) => {
|
||||||
|
const audioUrl = _buildAudioUrl(event);
|
||||||
|
if (audioUrl) {
|
||||||
|
const lang = event.item?.language || event.data?.language || '';
|
||||||
|
const charCount = event.item?.charCount || event.data?.charCount || 0;
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `voice-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
message: '',
|
||||||
|
_audioUrl: audioUrl,
|
||||||
|
_audioLang: lang,
|
||||||
|
_audioCharCount: charCount,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onWorkflowUpdated: (event) => {
|
||||||
|
if (event.workflowId) setWorkflowId(event.workflowId);
|
||||||
|
setWorkflowVersion(v => v + 1);
|
||||||
|
},
|
||||||
|
onComplete: (event) => {
|
||||||
|
setIsProcessing(false);
|
||||||
|
if (event.workflowId) setWorkflowId(event.workflowId);
|
||||||
|
},
|
||||||
|
onStopped: () => setIsProcessing(false),
|
||||||
|
onError: (event) => {
|
||||||
|
setIsProcessing(false);
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `error-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'system',
|
||||||
|
message: `Error: ${event.content || 'Unknown error'}`,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onConnectionError: (err) => {
|
||||||
|
setIsProcessing(false);
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `error-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'system',
|
||||||
|
message: `Connection error: ${err.message}`,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onStreamEnd: () => setIsProcessing(false),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[instanceId, isProcessing, workflowId, refreshFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopProcessing = useCallback(() => {
|
||||||
|
if (cleanupRef.current) {
|
||||||
|
cleanupRef.current();
|
||||||
|
cleanupRef.current = null;
|
||||||
|
}
|
||||||
|
if (instanceId && workflowId) {
|
||||||
|
api.post(`/api/workspace/${instanceId}/${workflowId}/stop`).catch(console.error);
|
||||||
|
}
|
||||||
|
setIsProcessing(false);
|
||||||
|
}, [instanceId, workflowId]);
|
||||||
|
|
||||||
|
const acceptEdit = useCallback(
|
||||||
|
(editId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setPendingEdits(prev =>
|
||||||
|
prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)),
|
||||||
|
);
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[instanceId, refreshFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
isProcessing,
|
||||||
|
sendMessage,
|
||||||
|
stopProcessing,
|
||||||
|
loadWorkflow,
|
||||||
|
resetToNew,
|
||||||
|
files,
|
||||||
|
folders,
|
||||||
|
dataSources,
|
||||||
|
featureDataSources,
|
||||||
|
refreshFeatureDataSources,
|
||||||
|
agentProgress,
|
||||||
|
toolActivities,
|
||||||
|
pendingEdits,
|
||||||
|
acceptEdit,
|
||||||
|
rejectEdit,
|
||||||
|
workflowId,
|
||||||
|
workflowVersion,
|
||||||
|
refreshFiles,
|
||||||
|
refreshFolders,
|
||||||
|
refreshDataSources,
|
||||||
|
dataSourceAccesses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal event handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _handleMessage(
|
||||||
|
event: SseEvent,
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
|
||||||
|
) {
|
||||||
|
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 || event.content || '',
|
||||||
|
publishedAt: item.createdAt || Date.now() / 1000,
|
||||||
|
documents: item.documents,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleChunk(
|
||||||
|
event: SseEvent,
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
|
||||||
|
) {
|
||||||
|
const chunkText = event.content || '';
|
||||||
|
if (!chunkText) return;
|
||||||
|
setMessages(prev => {
|
||||||
|
const lastMsg = prev[prev.length - 1];
|
||||||
|
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id?.startsWith('stream-')) {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...lastMsg, message: lastMsg.message + chunkText },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `stream-${Date.now()}`,
|
||||||
|
workflowId: '',
|
||||||
|
role: 'assistant',
|
||||||
|
message: chunkText,
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleStatus(
|
||||||
|
event: SseEvent,
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
|
||||||
|
) {
|
||||||
|
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 || event.content || '',
|
||||||
|
publishedAt: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleToolCall(
|
||||||
|
event: SseEvent,
|
||||||
|
setToolActivities: React.Dispatch<React.SetStateAction<ToolActivity[]>>,
|
||||||
|
) {
|
||||||
|
const data = event.item || event.data || {};
|
||||||
|
setToolActivities(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `tc-${Date.now()}-${Math.random()}`,
|
||||||
|
toolName: data.toolName || 'unknown',
|
||||||
|
status: 'calling',
|
||||||
|
args: data.args,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleToolResult(
|
||||||
|
event: SseEvent,
|
||||||
|
setToolActivities: React.Dispatch<React.SetStateAction<ToolActivity[]>>,
|
||||||
|
) {
|
||||||
|
const data = event.item || event.data || {};
|
||||||
|
setToolActivities(prev => {
|
||||||
|
const idx = [...prev].reverse().findIndex(t => t.toolName === data.toolName && t.status === 'calling');
|
||||||
|
if (idx >= 0) {
|
||||||
|
const realIdx = prev.length - 1 - idx;
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[realIdx] = {
|
||||||
|
...updated[realIdx],
|
||||||
|
status: data.success ? 'success' : 'error',
|
||||||
|
result: data.data,
|
||||||
|
error: data.error,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `tr-${Date.now()}-${Math.random()}`,
|
||||||
|
toolName: data.toolName || 'unknown',
|
||||||
|
status: data.success ? 'success' : 'error',
|
||||||
|
result: data.data,
|
||||||
|
error: data.error,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildAudioUrl(event: SseEvent): string | null {
|
||||||
|
const audioData = event.item?.audio || event.data?.audio;
|
||||||
|
if (!audioData) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const byteChars = atob(audioData);
|
||||||
|
const byteArray = new Uint8Array(byteChars.length);
|
||||||
|
for (let i = 0; i < byteChars.length; i++) {
|
||||||
|
byteArray[i] = byteChars.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([byteArray], { type: 'audio/mp3' });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decode voice response:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
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,839 +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]);
|
|
||||||
|
|
||||||
// Format bytes helper
|
|
||||||
const formatBytes = (bytes: number): string => {
|
|
||||||
if (!bytes || bytes < 0) return '0 B';
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
const kbytes = bytes / 1024;
|
|
||||||
if (kbytes < 1000) return `${Math.round(kbytes)} kB`;
|
|
||||||
const mbytes = kbytes / 1024;
|
|
||||||
return `${Math.round(mbytes * 10) / 10} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format duration helper (for stats)
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
if (!seconds || seconds < 0) return '0s';
|
|
||||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = Math.round(seconds % 60);
|
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
{/* Stats display in header */}
|
|
||||||
<div className={styles.headerStats}>
|
|
||||||
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
|
|
||||||
↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)}
|
|
||||||
</span>
|
|
||||||
{(latestStats?.processingTime ?? 0) > 0 && (
|
|
||||||
<span className={styles.headerStatItem} title="Verarbeitungszeit">
|
|
||||||
⏱️ {formatDuration(latestStats?.processingTime || 0)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(latestStats?.priceUsd ?? 0) > 0 && (
|
|
||||||
<span className={styles.headerStatItem} title="Kosten">
|
|
||||||
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
|
|
||||||
</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 },
|
{ 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: {
|
teamsbot: {
|
||||||
code: 'teamsbot',
|
code: 'teamsbot',
|
||||||
label: { de: 'Teams Bot', en: 'Teams Bot' },
|
label: { de: 'Teams Bot', en: 'Teams Bot' },
|
||||||
|
|
@ -301,6 +283,16 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'settings' },
|
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
workspace: {
|
||||||
|
code: 'workspace',
|
||||||
|
label: { de: 'AI Workspace', en: 'AI Workspace', fr: 'AI Workspace' },
|
||||||
|
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' },
|
||||||
|
]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
183
src/utils/sseClient.ts
Normal file
183
src/utils/sseClient.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* Shared SSE Client Utility
|
||||||
|
*
|
||||||
|
* Generic fetch-based SSE streaming for POST requests with JSON body.
|
||||||
|
* Reusable SSE implementation across all workspace features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './csrfUtils';
|
||||||
|
|
||||||
|
export interface SseEvent {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseEventHandlers {
|
||||||
|
onMessage?: (event: SseEvent) => void;
|
||||||
|
onChunk?: (event: SseEvent) => void;
|
||||||
|
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;
|
||||||
|
onAgentSummary?: (event: SseEvent) => void;
|
||||||
|
onFileCreated?: (event: SseEvent) => void;
|
||||||
|
onDataSourceAccess?: (event: SseEvent) => void;
|
||||||
|
onVoiceResponse?: (event: SseEvent) => void;
|
||||||
|
onWorkflowUpdated?: (event: SseEvent) => void;
|
||||||
|
onComplete?: (event: SseEvent) => void;
|
||||||
|
onStopped?: (event: SseEvent) => void;
|
||||||
|
onError?: (event: SseEvent) => void;
|
||||||
|
onRawEvent?: (event: SseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseClientOptions {
|
||||||
|
url: string;
|
||||||
|
body: Record<string, any>;
|
||||||
|
handlers: SseEventHandlers;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onConnectionError?: (error: Error) => void;
|
||||||
|
onStreamEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _EVENT_ROUTER: Record<string, keyof SseEventHandlers> = {
|
||||||
|
message: 'onMessage',
|
||||||
|
chunk: 'onChunk',
|
||||||
|
status: 'onStatus',
|
||||||
|
file_edit_proposal: 'onFileEditProposal',
|
||||||
|
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',
|
||||||
|
agentProgress: 'onAgentProgress',
|
||||||
|
agent_summary: 'onAgentSummary',
|
||||||
|
agentSummary: 'onAgentSummary',
|
||||||
|
fileCreated: 'onFileCreated',
|
||||||
|
dataSourceAccess: 'onDataSourceAccess',
|
||||||
|
voiceResponse: 'onVoiceResponse',
|
||||||
|
workflowUpdated: 'onWorkflowUpdated',
|
||||||
|
complete: 'onComplete',
|
||||||
|
stopped: 'onStopped',
|
||||||
|
error: 'onError',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an SSE stream via POST request.
|
||||||
|
* Returns a cleanup function to abort the connection.
|
||||||
|
*/
|
||||||
|
export function startSseStream(options: SseClientOptions): () => void {
|
||||||
|
const { url, body, handlers, signal, onConnectionError, onStreamEnd } = options;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const combinedSignal = signal
|
||||||
|
? _combineAbortSignals(signal, abortController.signal)
|
||||||
|
: abortController.signal;
|
||||||
|
|
||||||
|
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(body),
|
||||||
|
credentials: 'include',
|
||||||
|
signal: combinedSignal,
|
||||||
|
})
|
||||||
|
.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 {
|
||||||
|
if (jsonStr.trim()) {
|
||||||
|
const event: SseEvent = JSON.parse(jsonStr);
|
||||||
|
_dispatchEvent(event, handlers);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unparseable lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim()) {
|
||||||
|
for (const line of buffer.split('\n')) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const event: SseEvent = JSON.parse(line.slice(6));
|
||||||
|
_dispatchEvent(event, handlers);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStreamEnd?.();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
onConnectionError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dispatchEvent(event: SseEvent, handlers: SseEventHandlers): void {
|
||||||
|
handlers.onRawEvent?.(event);
|
||||||
|
|
||||||
|
const handlerKey = _EVENT_ROUTER[event.type];
|
||||||
|
if (handlerKey) {
|
||||||
|
const handler = handlers[handlerKey];
|
||||||
|
if (handler) {
|
||||||
|
(handler as (e: SseEvent) => void)(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _combineAbortSignals(...signals: AbortSignal[]): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
for (const sig of signals) {
|
||||||
|
if (sig.aborted) {
|
||||||
|
controller.abort(sig.reason);
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
sig.addEventListener('abort', () => controller.abort(sig.reason), { once: true });
|
||||||
|
}
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue