From 869d1f24c38c1fef127a78540faae3ec60913ffd Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 14 Mar 2026 11:52:17 +0100 Subject: [PATCH 1/8] removed ChatStats, only billing and transactions relevant --- src/api/workflowApi.ts | 34 ++--- .../WorkflowStatus/WorkflowStatus.tsx | 57 ++------ .../WorkflowStatus/WorkflowStatusTypes.ts | 7 +- src/hooks/playground/useWorkflowLifecycle.ts | 78 ++--------- src/pages/billing/BillingAdmin.tsx | 115 +++++++---------- src/pages/billing/BillingDataView.tsx | 122 +++++++++++++++++- src/pages/workflows/PlaygroundPage.tsx | 38 +----- 7 files changed, 199 insertions(+), 252 deletions(-) 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/UiComponents/WorkflowStatus/WorkflowStatus.tsx b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx index 59031c8..3002c35 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx @@ -88,28 +88,9 @@ const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round }; }; -// 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 = ({ @@ -185,33 +166,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/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/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 7624f24..d25bfba 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,12 +8,16 @@ */ 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 { useAdminMandates } from '../../hooks/useMandates'; 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 @@ -195,18 +199,18 @@ interface CreditAdderProps { settings: BillingSettings | null; accounts: AccountSummary[]; users: MandateUserSummary[]; - onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>; + onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } -const CreditAdder: React.FC = ({ settings, accounts, users, onCreateCheckout }) => { +const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); - const [amount, setAmount] = useState(10); + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState('Manuelles Aufladen durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; - // Map accounts by userId for balance lookup const accountsByUserId = accounts .filter(acc => acc.accountType === 'USER') .reduce((map, acc) => { @@ -214,9 +218,10 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on return map; }, {} as Record); - const handleSubmit = async (e: React.FormEvent) => { + const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (amount <= 0) { + const numAmount = parseFloat(amount); + if (!numAmount || numAmount <= 0) { setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); return; } @@ -225,24 +230,19 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on setMessage(null); try { - const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount); - window.location.href = redirectUrl; + await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); + setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` }); + setAmount(''); } catch (err: any) { setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); + } finally { setSaving(false); } }; - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - return (
-

Guthaben aufladen

+

Guthaben manuell aufladen

{message && (
@@ -250,7 +250,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on
)} -
+ {isPrepayUser && (
@@ -264,7 +264,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on {users.map((user) => { const account = accountsByUserId[user.id]; - const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; + const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)'; return (
@@ -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/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 00c2d9f..4b5371a 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -128,25 +128,6 @@ export const PlaygroundPage: React.FC = () => { } }, [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); @@ -589,22 +570,13 @@ export const PlaygroundPage: React.FC = () => {

Chat Playground

- {/* Stats display in header */} -
- - ↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)} - - {(latestStats?.processingTime ?? 0) > 0 && ( - - ⏱️ {formatDuration(latestStats?.processingTime || 0)} - - )} - {(latestStats?.priceUsd ?? 0) > 0 && ( + {latestStats?.priceCHF != null && latestStats.priceCHF > 0 && ( +
- πŸ’° CHF {(latestStats?.priceUsd || 0).toFixed(4)} + CHF {latestStats.priceCHF.toFixed(2)} - )} -
+
+ )}

Workflow-AusfΓΌhrung und Chat-Interaktion

From 04b6841c517f7efd301244e6d512e4acd3a00041 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 15 Mar 2026 23:38:44 +0100 Subject: [PATCH 2/8] new ai agent --- src/config/pageRegistry.tsx | 4 + src/layouts/MainLayout.module.css | 1 + src/layouts/MainLayout.tsx | 10 +- src/pages/FeatureView.tsx | 11 + src/pages/views/workspace/ChatStream.tsx | 392 ++++++++++++++ .../views/workspace/ConversationList.tsx | 256 +++++++++ src/pages/views/workspace/DataSourcePanel.tsx | 436 +++++++++++++++ src/pages/views/workspace/FileBrowser.tsx | 241 +++++++++ src/pages/views/workspace/FilePreview.tsx | 153 ++++++ src/pages/views/workspace/ToolActivityLog.tsx | 83 +++ src/pages/views/workspace/WorkspaceInput.tsx | 409 ++++++++++++++ .../views/workspace/WorkspaceKeepAlive.tsx | 48 ++ src/pages/views/workspace/WorkspacePage.tsx | 284 ++++++++++ src/pages/views/workspace/useWorkspace.ts | 500 ++++++++++++++++++ src/types/mandate.ts | 8 + src/utils/sseClient.ts | 176 ++++++ 16 files changed, 3011 insertions(+), 1 deletion(-) create mode 100644 src/pages/views/workspace/ChatStream.tsx create mode 100644 src/pages/views/workspace/ConversationList.tsx create mode 100644 src/pages/views/workspace/DataSourcePanel.tsx create mode 100644 src/pages/views/workspace/FileBrowser.tsx create mode 100644 src/pages/views/workspace/FilePreview.tsx create mode 100644 src/pages/views/workspace/ToolActivityLog.tsx create mode 100644 src/pages/views/workspace/WorkspaceInput.tsx create mode 100644 src/pages/views/workspace/WorkspaceKeepAlive.tsx create mode 100644 src/pages/views/workspace/WorkspacePage.tsx create mode 100644 src/pages/views/workspace/useWorkspace.ts create mode 100644 src/utils/sseClient.ts 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/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..15fb2b3 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\/[^/]+/; + // ============================================================================= // 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..c7629c2 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -36,6 +36,9 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView // CodeEditor Views import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor'; +// Workspace Views +import { WorkspacePage } from './views/workspace/WorkspacePage'; + // Teamsbot Views import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView'; @@ -137,6 +140,9 @@ const VIEW_COMPONENTS: Record> = { editor: CodeEditorPage, workflows: CodeEditorWorkflowsPage, }, + workspace: { + dashboard: WorkspacePage, + }, teamsbot: { dashboard: TeamsbotDashboardView, sessions: TeamsbotSessionView, @@ -199,6 +205,11 @@ export const FeatureViewPage: React.FC = ({ view }) => { return ; } + // Workspace is rendered persistently by WorkspaceKeepAlive at MainLayout level + if (featureCode === 'workspace') { + return null; + } + // View-Komponente finden const featureViews = VIEW_COMPONENTS[featureCode]; if (!featureViews) { diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx new file mode 100644 index 0000000..8111b22 --- /dev/null +++ b/src/pages/views/workspace/ChatStream.tsx @@ -0,0 +1,392 @@ +/** + * 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 } 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} /> + ))} +
+ )} +
+ )} +
+ ))} + + {/* 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 _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..bf9c57b --- /dev/null +++ b/src/pages/views/workspace/ConversationList.tsx @@ -0,0 +1,256 @@ +/** + * ConversationList -- Shows all workspace workflows/conversations. + * + * Loads conversations from the workspace API, displays them sorted by + * last activity. Names are auto-generated ("Chat N") and editable inline. + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import api from '../../../api'; + +interface Conversation { + id: string; + name: string; + status: string; + startedAt?: number; + lastActivity?: number; +} + +interface ConversationListProps { + instanceId: string; + activeWorkflowId: string | null; + onSelect: (workflowId: string) => void; +} + +export const ConversationList: React.FC = ({ + instanceId, + activeWorkflowId, + onSelect, +}) => { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [filterQuery, setFilterQuery] = useState(''); + const inputRef = useRef(null); + + const _loadConversations = useCallback(() => { + if (!instanceId) return; + setLoading(true); + api.get(`/api/workspace/${instanceId}/workflows`) + .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 (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 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + 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 _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), + ); + }; + + return ( +
+
+ Conversations + +
+ + {conversations.length > 0 && ( + setFilterQuery(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, + border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', + }} + /> + )} + + {conversations.length === 0 && !loading && ( +
+ No conversations yet. Send a message to start. +
+ )} + + {_filtered(conversations, filterQuery).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', + }} + onMouseEnter={e => { + if (!isActive) e.currentTarget.style.background = '#f5f5f5'; + }} + onMouseLeave={e => { + if (!isActive) e.currentTarget.style.background = 'transparent'; + }} + > + {/* 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="Double-click to rename" + > + {conv.name} + + )} + {!isEditing && ( + + )} +
+ + {/* Status + last activity */} +
+ + {conv.status === 'active' && ( + {'\u25CF'} active + )} + {conv.status === 'completed' && ( + {'\u25CF'} completed + )} + {conv.status !== 'active' && conv.status !== 'completed' && ( + {conv.status} + )} + + + {_formatTime(conv.lastActivity)} + +
+
+ ); + })} +
+ ); +}; diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx new file mode 100644 index 0000000..4cea053 --- /dev/null +++ b/src/pages/views/workspace/DataSourcePanel.tsx @@ -0,0 +1,436 @@ +/** + * 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', +}; + +/* ─── 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 => ( +
+ {'\u25CF'} + + {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..21e5f33 --- /dev/null +++ b/src/pages/views/workspace/FileBrowser.tsx @@ -0,0 +1,241 @@ +/** + * FileBrowser -- Folder + file browser panel with tags, search, and drag-and-drop upload. + */ + +import React, { useState, useCallback, useRef } 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; +} + +export const FileBrowser: React.FC = ({ + instanceId, + files, + folders, + onRefresh, + onFileSelect, +}) => { + const [currentFolderId, setCurrentFolderId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const currentFolders = folders.filter(f => + currentFolderId ? f.parentId === currentFolderId : !f.parentId, + ); + const currentFiles = files.filter(f => { + const inFolder = currentFolderId ? f.folderId === currentFolderId : !f.folderId; + const matchesSearch = !searchQuery + || f.fileName.toLowerCase().includes(searchQuery.toLowerCase()) + || (f.tags || []).some(t => t.toLowerCase().includes(searchQuery.toLowerCase())); + return inFolder && matchesSearch; + }); + + const _navigateUp = () => { + if (!currentFolderId) return; + const folder = folders.find(f => f.id === currentFolderId); + setCurrentFolderId(folder?.parentId || null); + }; + + 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); + if (currentFolderId) { + formData.append('folderId', currentFolderId); + } + 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, currentFolderId, 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 ( +
+ {/* Drag overlay */} + {isDragOver && ( +
+ Drop files to upload +
+ )} + + {/* 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', + }} + /> + + {/* Breadcrumb */} + {currentFolderId && ( +
+ ← Back +
+ )} + + {/* Folders */} + {currentFolders.map(folder => ( +
setCurrentFolderId(folder.id)} + style={{ + padding: '6px 8px', cursor: 'pointer', fontSize: 13, + display: 'flex', alignItems: 'center', gap: 6, + borderRadius: 4, + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={e => (e.currentTarget.style.background = '')} + > + πŸ“ + {folder.name} +
+ ))} + + {/* Files */} + {currentFiles.map(file => ( +
onFileSelect?.(file.id)} + style={{ + padding: '6px 8px', fontSize: 13, + 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 + +
+ ))} + + {currentFiles.length === 0 && currentFolders.length === 0 && ( +
+ {searchQuery ? 'No files match your search' : 'No files. Drag & drop to upload.'} +
+ )} +
+ ); +}; + +function _fileIcon(mime: string): string { + if (mime.startsWith('image/')) return 'πŸ–ΌοΈ'; + if (mime.includes('pdf')) return 'πŸ“•'; + if (mime.includes('word') || mime.includes('docx')) return 'πŸ“˜'; + if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return 'πŸ“Š'; + if (mime.includes('presentation') || mime.includes('pptx')) return 'πŸ“™'; + if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return 'πŸ“¦'; + if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return 'πŸ“'; + return 'πŸ“„'; +} 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..f91f450 --- /dev/null +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -0,0 +1,409 @@ +/** + * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, + * voice toggle, and data source selection. + */ + +import React, { useState, useCallback, useRef } from 'react'; +import { ProviderMultiSelect } from '../../../components/ProviderSelector'; +import type { WorkspaceFile, DataSource } from './useWorkspace'; + +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, + 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 [attachedFileIds, setAttachedFileIds] = useState([]); + const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); + const textareaRef = useRef(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + + 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); + setAttachedFileIds([]); + setAttachedDataSourceIds([]); + }, [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 _toggleVoice = useCallback(async () => { + if (voiceActive) { + mediaRecorderRef.current?.stop(); + setVoiceActive(false); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + recorder.ondataavailable = (e) => chunksRef.current.push(e.data); + recorder.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); + try { + const formData = new FormData(); + formData.append('audio', blob, 'voice.webm'); + const res = await fetch(`/api/workspace/${instanceId}/voice/transcribe`, { + method: 'POST', + body: formData, + }); + const data = await res.json(); + if (data.text) { + setPrompt(prev => prev + (prev ? ' ' : '') + data.text); + } + } catch (err) { + console.error('Voice transcription failed:', err); + } + }; + recorder.start(); + mediaRecorderRef.current = recorder; + setVoiceActive(true); + } catch (err) { + console.error('Microphone access denied:', err); + } + }, [voiceActive, instanceId]); + + 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 */} +
+