Merge pull request #16 from valueonag/refactor/service-migrations
Refactor/service migrations
This commit is contained in:
commit
9c1b9676c8
31 changed files with 4469 additions and 376 deletions
|
|
@ -57,8 +57,8 @@ export interface StartWorkflowResponse extends Workflow {
|
|||
export interface ChatDataResponse {
|
||||
messages: WorkflowMessage[];
|
||||
logs: WorkflowLog[];
|
||||
stats: WorkflowStats[];
|
||||
documents: WorkflowDocument[];
|
||||
workflowCost: number;
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
|
|
@ -259,35 +259,25 @@ export async function fetchChatData(
|
|||
|
||||
console.log('📥 fetchChatData response:', data);
|
||||
|
||||
// Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] }
|
||||
const workflowCost: number = data.workflowCost ?? 0;
|
||||
|
||||
if (data.items && Array.isArray(data.items)) {
|
||||
const messages: WorkflowMessage[] = [];
|
||||
const logs: WorkflowLog[] = [];
|
||||
const stats: WorkflowStats[] = [];
|
||||
const documents: WorkflowDocument[] = [];
|
||||
|
||||
data.items.forEach((item: any) => {
|
||||
if (item.type === 'message') {
|
||||
// Handle both formats: item.item or direct item data
|
||||
const messageData = item.item || item;
|
||||
if (messageData && (messageData.id || messageData.message)) {
|
||||
messages.push(messageData);
|
||||
} else {
|
||||
console.warn('⚠️ Invalid message item:', item);
|
||||
}
|
||||
} else if (item.type === 'log') {
|
||||
const logData = item.item || item;
|
||||
if (logData) {
|
||||
logs.push(logData);
|
||||
}
|
||||
} else if (item.type === 'stat') {
|
||||
const statData = item.item || item;
|
||||
if (statData) {
|
||||
stats.push(statData);
|
||||
}
|
||||
}
|
||||
// Documents might be in items or separate
|
||||
if (item.type === 'document') {
|
||||
} else if (item.type === 'document') {
|
||||
const docData = item.item || item;
|
||||
if (docData) {
|
||||
documents.push(docData);
|
||||
|
|
@ -295,27 +285,19 @@ export async function fetchChatData(
|
|||
}
|
||||
});
|
||||
|
||||
console.log('📦 Extracted from items:', {
|
||||
messages: messages.length,
|
||||
logs: logs.length,
|
||||
stats: stats.length,
|
||||
documents: documents.length
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
logs,
|
||||
stats,
|
||||
documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : [])
|
||||
documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []),
|
||||
workflowCost
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to direct format: { messages: [], logs: [], stats: [] }
|
||||
return {
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
logs: Array.isArray(data.logs) ? data.logs : [],
|
||||
stats: Array.isArray(data.stats) ? data.stats : [],
|
||||
documents: Array.isArray(data.documents) ? data.documents : []
|
||||
documents: Array.isArray(data.documents) ? data.documents : [],
|
||||
workflowCost
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,9 +49,12 @@ export const UserSection: React.FC = () => {
|
|||
}
|
||||
|
||||
// Initialen für Avatar
|
||||
const initials = user.fullName
|
||||
? user.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
: user.username.slice(0, 2).toUpperCase();
|
||||
const initials = (() => {
|
||||
const name = user.fullName || user.username || '';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toLocaleUpperCase();
|
||||
return [...name.trim()].slice(0, 2).join('').toLocaleUpperCase() || '?';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={styles.userSection}>
|
||||
|
|
|
|||
|
|
@ -2,114 +2,37 @@ import React, { useMemo } from 'react';
|
|||
import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes';
|
||||
import styles from './WorkflowStatus.module.css';
|
||||
|
||||
// Helper function to extract workflow status and round from log message
|
||||
const _STATUS_MAP: Record<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 } => {
|
||||
// First, check for completion messages with success status (these take priority)
|
||||
const completionMessages = logs.filter(log => {
|
||||
const message = (log.message || '').toLowerCase();
|
||||
if (!logs.length) return { status: null, round: null, timestamp: 0 };
|
||||
|
||||
const sorted = [...logs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||||
|
||||
for (const log of sorted) {
|
||||
const logStatus = (log.status || '').toLowerCase();
|
||||
return (message.includes('fast path completed') ||
|
||||
message.includes('completed successfully')) &&
|
||||
logStatus === 'success';
|
||||
});
|
||||
|
||||
// If we have completion messages, use the latest one
|
||||
if (completionMessages.length > 0) {
|
||||
const latestCompletion = completionMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
||||
|
||||
// Try to extract round from completion message
|
||||
let round: number | null = null;
|
||||
const message = (latestCompletion.message || '').toLowerCase();
|
||||
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
|
||||
if (roundMatch) {
|
||||
round = parseInt(roundMatch[1], 10);
|
||||
} else {
|
||||
// If no round in completion message, get round from latest workflow status message
|
||||
const statusMessages = logs.filter(log => {
|
||||
const msg = (log.message || '').toLowerCase();
|
||||
return msg.includes('workflow started') || msg.includes('workflow resumed');
|
||||
});
|
||||
if (statusMessages.length > 0) {
|
||||
const latestWorkflowStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
||||
const workflowMessage = (latestWorkflowStatus.message || '').toLowerCase();
|
||||
const workflowRoundMatch = workflowMessage.match(/\(?round\s+(\d+)\)?/i);
|
||||
if (workflowRoundMatch) {
|
||||
round = parseInt(workflowRoundMatch[1], 10);
|
||||
}
|
||||
}
|
||||
const mapped = _STATUS_MAP[logStatus];
|
||||
if (mapped) {
|
||||
const roundMatch = (log.message || '').match(/\(?round\s+(\d+)\)?/i);
|
||||
return { status: mapped, round: roundMatch ? parseInt(roundMatch[1], 10) : null, timestamp: log.timestamp || 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
round,
|
||||
timestamp: latestCompletion.timestamp || 0
|
||||
};
|
||||
}
|
||||
|
||||
// If no completion messages, look for workflow started/resumed/stopped messages
|
||||
const statusMessages = logs.filter(log => {
|
||||
const message = (log.message || '').toLowerCase();
|
||||
return message.includes('workflow started') ||
|
||||
message.includes('workflow resumed') ||
|
||||
message.includes('workflow stopped') ||
|
||||
message.includes('workflow failed') ||
|
||||
message.includes('workflow completed');
|
||||
});
|
||||
|
||||
if (statusMessages.length === 0) {
|
||||
return { status: null, round: null, timestamp: 0 };
|
||||
}
|
||||
|
||||
// Get the latest status message
|
||||
const latestStatus = statusMessages.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
||||
const message = (latestStatus.message || '').toLowerCase();
|
||||
|
||||
let status: WorkflowStatusType = null;
|
||||
if (message.includes('started')) {
|
||||
status = 'started';
|
||||
} else if (message.includes('resumed')) {
|
||||
status = 'resumed';
|
||||
} else if (message.includes('stopped')) {
|
||||
status = 'stopped';
|
||||
} else if (message.includes('failed')) {
|
||||
status = 'failed';
|
||||
} else if (message.includes('completed')) {
|
||||
status = 'completed';
|
||||
}
|
||||
|
||||
// Extract round number from message (e.g., "round 4", "round 2", or "(round 4)")
|
||||
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
|
||||
const round = roundMatch ? parseInt(roundMatch[1], 10) : null;
|
||||
|
||||
return {
|
||||
status,
|
||||
round,
|
||||
timestamp: latestStatus.timestamp || 0
|
||||
};
|
||||
return { status: null, round: null, timestamp: 0 };
|
||||
};
|
||||
|
||||
// Helper function to format bytes to KB or MB
|
||||
const formatBytes = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null) return '-';
|
||||
if (bytes === 0) return '0 B';
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(2)} KB`;
|
||||
}
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
// Helper function to format price
|
||||
const formatPrice = (price?: number): string => {
|
||||
if (price === undefined || price === null) return '-';
|
||||
return `$${price.toFixed(2)}`;
|
||||
};
|
||||
|
||||
// Helper function to format processing time
|
||||
const formatProcessingTime = (time?: number): string => {
|
||||
if (time === undefined || time === null) return '-';
|
||||
return `${time.toFixed(2)}s`;
|
||||
const _formatCurrency = (amount?: number): string => {
|
||||
if (amount === undefined || amount === null) return '-';
|
||||
return `${amount.toFixed(2)} CHF`;
|
||||
};
|
||||
|
||||
const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
||||
|
|
@ -122,40 +45,10 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
|||
}) => {
|
||||
// Use workflow status and round from API response, fallback to extracting from logs
|
||||
const workflowStatus = useMemo(() => {
|
||||
// If we have status from API, use it
|
||||
if (workflowStatusFromApi) {
|
||||
let status: WorkflowStatusType = null;
|
||||
const statusLower = workflowStatusFromApi.toLowerCase();
|
||||
|
||||
if (statusLower === 'completed') {
|
||||
status = 'completed';
|
||||
} else if (statusLower === 'running') {
|
||||
// Check if it's started or resumed from logs
|
||||
const startedResumedLogs = logs.filter(log => {
|
||||
const message = (log.message || '').toLowerCase();
|
||||
return message.includes('workflow started') || message.includes('workflow resumed');
|
||||
});
|
||||
if (startedResumedLogs.length > 0) {
|
||||
const latest = startedResumedLogs.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))[0];
|
||||
const message = (latest.message || '').toLowerCase();
|
||||
status = message.includes('resumed') ? 'resumed' : 'started';
|
||||
} else {
|
||||
status = 'started';
|
||||
}
|
||||
} else if (statusLower === 'stopped') {
|
||||
status = 'stopped';
|
||||
} else if (statusLower === 'failed') {
|
||||
status = 'failed';
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
round: currentRoundFromApi || null,
|
||||
timestamp: Date.now() / 1000 // Use current time since we don't have timestamp from API
|
||||
};
|
||||
const mapped = _STATUS_MAP[workflowStatusFromApi.toLowerCase()] || null;
|
||||
return { status: mapped, round: currentRoundFromApi || null, timestamp: Date.now() / 1000 };
|
||||
}
|
||||
|
||||
// Fallback to extracting from logs
|
||||
return extractWorkflowStatus(logs);
|
||||
}, [workflowStatusFromApi, currentRoundFromApi, logs]);
|
||||
|
||||
|
|
@ -185,33 +78,13 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Display */}
|
||||
{latestStats && (
|
||||
{/* Cost Display */}
|
||||
{latestStats && latestStats.priceCHF !== undefined && (
|
||||
<div className={styles.statsContainer}>
|
||||
{latestStats.priceUsd !== undefined && (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>Price:</span>
|
||||
<span className={styles.statValue}>{formatPrice(latestStats.priceUsd)}</span>
|
||||
</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 className={styles.statItem}>
|
||||
<span className={styles.statLabel}>Cost:</span>
|
||||
<span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,13 +44,10 @@ export interface WorkflowStatusProps {
|
|||
isRunning?: boolean;
|
||||
|
||||
/**
|
||||
* Latest statistics from the workflow (price, processing time, bytes sent/received)
|
||||
* Latest cost from billing transactions (single source of truth)
|
||||
*/
|
||||
latestStats?: {
|
||||
priceUsd?: number;
|
||||
processingTime?: number;
|
||||
bytesSent?: number;
|
||||
bytesReceived?: number;
|
||||
priceCHF?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.feature.chatbot.conversations': <FaComments />,
|
||||
'feature.chatbot': <FaComments />,
|
||||
'feature.teamsbot': <FaHeadset />,
|
||||
|
||||
// Feature pages - Workspace
|
||||
'page.feature.workspace.dashboard': <FaPlay />,
|
||||
'feature.workspace': <FaPlay />,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { useWorkflowPolling } from './useWorkflowPolling';
|
|||
import { getWorkflowApiBaseUrl } from '../useWorkflows';
|
||||
|
||||
interface UnifiedChatDataItem {
|
||||
type: 'message' | 'log' | 'stat';
|
||||
item: WorkflowMessage | WorkflowLog | any;
|
||||
type: 'message' | 'log';
|
||||
item: WorkflowMessage | WorkflowLog;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -76,13 +76,11 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
const [logs, setLogs] = useState<WorkflowLog[]>([]);
|
||||
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
|
||||
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
|
||||
const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null);
|
||||
const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null);
|
||||
|
||||
// === REFS FOR SYNC ACCESS ===
|
||||
const statusRef = useRef<string>('idle');
|
||||
const lastRenderedTimestampRef = useRef<number | null>(null);
|
||||
const processedStatIdsRef = useRef<Set<string>>(new Set());
|
||||
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
|
||||
|
||||
// === KEY STATE MACHINE FLAG ===
|
||||
// This flag tracks if the UI has rendered a message with status="last"
|
||||
|
|
@ -124,17 +122,15 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
}, [workflowId]);
|
||||
|
||||
// === CORE: Process unified chat data ===
|
||||
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => {
|
||||
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; workflowCost: number }) => {
|
||||
console.log('🔄 Processing chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0
|
||||
workflowCost: chatData.workflowCost ?? 0
|
||||
});
|
||||
|
||||
// Build unified timeline
|
||||
const timeline: UnifiedChatDataItem[] = [];
|
||||
|
||||
// Add messages
|
||||
(chatData.messages || []).forEach((message: WorkflowMessage) => {
|
||||
timeline.push({
|
||||
type: 'message',
|
||||
|
|
@ -143,7 +139,6 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
});
|
||||
});
|
||||
|
||||
// Add logs
|
||||
(chatData.logs || []).forEach((log: any) => {
|
||||
timeline.push({
|
||||
type: 'log',
|
||||
|
|
@ -152,17 +147,6 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
});
|
||||
});
|
||||
|
||||
// Add stats
|
||||
const rawStats = chatData.stats || [];
|
||||
rawStats.forEach((stat: any) => {
|
||||
timeline.push({
|
||||
type: 'stat',
|
||||
item: stat,
|
||||
createdAt: stat._createdAt || stat.createdAt || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Sort chronologically
|
||||
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Update lastRenderedTimestamp
|
||||
|
|
@ -290,44 +274,9 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
return [...allLogs].sort(sortLogs);
|
||||
});
|
||||
|
||||
// === PROCESS STATS ===
|
||||
const statsItems = timeline.filter(item => item.type === 'stat');
|
||||
|
||||
if (statsItems.length > 0) {
|
||||
let hasNewStats = false;
|
||||
|
||||
statsItems.forEach(statItem => {
|
||||
const statData = statItem.item;
|
||||
const statId = statData?.id;
|
||||
|
||||
if (statId && processedStatIdsRef.current.has(statId)) {
|
||||
return; // Skip already processed
|
||||
}
|
||||
|
||||
if (statData) {
|
||||
hasNewStats = true;
|
||||
if (statId) {
|
||||
processedStatIdsRef.current.add(statId);
|
||||
}
|
||||
|
||||
// Accumulate stats
|
||||
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
|
||||
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
|
||||
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
|
||||
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
|
||||
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNewStats) {
|
||||
setLatestStats({
|
||||
priceUsd: cumulativeStatsRef.current.priceUsd,
|
||||
processingTime: cumulativeStatsRef.current.processingTime,
|
||||
bytesSent: cumulativeStatsRef.current.bytesSent,
|
||||
bytesReceived: cumulativeStatsRef.current.bytesReceived
|
||||
});
|
||||
}
|
||||
}
|
||||
// === UPDATE COST from billing transactions (single source of truth) ===
|
||||
const cost = chatData.workflowCost ?? 0;
|
||||
setLatestStats(cost > 0 ? { priceCHF: cost } : null);
|
||||
}, [convertLogToFrontendFormat]);
|
||||
|
||||
// === POLLING FUNCTION ===
|
||||
|
|
@ -359,7 +308,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
console.log('📊 Polled chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0,
|
||||
workflowCost: chatData.workflowCost ?? 0,
|
||||
afterTimestamp
|
||||
});
|
||||
|
||||
|
|
@ -496,10 +445,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
setUnifiedContentLogs([]);
|
||||
setLatestStats(null);
|
||||
|
||||
// Reset refs
|
||||
lastRenderedTimestampRef.current = null;
|
||||
processedStatIdsRef.current.clear();
|
||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
|
||||
|
|
@ -511,13 +457,11 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
try {
|
||||
console.log('📥 Loading workflow:', workflowIdToSelect);
|
||||
|
||||
// Reset state
|
||||
setWorkflowId(workflowIdToSelect);
|
||||
lastRenderedTimestampRef.current = null;
|
||||
processedStatIdsRef.current.clear();
|
||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
setLatestStats(null);
|
||||
|
||||
// Fetch workflow data
|
||||
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null);
|
||||
|
|
@ -544,7 +488,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
console.log('📥 Loaded chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0
|
||||
workflowCost: chatData.workflowCost ?? 0
|
||||
});
|
||||
|
||||
// === STATE MACHINE: Check if last message has status="last" ===
|
||||
|
|
|
|||
|
|
@ -479,7 +479,7 @@ export function useFileOperations() {
|
|||
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
||||
* - Upload should now work correctly
|
||||
*/
|
||||
const handleFileUpload = async (file: globalThis.File, workflowId?: string) => {
|
||||
const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => {
|
||||
setUploadError(null);
|
||||
setUploadingFile(true);
|
||||
|
||||
|
|
@ -500,6 +500,9 @@ export function useFileOperations() {
|
|||
if (workflowId) {
|
||||
formData.append('workflowId', workflowId);
|
||||
}
|
||||
if (featureInstanceId) {
|
||||
formData.append('featureInstanceId', featureInstanceId);
|
||||
}
|
||||
|
||||
// FormData is now correctly configured for backend
|
||||
|
||||
|
|
|
|||
|
|
@ -276,12 +276,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
|
|||
} else if (attr.type === 'textarea') {
|
||||
fieldType = 'textarea';
|
||||
} else if (attr.type === 'text') {
|
||||
// Check if it should be textarea based on name
|
||||
if (attr.name.toLowerCase().includes('description') || attr.name.toLowerCase().includes('note')) {
|
||||
fieldType = 'textarea';
|
||||
} else {
|
||||
fieldType = 'string';
|
||||
}
|
||||
fieldType = (attr as any).multiline === true ? 'textarea' : 'string';
|
||||
}
|
||||
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
|
||||
// If needed, they should be handled via type casting: (attr as any).type === 'boolean'
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
/* Let child components handle their own scrolling for sticky headers */
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom';
|
|||
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||
import { UserSection } from '../components/Navigation/UserSection';
|
||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||
import styles from './MainLayout.module.css';
|
||||
|
||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||
|
||||
// =============================================================================
|
||||
// INNER LAYOUT (mit Zugriff auf Store)
|
||||
// =============================================================================
|
||||
|
|
@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => {
|
|||
className={styles.mobileLogo}
|
||||
/>
|
||||
</div>
|
||||
<Outlet />
|
||||
|
||||
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
||||
|
||||
<div style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : 'contents' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView
|
|||
// CodeEditor Views
|
||||
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
|
||||
|
||||
// Workspace Views
|
||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||
|
||||
// Teamsbot Views
|
||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||
|
|
@ -137,6 +141,10 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
editor: CodeEditorPage,
|
||||
workflows: CodeEditorWorkflowsPage,
|
||||
},
|
||||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
settings: WorkspaceSettingsPage,
|
||||
},
|
||||
teamsbot: {
|
||||
dashboard: TeamsbotDashboardView,
|
||||
sessions: TeamsbotSessionView,
|
||||
|
|
@ -199,6 +207,12 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
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
|
||||
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||
if (!featureViews) {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,34 @@
|
|||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.googleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #4285f4;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.googleButton:hover {
|
||||
background: #3367d6;
|
||||
}
|
||||
|
||||
.googleButton:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.googleButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Filter Section Styles */
|
||||
.filterSection {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => {
|
|||
{canCreate && (
|
||||
<>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
className={styles.googleButton}
|
||||
onClick={handleCreateGoogle}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
|
|
@ -255,7 +255,7 @@ export const ConnectionsPage: React.FC = () => {
|
|||
{canCreate && (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
className={styles.googleButton}
|
||||
onClick={handleCreateGoogle}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||
import { useAdminMandates } from '../../hooks/useMandates';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
||||
const _formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MANDATE SELECTOR
|
||||
|
|
@ -195,18 +199,18 @@ interface CreditAdderProps {
|
|||
settings: BillingSettings | null;
|
||||
accounts: AccountSummary[];
|
||||
users: MandateUserSummary[];
|
||||
onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>;
|
||||
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<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 [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 [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
|
||||
|
||||
// Map accounts by userId for balance lookup
|
||||
const accountsByUserId = accounts
|
||||
.filter(acc => acc.accountType === 'USER')
|
||||
.reduce((map, acc) => {
|
||||
|
|
@ -214,9 +218,10 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
return map;
|
||||
}, {} as Record<string, AccountSummary>);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const _handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (amount <= 0) {
|
||||
const numAmount = parseFloat(amount);
|
||||
if (!numAmount || numAmount <= 0) {
|
||||
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -225,24 +230,19 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
setMessage(null);
|
||||
|
||||
try {
|
||||
const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount);
|
||||
window.location.href = redirectUrl;
|
||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
||||
setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
|
||||
setAmount('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Guthaben aufladen</h3>
|
||||
<h3>Guthaben manuell aufladen</h3>
|
||||
|
||||
{message && (
|
||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||
|
|
@ -250,7 +250,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={_handleSubmit}>
|
||||
{isPrepayUser && (
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
|
|
@ -264,7 +264,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
<option value="">-- Benutzer wählen --</option>
|
||||
{users.map((user) => {
|
||||
const account = accountsByUserId[user.id];
|
||||
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)';
|
||||
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
|
||||
return (
|
||||
<option key={user.id} value={user.id}>
|
||||
{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.formGroup}>
|
||||
<label>Betrag (CHF)</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
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
|
||||
>
|
||||
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{preset} CHF
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Beschreibung der Gutschrift"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -370,18 +378,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
|||
// ============================================================================
|
||||
|
||||
export const BillingAdmin: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||
const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
|
||||
|
||||
const successParam = searchParams.get('success');
|
||||
const canceledParam = searchParams.get('canceled');
|
||||
|
||||
useEffect(() => {
|
||||
if (successParam === 'true' && selectedMandateId) {
|
||||
loadAccounts();
|
||||
}
|
||||
}, [successParam, selectedMandateId, loadAccounts]);
|
||||
const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
|
||||
|
||||
const handleMandateSelect = (mandateId: string) => {
|
||||
setSelectedMandateId(mandateId || null);
|
||||
|
|
@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => {
|
|||
await saveSettings(settingsUpdate);
|
||||
}, [selectedMandateId, saveSettings]);
|
||||
|
||||
const handleCreateCheckout = useCallback(async (userId: string | undefined, amount: number) => {
|
||||
const _handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => {
|
||||
if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt');
|
||||
const result = await createCheckout({ userId, amount });
|
||||
if (!result) throw new Error('Checkout konnte nicht erstellt werden');
|
||||
const result = await addCredit({ userId, amount, description });
|
||||
if (!result) throw new Error('Gutschrift konnte nicht erstellt werden');
|
||||
await loadAccounts();
|
||||
return result;
|
||||
}, [selectedMandateId, createCheckout]);
|
||||
|
||||
const clearStripeParams = useCallback(() => {
|
||||
searchParams.delete('success');
|
||||
searchParams.delete('canceled');
|
||||
searchParams.delete('session_id');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, [selectedMandateId, addCredit, loadAccounts]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
|
|
@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {
|
|||
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
|
||||
</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}>
|
||||
<MandateSelector
|
||||
selectedMandateId={selectedMandateId}
|
||||
|
|
@ -445,7 +424,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
settings={settings}
|
||||
accounts={accounts}
|
||||
users={users}
|
||||
onCreateCheckout={handleCreateCheckout}
|
||||
onAddCredit={_handleAddCredit}
|
||||
/>
|
||||
|
||||
<AccountsOverview
|
||||
|
|
|
|||
|
|
@ -8,14 +8,19 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
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';
|
||||
|
||||
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
||||
|
||||
// ============================================================================
|
||||
// HELPER: Currency formatter
|
||||
// ============================================================================
|
||||
|
|
@ -47,9 +52,14 @@ interface ViewStatistics {
|
|||
|
||||
interface BalanceCardProps {
|
||||
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) => {
|
||||
switch (model) {
|
||||
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 (
|
||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||
<div className={styles.balanceHeader}>
|
||||
|
|
@ -74,6 +88,47 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
|||
Niedriges Guthaben
|
||||
</div>
|
||||
)}
|
||||
{canTopUp && onCheckout && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{!showCheckout ? (
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
|
||||
onClick={() => setShowCheckout(true)}
|
||||
>
|
||||
Budget laden mit Kreditkarte
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedAmount}
|
||||
onChange={(e) => setSelectedAmount(Number(e.target.value))}
|
||||
style={{ flex: 1, fontSize: '13px' }}
|
||||
>
|
||||
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
||||
<option key={preset} value={preset}>{preset} CHF</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
|
||||
disabled={checkoutLoading}
|
||||
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
|
||||
>
|
||||
{checkoutLoading ? 'Laden...' : 'Zahlen'}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonSecondary || ''}`}
|
||||
style={{ fontSize: '13px', padding: '6px 12px' }}
|
||||
onClick={() => setShowCheckout(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
|||
|
||||
export const BillingDataView: React.FC = () => {
|
||||
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
|
||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
||||
|
|
@ -273,8 +332,47 @@ export const BillingDataView: React.FC = () => {
|
|||
const {
|
||||
balances,
|
||||
loading: dashboardLoading,
|
||||
refetch: refetchBalances,
|
||||
} = useBilling();
|
||||
|
||||
const successParam = searchParams.get('success');
|
||||
const canceledParam = searchParams.get('canceled');
|
||||
|
||||
useEffect(() => {
|
||||
if (successParam === 'true') {
|
||||
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
|
||||
refetchBalances();
|
||||
} else if (canceledParam === 'true') {
|
||||
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
|
||||
}
|
||||
}, [successParam, canceledParam, refetchBalances]);
|
||||
|
||||
const _clearStripeParams = useCallback(() => {
|
||||
searchParams.delete('success');
|
||||
searchParams.delete('canceled');
|
||||
searchParams.delete('session_id');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
setCheckoutMessage(null);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const _handleCheckout = useCallback(async (mandateId: string, amount: number) => {
|
||||
setCheckoutLoading(true);
|
||||
setCheckoutMessage(null);
|
||||
try {
|
||||
const currentUser = getUserDataCache();
|
||||
const result = await createCheckoutSession(request, mandateId, {
|
||||
userId: currentUser?.id,
|
||||
amount,
|
||||
});
|
||||
if (result?.redirectUrl) {
|
||||
window.location.href = result.redirectUrl;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' });
|
||||
setCheckoutLoading(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
// All user balances (for admin overview cards)
|
||||
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
||||
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
||||
|
|
@ -475,6 +573,15 @@ export const BillingDataView: React.FC = () => {
|
|||
|
||||
<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) */}
|
||||
{/* ================================================================ */}
|
||||
|
|
@ -502,7 +609,12 @@ export const BillingDataView: React.FC = () => {
|
|||
) : (
|
||||
<div className={styles.balanceGrid}>
|
||||
{filteredBalances.map((balance) => (
|
||||
<BalanceCard key={balance.mandateId} balance={balance} />
|
||||
<BalanceCard
|
||||
key={balance.mandateId}
|
||||
balance={balance}
|
||||
onCheckout={_handleCheckout}
|
||||
checkoutLoading={checkoutLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
514
src/pages/views/workspace/ChatStream.tsx
Normal file
514
src/pages/views/workspace/ChatStream.tsx
Normal 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">⬇</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function _getFileIcon(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA',
|
||||
doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
|
||||
md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB',
|
||||
html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC',
|
||||
py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD',
|
||||
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
|
||||
gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F',
|
||||
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6',
|
||||
pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA',
|
||||
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5',
|
||||
mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC',
|
||||
eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7',
|
||||
};
|
||||
return map[ext] || '\uD83D\uDCC4';
|
||||
}
|
||||
|
||||
function _AudioPlayer({ url, language }: { url: string; language?: string; charCount?: number }) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (audio.duration) setProgress(audio.currentTime / audio.duration);
|
||||
});
|
||||
audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); });
|
||||
audio.addEventListener('pause', () => setPlaying(false));
|
||||
audio.addEventListener('play', () => setPlaying(true));
|
||||
|
||||
audio.play().catch(() => {});
|
||||
|
||||
return () => {
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const _togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (playing) { audio.pause(); } else { audio.play().catch(() => {}); }
|
||||
}, [playing]);
|
||||
|
||||
const _stop = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
}, []);
|
||||
|
||||
const _formatTime = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
background: 'var(--audio-player-bg, #f0f4f8)',
|
||||
border: '1px solid var(--border-color, #e0e0e0)',
|
||||
maxWidth: 360, marginTop: 6,
|
||||
}}>
|
||||
<button
|
||||
onClick={_togglePlay}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--primary-color, #1976d2)', color: '#fff',
|
||||
cursor: 'pointer', fontSize: 14,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{playing ? '\u275A\u275A' : '\u25B6'}
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
height: 4, borderRadius: 2,
|
||||
background: 'var(--border-color, #ddd)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 2,
|
||||
background: 'var(--primary-color, #1976d2)',
|
||||
width: `${progress * 100}%`,
|
||||
transition: 'width 0.2s',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
|
||||
<span style={{ fontSize: 10, color: '#888' }}>
|
||||
{duration > 0 ? _formatTime(progress * duration) : '0:00'}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: '#888' }}>
|
||||
{duration > 0 ? _formatTime(duration) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={_stop}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
|
||||
background: 'transparent', color: '#888',
|
||||
cursor: 'pointer', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Stop"
|
||||
>
|
||||
■
|
||||
</button>
|
||||
|
||||
{language && (
|
||||
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function _CodeBlock({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !String(children).includes('\n');
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
background: '#f0f0f0',
|
||||
padding: '1px 5px',
|
||||
borderRadius: 3,
|
||||
fontSize: '0.9em',
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', margin: '8px 0' }}>
|
||||
{match && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
padding: '2px 8px', fontSize: 10, color: '#888',
|
||||
background: '#2d2d2d', borderBottomLeftRadius: 4,
|
||||
}}>
|
||||
{match[1]}
|
||||
</div>
|
||||
)}
|
||||
<pre style={{
|
||||
background: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 6,
|
||||
overflow: 'auto',
|
||||
fontSize: 13,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.5,
|
||||
margin: 0,
|
||||
}}>
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
453
src/pages/views/workspace/ConversationList.tsx
Normal file
453
src/pages/views/workspace/ConversationList.tsx
Normal 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"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
{conv.status === 'archived' ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
|
||||
style={{ ..._actionBtnStyle, color: '#4caf50' }}
|
||||
title="Reaktivieren"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
|
||||
style={_actionBtnStyle}
|
||||
title="Archivieren"
|
||||
>
|
||||
📦
|
||||
</button>
|
||||
)}
|
||||
{confirmDeleteId === conv.id ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', gap: 1, background: 'var(--color-secondary, #555)',
|
||||
borderRadius: 12, padding: '1px 2px', alignItems: 'center',
|
||||
}}>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); _handleDelete(conv.id); }}
|
||||
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||
title="Ja, loeschen"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
|
||||
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||
title="Abbrechen"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
|
||||
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
|
||||
title="Loeschen"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 }}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<span style={{ color: '#888' }}>{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
style={{ ..._pageBtnStyle, opacity: page >= totalPages - 1 ? 0.3 : 1 }}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const _actionBtnStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
padding: '0 2px',
|
||||
};
|
||||
|
||||
const _pageBtnStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
padding: '2px 8px',
|
||||
color: '#666',
|
||||
};
|
||||
470
src/pages/views/workspace/DataSourcePanel.tsx
Normal file
470
src/pages/views/workspace/DataSourcePanel.tsx
Normal 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;
|
||||
});
|
||||
}
|
||||
258
src/pages/views/workspace/FileBrowser.tsx
Normal file
258
src/pages/views/workspace/FileBrowser.tsx
Normal 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';
|
||||
}
|
||||
153
src/pages/views/workspace/FilePreview.tsx
Normal file
153
src/pages/views/workspace/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* FilePreview -- File preview / editor panel in the right sidebar.
|
||||
*
|
||||
* Displays content preview for selected files based on their MIME type:
|
||||
* - Text files: rendered as text with optional editing
|
||||
* - Images: rendered as preview
|
||||
* - PDFs: link to download
|
||||
* - Other: metadata display
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
import type { WorkspaceFile } from './useWorkspace';
|
||||
|
||||
interface FilePreviewProps {
|
||||
instanceId: string;
|
||||
fileId: string | null;
|
||||
files: WorkspaceFile[];
|
||||
}
|
||||
|
||||
export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, files }) => {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const file = fileId ? files.find(f => f.id === fileId) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setContent(null);
|
||||
setPreviewUrl(null);
|
||||
if (!file || !instanceId) return;
|
||||
|
||||
const isText = _isTextMime(file.mimeType);
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
|
||||
if (isText && file.fileSize < 500_000) {
|
||||
setLoading(true);
|
||||
api.get(`/api/files/${file.id}/download`, { responseType: 'text' })
|
||||
.then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)))
|
||||
.catch(() => setContent(null))
|
||||
.finally(() => setLoading(false));
|
||||
} else if (isImage) {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
setPreviewUrl(`${baseUrl}/api/files/${file.id}/download`);
|
||||
}
|
||||
}, [file, instanceId]);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#999', fontSize: 13 }}>
|
||||
Select a file to preview
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{file.fileName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
||||
<span>{file.mimeType}</span>
|
||||
<span>{_formatFileSize(file.fileSize)}</span>
|
||||
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
||||
</div>
|
||||
{file.description && (
|
||||
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>{file.description}</div>
|
||||
)}
|
||||
{file.tags && file.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
{file.tags.map(tag => (
|
||||
<span key={tag} style={{ fontSize: 10, padding: '1px 6px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>Loading...</div>
|
||||
)}
|
||||
|
||||
{content !== null && !loading && (
|
||||
<pre style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.5,
|
||||
padding: 8,
|
||||
background: '#f8f9fa',
|
||||
borderRadius: 4,
|
||||
margin: 0,
|
||||
maxHeight: '100%',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{previewUrl && (
|
||||
<div style={{ textAlign: 'center', padding: 8 }}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.fileName}
|
||||
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 4, objectFit: 'contain' }}
|
||||
onError={() => setPreviewUrl(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && content === null && !previewUrl && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
{file.fileSize > 500_000
|
||||
? 'File too large for inline preview'
|
||||
: `No preview available for ${file.mimeType}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function _isTextMime(mime: string): boolean {
|
||||
if (mime.startsWith('text/')) return true;
|
||||
const textTypes = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/javascript',
|
||||
'application/typescript',
|
||||
'application/x-python',
|
||||
'application/x-yaml',
|
||||
'application/yaml',
|
||||
'application/sql',
|
||||
'application/csv',
|
||||
];
|
||||
return textTypes.includes(mime);
|
||||
}
|
||||
|
||||
function _formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
83
src/pages/views/workspace/ToolActivityLog.tsx
Normal file
83
src/pages/views/workspace/ToolActivityLog.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* ToolActivityLog -- Real-time tool call activity display.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ToolActivity } from './useWorkspace';
|
||||
|
||||
interface ToolActivityLogProps {
|
||||
activities: ToolActivity[];
|
||||
}
|
||||
|
||||
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
|
||||
if (!activities.length) {
|
||||
return (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
No tool activity yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
{activities.map(activity => (
|
||||
<div
|
||||
key={activity.id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
border: `1px solid ${
|
||||
activity.status === 'calling'
|
||||
? '#ffc107'
|
||||
: activity.status === 'success'
|
||||
? '#4caf50'
|
||||
: '#f44336'
|
||||
}30`,
|
||||
background: activity.status === 'calling'
|
||||
? '#fff8e1'
|
||||
: activity.status === 'success'
|
||||
? '#e8f5e9'
|
||||
: '#ffebee',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600 }}>{activity.toolName}</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
background: activity.status === 'calling'
|
||||
? '#ffc107'
|
||||
: activity.status === 'success'
|
||||
? '#4caf50'
|
||||
: '#f44336',
|
||||
color: '#fff',
|
||||
}}>
|
||||
{activity.status}
|
||||
</span>
|
||||
</div>
|
||||
{activity.args && Object.keys(activity.args).length > 0 && (
|
||||
<div style={{ marginTop: 4, color: '#666', fontSize: 11 }}>
|
||||
{Object.entries(activity.args)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{activity.result && (
|
||||
<div style={{ marginTop: 4, color: '#388e3c', fontSize: 11, maxHeight: 60, overflow: 'hidden' }}>
|
||||
{activity.result.slice(0, 200)}
|
||||
{activity.result.length > 200 && '...'}
|
||||
</div>
|
||||
)}
|
||||
{activity.error && (
|
||||
<div style={{ marginTop: 4, color: '#c62828', fontSize: 11 }}>
|
||||
{activity.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
612
src/pages/views/workspace/WorkspaceInput.tsx
Normal file
612
src/pages/views/workspace/WorkspaceInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/pages/views/workspace/WorkspaceKeepAlive.tsx
Normal file
48
src/pages/views/workspace/WorkspaceKeepAlive.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* WorkspaceKeepAlive
|
||||
*
|
||||
* Renders the WorkspacePage permanently at the MainLayout level so it
|
||||
* survives route changes. Visibility is toggled via CSS `display`
|
||||
* instead of mount / unmount, preserving messages, SSE connections,
|
||||
* files, and all other workspace state.
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { WorkspacePage } from './WorkspacePage';
|
||||
|
||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
|
||||
|
||||
interface WorkspaceKeepAliveProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
|
||||
const location = useLocation();
|
||||
const cachedInstanceIdRef = useRef<string>('');
|
||||
|
||||
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
|
||||
if (match?.[2]) {
|
||||
cachedInstanceIdRef.current = match[2];
|
||||
}
|
||||
|
||||
const instanceId = cachedInstanceIdRef.current;
|
||||
if (!instanceId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: isVisible ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<WorkspacePage persistentInstanceId={instanceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
342
src/pages/views/workspace/WorkspacePage.tsx
Normal file
342
src/pages/views/workspace/WorkspacePage.tsx
Normal 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;
|
||||
173
src/pages/views/workspace/WorkspaceSettings.module.css
Normal file
173
src/pages/views/workspace/WorkspaceSettings.module.css
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
.settings {
|
||||
padding: 1rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fde8e8;
|
||||
color: var(--color-error, #d32f2f);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.3rem;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.select, .input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-input, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.voiceRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.voiceRow .select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.testBtn, .addBtn, .removeBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.testBtn:hover:not(:disabled),
|
||||
.addBtn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
|
||||
.testBtn:disabled,
|
||||
.addBtn:disabled {
|
||||
background: var(--color-medium-gray, #ccc);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
background: transparent;
|
||||
color: var(--color-error, #d32f2f);
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--color-error, #d32f2f);
|
||||
}
|
||||
|
||||
.removeBtn:hover { background: #fde8e8; }
|
||||
|
||||
.voiceTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.voiceTable th,
|
||||
.voiceTable td {
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.voiceTable th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--primary-color, #F25843);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.saveBtn:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.saveBtn:disabled {
|
||||
background: var(--color-medium-gray, #ccc);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary-color, #1976d2);
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.backBtn:hover { text-decoration: underline; }
|
||||
280
src/pages/views/workspace/WorkspaceSettings.tsx
Normal file
280
src/pages/views/workspace/WorkspaceSettings.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* WorkspaceSettings -- Voice preferences per language.
|
||||
*
|
||||
* Allows the user to configure a preferred voice for each TTS language.
|
||||
* Language detection is automatic; this page lets users override the
|
||||
* default Google Cloud voice for specific languages.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import styles from './WorkspaceSettings.module.css';
|
||||
|
||||
interface VoiceMapEntry {
|
||||
language: string;
|
||||
voiceName: string;
|
||||
}
|
||||
|
||||
interface WorkspaceSettingsProps {
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
export const WorkspaceSettings: React.FC<WorkspaceSettingsProps> = ({ instanceId }) => {
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [languages, setLanguages] = useState<any[]>([]);
|
||||
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||
|
||||
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||
const [addVoices, setAddVoices] = useState<any[]>([]);
|
||||
const [addVoiceName, setAddVoiceName] = useState('');
|
||||
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||
|
||||
const _loadSettings = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [settingsData, languagesData] = await Promise.all([
|
||||
request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }),
|
||||
request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }),
|
||||
]);
|
||||
|
||||
const langList = (languagesData as any)?.languages || [];
|
||||
setLanguages(langList);
|
||||
|
||||
const map: Record<string, any> = (settingsData as any)?.ttsVoiceMap || {};
|
||||
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
|
||||
language: lang,
|
||||
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
|
||||
}));
|
||||
setVoiceMap(entries);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Laden der Einstellungen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
useEffect(() => { _loadSettings(); }, [_loadSettings]);
|
||||
|
||||
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
|
||||
if (!instanceId) return;
|
||||
setLoadingVoices(true);
|
||||
try {
|
||||
const result = await request({
|
||||
url: `/api/workspace/${instanceId}/voice/voices`,
|
||||
method: 'get',
|
||||
params: { language: lang },
|
||||
});
|
||||
setAddVoices((result as any)?.voices || []);
|
||||
setAddVoiceName('');
|
||||
} catch {
|
||||
setAddVoices([]);
|
||||
} finally {
|
||||
setLoadingVoices(false);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
|
||||
|
||||
const _handleAddEntry = useCallback(() => {
|
||||
if (!addLanguage) return;
|
||||
const exists = voiceMap.some(e => e.language === addLanguage);
|
||||
if (exists) {
|
||||
setVoiceMap(prev => prev.map(e =>
|
||||
e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e
|
||||
));
|
||||
} else {
|
||||
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
|
||||
}
|
||||
setAddVoiceName('');
|
||||
}, [addLanguage, addVoiceName, voiceMap]);
|
||||
|
||||
const _handleRemoveEntry = useCallback((lang: string) => {
|
||||
setVoiceMap(prev => prev.filter(e => e.language !== lang));
|
||||
}, []);
|
||||
|
||||
const _handleSave = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const mapObj: Record<string, any> = {};
|
||||
voiceMap.forEach(e => {
|
||||
mapObj[e.language] = { voiceName: e.voiceName || '' };
|
||||
});
|
||||
const putResult = await request({
|
||||
url: `/api/workspace/${instanceId}/settings/voice`,
|
||||
method: 'put',
|
||||
data: { ttsVoiceMap: mapObj },
|
||||
});
|
||||
if ((putResult as any)?.error) {
|
||||
setError((putResult as any).error);
|
||||
return;
|
||||
}
|
||||
setSuccess('Einstellungen gespeichert');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
await _loadSettings();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Speichern');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, voiceMap]);
|
||||
|
||||
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
|
||||
if (!instanceId) return;
|
||||
setTesting(lang);
|
||||
try {
|
||||
const result: any = await request({
|
||||
url: `/api/workspace/${instanceId}/voice/test`,
|
||||
method: 'post',
|
||||
data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` },
|
||||
});
|
||||
if (result?.success && result?.audio) {
|
||||
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
||||
audio.play();
|
||||
}
|
||||
} catch {
|
||||
setError('Stimmtest fehlgeschlagen');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
const _getLanguageName = useCallback((code: string) => {
|
||||
const found = languages.find((l: any) => (l.code || l) === code);
|
||||
return found?.name || found?.code || code;
|
||||
}, [languages]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||
}
|
||||
|
||||
const _defaultLangs = [
|
||||
{ code: 'de-DE', name: 'Deutsch' },
|
||||
{ code: 'en-US', name: 'English (US)' },
|
||||
{ code: 'fr-FR', name: 'Francais' },
|
||||
{ code: 'it-IT', name: 'Italiano' },
|
||||
{ code: 'es-ES', name: 'Espanol' },
|
||||
];
|
||||
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
|
||||
|
||||
return (
|
||||
<div className={styles.settings}>
|
||||
<h2 className={styles.heading}>Stimmeneinstellungen</h2>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Konfigurierte Stimmen pro Sprache</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
|
||||
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
|
||||
</p>
|
||||
|
||||
{voiceMap.length === 0 ? (
|
||||
<div className={styles.emptyHint}>
|
||||
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.voiceTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sprache</th>
|
||||
<th>Stimme</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{voiceMap.map(entry => (
|
||||
<tr key={entry.language}>
|
||||
<td>{_getLanguageName(entry.language)}</td>
|
||||
<td>{entry.voiceName || 'Standard'}</td>
|
||||
<td>
|
||||
<button
|
||||
className={styles.testBtn}
|
||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
||||
onClick={() => _handleTestVoice(entry.language, entry.voiceName)}
|
||||
disabled={testing === entry.language}
|
||||
>
|
||||
{testing === entry.language ? '...' : 'Test'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button className={styles.removeBtn} onClick={() => _handleRemoveEntry(entry.language)}>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Stimme hinzufuegen / aendern</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Sprache</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={addLanguage}
|
||||
onChange={e => setAddLanguage(e.target.value)}
|
||||
>
|
||||
{_displayLanguages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>
|
||||
{lang.name || lang.code || lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Stimme</label>
|
||||
<div className={styles.voiceRow}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={addVoiceName}
|
||||
onChange={e => setAddVoiceName(e.target.value)}
|
||||
disabled={loadingVoices}
|
||||
>
|
||||
<option value="">Standard</option>
|
||||
{addVoices.map((v: any) => (
|
||||
<option key={v.name || v} value={v.name || v}>
|
||||
{v.displayName || v.name || v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={styles.testBtn}
|
||||
onClick={() => _handleTestVoice(addLanguage, addVoiceName)}
|
||||
disabled={testing !== null}
|
||||
>
|
||||
{testing === addLanguage ? '...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={styles.addBtn} onClick={_handleAddEntry}>
|
||||
Stimme zuweisen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className={styles.saveBtn} onClick={_handleSave} disabled={saving}>
|
||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSettings;
|
||||
72
src/pages/views/workspace/WorkspaceSettingsPage.tsx
Normal file
72
src/pages/views/workspace/WorkspaceSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* WorkspaceSettingsPage -- Tabbed settings for the AI Workspace.
|
||||
*
|
||||
* First tab: Voice / Language (WorkspaceSettings).
|
||||
* Additional tabs can be added here as needed.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||
|
||||
type SettingsTab = 'voice';
|
||||
|
||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||
{ key: 'voice', label: 'Sprache & Stimme' },
|
||||
];
|
||||
|
||||
export const WorkspaceSettingsPage: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('voice');
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||
Keine Workspace-Instanz ausgewaehlt.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<nav style={{
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #fafafa)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.key
|
||||
? '2px solid var(--primary-color, #1976d2)'
|
||||
: '2px solid transparent',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: activeTab === tab.key ? 600 : 400,
|
||||
color: activeTab === tab.key
|
||||
? 'var(--primary-color, #1976d2)'
|
||||
: 'var(--text-secondary, #888)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||
{activeTab === 'voice' && (
|
||||
<WorkspaceSettings instanceId={instanceId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSettingsPage;
|
||||
533
src/pages/views/workspace/useWorkspace.ts
Normal file
533
src/pages/views/workspace/useWorkspace.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -128,25 +128,6 @@ export const PlaygroundPage: React.FC = () => {
|
|||
}
|
||||
}, [urlWorkflowId, onWorkflowSelect]);
|
||||
|
||||
// Format bytes helper
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (!bytes || bytes < 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kbytes = bytes / 1024;
|
||||
if (kbytes < 1000) return `${Math.round(kbytes)} kB`;
|
||||
const mbytes = kbytes / 1024;
|
||||
return `${Math.round(mbytes * 10) / 10} MB`;
|
||||
};
|
||||
|
||||
// Format duration helper (for stats)
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (!seconds || seconds < 0) return '0s';
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
// Handle prompt selection
|
||||
const handlePromptSelect = (promptId: string) => {
|
||||
setSelectedPromptId(promptId);
|
||||
|
|
@ -589,22 +570,13 @@ export const PlaygroundPage: React.FC = () => {
|
|||
<div className={styles.headerLeft}>
|
||||
<div className={styles.headerTitleRow}>
|
||||
<h1 className={styles.pageTitle}>Chat Playground</h1>
|
||||
{/* Stats display in header */}
|
||||
<div className={styles.headerStats}>
|
||||
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
|
||||
↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)}
|
||||
</span>
|
||||
{(latestStats?.processingTime ?? 0) > 0 && (
|
||||
<span className={styles.headerStatItem} title="Verarbeitungszeit">
|
||||
⏱️ {formatDuration(latestStats?.processingTime || 0)}
|
||||
</span>
|
||||
)}
|
||||
{(latestStats?.priceUsd ?? 0) > 0 && (
|
||||
{latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
|
||||
<div className={styles.headerStats}>
|
||||
<span className={styles.headerStatItem} title="Kosten">
|
||||
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
|
||||
CHF {latestStats.priceCHF.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -301,6 +301,15 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
{ 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
178
src/utils/sseClient.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue