Merge pull request #16 from valueonag/refactor/service-migrations

Refactor/service migrations
This commit is contained in:
Patrick Motsch 2026-03-16 15:52:17 +01:00 committed by GitHub
commit 9c1b9676c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 4469 additions and 376 deletions

View file

@ -57,8 +57,8 @@ export interface StartWorkflowResponse extends Workflow {
export interface ChatDataResponse { export interface ChatDataResponse {
messages: WorkflowMessage[]; messages: WorkflowMessage[];
logs: WorkflowLog[]; logs: WorkflowLog[];
stats: WorkflowStats[];
documents: WorkflowDocument[]; documents: WorkflowDocument[];
workflowCost: number;
} }
// Type for the request function passed to API functions // Type for the request function passed to API functions
@ -259,35 +259,25 @@ export async function fetchChatData(
console.log('📥 fetchChatData response:', data); console.log('📥 fetchChatData response:', data);
// Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] } const workflowCost: number = data.workflowCost ?? 0;
if (data.items && Array.isArray(data.items)) { if (data.items && Array.isArray(data.items)) {
const messages: WorkflowMessage[] = []; const messages: WorkflowMessage[] = [];
const logs: WorkflowLog[] = []; const logs: WorkflowLog[] = [];
const stats: WorkflowStats[] = [];
const documents: WorkflowDocument[] = []; const documents: WorkflowDocument[] = [];
data.items.forEach((item: any) => { data.items.forEach((item: any) => {
if (item.type === 'message') { if (item.type === 'message') {
// Handle both formats: item.item or direct item data
const messageData = item.item || item; const messageData = item.item || item;
if (messageData && (messageData.id || messageData.message)) { if (messageData && (messageData.id || messageData.message)) {
messages.push(messageData); messages.push(messageData);
} else {
console.warn('⚠️ Invalid message item:', item);
} }
} else if (item.type === 'log') { } else if (item.type === 'log') {
const logData = item.item || item; const logData = item.item || item;
if (logData) { if (logData) {
logs.push(logData); logs.push(logData);
} }
} else if (item.type === 'stat') { } else if (item.type === 'document') {
const statData = item.item || item;
if (statData) {
stats.push(statData);
}
}
// Documents might be in items or separate
if (item.type === 'document') {
const docData = item.item || item; const docData = item.item || item;
if (docData) { if (docData) {
documents.push(docData); documents.push(docData);
@ -295,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 { return {
messages, messages,
logs, logs,
stats, documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []),
documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []) workflowCost
}; };
} }
// Fallback to direct format: { messages: [], logs: [], stats: [] }
return { return {
messages: Array.isArray(data.messages) ? data.messages : [], messages: Array.isArray(data.messages) ? data.messages : [],
logs: Array.isArray(data.logs) ? data.logs : [], logs: Array.isArray(data.logs) ? data.logs : [],
stats: Array.isArray(data.stats) ? data.stats : [], documents: Array.isArray(data.documents) ? data.documents : [],
documents: Array.isArray(data.documents) ? data.documents : [] workflowCost
}; };
} }

View file

@ -49,9 +49,12 @@ export const UserSection: React.FC = () => {
} }
// Initialen für Avatar // Initialen für Avatar
const initials = user.fullName const initials = (() => {
? user.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) const name = user.fullName || user.username || '';
: user.username.slice(0, 2).toUpperCase(); const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toLocaleUpperCase();
return [...name.trim()].slice(0, 2).join('').toLocaleUpperCase() || '?';
})();
return ( return (
<div className={styles.userSection}> <div className={styles.userSection}>

View file

@ -2,114 +2,37 @@ import React, { useMemo } from 'react';
import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes'; import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes';
import styles from './WorkflowStatus.module.css'; import styles from './WorkflowStatus.module.css';
// Helper function to extract workflow status and round from log message const _STATUS_MAP: Record<string, WorkflowStatusType> = {
success: 'completed',
completed: 'completed',
started: 'started',
running: 'started',
resumed: 'resumed',
stopped: 'stopped',
failed: 'failed',
error: 'failed',
};
const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round: number | null; timestamp: number } => { const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round: number | null; timestamp: number } => {
// First, check for completion messages with success status (these take priority) if (!logs.length) return { status: null, round: null, timestamp: 0 };
const completionMessages = logs.filter(log => {
const message = (log.message || '').toLowerCase(); const sorted = [...logs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
for (const log of sorted) {
const logStatus = (log.status || '').toLowerCase(); const logStatus = (log.status || '').toLowerCase();
return (message.includes('fast path completed') || const mapped = _STATUS_MAP[logStatus];
message.includes('completed successfully')) && if (mapped) {
logStatus === 'success'; const roundMatch = (log.message || '').match(/\(?round\s+(\d+)\)?/i);
}); return { status: mapped, round: roundMatch ? parseInt(roundMatch[1], 10) : null, timestamp: log.timestamp || 0 };
// If we have completion messages, use the latest one
if (completionMessages.length > 0) {
const latestCompletion = completionMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
// Try to extract round from completion message
let round: number | null = null;
const message = (latestCompletion.message || '').toLowerCase();
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
if (roundMatch) {
round = parseInt(roundMatch[1], 10);
} else {
// If no round in completion message, get round from latest workflow status message
const statusMessages = logs.filter(log => {
const msg = (log.message || '').toLowerCase();
return msg.includes('workflow started') || msg.includes('workflow resumed');
});
if (statusMessages.length > 0) {
const latestWorkflowStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
const workflowMessage = (latestWorkflowStatus.message || '').toLowerCase();
const workflowRoundMatch = workflowMessage.match(/\(?round\s+(\d+)\)?/i);
if (workflowRoundMatch) {
round = parseInt(workflowRoundMatch[1], 10);
}
} }
} }
return {
status: 'completed',
round,
timestamp: latestCompletion.timestamp || 0
};
}
// If no completion messages, look for workflow started/resumed/stopped messages
const statusMessages = logs.filter(log => {
const message = (log.message || '').toLowerCase();
return message.includes('workflow started') ||
message.includes('workflow resumed') ||
message.includes('workflow stopped') ||
message.includes('workflow failed') ||
message.includes('workflow completed');
});
if (statusMessages.length === 0) {
return { status: null, round: null, timestamp: 0 }; return { status: null, round: null, timestamp: 0 };
}
// Get the latest status message
const latestStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
const message = (latestStatus.message || '').toLowerCase();
let status: WorkflowStatusType = null;
if (message.includes('started')) {
status = 'started';
} else if (message.includes('resumed')) {
status = 'resumed';
} else if (message.includes('stopped')) {
status = 'stopped';
} else if (message.includes('failed')) {
status = 'failed';
} else if (message.includes('completed')) {
status = 'completed';
}
// Extract round number from message (e.g., "round 4", "round 2", or "(round 4)")
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
const round = roundMatch ? parseInt(roundMatch[1], 10) : null;
return {
status,
round,
timestamp: latestStatus.timestamp || 0
};
}; };
// Helper function to format bytes to KB or MB const _formatCurrency = (amount?: number): string => {
const formatBytes = (bytes?: number): string => { if (amount === undefined || amount === null) return '-';
if (bytes === undefined || bytes === null) return '-'; return `${amount.toFixed(2)} CHF`;
if (bytes === 0) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) {
return `${kb.toFixed(2)} KB`;
}
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
};
// Helper function to format price
const formatPrice = (price?: number): string => {
if (price === undefined || price === null) return '-';
return `$${price.toFixed(2)}`;
};
// Helper function to format processing time
const formatProcessingTime = (time?: number): string => {
if (time === undefined || time === null) return '-';
return `${time.toFixed(2)}s`;
}; };
const WorkflowStatus: React.FC<WorkflowStatusProps> = ({ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
@ -122,40 +45,10 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
}) => { }) => {
// Use workflow status and round from API response, fallback to extracting from logs // Use workflow status and round from API response, fallback to extracting from logs
const workflowStatus = useMemo(() => { const workflowStatus = useMemo(() => {
// If we have status from API, use it
if (workflowStatusFromApi) { if (workflowStatusFromApi) {
let status: WorkflowStatusType = null; const mapped = _STATUS_MAP[workflowStatusFromApi.toLowerCase()] || null;
const statusLower = workflowStatusFromApi.toLowerCase(); return { status: mapped, round: currentRoundFromApi || null, timestamp: Date.now() / 1000 };
if (statusLower === 'completed') {
status = 'completed';
} else if (statusLower === 'running') {
// Check if it's started or resumed from logs
const startedResumedLogs = logs.filter(log => {
const message = (log.message || '').toLowerCase();
return message.includes('workflow started') || message.includes('workflow resumed');
});
if (startedResumedLogs.length > 0) {
const latest = startedResumedLogs.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
const message = (latest.message || '').toLowerCase();
status = message.includes('resumed') ? 'resumed' : 'started';
} else {
status = 'started';
} }
} else if (statusLower === 'stopped') {
status = 'stopped';
} else if (statusLower === 'failed') {
status = 'failed';
}
return {
status,
round: currentRoundFromApi || null,
timestamp: Date.now() / 1000 // Use current time since we don't have timestamp from API
};
}
// Fallback to extracting from logs
return extractWorkflowStatus(logs); return extractWorkflowStatus(logs);
}, [workflowStatusFromApi, currentRoundFromApi, logs]); }, [workflowStatusFromApi, currentRoundFromApi, logs]);
@ -185,33 +78,13 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
)} )}
</div> </div>
{/* Stats Display */} {/* Cost Display */}
{latestStats && ( {latestStats && latestStats.priceCHF !== undefined && (
<div className={styles.statsContainer}> <div className={styles.statsContainer}>
{latestStats.priceUsd !== undefined && (
<div className={styles.statItem}> <div className={styles.statItem}>
<span className={styles.statLabel}>Price:</span> <span className={styles.statLabel}>Cost:</span>
<span className={styles.statValue}>{formatPrice(latestStats.priceUsd)}</span> <span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</span>
</div> </div>
)}
{latestStats.processingTime !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Time:</span>
<span className={styles.statValue}>{formatProcessingTime(latestStats.processingTime)}</span>
</div>
)}
{latestStats.bytesSent !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Sent:</span>
<span className={styles.statValue}>{formatBytes(latestStats.bytesSent)}</span>
</div>
)}
{latestStats.bytesReceived !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Received:</span>
<span className={styles.statValue}>{formatBytes(latestStats.bytesReceived)}</span>
</div>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -44,13 +44,10 @@ export interface WorkflowStatusProps {
isRunning?: boolean; isRunning?: boolean;
/** /**
* Latest statistics from the workflow (price, processing time, bytes sent/received) * Latest cost from billing transactions (single source of truth)
*/ */
latestStats?: { latestStats?: {
priceUsd?: number; priceCHF?: number;
processingTime?: number;
bytesSent?: number;
bytesReceived?: number;
} | null; } | null;
} }

View file

@ -116,6 +116,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.feature.chatbot.conversations': <FaComments />, 'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />, 'feature.chatbot': <FaComments />,
'feature.teamsbot': <FaHeadset />, 'feature.teamsbot': <FaHeadset />,
// Feature pages - Workspace
'page.feature.workspace.dashboard': <FaPlay />,
'feature.workspace': <FaPlay />,
}; };
// ============================================================================= // =============================================================================

View file

@ -14,8 +14,8 @@ import { useWorkflowPolling } from './useWorkflowPolling';
import { getWorkflowApiBaseUrl } from '../useWorkflows'; import { getWorkflowApiBaseUrl } from '../useWorkflows';
interface UnifiedChatDataItem { interface UnifiedChatDataItem {
type: 'message' | 'log' | 'stat'; type: 'message' | 'log';
item: WorkflowMessage | WorkflowLog | any; item: WorkflowMessage | WorkflowLog;
createdAt: number; createdAt: number;
} }
@ -76,13 +76,11 @@ export function useWorkflowLifecycle(instanceId: string) {
const [logs, setLogs] = useState<WorkflowLog[]>([]); const [logs, setLogs] = useState<WorkflowLog[]>([]);
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]); const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
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 === // === REFS FOR SYNC ACCESS ===
const statusRef = useRef<string>('idle'); const statusRef = useRef<string>('idle');
const lastRenderedTimestampRef = useRef<number | null>(null); const lastRenderedTimestampRef = useRef<number | null>(null);
const processedStatIdsRef = useRef<Set<string>>(new Set());
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
// === KEY STATE MACHINE FLAG === // === KEY STATE MACHINE FLAG ===
// This flag tracks if the UI has rendered a message with status="last" // This flag tracks if the UI has rendered a message with status="last"
@ -124,17 +122,15 @@ export function useWorkflowLifecycle(instanceId: string) {
}, [workflowId]); }, [workflowId]);
// === CORE: Process unified chat data === // === 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:', { console.log('🔄 Processing chat data:', {
messages: chatData.messages?.length || 0, messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0, logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0 workflowCost: chatData.workflowCost ?? 0
}); });
// Build unified timeline
const timeline: UnifiedChatDataItem[] = []; const timeline: UnifiedChatDataItem[] = [];
// Add messages
(chatData.messages || []).forEach((message: WorkflowMessage) => { (chatData.messages || []).forEach((message: WorkflowMessage) => {
timeline.push({ timeline.push({
type: 'message', type: 'message',
@ -143,7 +139,6 @@ export function useWorkflowLifecycle(instanceId: string) {
}); });
}); });
// Add logs
(chatData.logs || []).forEach((log: any) => { (chatData.logs || []).forEach((log: any) => {
timeline.push({ timeline.push({
type: 'log', 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); timeline.sort((a, b) => a.createdAt - b.createdAt);
// Update lastRenderedTimestamp // Update lastRenderedTimestamp
@ -290,44 +274,9 @@ export function useWorkflowLifecycle(instanceId: string) {
return [...allLogs].sort(sortLogs); return [...allLogs].sort(sortLogs);
}); });
// === PROCESS STATS === // === UPDATE COST from billing transactions (single source of truth) ===
const statsItems = timeline.filter(item => item.type === 'stat'); const cost = chatData.workflowCost ?? 0;
setLatestStats(cost > 0 ? { priceCHF: cost } : null);
if (statsItems.length > 0) {
let hasNewStats = false;
statsItems.forEach(statItem => {
const statData = statItem.item;
const statId = statData?.id;
if (statId && processedStatIdsRef.current.has(statId)) {
return; // Skip already processed
}
if (statData) {
hasNewStats = true;
if (statId) {
processedStatIdsRef.current.add(statId);
}
// Accumulate stats
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
}
});
if (hasNewStats) {
setLatestStats({
priceUsd: cumulativeStatsRef.current.priceUsd,
processingTime: cumulativeStatsRef.current.processingTime,
bytesSent: cumulativeStatsRef.current.bytesSent,
bytesReceived: cumulativeStatsRef.current.bytesReceived
});
}
}
}, [convertLogToFrontendFormat]); }, [convertLogToFrontendFormat]);
// === POLLING FUNCTION === // === POLLING FUNCTION ===
@ -359,7 +308,7 @@ export function useWorkflowLifecycle(instanceId: string) {
console.log('📊 Polled chat data:', { console.log('📊 Polled chat data:', {
messages: chatData.messages?.length || 0, messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0, logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0, workflowCost: chatData.workflowCost ?? 0,
afterTimestamp afterTimestamp
}); });
@ -496,10 +445,7 @@ export function useWorkflowLifecycle(instanceId: string) {
setUnifiedContentLogs([]); setUnifiedContentLogs([]);
setLatestStats(null); setLatestStats(null);
// Reset refs
lastRenderedTimestampRef.current = null; lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
hasRenderedLastMessageRef.current = false; hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false); setHasRenderedLastMessage(false);
@ -511,13 +457,11 @@ export function useWorkflowLifecycle(instanceId: string) {
try { try {
console.log('📥 Loading workflow:', workflowIdToSelect); console.log('📥 Loading workflow:', workflowIdToSelect);
// Reset state
setWorkflowId(workflowIdToSelect); setWorkflowId(workflowIdToSelect);
lastRenderedTimestampRef.current = null; lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
hasRenderedLastMessageRef.current = false; hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false); setHasRenderedLastMessage(false);
setLatestStats(null);
// Fetch workflow data // Fetch workflow data
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null); const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null);
@ -544,7 +488,7 @@ export function useWorkflowLifecycle(instanceId: string) {
console.log('📥 Loaded chat data:', { console.log('📥 Loaded chat data:', {
messages: chatData.messages?.length || 0, messages: chatData.messages?.length || 0,
logs: chatData.logs?.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" === // === STATE MACHINE: Check if last message has status="last" ===

View file

@ -479,7 +479,7 @@ export function useFileOperations() {
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py * - Removed workflowId from FileItem creation in interfaceComponentObjects.py
* - Upload should now work correctly * - Upload should now work correctly
*/ */
const handleFileUpload = async (file: globalThis.File, workflowId?: string) => { const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => {
setUploadError(null); setUploadError(null);
setUploadingFile(true); setUploadingFile(true);
@ -500,6 +500,9 @@ export function useFileOperations() {
if (workflowId) { if (workflowId) {
formData.append('workflowId', workflowId); formData.append('workflowId', workflowId);
} }
if (featureInstanceId) {
formData.append('featureInstanceId', featureInstanceId);
}
// FormData is now correctly configured for backend // FormData is now correctly configured for backend

View file

@ -276,12 +276,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
} else if (attr.type === 'textarea') { } else if (attr.type === 'textarea') {
fieldType = 'textarea'; fieldType = 'textarea';
} else if (attr.type === 'text') { } else if (attr.type === 'text') {
// Check if it should be textarea based on name fieldType = (attr as any).multiline === true ? 'textarea' : 'string';
if (attr.name.toLowerCase().includes('description') || attr.name.toLowerCase().includes('note')) {
fieldType = 'textarea';
} else {
fieldType = 'string';
}
} }
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union // Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
// If needed, they should be handled via type casting: (attr as any).type === 'boolean' // If needed, they should be handled via type casting: (attr as any).type === 'boolean'

View file

@ -92,6 +92,7 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
position: relative;
/* Let child components handle their own scrolling for sticky headers */ /* Let child components handle their own scrolling for sticky headers */
overflow: hidden; overflow: hidden;
background: var(--bg-primary, #ffffff); background: var(--bg-primary, #ffffff);

View file

@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection'; import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import styles from './MainLayout.module.css'; import styles from './MainLayout.module.css';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
// ============================================================================= // =============================================================================
// INNER LAYOUT (mit Zugriff auf Store) // INNER LAYOUT (mit Zugriff auf Store)
// ============================================================================= // =============================================================================
@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => {
className={styles.mobileLogo} className={styles.mobileLogo}
/> />
</div> </div>
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
<div style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : 'contents' }}>
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
); );

View file

@ -36,6 +36,10 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView
// CodeEditor Views // CodeEditor Views
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor'; import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
// Workspace Views
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
// Teamsbot Views // Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView'; import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
@ -137,6 +141,10 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
editor: CodeEditorPage, editor: CodeEditorPage,
workflows: CodeEditorWorkflowsPage, workflows: CodeEditorWorkflowsPage,
}, },
workspace: {
dashboard: WorkspacePage,
settings: WorkspaceSettingsPage,
},
teamsbot: { teamsbot: {
dashboard: TeamsbotDashboardView, dashboard: TeamsbotDashboardView,
sessions: TeamsbotSessionView, sessions: TeamsbotSessionView,
@ -199,6 +207,12 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <AccessDenied />; return <AccessDenied />;
} }
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings') {
return null;
}
// View-Komponente finden // View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode]; const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) { if (!featureViews) {

View file

@ -88,6 +88,34 @@
border-color: var(--text-secondary); border-color: var(--text-secondary);
} }
.googleButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #4285f4;
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.googleButton:hover {
background: #3367d6;
}
.googleButton:active {
transform: scale(0.98);
}
.googleButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Filter Section Styles */ /* Filter Section Styles */
.filterSection { .filterSection {
display: flex; display: flex;

View file

@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => {
{canCreate && ( {canCreate && (
<> <>
<button <button
className={styles.secondaryButton} className={styles.googleButton}
onClick={handleCreateGoogle} onClick={handleCreateGoogle}
disabled={isConnecting} disabled={isConnecting}
> >
@ -255,7 +255,7 @@ export const ConnectionsPage: React.FC = () => {
{canCreate && ( {canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}> <div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button <button
className={styles.secondaryButton} className={styles.googleButton}
onClick={handleCreateGoogle} onClick={handleCreateGoogle}
disabled={isConnecting} disabled={isConnecting}
> >

View file

@ -8,12 +8,16 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates'; import { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500]; const _formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
// ============================================================================ // ============================================================================
// MANDATE SELECTOR // MANDATE SELECTOR
@ -195,18 +199,18 @@ interface CreditAdderProps {
settings: BillingSettings | null; settings: BillingSettings | null;
accounts: AccountSummary[]; accounts: AccountSummary[];
users: MandateUserSummary[]; users: MandateUserSummary[];
onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>; onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
} }
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onCreateCheckout }) => { const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>(''); const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10); const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState<string>('Manuelles Aufladen durch Admin');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
// Map accounts by userId for balance lookup
const accountsByUserId = accounts const accountsByUserId = accounts
.filter(acc => acc.accountType === 'USER') .filter(acc => acc.accountType === 'USER')
.reduce((map, acc) => { .reduce((map, acc) => {
@ -214,9 +218,10 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
return map; return map;
}, {} as Record<string, AccountSummary>); }, {} as Record<string, AccountSummary>);
const handleSubmit = async (e: React.FormEvent) => { const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (amount <= 0) { const numAmount = parseFloat(amount);
if (!numAmount || numAmount <= 0) {
setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
return; return;
} }
@ -225,24 +230,19 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
setMessage(null); setMessage(null);
try { try {
const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount); await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
window.location.href = redirectUrl; setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
setAmount('');
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
} finally {
setSaving(false); setSaving(false);
} }
}; };
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
return ( return (
<div className={styles.adminSection}> <div className={styles.adminSection}>
<h3>Guthaben aufladen</h3> <h3>Guthaben manuell aufladen</h3>
{message && ( {message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}> <div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
@ -250,7 +250,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={_handleSubmit}>
{isPrepayUser && ( {isPrepayUser && (
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@ -264,7 +264,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<option value="">-- Benutzer wählen --</option> <option value="">-- Benutzer wählen --</option>
{users.map((user) => { {users.map((user) => {
const account = accountsByUserId[user.id]; const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
return ( return (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo} {user.displayName || user.username || user.id}{balanceInfo}
@ -279,27 +279,35 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>Betrag (CHF)</label> <label>Betrag (CHF)</label>
<select <input
className={styles.select} type="number"
className={styles.input}
value={amount} value={amount}
onChange={(e) => setAmount(Number(e.target.value))} onChange={(e) => setAmount(e.target.value)}
placeholder="z.B. 50"
min="0.01"
step="0.01"
required required
> />
{STRIPE_AMOUNT_PRESETS.map((preset) => ( </div>
<option key={preset} value={preset}> <div className={styles.formGroup}>
{preset} CHF <label>Beschreibung</label>
</option> <input
))} type="text"
</select> className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Beschreibung der Gutschrift"
/>
</div> </div>
</div> </div>
<button <button
type="submit" type="submit"
className={`${styles.button} ${styles.buttonPrimary}`} className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId)} disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
> >
{saving ? 'Weiterleitung zu Stripe...' : 'Mit Stripe aufladen'} {saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
</button> </button>
</form> </form>
</div> </div>
@ -370,18 +378,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
// ============================================================================ // ============================================================================
export const BillingAdmin: React.FC = () => { export const BillingAdmin: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null); const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
useEffect(() => {
if (successParam === 'true' && selectedMandateId) {
loadAccounts();
}
}, [successParam, selectedMandateId, loadAccounts]);
const handleMandateSelect = (mandateId: string) => { const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null); setSelectedMandateId(mandateId || null);
@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => {
await saveSettings(settingsUpdate); await saveSettings(settingsUpdate);
}, [selectedMandateId, saveSettings]); }, [selectedMandateId, saveSettings]);
const handleCreateCheckout = useCallback(async (userId: string | undefined, amount: number) => { const _handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => {
if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt'); if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt');
const result = await createCheckout({ userId, amount }); const result = await addCredit({ userId, amount, description });
if (!result) throw new Error('Checkout konnte nicht erstellt werden'); if (!result) throw new Error('Gutschrift konnte nicht erstellt werden');
await loadAccounts();
return result; return result;
}, [selectedMandateId, createCheckout]); }, [selectedMandateId, addCredit, loadAccounts]);
const clearStripeParams = useCallback(() => {
searchParams.delete('success');
searchParams.delete('canceled');
searchParams.delete('session_id');
setSearchParams(searchParams, { replace: true });
}, [searchParams, setSearchParams]);
return ( return (
<div className={styles.billingDashboard}> <div className={styles.billingDashboard}>
@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p> <p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
</header> </header>
{successParam === 'true' && (
<div className={styles.successMessage} style={{ marginBottom: '1rem' }}>
Zahlung erfolgreich. Guthaben wird gutgeschrieben.
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
</div>
)}
{canceledParam === 'true' && (
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
Zahlung abgebrochen.
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
</div>
)}
<section className={styles.section}> <section className={styles.section}>
<MandateSelector <MandateSelector
selectedMandateId={selectedMandateId} selectedMandateId={selectedMandateId}
@ -445,7 +424,7 @@ export const BillingAdmin: React.FC = () => {
settings={settings} settings={settings}
accounts={accounts} accounts={accounts}
users={users} users={users}
onCreateCheckout={handleCreateCheckout} onAddCredit={_handleAddCredit}
/> />
<AccountsOverview <AccountsOverview

View file

@ -8,14 +8,19 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api'; import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { useBilling, type BillingBalance } from '../../hooks/useBilling'; import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi'; import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
import { getUserDataCache } from '../../utils/userCache';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================ // ============================================================================
// HELPER: Currency formatter // HELPER: Currency formatter
// ============================================================================ // ============================================================================
@ -47,9 +52,14 @@ interface ViewStatistics {
interface BalanceCardProps { interface BalanceCardProps {
balance: BillingBalance; balance: BillingBalance;
onCheckout?: (mandateId: string, amount: number) => void;
checkoutLoading?: boolean;
} }
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => { const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]);
const [showCheckout, setShowCheckout] = useState(false);
const _getBillingModelLabel = (model: string) => { const _getBillingModelLabel = (model: string) => {
switch (model) { switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
@ -60,6 +70,10 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
} }
}; };
const canTopUp = balance.billingModel === 'PREPAY_USER'
|| balance.billingModel === 'PREPAY_MANDATE'
|| balance.billingModel === 'CREDIT_POSTPAY';
return ( return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}> <div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}> <div className={styles.balanceHeader}>
@ -74,6 +88,47 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
Niedriges Guthaben Niedriges Guthaben
</div> </div>
)} )}
{canTopUp && onCheckout && (
<div style={{ marginTop: '12px' }}>
{!showCheckout ? (
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(true)}
>
Budget laden mit Kreditkarte
</button>
) : (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
className={styles.select}
value={selectedAmount}
onChange={(e) => setSelectedAmount(Number(e.target.value))}
style={{ flex: 1, fontSize: '13px' }}
>
{STRIPE_AMOUNT_PRESETS.map((preset) => (
<option key={preset} value={preset}>{preset} CHF</option>
))}
</select>
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
disabled={checkoutLoading}
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
>
{checkoutLoading ? 'Laden...' : 'Zahlen'}
</button>
<button
className={`${styles.button} ${styles.buttonSecondary || ''}`}
style={{ fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(false)}
>
&times;
</button>
</div>
)}
</div>
)}
</div> </div>
); );
}; };
@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => { export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams();
const { request } = useApiRequest();
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Scope filter: 'personal' | 'all' | mandateId // Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal'); const [selectedScope, setSelectedScope] = useState<string>('personal');
@ -273,8 +332,47 @@ export const BillingDataView: React.FC = () => {
const { const {
balances, balances,
loading: dashboardLoading, loading: dashboardLoading,
refetch: refetchBalances,
} = useBilling(); } = useBilling();
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
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) // All user balances (for admin overview cards)
const [allUserBalances, setAllUserBalances] = useState<any[]>([]); const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
@ -475,6 +573,15 @@ export const BillingDataView: React.FC = () => {
<TabNav activeTab={activeTab} onTabChange={setActiveTab} /> <TabNav activeTab={activeTab} onTabChange={setActiveTab} />
{checkoutMessage && (
<div className={checkoutMessage.type === 'success' ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '1rem' }}>
{checkoutMessage.text}
{(successParam || canceledParam) && (
<button type="button" onClick={_clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', color: 'inherit' }}>Schliessen</button>
)}
</div>
)}
{/* ================================================================ */} {/* ================================================================ */}
{/* Tab: Übersicht (My Overview) */} {/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */} {/* ================================================================ */}
@ -502,7 +609,12 @@ export const BillingDataView: React.FC = () => {
) : ( ) : (
<div className={styles.balanceGrid}> <div className={styles.balanceGrid}>
{filteredBalances.map((balance) => ( {filteredBalances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} /> <BalanceCard
key={balance.mandateId}
balance={balance}
onCheckout={_handleCheckout}
checkoutLoading={checkoutLoading}
/>
))} ))}
</div> </div>
)} )}

View file

@ -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<ChatStreamProps> = ({
messages,
agentProgress,
isProcessing,
pendingEdits,
onAcceptEdit,
onRejectEdit,
}) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, agentProgress]);
return (
<div style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
padding: '16px 24px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
flexShrink: 0,
padding: '10px 14px',
borderRadius: 8,
maxWidth: '85%',
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
background: _getBubbleBackground(msg.role || 'assistant'),
border: msg.role === 'user'
? 'none'
: '1px solid var(--border-color, #e0e0e0)',
fontSize: msg.role === 'status' ? 12 : 14,
color: msg.role === 'status' ? '#795548' : 'inherit',
fontStyle: msg.role === 'status' ? 'italic' : 'normal',
wordBreak: 'break-word',
overflow: 'hidden',
}}
>
{msg.role === 'assistant' && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Assistant</div>
)}
{msg.role === 'status' ? (
<span>{msg.message}</span>
) : (
<div className="workspace-markdown">
{msg.message && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: _CodeBlock,
table: ({ children }) => (
<div style={{ overflowX: 'auto', margin: '8px 0' }}>
<table style={{
borderCollapse: 'collapse',
width: '100%',
fontSize: 13,
}}>
{children}
</table>
</div>
),
th: ({ children }) => (
<th style={{
borderBottom: '2px solid #ddd',
padding: '6px 10px',
textAlign: 'left',
fontWeight: 600,
background: '#f8f9fa',
fontSize: 12,
}}>
{children}
</th>
),
td: ({ children }) => (
<td style={{
borderBottom: '1px solid #eee',
padding: '5px 10px',
fontSize: 12,
}}>
{children}
</td>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#1976d2' }}>
{children}
</a>
),
}}
>
{msg.message}
</ReactMarkdown>
)}
{msg.documents && msg.documents.length > 0 && (
<div style={{ marginTop: msg.message ? 8 : 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
{msg.documents.map((doc) => (
<_FileCard key={doc.id || doc.fileId} doc={doc} />
))}
</div>
)}
{(msg as any)._audioUrl && (
<_AudioPlayer
url={(msg as any)._audioUrl}
language={(msg as any)._audioLang}
charCount={(msg as any)._audioCharCount}
/>
)}
</div>
)}
</div>
))}
{/* File edit proposals */}
{pendingEdits.filter(e => e.status === 'pending').map((edit) => (
<div
key={edit.id}
style={{
flexShrink: 0,
padding: 12,
borderRadius: 8,
border: '1px solid var(--warning-color, #ff9800)',
background: 'var(--edit-bg, #fff8e1)',
alignSelf: 'flex-start',
maxWidth: '85%',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#ff9800' }}></span>
File Edit Proposal: {edit.fileName}
</div>
<pre style={{
fontSize: 12,
maxHeight: 160,
overflow: 'auto',
margin: 0,
padding: 8,
background: '#1e1e1e',
color: '#d4d4d4',
borderRadius: 4,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{edit.newContent?.slice(0, 800)}
{(edit.newContent?.length || 0) > 800 && '\n...'}
</pre>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<button
onClick={() => onAcceptEdit(edit.id)}
style={{
padding: '4px 14px', borderRadius: 4, border: 'none',
background: 'var(--success-color, #4caf50)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Accept
</button>
<button
onClick={() => onRejectEdit(edit.id)}
style={{
padding: '4px 14px', borderRadius: 4,
border: '1px solid var(--border-color, #ccc)',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}
>
Reject
</button>
</div>
</div>
))}
{/* Agent progress */}
{isProcessing && agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
background: 'var(--progress-bg, #e8f5e9)',
border: '1px solid var(--progress-border, #c8e6c9)',
alignSelf: 'flex-start',
display: 'flex', gap: 12, alignItems: 'center',
}}>
<span style={{ fontWeight: 600 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
</span>
<span>{agentProgress.totalToolCalls} tools</span>
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
</div>
)}
{isProcessing && !agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
color: '#666', alignSelf: 'flex-start', fontStyle: 'italic',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span className="workspace-spinner" style={{
display: 'inline-block', width: 12, height: 12,
border: '2px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%', animation: 'workspace-spin 0.8s linear infinite',
}} />
Processing...
</div>
)}
<div ref={bottomRef} />
<style>{`
@keyframes workspace-spin {
to { transform: rotate(360deg); }
}
.workspace-markdown p { margin: 4px 0; }
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
.workspace-markdown blockquote {
margin: 8px 0; padding: 4px 12px;
border-left: 3px solid #ddd; color: #666;
}
.workspace-markdown h1, .workspace-markdown h2, .workspace-markdown h3 {
margin: 8px 0 4px; line-height: 1.3;
}
.workspace-markdown img { max-width: 100%; border-radius: 4px; }
.workspace-markdown hr { border: none; border-top: 1px solid #e0e0e0; margin: 8px 0; }
`}</style>
</div>
);
};
function _getBubbleBackground(role: string): string {
switch (role) {
case 'user': return 'var(--primary-light, #e3f2fd)';
case 'status': return 'var(--status-bg, #fff3e0)';
case 'system': return 'var(--system-bg, #f5f5f5)';
default: return 'var(--assistant-bg, #ffffff)';
}
}
function _FileCard({ doc }: { doc: MessageDocument }) {
const _handleDownload = useCallback(async () => {
try {
const res = await api.get(`/api/files/${doc.fileId}/download`, {
responseType: 'blob',
});
const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.fileName || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
}, [doc]);
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext);
const sizeLabel = doc.fileSize
? doc.fileSize > 1024 * 1024
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
: `${(doc.fileSize / 1024).toFixed(1)} KB`
: '';
return (
<div
onClick={_handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--file-card-bg, #f8f9fa)',
cursor: 'pointer',
transition: 'background 0.15s',
maxWidth: 340,
}}
title={`Download ${doc.fileName}`}
onMouseEnter={e => (e.currentTarget.style.background = '#e8f0fe')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')}
>
<span style={{ fontSize: 22 }}>{icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{doc.fileName}
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
</div>
</div>
<span style={{ fontSize: 14, color: '#1976d2' }} title="Download">&#x2B07;</span>
</div>
);
}
function _getFileIcon(ext: string): string {
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA',
doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB',
html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC',
py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6',
pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC',
eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7',
};
return map[ext] || '\uD83D\uDCC4';
}
function _AudioPlayer({ url, language }: { url: string; language?: string; charCount?: number }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const audio = new Audio(url);
audioRef.current = audio;
audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
audio.addEventListener('timeupdate', () => {
if (audio.duration) setProgress(audio.currentTime / audio.duration);
});
audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); });
audio.addEventListener('pause', () => setPlaying(false));
audio.addEventListener('play', () => setPlaying(true));
audio.play().catch(() => {});
return () => {
audio.pause();
audio.src = '';
};
}, [url]);
const _togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (playing) { audio.pause(); } else { audio.play().catch(() => {}); }
}, [playing]);
const _stop = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
audio.pause();
audio.currentTime = 0;
setPlaying(false);
setProgress(0);
}, []);
const _formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
};
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', borderRadius: 8,
background: 'var(--audio-player-bg, #f0f4f8)',
border: '1px solid var(--border-color, #e0e0e0)',
maxWidth: 360, marginTop: 6,
}}>
<button
onClick={_togglePlay}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none',
background: 'var(--primary-color, #1976d2)', color: '#fff',
cursor: 'pointer', fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={playing ? 'Pause' : 'Play'}
>
{playing ? '\u275A\u275A' : '\u25B6'}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
height: 4, borderRadius: 2,
background: 'var(--border-color, #ddd)',
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 2,
background: 'var(--primary-color, #1976d2)',
width: `${progress * 100}%`,
transition: 'width 0.2s',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(progress * duration) : '0:00'}
</span>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(duration) : '--:--'}
</span>
</div>
</div>
<button
onClick={_stop}
style={{
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
background: 'transparent', color: '#888',
cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title="Stop"
>
&#x25A0;
</button>
{language && (
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
{language}
</span>
)}
</div>
);
}
function _CodeBlock({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
if (isInline) {
return (
<code
style={{
background: '#f0f0f0',
padding: '1px 5px',
borderRadius: 3,
fontSize: '0.9em',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
}}
{...props}
>
{children}
</code>
);
}
return (
<div style={{ position: 'relative', margin: '8px 0' }}>
{match && (
<div style={{
position: 'absolute', top: 0, right: 0,
padding: '2px 8px', fontSize: 10, color: '#888',
background: '#2d2d2d', borderBottomLeftRadius: 4,
}}>
{match[1]}
</div>
)}
<pre style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: '12px 14px',
borderRadius: 6,
overflow: 'auto',
fontSize: 13,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: 1.5,
margin: 0,
}}>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}

View file

@ -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<ConversationListProps> = ({
instanceId,
activeWorkflowId,
onSelect,
onCreateNew,
refreshTrigger,
}) => {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [filterQuery, setFilterQuery] = useState('');
const [page, setPage] = useState(0);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'active' | 'archived'>('active');
const inputRef = useRef<HTMLInputElement>(null);
const _loadConversations = useCallback(() => {
if (!instanceId) return;
setLoading(true);
api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } })
.then(res => {
const items = (res.data.workflows || res.data || [])
.map((w: any) => ({
id: w.id,
name: w.name || w.label || 'Untitled',
status: w.status || 'unknown',
startedAt: w.startedAt || w.createdAt,
lastActivity: w.lastActivity || w.updatedAt || w.startedAt,
}))
.sort((a: Conversation, b: Conversation) =>
(b.lastActivity || 0) - (a.lastActivity || 0),
);
setConversations(items);
})
.catch(() => setConversations([]))
.finally(() => setLoading(false));
}, [instanceId]);
useEffect(() => {
_loadConversations();
}, [_loadConversations]);
useEffect(() => {
if (refreshTrigger) _loadConversations();
}, [refreshTrigger, _loadConversations]);
useEffect(() => {
if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) {
_loadConversations();
}
}, [activeWorkflowId, conversations, _loadConversations]);
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
const _formatTime = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const _formatDate = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const _startEditing = (conv: Conversation) => {
setEditingId(conv.id);
setEditName(conv.name);
};
const _commitRename = (convId: string) => {
const trimmed = editName.trim();
if (!trimmed) {
setEditingId(null);
return;
}
setConversations(prev =>
prev.map(c => c.id === convId ? { ...c, name: trimmed } : c),
);
setEditingId(null);
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed })
.catch(() => _loadConversations());
};
const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(convId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _handleDelete = (convId: string) => {
setConversations(prev => prev.filter(c => c.id !== convId));
if (activeWorkflowId === convId) onSelect('');
api.delete(`/api/workspace/${instanceId}/workflows/${convId}`)
.catch(() => _loadConversations());
};
const _handleArchive = (convId: string) => {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, status: 'archived' } : c,
));
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' })
.catch(() => _loadConversations());
};
const _handleReactivate = (convId: string) => {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, status: 'active' } : c,
));
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' })
.catch(() => _loadConversations());
};
const _handleCreateNew = () => {
if (onCreateNew) onCreateNew();
};
const _filtered = (items: Conversation[], query: string): Conversation[] => {
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter(c =>
c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q),
);
};
const _byStatus = viewMode === 'archived'
? conversations.filter(c => c.status === 'archived')
: conversations.filter(c => c.status !== 'archived');
const filtered = _filtered(_byStatus, filterQuery);
const totalPages = Math.ceil(filtered.length / _PAGE_SIZE);
const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE);
const _archivedCount = conversations.filter(c => c.status === 'archived').length;
const _activeCount = conversations.filter(c => c.status !== 'archived').length;
useEffect(() => { setPage(0); }, [filterQuery, viewMode]);
return (
<div style={{ padding: 8 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={_handleCreateNew}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#1976d2' }}
title="Neuer Chat"
>
+
</button>
<button
onClick={_loadConversations}
disabled={loading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loading ? '...' : '\u21BB'}
</button>
</div>
</div>
{/* View mode toggle */}
<div style={{ display: 'flex', marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #ddd' }}>
<button
onClick={() => setViewMode('active')}
style={{
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
background: viewMode === 'active' ? 'var(--primary-color, #1976d2)' : 'transparent',
color: viewMode === 'active' ? '#fff' : '#888',
transition: 'background 0.15s, color 0.15s',
}}
>
Aktiv {_activeCount > 0 && <span style={{ fontWeight: 400 }}>({_activeCount})</span>}
</button>
<button
onClick={() => setViewMode('archived')}
style={{
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
borderLeft: '1px solid #ddd',
background: viewMode === 'archived' ? '#ff9800' : 'transparent',
color: viewMode === 'archived' ? '#fff' : '#888',
transition: 'background 0.15s, color 0.15s',
}}
>
Archiv {_archivedCount > 0 && <span style={{ fontWeight: 400 }}>({_archivedCount})</span>}
</button>
</div>
{/* Filter */}
{filtered.length > 3 && (
<input
type="text"
placeholder="Filter chats..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
}}
/>
)}
{/* Empty state */}
{filtered.length === 0 && !loading && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{viewMode === 'archived'
? 'Keine archivierten Chats.'
: 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'}
</div>
)}
{/* List */}
{paginated.map(conv => {
const isActive = conv.id === activeWorkflowId;
const isEditing = editingId === conv.id;
return (
<div
key={conv.id}
onClick={() => { if (!isEditing) onSelect(conv.id); }}
style={{
padding: '8px 10px',
marginBottom: 4,
borderRadius: 6,
cursor: isEditing ? 'default' : 'pointer',
background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent',
border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent',
transition: 'background 0.15s',
position: 'relative',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
if (actions) actions.style.opacity = '1';
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent';
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
if (actions) actions.style.opacity = '0';
if (confirmDeleteId === conv.id) setConfirmDeleteId(null);
}}
>
{/* Name row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 4 }}>
{isEditing ? (
<input
ref={inputRef}
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={() => _commitRename(conv.id)}
onKeyDown={e => _handleKeyDown(e, conv.id)}
onClick={e => e.stopPropagation()}
style={{
flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600,
padding: '1px 4px', borderRadius: 3,
border: '1px solid var(--primary-color, #1976d2)',
outline: 'none', background: '#fff',
}}
/>
) : (
<span
style={{
fontSize: 13,
fontWeight: isActive ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
title={conv.name}
>
{conv.name}
</span>
)}
{/* Action buttons (visible on hover) */}
{!isEditing && (
<span
data-actions=""
style={{ display: 'flex', gap: 2, opacity: 0, transition: 'opacity 0.15s', flexShrink: 0 }}
>
<button
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
style={_actionBtnStyle}
title="Umbenennen"
>
&#x270E;
</button>
{conv.status === 'archived' ? (
<button
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
style={{ ..._actionBtnStyle, color: '#4caf50' }}
title="Reaktivieren"
>
&#x21A9;
</button>
) : (
<button
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
style={_actionBtnStyle}
title="Archivieren"
>
&#x1F4E6;
</button>
)}
{confirmDeleteId === conv.id ? (
<span style={{
display: 'inline-flex', gap: 1, background: 'var(--color-secondary, #555)',
borderRadius: 12, padding: '1px 2px', alignItems: 'center',
}}>
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); _handleDelete(conv.id); }}
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
title="Ja, loeschen"
>
&#x2713;
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
title="Abbrechen"
>
&#x2717;
</button>
</span>
) : (
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
title="Loeschen"
>
&#x1F5D1;
</button>
)}
</span>
)}
</div>
{/* Status + last activity */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 3 }}>
<span style={{ fontSize: 10, color: '#999' }}>
{conv.status === 'active' && (
<span style={{ color: '#4caf50' }}>{'\u25CF'} aktiv</span>
)}
{conv.status === 'completed' && (
<span style={{ color: '#888' }}>{'\u25CF'} abgeschlossen</span>
)}
{conv.status === 'archived' && (
<span style={{ color: '#ff9800' }}>{'\u25CF'} archiviert</span>
)}
{!['active', 'completed', 'archived'].includes(conv.status) && (
<span>{conv.status}</span>
)}
</span>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
</div>
</div>
);
})}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 8, fontSize: 12 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{ ..._pageBtnStyle, opacity: page === 0 ? 0.3 : 1 }}
>
&lt;
</button>
<span style={{ color: '#888' }}>{page + 1} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
style={{ ..._pageBtnStyle, opacity: page >= totalPages - 1 ? 0.3 : 1 }}
>
&gt;
</button>
</div>
)}
</div>
);
};
const _actionBtnStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 11,
color: '#999',
padding: '0 2px',
};
const _pageBtnStyle: React.CSSProperties = {
background: 'none',
border: '1px solid #ddd',
borderRadius: 4,
cursor: 'pointer',
padding: '2px 8px',
color: '#666',
};

View file

@ -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<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
};
const _SERVICE_ICONS: Record<string, string> = {
sharepoint: '\uD83D\uDCC1',
onedrive: '\u2601\uFE0F',
outlook: '\uD83D\uDCE7',
teams: '\uD83D\uDCAC',
drive: '\uD83D\uDCC2',
gmail: '\uD83D\uDCE8',
files: '\uD83D\uDCC2',
};
/* ─── Source colors & icons ──────────────────────────────────────────── */
const _SOURCE_COLORS: Record<string, string> = {
sharepointFolder: '#0078d4',
onedriveFolder: '#0078d4',
outlookFolder: '#0078d4',
googleDriveFolder: '#34a853',
gmailFolder: '#ea4335',
ftpFolder: '#795548',
};
function _getSourceColor(sourceType: string): string {
return _SOURCE_COLORS[sourceType] || '#1976d2';
}
function _getSourceIcon(sourceType: string): string {
const map: Record<string, string> = {
sharepointFolder: '\uD83D\uDCC1',
onedriveFolder: '\u2601\uFE0F',
outlookFolder: '\uD83D\uDCE7',
googleDriveFolder: '\uD83D\uDCC2',
gmailFolder: '\uD83D\uDCE8',
ftpFolder: '\uD83D\uDD17',
};
return map[sourceType] || '\uD83D\uDCC1';
}
/* ─── Component ─────────────────────────────────────────────────────── */
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
instanceId,
dataSources,
onRefresh,
}) => {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
/* ── Load Level 1: UserConnections ── */
const _loadConnections = useCallback(() => {
if (!instanceId) return;
setLoadingRoot(true);
api.get(`/api/workspace/${instanceId}/connections`)
.then(res => {
if (!mountedRef.current) return;
const conns = res.data.connections || [];
const nodes: TreeNode[] = conns
.filter((c: any) => c.status === 'active')
.map((c: any) => ({
key: `conn-${c.id}`,
label: c.externalEmail || c.externalUsername || c.authority,
icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
type: 'connection' as const,
expanded: false,
loading: false,
children: null,
connectionId: c.id,
authority: c.authority,
}));
setTree(nodes);
})
.catch(() => { if (mountedRef.current) setTree([]); })
.finally(() => { if (mountedRef.current) setLoadingRoot(false); });
}, [instanceId]);
useEffect(() => { _loadConnections(); }, [_loadConnections]);
/* ── Generic tree update helper ── */
const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => {
setTree(prev => _mapTree(prev, key, updater));
}, []);
/* ── Toggle expand/collapse ── */
const _toggleNode = useCallback(async (node: TreeNode) => {
if (node.expanded) {
_updateNode(node.key, n => ({ ...n, expanded: false }));
return;
}
if (node.children !== null) {
_updateNode(node.key, n => ({ ...n, expanded: true }));
return;
}
_updateNode(node.key, n => ({ ...n, loading: true, expanded: true }));
try {
let children: TreeNode[] = [];
if (node.type === 'connection') {
children = await _loadServices(instanceId, node.connectionId);
} else if (node.type === 'service' || node.type === 'folder') {
children = await _browseService(instanceId, node.connectionId, node.service!, node.path || '/');
}
if (mountedRef.current) {
_updateNode(node.key, n => ({ ...n, loading: false, children }));
}
} catch {
if (mountedRef.current) {
_updateNode(node.key, n => ({ ...n, loading: false, children: [] }));
}
}
}, [instanceId, _updateNode]);
/* ── Add as DataSource ── */
const _addAsDataSource = useCallback(async (node: TreeNode) => {
if (!node.service || !node.connectionId) return;
setAddingPath(node.key);
try {
const sourceTypeMap: Record<string, string> = {
sharepoint: 'sharepointFolder',
onedrive: 'onedriveFolder',
outlook: 'outlookFolder',
drive: 'googleDriveFolder',
gmail: 'gmailFolder',
files: 'ftpFolder',
};
await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: node.connectionId,
sourceType: sourceTypeMap[node.service] || node.service,
path: node.path || '/',
label: node.label,
});
onRefresh();
} catch (err) {
console.error('Failed to add data source:', err);
} finally {
if (mountedRef.current) setAddingPath(null);
}
}, [instanceId, onRefresh]);
/* ── Remove DataSource ── */
const _removeDatasource = useCallback(async (dsId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
onRefresh();
} catch (err) {
console.error('Failed to remove data source:', err);
}
}, [instanceId, onRefresh]);
/* ── Check if a path is already added ── */
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
return dataSources.some(ds =>
ds.connectionId === connectionId && ds.path === (path || '/'),
);
}, [dataSources]);
return (
<div style={{ padding: 8, fontSize: 13 }}>
{/* Active DataSources */}
{dataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Sources
</div>
{dataSources.map(ds => {
const connColor = _getSourceColor(ds.sourceType);
const connNode = tree.find(n => n.connectionId === ds.connectionId);
const connLabel = connNode?.label || ds.connectionId;
const fullPath = `${connLabel} ${ds.sourceType} ${ds.path}`;
return (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: `${connColor}18`,
borderLeft: `3px solid ${connColor}`,
fontSize: 12,
}} title={fullPath}>
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label}
</span>
<button
onClick={() => _removeDatasource(ds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
>
{'\u2715'}
</button>
</div>
);
})}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* Tree header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Browse Sources
</span>
<button
onClick={_loadConnections}
disabled={loadingRoot}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loadingRoot ? '...' : '\u21BB'}
</button>
</div>
{/* Tree */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading connections...
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No active connections found.
</div>
)}
{tree.map(node => (
<_TreeNodeView
key={node.key}
node={node}
depth={0}
onToggle={_toggleNode}
onAdd={_addAsDataSource}
isAdded={_isAdded}
addingPath={addingPath}
/>
))}
</div>
);
};
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
interface TreeNodeViewProps {
node: TreeNode;
depth: number;
onToggle: (node: TreeNode) => void;
onAdd: (node: TreeNode) => void;
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
addingPath: string | null;
}
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
node, depth, onToggle, onAdd, isAdded, addingPath,
}) => {
const [hovered, setHovered] = useState(false);
const hasChildren = node.type !== 'file';
const chevron = hasChildren
? (node.expanded ? '\u25BE' : '\u25B8')
: '\u00A0\u00A0';
const canAdd = node.type === 'folder' || node.type === 'service';
const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path);
const isAdding = addingPath === node.key;
return (
<div>
<div
onClick={() => { if (hasChildren) onToggle(node); }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
paddingLeft: depth * 16 + 4,
paddingRight: 4,
paddingTop: 3,
paddingBottom: 3,
cursor: hasChildren ? 'pointer' : 'default',
borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s',
userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12,
fontWeight: node.type === 'connection' ? 600 : 400,
}}>
{node.label}
</span>
{canAdd && hovered && !alreadyAdded && (
<button
onClick={e => { e.stopPropagation(); onAdd(node); }}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #1976d2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#1976d2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
title="Add as data source"
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{canAdd && alreadyAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
{'\u2713'}
</span>
)}
</div>
{/* Children */}
{node.expanded && node.children && node.children.length > 0 && (
<div>
{node.children.map(child => (
<_TreeNodeView
key={child.key}
node={child}
depth={depth + 1}
onToggle={onToggle}
onAdd={onAdd}
isAdded={isAdded}
addingPath={addingPath}
/>
))}
</div>
)}
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
(empty)
</div>
)}
</div>
);
};
/* ─── Spinner (inline) ──────────────────────────────────────────────── */
function _Spinner(): React.ReactElement {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Data fetching ─────────────────────────────────────────────────── */
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
const services = res.data.services || [];
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
type: 'service' as const,
expanded: false,
loading: false,
children: null,
connectionId,
service: s.service,
path: '/',
}));
}
async function _browseService(
instanceId: string, connectionId: string, service: string, path: string,
): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
params: { service, path },
});
const items = res.data.items || [];
return items.map((entry: any, idx: number) => ({
key: `item-${connectionId}-${service}-${entry.path || idx}`,
label: entry.name,
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
type: entry.isFolder ? 'folder' as const : 'file' as const,
expanded: false,
loading: false,
children: entry.isFolder ? null : [],
connectionId,
service,
path: entry.path,
}));
}
function _fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
};
return map[ext] || '\uD83D\uDCC4';
}
/* ─── Tree map utility ──────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
return nodes.map(n => {
if (n.key === key) return updater(n);
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
return n;
});
}

View file

@ -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<FileBrowserProps> = ({
instanceId,
files,
folders: _folders,
onRefresh,
onFileSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement>(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<string, _InstanceGroup> = {};
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<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
_uploadFiles(e.target.files);
e.target.value = '';
}
}, [_uploadFiles]);
return (
<div
style={{ padding: 8, position: 'relative' }}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #1976d2', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#1976d2',
}}>
Dateien hier ablegen
</div>
)}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button onClick={onRefresh} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
</div>
</div>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Search */}
<input
type="text"
placeholder="Dateien suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
}}
/>
{/* Tree */}
{_groups.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
{_groups.map(group => {
const isCollapsed = !!collapsed[group.instanceId];
return (
<div key={group.instanceId} style={{ marginBottom: 4 }}>
{/* Group header */}
<div
onClick={() => _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)')}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center' }}>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
<span style={{ fontSize: 12 }}>{'\uD83D\uDCC1'}</span>
<span style={{ fontSize: 12, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{group.label}
</span>
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>{group.files.length}</span>
</div>
{/* Files */}
{!isCollapsed && group.files.map(file => (
<div
key={file.id}
onClick={() => 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 = '')}
>
<span style={{ fontSize: 11, flexShrink: 0 }}>{_fileIcon(file.mimeType)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.fileName}
</div>
{file.tags && file.tags.length > 0 && (
<div style={{ display: 'flex', gap: 3, marginTop: 2 }}>
{file.tags.map(tag => (
<span key={tag} style={{ fontSize: 9, padding: '1px 5px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
{tag}
</span>
))}
</div>
)}
</div>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
</div>
))}
</div>
);
})}
</div>
);
};
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';
}

View file

@ -0,0 +1,153 @@
/**
* FilePreview -- File preview / editor panel in the right sidebar.
*
* Displays content preview for selected files based on their MIME type:
* - Text files: rendered as text with optional editing
* - Images: rendered as preview
* - PDFs: link to download
* - Other: metadata display
*/
import React, { useState, useEffect } from 'react';
import api from '../../../api';
import type { WorkspaceFile } from './useWorkspace';
interface FilePreviewProps {
instanceId: string;
fileId: string | null;
files: WorkspaceFile[];
}
export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, files }) => {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const file = fileId ? files.find(f => f.id === fileId) : null;
useEffect(() => {
setContent(null);
setPreviewUrl(null);
if (!file || !instanceId) return;
const isText = _isTextMime(file.mimeType);
const isImage = file.mimeType.startsWith('image/');
if (isText && file.fileSize < 500_000) {
setLoading(true);
api.get(`/api/files/${file.id}/download`, { responseType: 'text' })
.then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)))
.catch(() => setContent(null))
.finally(() => setLoading(false));
} else if (isImage) {
const baseUrl = api.defaults.baseURL || '';
setPreviewUrl(`${baseUrl}/api/files/${file.id}/download`);
}
}, [file, instanceId]);
if (!file) {
return (
<div style={{ padding: 24, textAlign: 'center', color: '#999', fontSize: 13 }}>
Select a file to preview
</div>
);
}
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<div style={{
padding: '8px 0',
borderBottom: '1px solid var(--border-color, #e0e0e0)',
marginBottom: 8,
}}>
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.fileName}
</div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
<span>{file.mimeType}</span>
<span>{_formatFileSize(file.fileSize)}</span>
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
</div>
{file.description && (
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>{file.description}</div>
)}
{file.tags && file.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{file.tags.map(tag => (
<span key={tag} style={{ fontSize: 10, padding: '1px 6px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
{tag}
</span>
))}
</div>
)}
</div>
{/* Content area */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading && (
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>Loading...</div>
)}
{content !== null && !loading && (
<pre style={{
fontSize: 12,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
lineHeight: 1.5,
padding: 8,
background: '#f8f9fa',
borderRadius: 4,
margin: 0,
maxHeight: '100%',
overflow: 'auto',
}}>
{content}
</pre>
)}
{previewUrl && (
<div style={{ textAlign: 'center', padding: 8 }}>
<img
src={previewUrl}
alt={file.fileName}
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 4, objectFit: 'contain' }}
onError={() => setPreviewUrl(null)}
/>
</div>
)}
{!loading && content === null && !previewUrl && (
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
{file.fileSize > 500_000
? 'File too large for inline preview'
: `No preview available for ${file.mimeType}`}
</div>
)}
</div>
</div>
);
};
function _isTextMime(mime: string): boolean {
if (mime.startsWith('text/')) return true;
const textTypes = [
'application/json',
'application/xml',
'application/javascript',
'application/typescript',
'application/x-python',
'application/x-yaml',
'application/yaml',
'application/sql',
'application/csv',
];
return textTypes.includes(mime);
}
function _formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -0,0 +1,83 @@
/**
* ToolActivityLog -- Real-time tool call activity display.
*/
import React from 'react';
import type { ToolActivity } from './useWorkspace';
interface ToolActivityLogProps {
activities: ToolActivity[];
}
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
if (!activities.length) {
return (
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
No tool activity yet
</div>
);
}
return (
<div style={{ padding: 8 }}>
{activities.map(activity => (
<div
key={activity.id}
style={{
padding: '8px 10px',
marginBottom: 6,
borderRadius: 6,
fontSize: 12,
border: `1px solid ${
activity.status === 'calling'
? '#ffc107'
: activity.status === 'success'
? '#4caf50'
: '#f44336'
}30`,
background: activity.status === 'calling'
? '#fff8e1'
: activity.status === 'success'
? '#e8f5e9'
: '#ffebee',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600 }}>{activity.toolName}</span>
<span style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 3,
background: activity.status === 'calling'
? '#ffc107'
: activity.status === 'success'
? '#4caf50'
: '#f44336',
color: '#fff',
}}>
{activity.status}
</span>
</div>
{activity.args && Object.keys(activity.args).length > 0 && (
<div style={{ marginTop: 4, color: '#666', fontSize: 11 }}>
{Object.entries(activity.args)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`)
.join(', ')}
</div>
)}
{activity.result && (
<div style={{ marginTop: 4, color: '#388e3c', fontSize: 11, maxHeight: 60, overflow: 'hidden' }}>
{activity.result.slice(0, 200)}
{activity.result.length > 200 && '...'}
</div>
)}
{activity.error && (
<div style={{ marginTop: 4, color: '#c62828', fontSize: 11 }}>
{activity.error}
</div>
)}
</div>
))}
</div>
);
};

View file

@ -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<WorkspaceInputProps> = ({
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<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
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<HTMLTextAreaElement>) => {
const value = e.target.value;
setPrompt(value);
const cursorPos = e.target.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/);
if (atMatch) {
setAutocompleteFilter(atMatch[1].toLowerCase());
setShowAutocomplete(true);
} else {
setShowAutocomplete(false);
}
},
[],
);
const _insertFileRef = useCallback(
(fileName: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBefore = prompt.slice(0, cursorPos);
const textAfter = prompt.slice(cursorPos);
const atStart = textBefore.lastIndexOf('@');
const newText = textBefore.slice(0, atStart) + `@${fileName} ` + textAfter;
setPrompt(newText);
setShowAutocomplete(false);
textarea.focus();
},
[prompt],
);
const _removeAttachedFile = useCallback((fileId: string) => {
setAttachedFileIds(prev => prev.filter(id => id !== fileId));
}, []);
const _removeAttachedDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
}, []);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
);
}, []);
const _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 (
<div style={{
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
}}>
{/* Pending uploaded files */}
{pendingFiles.length > 0 && (
<div style={{
padding: '6px 24px',
display: 'flex',
gap: 6,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: 'var(--bg-secondary, #fafafa)',
}}>
{pendingFiles.map(pf => (
<span
key={pf.fileId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#fff3e0', color: '#e65100', fontWeight: 500,
border: '1px solid #ffe0b2',
}}
>
📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
{onRemovePendingFile && (
<button
onClick={() => onRemovePendingFile(pf.fileId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#e65100', padding: 0, lineHeight: 1,
}}
>
×
</button>
)}
</span>
))}
</div>
)}
{/* Attachment bar */}
{hasAttachments && (
<div style={{
padding: '6px 24px',
display: 'flex',
gap: 6,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: '#fafafa',
}}>
{attachedFileIds.map(fId => {
const file = files.find(f => f.id === fId);
return (
<span
key={fId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
}}
>
📄 {file?.fileName || fId}
<button
onClick={() => _removeAttachedFile(fId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#1565c0', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
{attachedDataSourceIds.map(dsId => {
const ds = dataSources.find(d => d.id === dsId);
return (
<span
key={dsId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}
>
🔗 {ds?.label || dsId}
<button
onClick={() => _removeAttachedDataSource(dsId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#2e7d32', padding: 0, lineHeight: 1,
}}
>
×
</button>
</span>
);
})}
</div>
)}
{/* Autocomplete dropdown */}
{showAutocomplete && filteredFiles.length > 0 && (
<div style={{
position: 'absolute',
bottom: '100%',
left: 24,
right: 24,
maxHeight: 200,
overflowY: 'auto',
background: '#fff',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
boxShadow: '0 -2px 8px rgba(0,0,0,0.1)',
zIndex: 10,
}}>
{filteredFiles.slice(0, 10).map(f => (
<div
key={f.id}
onClick={() => _insertFileRef(f.fileName)}
style={{
padding: '8px 12px',
cursor: 'pointer',
fontSize: 13,
borderBottom: '1px solid #f0f0f0',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={e => (e.currentTarget.style.background = '')}
>
@{f.fileName}
<span style={{ color: '#999', marginLeft: 8, fontSize: 11 }}>
{f.mimeType} · {(f.fileSize / 1024).toFixed(1)}KB
</span>
</div>
))}
</div>
)}
{/* Main input row */}
<div style={{ padding: '8px 24px 12px', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<textarea
ref={textareaRef}
value={prompt}
onChange={_handleChange}
onKeyDown={_handleKeyDown}
placeholder="Type a message... Use @filename to reference files"
disabled={isProcessing}
style={{
flex: 1,
minHeight: 40,
maxHeight: 120,
resize: 'vertical',
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--border-color, #ccc)',
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
}}
rows={1}
/>
<button
onClick={onFileUploadClick}
disabled={uploading || isProcessing}
title="Datei anhängen"
style={{
width: 40, height: 40, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)',
color: uploading ? '#1976d2' : '#666',
cursor: uploading || isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
}}
>
{uploading ? '...' : '+'}
</button>
{dataSources.length > 0 && (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={isProcessing}
title="Datenquellen anhängen"
style={{
width: 40, height: 40, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: attachedDataSourceIds.length > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: attachedDataSourceIds.length > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
position: 'relative',
}}
>
🔗
{attachedDataSourceIds.length > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{attachedDataSourceIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div
key={ds.id}
onClick={() => _toggleDataSource(ds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
</div>
)}
</div>
)}
{onProvidersChange && (
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
excludeByDefault={['privatellm']}
disabled={isProcessing}
/>
)}
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
<button
onClick={() => setShowLangPicker(prev => !prev)}
title="Sprache waehlen"
style={{
height: 40, borderRadius: '8px 0 0 8px', border: '1px solid var(--border-color, #ddd)',
borderRight: 'none',
background: 'var(--secondary-bg, #f5f5f5)',
color: '#666', cursor: 'pointer', fontSize: 10, padding: '0 6px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{voiceLanguage.split('-')[0].toUpperCase()}
</button>
<button
onClick={_toggleVoice}
title={voiceActive ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
style={{
width: 40, height: 40, borderRadius: '0 8px 8px 0', border: 'none',
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
color: voiceActive ? '#fff' : '#666',
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{voiceActive ? '■' : '\uD83C\uDFA4'}
</button>
{showLangPicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
maxHeight: 240, overflowY: 'auto', minWidth: 160,
}}>
{_STT_LANGUAGES.map(lang => (
<div
key={lang.code}
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}}
onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
>
{lang.label} ({lang.code})
</div>
))}
</div>
)}
</div>
{isProcessing ? (
<button
onClick={onStop}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600,
}}
>
Stop
</button>
) : (
<button
onClick={_handleSend}
disabled={!prompt.trim()}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: prompt.trim() ? 'var(--primary-color, #1976d2)' : '#ccc',
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
}}
>
Send
</button>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,48 @@
/**
* WorkspaceKeepAlive
*
* Renders the WorkspacePage permanently at the MainLayout level so it
* survives route changes. Visibility is toggled via CSS `display`
* instead of mount / unmount, preserving messages, SSE connections,
* files, and all other workspace state.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { WorkspacePage } from './WorkspacePage';
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
interface WorkspaceKeepAliveProps {
isVisible: boolean;
}
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
if (match?.[2]) {
cachedInstanceIdRef.current = match[2];
}
const instanceId = cachedInstanceIdRef.current;
if (!instanceId) return null;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<WorkspacePage persistentInstanceId={instanceId} />
</div>
);
};

View file

@ -0,0 +1,342 @@
/**
* WorkspacePage -- Unified AI Workspace
*
* 3-column layout:
* Left sidebar: ConversationList, FileBrowser, DataSourcePanel
* Center: ChatStream + WorkspaceInput
* Right sidebar: FilePreview, ToolActivityLog
*/
import React, { useState, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useFileOperations } from '../../../hooks/useFiles';
import { useWorkspace } from './useWorkspace';
import { ChatStream } from './ChatStream';
import { WorkspaceInput } from './WorkspaceInput';
import { ConversationList } from './ConversationList';
import { FileBrowser } from './FileBrowser';
import { DataSourcePanel } from './DataSourcePanel';
import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog';
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
const [width, setWidth] = useState(initialWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const _onMouseDown = useCallback((e: React.MouseEvent, direction: 1 | -1 = 1) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
const _onMouseMove = (ev: MouseEvent) => {
if (!dragging.current) return;
const delta = (ev.clientX - startX.current) * direction;
setWidth(Math.max(minWidth, Math.min(maxWidth, startW.current + delta)));
};
const _onMouseUp = () => {
dragging.current = false;
document.removeEventListener('mousemove', _onMouseMove);
document.removeEventListener('mouseup', _onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', _onMouseMove);
document.addEventListener('mouseup', _onMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [width, minWidth, maxWidth]);
return { width, onMouseDown: _onMouseDown };
}
type LeftTab = 'conversations' | 'files' | 'datasources';
type RightTab = 'activity' | 'preview';
interface PendingFile {
fileId: string;
fileName: string;
}
interface WorkspacePageProps {
persistentInstanceId?: string;
}
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId }) => {
const { instance } = useCurrentInstance();
const instanceId = persistentInstanceId || instance?.id || '';
const workspace = useWorkspace(instanceId);
const fileOps = useFileOperations();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const _leftResize = _useResizable(280, 200, 450);
const _rightResize = _useResizable(320, 200, 500);
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
const [rightTab, setRightTab] = useState<RightTab>('activity');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const _uploadAndAttach = useCallback(async (file: File) => {
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
if (result.success && result.fileData) {
const data = result.fileData.file || result.fileData;
if (data?.id) {
setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]);
}
workspace.refreshFiles();
}
}, [fileOps, workspace, instanceId]);
const _handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragOver(false);
}, []);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
const droppedFiles = e.dataTransfer.files;
if (droppedFiles.length > 0) {
for (const file of Array.from(droppedFiles)) {
await _uploadAndAttach(file);
}
}
}, [_uploadAndAttach]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
Array.from(e.target.files).forEach(file => _uploadAndAttach(file));
e.target.value = '';
}
}, [_uploadAndAttach]);
const _handleRemovePendingFile = useCallback((fileId: string) => {
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
No workspace instance selected.
</div>
);
}
const _handleFileSelect = (fileId: string) => {
setSelectedFileId(fileId);
setRightTab('preview');
setRightCollapsed(false);
};
const _handleConversationSelect = (wfId: string) => {
workspace.loadWorkflow(wfId);
};
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
flex: 1,
padding: '6px 0',
border: 'none',
borderBottom: active ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 11,
fontWeight: active ? 600 : 400,
color: active ? 'var(--primary-color, #1976d2)' : '#888',
textTransform: 'uppercase' as const,
});
return (
<div style={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden' }}>
{/* Left sidebar */}
{!leftCollapsed && (
<aside style={{
width: _leftResize.width,
minWidth: 200,
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
}}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<button style={tabButtonStyle(leftTab === 'conversations')} onClick={() => setLeftTab('conversations')}>Chats</button>
<button style={tabButtonStyle(leftTab === 'files')} onClick={() => setLeftTab('files')}>Files</button>
<button style={tabButtonStyle(leftTab === 'datasources')} onClick={() => setLeftTab('datasources')}>Sources</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{leftTab === 'conversations' && (
<ConversationList
instanceId={instanceId}
activeWorkflowId={workspace.workflowId}
onSelect={_handleConversationSelect}
onCreateNew={workspace.resetToNew}
refreshTrigger={workspace.workflowVersion}
/>
)}
{leftTab === 'files' && (
<FileBrowser
instanceId={instanceId}
files={workspace.files}
folders={workspace.folders}
onRefresh={workspace.refreshFiles}
onFileSelect={_handleFileSelect}
/>
)}
{leftTab === 'datasources' && (
<DataSourcePanel
instanceId={instanceId}
dataSources={workspace.dataSources}
onRefresh={workspace.refreshDataSources}
/>
)}
</div>
</aside>
)}
{/* Left resize handle */}
{!leftCollapsed && (
<div
onMouseDown={e => _leftResize.onMouseDown(e, 1)}
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
/>
)}
{leftCollapsed && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderRight: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setLeftCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
{/* Hidden file input */}
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Center - Chat + Input */}
<main
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, minHeight: 0, position: 'relative' }}
onDragEnter={_handleDragEnter}
onDragLeave={_handleDragLeave}
onDragOver={_handleDragOver}
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #1976d2', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, fontWeight: 600, color: '#1976d2',
pointerEvents: 'none',
}}>
Dateien hier ablegen
</div>
)}
<ChatStream
messages={workspace.messages}
agentProgress={workspace.agentProgress}
isProcessing={workspace.isProcessing}
pendingEdits={workspace.pendingEdits}
onAcceptEdit={workspace.acceptEdit}
onRejectEdit={workspace.rejectEdit}
/>
<WorkspaceInput
instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders);
setPendingFiles([]);
}}
isProcessing={workspace.isProcessing}
onStop={workspace.stopProcessing}
files={workspace.files}
dataSources={workspace.dataSources}
pendingFiles={pendingFiles}
onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()}
uploading={fileOps.uploadingFile}
selectedProviders={selectedProviders}
onProvidersChange={setSelectedProviders}
/>
</main>
{/* Right resize handle */}
{!rightCollapsed && (
<div
onMouseDown={e => _rightResize.onMouseDown(e, -1)}
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
/>
)}
{/* Right sidebar */}
{!rightCollapsed && (
<aside style={{
width: _rightResize.width,
minWidth: 200,
borderLeft: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
}}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button style={tabButtonStyle(rightTab === 'activity')} onClick={() => setRightTab('activity')}>Activity</button>
<button style={tabButtonStyle(rightTab === 'preview')} onClick={() => setRightTab('preview')}>Preview</button>
</div>
<button onClick={() => setRightCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{rightTab === 'activity' && (
<ToolActivityLog activities={workspace.toolActivities} />
)}
{rightTab === 'preview' && (
<FilePreview
instanceId={instanceId}
fileId={selectedFileId}
files={workspace.files}
/>
)}
</div>
</aside>
)}
{rightCollapsed && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderLeft: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setRightCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
</div>
);
};
export default WorkspacePage;

View file

@ -0,0 +1,173 @@
.settings {
padding: 1rem;
max-width: 600px;
}
.heading {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--text-primary, #333);
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
padding: 0.5rem 0.75rem;
background: #fde8e8;
color: var(--color-error, #d32f2f);
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.success {
padding: 0.5rem 0.75rem;
background: #e8f5e9;
color: #2e7d32;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.section {
margin-bottom: 2rem;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text-primary, #333);
}
.field {
margin-bottom: 0.75rem;
}
.label {
display: block;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.3rem;
color: var(--text-primary, #333);
}
.select, .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-input, #fff);
color: var(--text-primary, #333);
}
.voiceRow {
display: flex;
gap: 0.5rem;
}
.voiceRow .select {
flex: 1;
}
.testBtn, .addBtn, .removeBtn {
padding: 0.5rem 1rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.testBtn:hover:not(:disabled),
.addBtn:hover:not(:disabled) { filter: brightness(1.08); }
.testBtn:disabled,
.addBtn:disabled {
background: var(--color-medium-gray, #ccc);
color: var(--text-secondary, #888);
cursor: not-allowed;
opacity: 0.8;
}
.removeBtn {
background: transparent;
color: var(--color-error, #d32f2f);
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
border: 1px solid var(--color-error, #d32f2f);
}
.removeBtn:hover { background: #fde8e8; }
.voiceTable {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
}
.voiceTable th,
.voiceTable td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 0.85rem;
}
.voiceTable th {
font-weight: 600;
color: var(--text-secondary, #666);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.emptyHint {
color: var(--text-secondary, #999);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem 0;
}
.saveBtn {
width: 100%;
padding: 0.6rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.saveBtn:hover:not(:disabled) { filter: brightness(1.08); }
.saveBtn:disabled {
background: var(--color-medium-gray, #ccc);
color: var(--text-secondary, #888);
cursor: not-allowed;
opacity: 0.8;
}
.backBtn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
color: var(--primary-color, #1976d2);
padding: 0;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 4px;
}
.backBtn:hover { text-decoration: underline; }

View file

@ -0,0 +1,280 @@
/**
* WorkspaceSettings -- Voice preferences per language.
*
* Allows the user to configure a preferred voice for each TTS language.
* Language detection is automatic; this page lets users override the
* default Google Cloud voice for specific languages.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceSettings.module.css';
interface VoiceMapEntry {
language: string;
voiceName: string;
}
interface WorkspaceSettingsProps {
instanceId: string;
}
export const WorkspaceSettings: React.FC<WorkspaceSettingsProps> = ({ instanceId }) => {
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [languages, setLanguages] = useState<any[]>([]);
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
const [addLanguage, setAddLanguage] = useState('de-DE');
const [addVoices, setAddVoices] = useState<any[]>([]);
const [addVoiceName, setAddVoiceName] = useState('');
const [loadingVoices, setLoadingVoices] = useState(false);
const _loadSettings = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
try {
const [settingsData, languagesData] = await Promise.all([
request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }),
request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
setLanguages(langList);
const map: Record<string, any> = (settingsData as any)?.ttsVoiceMap || {};
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
language: lang,
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
}));
setVoiceMap(entries);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Einstellungen');
} finally {
setLoading(false);
}
}, [request, instanceId]);
useEffect(() => { _loadSettings(); }, [_loadSettings]);
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
if (!instanceId) return;
setLoadingVoices(true);
try {
const result = await request({
url: `/api/workspace/${instanceId}/voice/voices`,
method: 'get',
params: { language: lang },
});
setAddVoices((result as any)?.voices || []);
setAddVoiceName('');
} catch {
setAddVoices([]);
} finally {
setLoadingVoices(false);
}
}, [request, instanceId]);
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
const _handleAddEntry = useCallback(() => {
if (!addLanguage) return;
const exists = voiceMap.some(e => e.language === addLanguage);
if (exists) {
setVoiceMap(prev => prev.map(e =>
e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e
));
} else {
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
}
setAddVoiceName('');
}, [addLanguage, addVoiceName, voiceMap]);
const _handleRemoveEntry = useCallback((lang: string) => {
setVoiceMap(prev => prev.filter(e => e.language !== lang));
}, []);
const _handleSave = useCallback(async () => {
if (!instanceId) return;
setSaving(true);
setError(null);
setSuccess(null);
try {
const mapObj: Record<string, any> = {};
voiceMap.forEach(e => {
mapObj[e.language] = { voiceName: e.voiceName || '' };
});
const putResult = await request({
url: `/api/workspace/${instanceId}/settings/voice`,
method: 'put',
data: { ttsVoiceMap: mapObj },
});
if ((putResult as any)?.error) {
setError((putResult as any).error);
return;
}
setSuccess('Einstellungen gespeichert');
setTimeout(() => setSuccess(null), 3000);
await _loadSettings();
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern');
} finally {
setSaving(false);
}
}, [request, instanceId, voiceMap]);
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
if (!instanceId) return;
setTesting(lang);
try {
const result: any = await request({
url: `/api/workspace/${instanceId}/voice/test`,
method: 'post',
data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` },
});
if (result?.success && result?.audio) {
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
audio.play();
}
} catch {
setError('Stimmtest fehlgeschlagen');
} finally {
setTesting(null);
}
}, [request, instanceId]);
const _getLanguageName = useCallback((code: string) => {
const found = languages.find((l: any) => (l.code || l) === code);
return found?.name || found?.code || code;
}, [languages]);
if (loading) {
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
}
const _defaultLangs = [
{ code: 'de-DE', name: 'Deutsch' },
{ code: 'en-US', name: 'English (US)' },
{ code: 'fr-FR', name: 'Francais' },
{ code: 'it-IT', name: 'Italiano' },
{ code: 'es-ES', name: 'Espanol' },
];
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
return (
<div className={styles.settings}>
<h2 className={styles.heading}>Stimmeneinstellungen</h2>
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Konfigurierte Stimmen pro Sprache</h3>
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
</p>
{voiceMap.length === 0 ? (
<div className={styles.emptyHint}>
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
</div>
) : (
<table className={styles.voiceTable}>
<thead>
<tr>
<th>Sprache</th>
<th>Stimme</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{voiceMap.map(entry => (
<tr key={entry.language}>
<td>{_getLanguageName(entry.language)}</td>
<td>{entry.voiceName || 'Standard'}</td>
<td>
<button
className={styles.testBtn}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
onClick={() => _handleTestVoice(entry.language, entry.voiceName)}
disabled={testing === entry.language}
>
{testing === entry.language ? '...' : 'Test'}
</button>
</td>
<td>
<button className={styles.removeBtn} onClick={() => _handleRemoveEntry(entry.language)}>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Stimme hinzufuegen / aendern</h3>
<div className={styles.field}>
<label className={styles.label}>Sprache</label>
<select
className={styles.select}
value={addLanguage}
onChange={e => setAddLanguage(e.target.value)}
>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>
{lang.name || lang.code || lang}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.label}>Stimme</label>
<div className={styles.voiceRow}>
<select
className={styles.select}
value={addVoiceName}
onChange={e => setAddVoiceName(e.target.value)}
disabled={loadingVoices}
>
<option value="">Standard</option>
{addVoices.map((v: any) => (
<option key={v.name || v} value={v.name || v}>
{v.displayName || v.name || v}
</option>
))}
</select>
<button
className={styles.testBtn}
onClick={() => _handleTestVoice(addLanguage, addVoiceName)}
disabled={testing !== null}
>
{testing === addLanguage ? '...' : 'Testen'}
</button>
</div>
</div>
<button className={styles.addBtn} onClick={_handleAddEntry}>
Stimme zuweisen
</button>
</div>
<button className={styles.saveBtn} onClick={_handleSave} disabled={saving}>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</div>
);
};
export default WorkspaceSettings;

View file

@ -0,0 +1,72 @@
/**
* WorkspaceSettingsPage -- Tabbed settings for the AI Workspace.
*
* First tab: Voice / Language (WorkspaceSettings).
* Additional tabs can be added here as needed.
*/
import React, { useState } from 'react';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { WorkspaceSettings } from './WorkspaceSettings';
type SettingsTab = 'voice';
const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'voice', label: 'Sprache & Stimme' },
];
export const WorkspaceSettingsPage: React.FC = () => {
const instanceId = useInstanceId();
const [activeTab, setActiveTab] = useState<SettingsTab>('voice');
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
Keine Workspace-Instanz ausgewaehlt.
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<nav style={{
display: 'flex',
gap: 0,
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #fafafa)',
flexShrink: 0,
}}>
{_TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab.key
? '2px solid var(--primary-color, #1976d2)'
: '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 14,
fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key
? 'var(--primary-color, #1976d2)'
: 'var(--text-secondary, #888)',
}}
>
{tab.label}
</button>
))}
</nav>
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
{activeTab === 'voice' && (
<WorkspaceSettings instanceId={instanceId} />
)}
</div>
</div>
);
};
export default WorkspaceSettingsPage;

View file

@ -0,0 +1,533 @@
/**
* useWorkspace Hook
*
* Central state management for the Unified AI Workspace.
* Manages SSE streaming, messages, files, folders, data sources,
* tool activity, voice, and file previews via the shared sseClient.
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import api from '../../../api';
import { startSseStream, SseEvent } from '../../../utils/sseClient';
import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes';
export interface AgentProgress {
round: number;
maxRounds?: number;
totalAiCalls: number;
totalToolCalls: number;
costCHF: number;
}
export interface ToolActivity {
id: string;
toolName: string;
status: 'calling' | 'success' | 'error';
args?: Record<string, any>;
result?: string;
error?: string;
timestamp: number;
}
export interface WorkspaceFile {
id: string;
fileName: string;
mimeType: string;
fileSize: number;
tags?: string[];
folderId?: string;
status?: string;
description?: string;
featureInstanceId?: string;
featureInstanceLabel?: string;
}
export interface WorkspaceFolder {
id: string;
name: string;
parentId?: string;
}
export interface DataSource {
id: string;
connectionId: string;
sourceType: string;
path: string;
label: string;
}
export interface FileEditProposal {
id: string;
fileId: string;
fileName: string;
newContent: string;
status: 'pending' | 'accepted' | 'rejected';
}
export interface DataSourceAccessEvent {
sourceType: string;
label: string;
path: string;
action: string;
}
interface UseWorkspaceReturn {
messages: Message[];
isProcessing: boolean;
sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[]) => void;
stopProcessing: () => void;
loadWorkflow: (workflowId: string) => void;
resetToNew: () => void;
files: WorkspaceFile[];
folders: WorkspaceFolder[];
dataSources: DataSource[];
agentProgress: AgentProgress | null;
toolActivities: ToolActivity[];
pendingEdits: FileEditProposal[];
acceptEdit: (editId: string) => void;
rejectEdit: (editId: string) => void;
workflowId: string | null;
workflowVersion: number;
refreshFiles: () => void;
refreshFolders: () => void;
refreshDataSources: () => void;
dataSourceAccesses: DataSourceAccessEvent[];
}
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const [messages, setMessages] = useState<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [files, setFiles] = useState<WorkspaceFile[]>([]);
const [folders, setFolders] = useState<WorkspaceFolder[]>([]);
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]);
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [workflowVersion, setWorkflowVersion] = useState(0);
const [dataSourceAccesses, setDataSourceAccesses] = useState<DataSourceAccessEvent[]>([]);
const cleanupRef = useRef<(() => void) | null>(null);
const refreshFiles = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/files`)
.then(res => setFiles(res.data.files || []))
.catch(err => console.error('Failed to load workspace files:', err));
}, [instanceId]);
const refreshFolders = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/folders`)
.then(res => setFolders(res.data.folders || []))
.catch(err => console.error('Failed to load workspace folders:', err));
}, [instanceId]);
const refreshDataSources = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`)
.then(res => setDataSources(res.data.dataSources || []))
.catch(() => {});
}, [instanceId]);
useEffect(() => {
if (!instanceId) return;
refreshFiles();
refreshFolders();
refreshDataSources();
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources]);
const loadWorkflow = useCallback((wfId: string) => {
if (!instanceId || !wfId) return;
setWorkflowId(wfId);
setMessages([]);
setToolActivities([]);
setPendingEdits([]);
setAgentProgress(null);
setDataSourceAccesses([]);
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
.then(res => {
const msgs = (res.data.messages || []).map((m: any) => ({
id: m.id || `loaded-${Math.random()}`,
workflowId: wfId,
role: m.role || 'assistant',
message: m.content || m.message || '',
publishedAt: m.createdAt || Date.now() / 1000,
}));
setMessages(msgs);
})
.catch(() => {});
}, [instanceId]);
const resetToNew = useCallback(() => {
setWorkflowId(null);
setMessages([]);
setToolActivities([]);
setPendingEdits([]);
setAgentProgress(null);
setDataSourceAccesses([]);
}, []);
const sendMessage = useCallback(
(prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = []) => {
if (!instanceId || isProcessing) return;
setIsProcessing(true);
setAgentProgress(null);
setToolActivities([]);
setDataSourceAccesses([]);
setMessages(prev => [
...prev,
{
id: `user-${Date.now()}`,
workflowId: workflowId || '',
role: 'user',
message: prompt,
publishedAt: Date.now() / 1000,
},
]);
if (cleanupRef.current) {
cleanupRef.current();
}
const baseURL = api.defaults.baseURL || '';
const url = `${baseURL}/api/workspace/${instanceId}/start/stream`;
const body: Record<string, any> = {
prompt,
fileIds,
dataSourceIds,
userLanguage: navigator.language?.slice(0, 2) || 'en',
};
if (workflowId) {
body.workflowId = workflowId;
}
if (allowedProviders.length > 0) {
body.allowedProviders = allowedProviders;
}
cleanupRef.current = startSseStream({
url,
body,
handlers: {
onMessage: (event) => _handleMessage(event, setMessages),
onChunk: (event) => _handleChunk(event, setMessages),
onStatus: (event) => _handleStatus(event, setMessages),
onToolCall: (event) => _handleToolCall(event, setToolActivities),
onToolResult: (event) => _handleToolResult(event, setToolActivities),
onAgentProgress: (event) => setAgentProgress(event.item || event.data || null),
onAgentSummary: (event) => {
const s = event.item || event.data || {};
setMessages(prev => [
...prev,
{
id: `summary-${Date.now()}`,
workflowId: '',
role: 'system',
message: `Agent completed: ${s.rounds || '?'} rounds, ${s.totalToolCalls || 0} tool calls, ${s.costCHF || '?'} CHF`,
publishedAt: Date.now() / 1000,
},
]);
setAgentProgress(null);
},
onFileEditProposal: (event) => {
if (event.item) {
setPendingEdits(prev => [...prev, event.item]);
}
},
onFileVersion: (event) => {
const data = event.item || event.data || {};
if (data.fileId) {
setPendingEdits(prev =>
prev.map(e =>
e.fileId === data.fileId
? { ...e, status: 'accepted' as const }
: e,
),
);
refreshFiles();
}
},
onFileCreated: (event) => {
refreshFiles();
const data = event.item || event.data || {};
if (data.fileId && data.fileName) {
setMessages(prev => [
...prev,
{
id: `file-${data.fileId}-${Date.now()}`,
workflowId: '',
role: 'assistant',
message: '',
publishedAt: Date.now() / 1000,
documents: [{
id: data.fileId,
messageId: '',
fileId: data.fileId,
fileName: data.fileName,
mimeType: data.mimeType || 'application/octet-stream',
fileSize: data.fileSize || 0,
roundNumber: 0,
taskNumber: 0,
actionNumber: 0,
actionId: '',
}],
},
]);
}
},
onDataSourceAccess: (event) => {
const data = event.item || event.data || {};
setDataSourceAccesses(prev => [...prev, {
sourceType: data.sourceType || '',
label: data.label || '',
path: data.path || '',
action: data.action || 'access',
}]);
},
onVoiceResponse: (event) => {
const audioUrl = _buildAudioUrl(event);
if (audioUrl) {
const lang = event.item?.language || event.data?.language || '';
const charCount = event.item?.charCount || event.data?.charCount || 0;
setMessages(prev => [
...prev,
{
id: `voice-${Date.now()}`,
role: 'assistant',
message: '',
_audioUrl: audioUrl,
_audioLang: lang,
_audioCharCount: charCount,
} as any,
]);
}
},
onWorkflowUpdated: (event) => {
if (event.workflowId) setWorkflowId(event.workflowId);
setWorkflowVersion(v => v + 1);
},
onComplete: (event) => {
setIsProcessing(false);
if (event.workflowId) setWorkflowId(event.workflowId);
},
onStopped: () => setIsProcessing(false),
onError: (event) => {
setIsProcessing(false);
setMessages(prev => [
...prev,
{
id: `error-${Date.now()}`,
workflowId: '',
role: 'system',
message: `Error: ${event.content || 'Unknown error'}`,
publishedAt: Date.now() / 1000,
},
]);
},
},
onConnectionError: (err) => {
setIsProcessing(false);
setMessages(prev => [
...prev,
{
id: `error-${Date.now()}`,
workflowId: '',
role: 'system',
message: `Connection error: ${err.message}`,
publishedAt: Date.now() / 1000,
},
]);
},
onStreamEnd: () => setIsProcessing(false),
});
},
[instanceId, isProcessing, workflowId, refreshFiles],
);
const stopProcessing = useCallback(() => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (instanceId && workflowId) {
api.post(`/api/workspace/${instanceId}/${workflowId}/stop`).catch(console.error);
}
setIsProcessing(false);
}, [instanceId, workflowId]);
const acceptEdit = useCallback(
(editId: string) => {
const edit = pendingEdits.find(e => e.id === editId);
if (!edit || !instanceId || !workflowId) return;
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)),
);
refreshFiles();
},
[pendingEdits, instanceId, workflowId, refreshFiles],
);
const rejectEdit = useCallback((editId: string) => {
setPendingEdits(prev =>
prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)),
);
}, []);
return {
messages,
isProcessing,
sendMessage,
stopProcessing,
loadWorkflow,
resetToNew,
files,
folders,
dataSources,
agentProgress,
toolActivities,
pendingEdits,
acceptEdit,
rejectEdit,
workflowId,
workflowVersion,
refreshFiles,
refreshFolders,
refreshDataSources,
dataSourceAccesses,
};
}
// ---------------------------------------------------------------------------
// Internal event handlers
// ---------------------------------------------------------------------------
function _handleMessage(
event: SseEvent,
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
) {
const item = event.item || {};
setMessages(prev => [
...prev,
{
id: item.id || `msg-${Date.now()}-${Math.random()}`,
workflowId: item.workflowId || '',
role: item.role || 'assistant',
message: item.content || event.content || '',
publishedAt: item.createdAt || Date.now() / 1000,
documents: item.documents,
},
]);
}
function _handleChunk(
event: SseEvent,
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
) {
const chunkText = event.content || '';
if (!chunkText) return;
setMessages(prev => {
const lastMsg = prev[prev.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id?.startsWith('stream-')) {
return [
...prev.slice(0, -1),
{ ...lastMsg, message: lastMsg.message + chunkText },
];
}
return [
...prev,
{
id: `stream-${Date.now()}`,
workflowId: '',
role: 'assistant',
message: chunkText,
publishedAt: Date.now() / 1000,
},
];
});
}
function _handleStatus(
event: SseEvent,
setMessages: React.Dispatch<React.SetStateAction<Message[]>>,
) {
setMessages(prev => {
const lastIsStatus = prev.length > 0 && prev[prev.length - 1].role === 'status';
const statusMsg: Message = {
id: `status-${Date.now()}`,
workflowId: '',
role: 'status',
message: event.label || event.content || '',
publishedAt: Date.now() / 1000,
};
return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg];
});
}
function _handleToolCall(
event: SseEvent,
setToolActivities: React.Dispatch<React.SetStateAction<ToolActivity[]>>,
) {
const data = event.item || event.data || {};
setToolActivities(prev => [
...prev,
{
id: `tc-${Date.now()}-${Math.random()}`,
toolName: data.toolName || 'unknown',
status: 'calling',
args: data.args,
timestamp: Date.now(),
},
]);
}
function _handleToolResult(
event: SseEvent,
setToolActivities: React.Dispatch<React.SetStateAction<ToolActivity[]>>,
) {
const data = event.item || event.data || {};
setToolActivities(prev => {
const idx = [...prev].reverse().findIndex(t => t.toolName === data.toolName && t.status === 'calling');
if (idx >= 0) {
const realIdx = prev.length - 1 - idx;
const updated = [...prev];
updated[realIdx] = {
...updated[realIdx],
status: data.success ? 'success' : 'error',
result: data.data,
error: data.error,
};
return updated;
}
return [
...prev,
{
id: `tr-${Date.now()}-${Math.random()}`,
toolName: data.toolName || 'unknown',
status: data.success ? 'success' : 'error',
result: data.data,
error: data.error,
timestamp: Date.now(),
},
];
});
}
function _buildAudioUrl(event: SseEvent): string | null {
const audioData = event.item?.audio || event.data?.audio;
if (!audioData) return null;
try {
const byteChars = atob(audioData);
const byteArray = new Uint8Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteArray[i] = byteChars.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: 'audio/mp3' });
return URL.createObjectURL(blob);
} catch (err) {
console.error('Failed to decode voice response:', err);
return null;
}
}

View file

@ -128,25 +128,6 @@ export const PlaygroundPage: React.FC = () => {
} }
}, [urlWorkflowId, onWorkflowSelect]); }, [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 // Handle prompt selection
const handlePromptSelect = (promptId: string) => { const handlePromptSelect = (promptId: string) => {
setSelectedPromptId(promptId); setSelectedPromptId(promptId);
@ -589,22 +570,13 @@ export const PlaygroundPage: React.FC = () => {
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<div className={styles.headerTitleRow}> <div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1> <h1 className={styles.pageTitle}>Chat Playground</h1>
{/* Stats display in header */} {latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
<div className={styles.headerStats}> <div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
{formatBytes(latestStats?.bytesSent || 0)} / {formatBytes(latestStats?.bytesReceived || 0)}
</span>
{(latestStats?.processingTime ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Verarbeitungszeit">
{formatDuration(latestStats?.processingTime || 0)}
</span>
)}
{(latestStats?.priceUsd ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Kosten"> <span className={styles.headerStatItem} title="Kosten">
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} CHF {latestStats.priceCHF.toFixed(2)}
</span> </span>
)}
</div> </div>
)}
</div> </div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p> <p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div> </div>

View file

@ -301,6 +301,15 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'settings' }, { code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'settings' },
] ]
}, },
workspace: {
code: 'workspace',
label: { de: 'AI Workspace', en: 'AI Workspace', fr: 'AI Workspace' },
icon: 'psychology',
views: [
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
]
},
}; };
// ============================================================================= // =============================================================================

178
src/utils/sseClient.ts Normal file
View file

@ -0,0 +1,178 @@
/**
* Shared SSE Client Utility
*
* Generic fetch-based SSE streaming for POST requests with JSON body.
* Extracted from useCodeEditor.ts and chatbotApi.ts to provide a single
* reusable SSE implementation across all workspace features.
*/
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './csrfUtils';
export interface SseEvent {
type: string;
[key: string]: any;
}
export interface SseEventHandlers {
onMessage?: (event: SseEvent) => void;
onChunk?: (event: SseEvent) => void;
onStatus?: (event: SseEvent) => void;
onFileEditProposal?: (event: SseEvent) => void;
onFileVersion?: (event: SseEvent) => void;
onToolCall?: (event: SseEvent) => void;
onToolResult?: (event: SseEvent) => void;
onAgentProgress?: (event: SseEvent) => void;
onAgentSummary?: (event: SseEvent) => void;
onFileCreated?: (event: SseEvent) => void;
onDataSourceAccess?: (event: SseEvent) => void;
onVoiceResponse?: (event: SseEvent) => void;
onWorkflowUpdated?: (event: SseEvent) => void;
onComplete?: (event: SseEvent) => void;
onStopped?: (event: SseEvent) => void;
onError?: (event: SseEvent) => void;
onRawEvent?: (event: SseEvent) => void;
}
export interface SseClientOptions {
url: string;
body: Record<string, any>;
handlers: SseEventHandlers;
signal?: AbortSignal;
onConnectionError?: (error: Error) => void;
onStreamEnd?: () => void;
}
const _EVENT_ROUTER: Record<string, keyof SseEventHandlers> = {
message: 'onMessage',
chunk: 'onChunk',
status: 'onStatus',
file_edit_proposal: 'onFileEditProposal',
fileEditProposal: 'onFileEditProposal',
file_version: 'onFileVersion',
fileVersion: 'onFileVersion',
toolCall: 'onToolCall',
toolResult: 'onToolResult',
agent_progress: 'onAgentProgress',
agentProgress: 'onAgentProgress',
agent_summary: 'onAgentSummary',
agentSummary: 'onAgentSummary',
fileCreated: 'onFileCreated',
dataSourceAccess: 'onDataSourceAccess',
voiceResponse: 'onVoiceResponse',
workflowUpdated: 'onWorkflowUpdated',
complete: 'onComplete',
stopped: 'onStopped',
error: 'onError',
};
/**
* Start an SSE stream via POST request.
* Returns a cleanup function to abort the connection.
*/
export function startSseStream(options: SseClientOptions): () => void {
const { url, body, handlers, signal, onConnectionError, onStreamEnd } = options;
const abortController = new AbortController();
const combinedSignal = signal
? _combineAbortSignals(signal, abortController.signal)
: abortController.signal;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const authToken = localStorage.getItem('authToken');
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
if (!getCSRFToken()) {
generateAndStoreCSRFToken();
}
addCSRFTokenToHeaders(headers);
fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include',
signal: combinedSignal,
})
.then(async (response) => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6);
try {
if (jsonStr.trim()) {
const event: SseEvent = JSON.parse(jsonStr);
_dispatchEvent(event, handlers);
}
} catch {
// skip unparseable lines
}
}
}
}
if (buffer.trim()) {
for (const line of buffer.split('\n')) {
if (line.startsWith('data: ')) {
try {
const event: SseEvent = JSON.parse(line.slice(6));
_dispatchEvent(event, handlers);
} catch { /* skip */ }
}
}
}
onStreamEnd?.();
})
.catch((err) => {
if (err.name === 'AbortError') return;
onConnectionError?.(err instanceof Error ? err : new Error(String(err)));
});
return () => abortController.abort();
}
function _dispatchEvent(event: SseEvent, handlers: SseEventHandlers): void {
handlers.onRawEvent?.(event);
const handlerKey = _EVENT_ROUTER[event.type];
if (handlerKey) {
const handler = handlers[handlerKey];
if (handler) {
(handler as (e: SseEvent) => void)(event);
}
}
}
function _combineAbortSignals(...signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const sig of signals) {
if (sig.aborted) {
controller.abort(sig.reason);
return controller.signal;
}
sig.addEventListener('abort', () => controller.abort(sig.reason), { once: true });
}
return controller.signal;
}