diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 7254640..ce902c1 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -57,8 +57,8 @@ export interface StartWorkflowResponse extends Workflow { export interface ChatDataResponse { messages: WorkflowMessage[]; logs: WorkflowLog[]; - stats: WorkflowStats[]; documents: WorkflowDocument[]; + workflowCost: number; } // Type for the request function passed to API functions @@ -259,35 +259,25 @@ export async function fetchChatData( 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)) { const messages: WorkflowMessage[] = []; const logs: WorkflowLog[] = []; - const stats: WorkflowStats[] = []; const documents: WorkflowDocument[] = []; data.items.forEach((item: any) => { if (item.type === 'message') { - // Handle both formats: item.item or direct item data const messageData = item.item || item; if (messageData && (messageData.id || messageData.message)) { messages.push(messageData); - } else { - console.warn('⚠️ Invalid message item:', item); } } else if (item.type === 'log') { const logData = item.item || item; if (logData) { logs.push(logData); } - } else if (item.type === 'stat') { - const statData = item.item || item; - if (statData) { - stats.push(statData); - } - } - // Documents might be in items or separate - if (item.type === 'document') { + } else if (item.type === 'document') { const docData = item.item || item; if (docData) { documents.push(docData); @@ -295,27 +285,19 @@ export async function fetchChatData( } }); - console.log('📦 Extracted from items:', { - messages: messages.length, - logs: logs.length, - stats: stats.length, - documents: documents.length - }); - return { messages, 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 { messages: Array.isArray(data.messages) ? data.messages : [], 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 }; } diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index e19cd0e..1b6f9e7 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -49,9 +49,12 @@ export const UserSection: React.FC = () => { } // Initialen für Avatar - const initials = user.fullName - ? user.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - : user.username.slice(0, 2).toUpperCase(); + const initials = (() => { + const name = user.fullName || user.username || ''; + 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 (
diff --git a/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx index 59031c8..41d9692 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx @@ -2,114 +2,37 @@ import React, { useMemo } from 'react'; import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes'; import styles from './WorkflowStatus.module.css'; -// Helper function to extract workflow status and round from log message +const _STATUS_MAP: Record = { + 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 } => { - // First, check for completion messages with success status (these take priority) - const completionMessages = logs.filter(log => { - const message = (log.message || '').toLowerCase(); + if (!logs.length) return { status: null, round: null, timestamp: 0 }; + + const sorted = [...logs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + + for (const log of sorted) { const logStatus = (log.status || '').toLowerCase(); - return (message.includes('fast path completed') || - message.includes('completed successfully')) && - logStatus === 'success'; - }); - - // 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); - } - } + const mapped = _STATUS_MAP[logStatus]; + if (mapped) { + const roundMatch = (log.message || '').match(/\(?round\s+(\d+)\)?/i); + return { status: mapped, round: roundMatch ? parseInt(roundMatch[1], 10) : null, timestamp: log.timestamp || 0 }; } - - 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 }; - } - - // 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 - }; + return { status: null, round: null, timestamp: 0 }; }; -// Helper function to format bytes to KB or MB -const formatBytes = (bytes?: number): string => { - if (bytes === undefined || bytes === null) return '-'; - 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 _formatCurrency = (amount?: number): string => { + if (amount === undefined || amount === null) return '-'; + return `${amount.toFixed(2)} CHF`; }; const WorkflowStatus: React.FC = ({ @@ -122,40 +45,10 @@ const WorkflowStatus: React.FC = ({ }) => { // Use workflow status and round from API response, fallback to extracting from logs const workflowStatus = useMemo(() => { - // If we have status from API, use it if (workflowStatusFromApi) { - let status: WorkflowStatusType = null; - const statusLower = workflowStatusFromApi.toLowerCase(); - - 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 - }; + const mapped = _STATUS_MAP[workflowStatusFromApi.toLowerCase()] || null; + return { status: mapped, round: currentRoundFromApi || null, timestamp: Date.now() / 1000 }; } - - // Fallback to extracting from logs return extractWorkflowStatus(logs); }, [workflowStatusFromApi, currentRoundFromApi, logs]); @@ -185,33 +78,13 @@ const WorkflowStatus: React.FC = ({ )}
- {/* Stats Display */} - {latestStats && ( + {/* Cost Display */} + {latestStats && latestStats.priceCHF !== undefined && (
- {latestStats.priceUsd !== undefined && ( -
- Price: - {formatPrice(latestStats.priceUsd)} -
- )} - {latestStats.processingTime !== undefined && ( -
- Time: - {formatProcessingTime(latestStats.processingTime)} -
- )} - {latestStats.bytesSent !== undefined && ( -
- Sent: - {formatBytes(latestStats.bytesSent)} -
- )} - {latestStats.bytesReceived !== undefined && ( -
- Received: - {formatBytes(latestStats.bytesReceived)} -
- )} +
+ Cost: + {_formatCurrency(latestStats.priceCHF)} +
)} diff --git a/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts b/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts index d83ca14..5275b9a 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts @@ -44,13 +44,10 @@ export interface WorkflowStatusProps { isRunning?: boolean; /** - * Latest statistics from the workflow (price, processing time, bytes sent/received) + * Latest cost from billing transactions (single source of truth) */ latestStats?: { - priceUsd?: number; - processingTime?: number; - bytesSent?: number; - bytesReceived?: number; + priceCHF?: number; } | null; } diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 902c588..f68c91c 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -116,6 +116,10 @@ export const PAGE_ICONS: Record = { 'page.feature.chatbot.conversations': , 'feature.chatbot': , 'feature.teamsbot': , + + // Feature pages - Workspace + 'page.feature.workspace.dashboard': , + 'feature.workspace': , }; // ============================================================================= diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index 9b1be84..085d6f7 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -14,8 +14,8 @@ import { useWorkflowPolling } from './useWorkflowPolling'; import { getWorkflowApiBaseUrl } from '../useWorkflows'; interface UnifiedChatDataItem { - type: 'message' | 'log' | 'stat'; - item: WorkflowMessage | WorkflowLog | any; + type: 'message' | 'log'; + item: WorkflowMessage | WorkflowLog; createdAt: number; } @@ -76,13 +76,11 @@ export function useWorkflowLifecycle(instanceId: string) { const [logs, setLogs] = useState([]); const [dashboardLogs, setDashboardLogs] = useState([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null); + const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null); // === REFS FOR SYNC ACCESS === const statusRef = useRef('idle'); const lastRenderedTimestampRef = useRef(null); - const processedStatIdsRef = useRef>(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" @@ -124,17 +122,15 @@ export function useWorkflowLifecycle(instanceId: string) { }, [workflowId]); // === CORE: Process unified chat data === - const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => { + const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; workflowCost: number }) => { console.log('🔄 Processing chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0 + workflowCost: chatData.workflowCost ?? 0 }); - // Build unified timeline const timeline: UnifiedChatDataItem[] = []; - // Add messages (chatData.messages || []).forEach((message: WorkflowMessage) => { timeline.push({ type: 'message', @@ -143,7 +139,6 @@ export function useWorkflowLifecycle(instanceId: string) { }); }); - // Add logs (chatData.logs || []).forEach((log: any) => { timeline.push({ type: 'log', @@ -152,17 +147,6 @@ export function useWorkflowLifecycle(instanceId: string) { }); }); - // 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 @@ -290,44 +274,9 @@ export function useWorkflowLifecycle(instanceId: string) { 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 - }); - } - } + // === UPDATE COST from billing transactions (single source of truth) === + const cost = chatData.workflowCost ?? 0; + setLatestStats(cost > 0 ? { priceCHF: cost } : null); }, [convertLogToFrontendFormat]); // === POLLING FUNCTION === @@ -359,7 +308,7 @@ export function useWorkflowLifecycle(instanceId: string) { console.log('📊 Polled chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0, + workflowCost: chatData.workflowCost ?? 0, afterTimestamp }); @@ -496,10 +445,7 @@ export function useWorkflowLifecycle(instanceId: string) { 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); @@ -511,13 +457,11 @@ export function useWorkflowLifecycle(instanceId: 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); + setLatestStats(null); // Fetch workflow data const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null); @@ -544,7 +488,7 @@ export function useWorkflowLifecycle(instanceId: string) { console.log('📥 Loaded chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0 + workflowCost: chatData.workflowCost ?? 0 }); // === STATE MACHINE: Check if last message has status="last" === diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 9812b26..f680077 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -479,7 +479,7 @@ export function useFileOperations() { * - Removed workflowId from FileItem creation in interfaceComponentObjects.py * - 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); setUploadingFile(true); @@ -500,6 +500,9 @@ export function useFileOperations() { if (workflowId) { formData.append('workflowId', workflowId); } + if (featureInstanceId) { + formData.append('featureInstanceId', featureInstanceId); + } // FormData is now correctly configured for backend diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index cdc25dc..ebaf1c5 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -276,12 +276,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: } else if (attr.type === 'textarea') { fieldType = 'textarea'; } else if (attr.type === 'text') { - // Check if it should be textarea based on name - if (attr.name.toLowerCase().includes('description') || attr.name.toLowerCase().includes('note')) { - fieldType = 'textarea'; - } else { - fieldType = 'string'; - } + fieldType = (attr as any).multiline === true ? 'textarea' : 'string'; } // 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' diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index 3516819..668aa7c 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -92,6 +92,7 @@ flex: 1; min-width: 0; min-height: 0; + position: relative; /* Let child components handle their own scrolling for sticky headers */ overflow: hidden; background: var(--bg-primary, #ffffff); diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 02ea999..7244eb0 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { UserSection } from '../components/Navigation/UserSection'; +import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'; import styles from './MainLayout.module.css'; +const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/; + // ============================================================================= // INNER LAYOUT (mit Zugriff auf Store) // ============================================================================= @@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => { className={styles.mobileLogo} /> - + + + +
+ +
); diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index c0a29bc..80a10e1 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -36,6 +36,10 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView // CodeEditor Views import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor'; +// Workspace Views +import { WorkspacePage } from './views/workspace/WorkspacePage'; +import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage'; + // Teamsbot Views import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView'; @@ -137,6 +141,10 @@ const VIEW_COMPONENTS: Record> = { editor: CodeEditorPage, workflows: CodeEditorWorkflowsPage, }, + workspace: { + dashboard: WorkspacePage, + settings: WorkspaceSettingsPage, + }, teamsbot: { dashboard: TeamsbotDashboardView, sessions: TeamsbotSessionView, @@ -199,6 +207,12 @@ export const FeatureViewPage: React.FC = ({ view }) => { return ; } + // Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level; + // other workspace views (e.g. settings) use the standard FeatureViewPage rendering. + if (featureCode === 'workspace' && view !== 'settings') { + return null; + } + // View-Komponente finden const featureViews = VIEW_COMPONENTS[featureCode]; if (!featureViews) { diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index ccb0b9b..4fcc8df 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -88,6 +88,34 @@ 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 */ .filterSection { display: flex; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 0661fdb..9cd0b7e 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => { {canCreate && ( <> @@ -370,18 +378,8 @@ const AccountsOverview: React.FC = ({ accounts, users, lo // ============================================================================ export const BillingAdmin: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams(); const [selectedMandateId, setSelectedMandateId] = useState(null); - const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); - - const successParam = searchParams.get('success'); - const canceledParam = searchParams.get('canceled'); - - useEffect(() => { - if (successParam === 'true' && selectedMandateId) { - loadAccounts(); - } - }, [successParam, selectedMandateId, loadAccounts]); + const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const handleMandateSelect = (mandateId: string) => { setSelectedMandateId(mandateId || null); @@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => { await saveSettings(settingsUpdate); }, [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'); - const result = await createCheckout({ userId, amount }); - if (!result) throw new Error('Checkout konnte nicht erstellt werden'); + const result = await addCredit({ userId, amount, description }); + if (!result) throw new Error('Gutschrift konnte nicht erstellt werden'); + await loadAccounts(); return result; - }, [selectedMandateId, createCheckout]); - - const clearStripeParams = useCallback(() => { - searchParams.delete('success'); - searchParams.delete('canceled'); - searchParams.delete('session_id'); - setSearchParams(searchParams, { replace: true }); - }, [searchParams, setSearchParams]); + }, [selectedMandateId, addCredit, loadAccounts]); return (
@@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {

Verwaltung von Abrechnungseinstellungen und Guthaben

- {successParam === 'true' && ( -
- Zahlung erfolgreich. Guthaben wird gutgeschrieben. - -
- )} - {canceledParam === 'true' && ( -
- Zahlung abgebrochen. - -
- )} -
{ settings={settings} accounts={accounts} users={users} - onCreateCheckout={handleCreateCheckout} + onAddCredit={_handleAddCredit} /> void; + checkoutLoading?: boolean; } -const BalanceCard: React.FC = ({ balance }) => { +const BalanceCard: React.FC = ({ balance, onCheckout, checkoutLoading }) => { + const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]); + const [showCheckout, setShowCheckout] = useState(false); + const _getBillingModelLabel = (model: string) => { switch (model) { case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; @@ -59,7 +69,11 @@ const BalanceCard: React.FC = ({ balance }) => { default: return model; } }; - + + const canTopUp = balance.billingModel === 'PREPAY_USER' + || balance.billingModel === 'PREPAY_MANDATE' + || balance.billingModel === 'CREDIT_POSTPAY'; + return (
@@ -74,6 +88,47 @@ const BalanceCard: React.FC = ({ balance }) => { Niedriges Guthaben
)} + {canTopUp && onCheckout && ( +
+ {!showCheckout ? ( + + ) : ( +
+ + + +
+ )} +
+ )}
); }; @@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('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 const [selectedScope, setSelectedScope] = useState('personal'); @@ -272,9 +331,48 @@ export const BillingDataView: React.FC = () => { // Dashboard state (for Overview tab) const { balances, - loading: dashboardLoading, + loading: dashboardLoading, + refetch: refetchBalances, } = useBilling(); + const successParam = searchParams.get('success'); + const canceledParam = searchParams.get('canceled'); + + useEffect(() => { + if (successParam === 'true') { + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' }); + refetchBalances(); + } else if (canceledParam === 'true') { + setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); + } + }, [successParam, canceledParam, refetchBalances]); + + 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 result = await createCheckoutSession(request, mandateId, { + userId: currentUser?.id, + amount, + }); + 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) const [allUserBalances, setAllUserBalances] = useState([]); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); @@ -475,6 +573,15 @@ export const BillingDataView: React.FC = () => { + {checkoutMessage && ( +
+ {checkoutMessage.text} + {(successParam || canceledParam) && ( + + )} +
+ )} + {/* ================================================================ */} {/* Tab: Ăśbersicht (My Overview) */} {/* ================================================================ */} @@ -502,7 +609,12 @@ export const BillingDataView: React.FC = () => { ) : (
{filteredBalances.map((balance) => ( - + ))}
)} diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx new file mode 100644 index 0000000..1b39e39 --- /dev/null +++ b/src/pages/views/workspace/ChatStream.tsx @@ -0,0 +1,514 @@ +/** + * 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; +} + +export const ChatStream: React.FC = ({ + messages, + agentProgress, + isProcessing, + pendingEdits, + onAcceptEdit, + onRejectEdit, +}) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, agentProgress]); + + return ( +
+ {messages.map((msg) => ( +
+ {msg.role === 'assistant' && ( +
Assistant
+ )} + {msg.role === 'status' ? ( + {msg.message} + ) : ( +
+ {msg.message && ( + ( +
+ + {children} +
+
+ ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {msg.message} +
+ )} + {msg.documents && msg.documents.length > 0 && ( +
+ {msg.documents.map((doc) => ( + <_FileCard key={doc.id || doc.fileId} doc={doc} /> + ))} +
+ )} + {(msg as any)._audioUrl && ( + <_AudioPlayer + url={(msg as any)._audioUrl} + language={(msg as any)._audioLang} + charCount={(msg as any)._audioCharCount} + /> + )} +
+ )} +
+ ))} + + {/* File edit proposals */} + {pendingEdits.filter(e => e.status === 'pending').map((edit) => ( +
+
+ ✎ + File Edit Proposal: {edit.fileName} +
+
+            {edit.newContent?.slice(0, 800)}
+            {(edit.newContent?.length || 0) > 800 && '\n...'}
+          
+
+ + +
+
+ ))} + + {/* Agent progress */} + {isProcessing && agentProgress && ( +
+ + Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} + + {agentProgress.totalToolCalls} tools + {agentProgress.costCHF?.toFixed(4) || '0'} CHF +
+ )} + + {isProcessing && !agentProgress && ( +
+ + Processing... +
+ )} + +
+ + +
+ ); +}; + +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 ( +
(e.currentTarget.style.background = '#e8f0fe')} + onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')} + > + {icon} +
+
+ {doc.fileName} +
+
+ {ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''} +
+
+ +
+ ); +} + +function _getFileIcon(ext: string): string { + const map: Record = { + 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(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 ( +
+ + +
+
+
+
+
+ + {duration > 0 ? _formatTime(progress * duration) : '0:00'} + + + {duration > 0 ? _formatTime(duration) : '--:--'} + +
+
+ + + + {language && ( + + {language} + + )} +
+ ); +} + +function _CodeBlock({ + className, + children, + ...props +}: React.HTMLAttributes & { inline?: boolean }) { + const match = /language-(\w+)/.exec(className || ''); + const isInline = !match && !String(children).includes('\n'); + + if (isInline) { + return ( + + {children} + + ); + } + + return ( +
+ {match && ( +
+ {match[1]} +
+ )} +
+        
+          {children}
+        
+      
+
+ ); +} diff --git a/src/pages/views/workspace/ConversationList.tsx b/src/pages/views/workspace/ConversationList.tsx new file mode 100644 index 0000000..46abf22 --- /dev/null +++ b/src/pages/views/workspace/ConversationList.tsx @@ -0,0 +1,453 @@ +/** + * 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 = ({ + instanceId, + activeWorkflowId, + onSelect, + onCreateNew, + refreshTrigger, +}) => { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [filterQuery, setFilterQuery] = useState(''); + const [page, setPage] = useState(0); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [viewMode, setViewMode] = useState<'active' | 'archived'>('active'); + const inputRef = useRef(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 ( +
+ {/* Header */} +
+ Conversations +
+ + +
+
+ + {/* View mode toggle */} +
+ + +
+ + {/* Filter */} + {filtered.length > 3 && ( + 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 && ( +
+ {viewMode === 'archived' + ? 'Keine archivierten Chats.' + : 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'} +
+ )} + + {/* List */} + {paginated.map(conv => { + const isActive = conv.id === activeWorkflowId; + const isEditing = editingId === conv.id; + return ( +
{ 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 */} +
+ {isEditing ? ( + 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', + }} + /> + ) : ( + { e.stopPropagation(); _startEditing(conv); }} + title={conv.name} + > + {conv.name} + + )} + + {/* Action buttons (visible on hover) */} + {!isEditing && ( + + + {conv.status === 'archived' ? ( + + ) : ( + + )} + {confirmDeleteId === conv.id ? ( + + + + + ) : ( + + )} + + )} +
+ + {/* Status + last activity */} +
+ + {conv.status === 'active' && ( + {'\u25CF'} aktiv + )} + {conv.status === 'completed' && ( + {'\u25CF'} abgeschlossen + )} + {conv.status === 'archived' && ( + {'\u25CF'} archiviert + )} + {!['active', 'completed', 'archived'].includes(conv.status) && ( + {conv.status} + )} + + + {_formatTime(conv.lastActivity)} + +
+
+ ); + })} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {page + 1} / {totalPages} + +
+ )} +
+ ); +}; + +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', +}; diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx new file mode 100644 index 0000000..d5be787 --- /dev/null +++ b/src/pages/views/workspace/DataSourcePanel.tsx @@ -0,0 +1,470 @@ +/** + * 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 type { DataSource } 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 DataSourcePanelProps { + instanceId: string; + dataSources: DataSource[]; + onRefresh: () => void; +} + +/* ─── Icons ─────────────────────────────────────────────────────────── */ + +const _AUTHORITY_ICONS: Record = { + msft: '\uD83D\uDFE6', + google: '\uD83D\uDFE9', + 'local:ftp': '\uD83D\uDD17', + 'local:jira': '\uD83D\uDD27', +}; + +const _SERVICE_ICONS: Record = { + 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 = { + 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 = { + 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 = ({ + instanceId, + dataSources, + onRefresh, +}) => { + const [tree, setTree] = useState([]); + const [loadingRoot, setLoadingRoot] = useState(false); + const [addingPath, setAddingPath] = useState(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 = { + 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]); + + return ( +
+ {/* Active DataSources */} + {dataSources.length > 0 && ( +
+
+ Active Sources +
+ {dataSources.map(ds => { + const connColor = _getSourceColor(ds.sourceType); + const connNode = tree.find(n => n.connectionId === ds.connectionId); + const connLabel = connNode?.label || ds.connectionId; + const fullPath = `${connLabel} › ${ds.sourceType} › ${ds.path}`; + return ( +
+ {_getSourceIcon(ds.sourceType)} + + {ds.label} + + +
+ ); + })} +
+
+ )} + + {/* Tree header */} +
+ + Browse Sources + + +
+ + {/* Tree */} + {loadingRoot && tree.length === 0 && ( +
+ Loading connections... +
+ )} + + {!loadingRoot && tree.length === 0 && ( +
+ No active connections found. +
+ )} + + {tree.map(node => ( + <_TreeNodeView + key={node.key} + node={node} + depth={0} + onToggle={_toggleNode} + onAdd={_addAsDataSource} + isAdded={_isAdded} + addingPath={addingPath} + /> + ))} +
+ ); +}; + +/* ─── 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 = ({ + 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 ( +
+
{ 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', + }} + > + + {node.loading ? _Spinner() : chevron} + + {node.icon} + + {node.label} + + {canAdd && hovered && !alreadyAdded && ( + + )} + {canAdd && alreadyAdded && ( + + {'\u2713'} + + )} +
+ + {/* Children */} + {node.expanded && node.children && node.children.length > 0 && ( +
+ {node.children.map(child => ( + <_TreeNodeView + key={child.key} + node={child} + depth={depth + 1} + onToggle={onToggle} + onAdd={onAdd} + isAdded={isAdded} + addingPath={addingPath} + /> + ))} +
+ )} + + {node.expanded && node.children && node.children.length === 0 && !node.loading && ( +
+ (empty) +
+ )} +
+ ); +}; + +/* ─── Spinner (inline) ──────────────────────────────────────────────── */ + +function _Spinner(): React.ReactElement { + return ( + + ); +} + +/* ─── Data fetching ─────────────────────────────────────────────────── */ + +async function _loadServices(instanceId: string, connectionId: string): Promise { + 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 { + 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 = { + 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; + }); +} diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx new file mode 100644 index 0000000..6b28842 --- /dev/null +++ b/src/pages/views/workspace/FileBrowser.tsx @@ -0,0 +1,258 @@ +/** + * FileBrowser -- Tree-structured file browser. + * + * Level 1: Feature instance (group header, collapsible) + * Level 2: Files sorted alphabetically + * + * Supports search, drag-and-drop upload, and file selection. + */ + +import React, { useState, useCallback, useRef, useMemo } from 'react'; +import api from '../../../api'; +import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace'; + +interface FileBrowserProps { + instanceId: string; + files: WorkspaceFile[]; + folders: WorkspaceFolder[]; + onRefresh: () => void; + onFileSelect?: (fileId: string) => void; +} + +interface _InstanceGroup { + instanceId: string; + label: string; + files: WorkspaceFile[]; +} + +export const FileBrowser: React.FC = ({ + instanceId, + files, + folders: _folders, + onRefresh, + onFileSelect, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [collapsed, setCollapsed] = useState>({}); + const fileInputRef = useRef(null); + + const _filteredFiles = useMemo(() => { + if (!searchQuery.trim()) return files; + const q = searchQuery.toLowerCase(); + return files.filter(f => + f.fileName.toLowerCase().includes(q) + || (f.tags || []).some(t => t.toLowerCase().includes(q)), + ); + }, [files, searchQuery]); + + const _groups = useMemo((): _InstanceGroup[] => { + const map: Record = {}; + for (const f of _filteredFiles) { + const key = f.featureInstanceId || '_workspace'; + if (!map[key]) { + map[key] = { + instanceId: key, + label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)), + files: [], + }; + } + map[key].files.push(f); + } + for (const g of Object.values(map)) { + g.files.sort((a, b) => a.fileName.localeCompare(b.fileName)); + } + const groups = Object.values(map); + groups.sort((a, b) => a.label.localeCompare(b.label)); + return groups; + }, [_filteredFiles]); + + const _toggleGroup = (key: string) => { + setCollapsed(prev => ({ ...prev, [key]: !prev[key] })); + }; + + const _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' }, + }); + } + onRefresh(); + } catch (err) { + console.error('File upload failed:', err); + } finally { + setUploading(false); + } + }, [instanceId, uploading, onRefresh]); + + const _handleDragOver = useCallback((e: React.DragEvent) => { + 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) => { + if (e.target.files && e.target.files.length > 0) { + _uploadFiles(e.target.files); + e.target.value = ''; + } + }, [_uploadFiles]); + + return ( +
+ {isDragOver && ( +
+ Dateien hier ablegen +
+ )} + + {/* Header */} +
+ Files +
+ + +
+
+ + + + {/* Search */} + setSearchQuery(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, + border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', + }} + /> + + {/* Tree */} + {_groups.length === 0 && ( +
+ {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} +
+ )} + + {_groups.map(group => { + const isCollapsed = !!collapsed[group.instanceId]; + return ( +
+ {/* Group header */} +
_toggleGroup(group.instanceId)} + style={{ + display: 'flex', alignItems: 'center', gap: 6, + padding: '5px 6px', cursor: 'pointer', borderRadius: 4, + background: 'var(--bg-secondary, #f5f5f5)', + marginBottom: 2, + }} + onMouseEnter={e => (e.currentTarget.style.background = '#eee')} + onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)')} + > + + {isCollapsed ? '\u25B6' : '\u25BC'} + + {'\uD83D\uDCC1'} + + {group.label} + + {group.files.length} +
+ + {/* Files */} + {!isCollapsed && group.files.map(file => ( +
onFileSelect?.(file.id)} + style={{ + padding: '4px 8px 4px 28px', fontSize: 12, + display: 'flex', alignItems: 'center', gap: 6, + borderRadius: 4, + cursor: onFileSelect ? 'pointer' : 'default', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={e => (e.currentTarget.style.background = '')} + > + {_fileIcon(file.mimeType)} +
+
+ {file.fileName} +
+ {file.tags && file.tags.length > 0 && ( +
+ {file.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ + {(file.fileSize / 1024).toFixed(0)}K + +
+ ))} +
+ ); + })} +
+ ); +}; + +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'; +} diff --git a/src/pages/views/workspace/FilePreview.tsx b/src/pages/views/workspace/FilePreview.tsx new file mode 100644 index 0000000..20ec03a --- /dev/null +++ b/src/pages/views/workspace/FilePreview.tsx @@ -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 = ({ instanceId, fileId, files }) => { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [previewUrl, setPreviewUrl] = useState(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 ( +
+ Select a file to preview +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {file.fileName} +
+
+ {file.mimeType} + {_formatFileSize(file.fileSize)} + {file.status && {file.status}} +
+ {file.description && ( +
{file.description}
+ )} + {file.tags && file.tags.length > 0 && ( +
+ {file.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ + {/* Content area */} +
+ {loading && ( +
Loading...
+ )} + + {content !== null && !loading && ( +
+            {content}
+          
+ )} + + {previewUrl && ( +
+ {file.fileName} setPreviewUrl(null)} + /> +
+ )} + + {!loading && content === null && !previewUrl && ( +
+ {file.fileSize > 500_000 + ? 'File too large for inline preview' + : `No preview available for ${file.mimeType}`} +
+ )} +
+
+ ); +}; + +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`; +} diff --git a/src/pages/views/workspace/ToolActivityLog.tsx b/src/pages/views/workspace/ToolActivityLog.tsx new file mode 100644 index 0000000..3ee1130 --- /dev/null +++ b/src/pages/views/workspace/ToolActivityLog.tsx @@ -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 = ({ activities }) => { + if (!activities.length) { + return ( +
+ No tool activity yet +
+ ); + } + + return ( +
+ {activities.map(activity => ( +
+
+ {activity.toolName} + + {activity.status} + +
+ {activity.args && Object.keys(activity.args).length > 0 && ( +
+ {Object.entries(activity.args) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`) + .join(', ')} +
+ )} + {activity.result && ( +
+ {activity.result.slice(0, 200)} + {activity.result.length > 200 && '...'} +
+ )} + {activity.error && ( +
+ {activity.error} +
+ )} +
+ ))} +
+ ); +}; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx new file mode 100644 index 0000000..6ff1095 --- /dev/null +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -0,0 +1,612 @@ +/** + * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, + * voice toggle (live transcript via SpeechRecognition), and data source selection. + */ + +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { ProviderMultiSelect } from '../../../components/ProviderSelector'; +import type { WorkspaceFile, DataSource } 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' }, +]; + +function _getSpeechRecognitionApi(): (new () => SpeechRecognition) | null { + return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; +} + +interface PendingFile { + fileId: string; + fileName: string; +} + +interface WorkspaceInputProps { + instanceId: string; + onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[]) => void; + isProcessing: boolean; + onStop: () => void; + files: WorkspaceFile[]; + dataSources: DataSource[]; + pendingFiles?: PendingFile[]; + onRemovePendingFile?: (fileId: string) => void; + onFileUploadClick?: () => void; + uploading?: boolean; + selectedProviders?: string[]; + onProvidersChange?: (providers: string[]) => void; +} + +export const WorkspaceInput: React.FC = ({ + instanceId: _instanceId, + onSend, + isProcessing, + onStop, + files, + dataSources, + pendingFiles = [], + onRemovePendingFile, + onFileUploadClick, + uploading = false, + selectedProviders = [], + onProvidersChange, +}) => { + const [prompt, setPrompt] = useState(''); + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteFilter, setAutocompleteFilter] = useState(''); + const [voiceActive, setVoiceActive] = useState(false); + const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE'); + const [, setLiveTranscript] = useState(''); + const [showLangPicker, setShowLangPicker] = useState(false); + const [attachedFileIds, setAttachedFileIds] = useState([]); + const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); + const textareaRef = useRef(null); + const recognitionRef = useRef(null); + const transcriptPartsRef = useRef([]); + const processedIndexRef = useRef(0); + const promptBeforeVoiceRef = 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); + setPrompt(''); + setShowAutocomplete(false); + setShowSourcePicker(false); + setAttachedFileIds([]); + }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, onSend]); + + const _handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + _handleSend(); + } + }, + [_handleSend], + ); + + const _handleChange = useCallback( + (e: React.ChangeEvent) => { + 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 _stopRecognition = useCallback(() => { + if (recognitionRef.current) { + try { recognitionRef.current.stop(); } catch { /* ignore */ } + recognitionRef.current = null; + } + const finalText = transcriptPartsRef.current.join(' ').trim(); + if (finalText) { + setPrompt(() => { + const base = promptBeforeVoiceRef.current; + return base ? `${base} ${finalText}` : finalText; + }); + } + setLiveTranscript(''); + transcriptPartsRef.current = []; + processedIndexRef.current = 0; + setVoiceActive(false); + }, []); + + const _toggleVoice = useCallback(async () => { + if (voiceActive) { + _stopRecognition(); + return; + } + + const SpeechRecognitionApi = _getSpeechRecognitionApi(); + if (!SpeechRecognitionApi) { + console.error('SpeechRecognition not supported in this browser'); + return; + } + + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + console.error('Microphone access denied'); + return; + } + + promptBeforeVoiceRef.current = prompt; + transcriptPartsRef.current = []; + processedIndexRef.current = 0; + setLiveTranscript(''); + + const recognition = new SpeechRecognitionApi(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = voiceLanguage; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + const interimParts: string[] = []; + for (let i = processedIndexRef.current; i < event.results.length; i++) { + const r = event.results[i]; + if (r.isFinal) { + const text = r[0].transcript.trim(); + if (text) transcriptPartsRef.current.push(text); + processedIndexRef.current = i + 1; + } else { + const text = r[0].transcript.trim(); + if (text) interimParts.push(text); + } + } + const finalSoFar = transcriptPartsRef.current.join(' '); + const interim = interimParts.join(' '); + const combined = [finalSoFar, interim].filter(Boolean).join(' '); + setLiveTranscript(combined); + + const base = promptBeforeVoiceRef.current; + const display = base ? `${base} ${combined}` : combined; + setPrompt(display); + }; + + recognition.onerror = (event: any) => { + if (event.error === 'no-speech' || event.error === 'aborted') return; + console.warn('SpeechRecognition error:', event.error); + }; + + recognition.onend = () => { + if (!recognitionRef.current) return; + processedIndexRef.current = 0; + setTimeout(() => { + if (!recognitionRef.current) return; + try { recognitionRef.current.start(); } catch { /* ignore */ } + }, 300); + }; + + try { + recognition.start(); + recognitionRef.current = recognition; + setVoiceActive(true); + } catch (err) { + console.error('SpeechRecognition start failed:', err); + } + }, [voiceActive, voiceLanguage, prompt, _stopRecognition]); + + const filteredFiles = showAutocomplete + ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) + : []; + + const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0; + + return ( +
+ {/* Pending uploaded files */} + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map(pf => ( + + 📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName} + {onRemovePendingFile && ( + + )} + + ))} +
+ )} + + {/* Attachment bar */} + {hasAttachments && ( +
+ {attachedFileIds.map(fId => { + const file = files.find(f => f.id === fId); + return ( + + đź“„ {file?.fileName || fId} + + + ); + })} + {attachedDataSourceIds.map(dsId => { + const ds = dataSources.find(d => d.id === dsId); + return ( + + đź”— {ds?.label || dsId} + + + ); + })} +
+ )} + + {/* Autocomplete dropdown */} + {showAutocomplete && filteredFiles.length > 0 && ( +
+ {filteredFiles.slice(0, 10).map(f => ( +
_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} + + {f.mimeType} · {(f.fileSize / 1024).toFixed(1)}KB + +
+ ))} +
+ )} + + {/* Main input row */} +
+