From 04b6841c517f7efd301244e6d512e4acd3a00041 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 15 Mar 2026 23:38:44 +0100 Subject: [PATCH] new ai agent --- src/config/pageRegistry.tsx | 4 + src/layouts/MainLayout.module.css | 1 + src/layouts/MainLayout.tsx | 10 +- src/pages/FeatureView.tsx | 11 + src/pages/views/workspace/ChatStream.tsx | 392 ++++++++++++++ .../views/workspace/ConversationList.tsx | 256 +++++++++ src/pages/views/workspace/DataSourcePanel.tsx | 436 +++++++++++++++ src/pages/views/workspace/FileBrowser.tsx | 241 +++++++++ src/pages/views/workspace/FilePreview.tsx | 153 ++++++ src/pages/views/workspace/ToolActivityLog.tsx | 83 +++ src/pages/views/workspace/WorkspaceInput.tsx | 409 ++++++++++++++ .../views/workspace/WorkspaceKeepAlive.tsx | 48 ++ src/pages/views/workspace/WorkspacePage.tsx | 284 ++++++++++ src/pages/views/workspace/useWorkspace.ts | 500 ++++++++++++++++++ src/types/mandate.ts | 8 + src/utils/sseClient.ts | 176 ++++++ 16 files changed, 3011 insertions(+), 1 deletion(-) create mode 100644 src/pages/views/workspace/ChatStream.tsx create mode 100644 src/pages/views/workspace/ConversationList.tsx create mode 100644 src/pages/views/workspace/DataSourcePanel.tsx create mode 100644 src/pages/views/workspace/FileBrowser.tsx create mode 100644 src/pages/views/workspace/FilePreview.tsx create mode 100644 src/pages/views/workspace/ToolActivityLog.tsx create mode 100644 src/pages/views/workspace/WorkspaceInput.tsx create mode 100644 src/pages/views/workspace/WorkspaceKeepAlive.tsx create mode 100644 src/pages/views/workspace/WorkspacePage.tsx create mode 100644 src/pages/views/workspace/useWorkspace.ts create mode 100644 src/utils/sseClient.ts diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 902c588..f68c91c 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -116,6 +116,10 @@ export const PAGE_ICONS: Record = { 'page.feature.chatbot.conversations': , 'feature.chatbot': , 'feature.teamsbot': , + + // Feature pages - Workspace + 'page.feature.workspace.dashboard': , + 'feature.workspace': , }; // ============================================================================= diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index 3516819..668aa7c 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -92,6 +92,7 @@ flex: 1; min-width: 0; min-height: 0; + position: relative; /* Let child components handle their own scrolling for sticky headers */ overflow: hidden; background: var(--bg-primary, #ffffff); diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 02ea999..15fb2b3 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom'; import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { UserSection } from '../components/Navigation/UserSection'; +import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'; import styles from './MainLayout.module.css'; +const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+/; + // ============================================================================= // INNER LAYOUT (mit Zugriff auf Store) // ============================================================================= @@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => { className={styles.mobileLogo} /> - + + + +
+ +
); diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index c0a29bc..c7629c2 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -36,6 +36,9 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView // CodeEditor Views import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor'; +// Workspace Views +import { WorkspacePage } from './views/workspace/WorkspacePage'; + // Teamsbot Views import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView'; @@ -137,6 +140,9 @@ const VIEW_COMPONENTS: Record> = { editor: CodeEditorPage, workflows: CodeEditorWorkflowsPage, }, + workspace: { + dashboard: WorkspacePage, + }, teamsbot: { dashboard: TeamsbotDashboardView, sessions: TeamsbotSessionView, @@ -199,6 +205,11 @@ export const FeatureViewPage: React.FC = ({ view }) => { return ; } + // Workspace is rendered persistently by WorkspaceKeepAlive at MainLayout level + if (featureCode === 'workspace') { + return null; + } + // View-Komponente finden const featureViews = VIEW_COMPONENTS[featureCode]; if (!featureViews) { diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx new file mode 100644 index 0000000..8111b22 --- /dev/null +++ b/src/pages/views/workspace/ChatStream.tsx @@ -0,0 +1,392 @@ +/** + * ChatStream -- SSE-driven message display for the workspace. + * + * Renders messages with full Markdown (GFM tables, code blocks with syntax + * highlighting), agent progress indicators, and file edit proposals. + */ + +import React, { useRef, useEffect, useCallback } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import api from '../../../api'; +import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; +import type { AgentProgress, FileEditProposal } from './useWorkspace'; + +interface ChatStreamProps { + messages: Message[]; + agentProgress: AgentProgress | null; + isProcessing: boolean; + pendingEdits: FileEditProposal[]; + onAcceptEdit: (editId: string) => void; + onRejectEdit: (editId: string) => void; +} + +export const ChatStream: React.FC = ({ + messages, + agentProgress, + isProcessing, + pendingEdits, + onAcceptEdit, + onRejectEdit, +}) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, agentProgress]); + + return ( +
+ {messages.map((msg) => ( +
+ {msg.role === 'assistant' && ( +
Assistant
+ )} + {msg.role === 'status' ? ( + {msg.message} + ) : ( +
+ {msg.message && ( + ( +
+ + {children} +
+
+ ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {msg.message} +
+ )} + {msg.documents && msg.documents.length > 0 && ( +
+ {msg.documents.map((doc) => ( + <_FileCard key={doc.id || doc.fileId} doc={doc} /> + ))} +
+ )} +
+ )} +
+ ))} + + {/* File edit proposals */} + {pendingEdits.filter(e => e.status === 'pending').map((edit) => ( +
+
+ + File Edit Proposal: {edit.fileName} +
+
+            {edit.newContent?.slice(0, 800)}
+            {(edit.newContent?.length || 0) > 800 && '\n...'}
+          
+
+ + +
+
+ ))} + + {/* Agent progress */} + {isProcessing && agentProgress && ( +
+ + Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} + + {agentProgress.totalToolCalls} tools + {agentProgress.costCHF?.toFixed(4) || '0'} CHF +
+ )} + + {isProcessing && !agentProgress && ( +
+ + Processing... +
+ )} + +
+ + +
+ ); +}; + +function _getBubbleBackground(role: string): string { + switch (role) { + case 'user': return 'var(--primary-light, #e3f2fd)'; + case 'status': return 'var(--status-bg, #fff3e0)'; + case 'system': return 'var(--system-bg, #f5f5f5)'; + default: return 'var(--assistant-bg, #ffffff)'; + } +} + +function _FileCard({ doc }: { doc: MessageDocument }) { + const _handleDownload = useCallback(async () => { + try { + const res = await api.get(`/api/files/${doc.fileId}/download`, { + responseType: 'blob', + }); + const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = doc.fileName || 'download'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Download failed:', err); + } + }, [doc]); + + const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || ''; + const icon = _getFileIcon(ext); + const sizeLabel = doc.fileSize + ? doc.fileSize > 1024 * 1024 + ? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB` + : `${(doc.fileSize / 1024).toFixed(1)} KB` + : ''; + + return ( +
(e.currentTarget.style.background = '#e8f0fe')} + onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')} + > + {icon} +
+
+ {doc.fileName} +
+
+ {ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''} +
+
+ +
+ ); +} + +function _getFileIcon(ext: string): string { + const map: Record = { + pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA', + doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', + md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB', + html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC', + py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD', + png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', + gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F', + zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6', + pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA', + mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5', + mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC', + eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7', + }; + return map[ext] || '\uD83D\uDCC4'; +} + +function _CodeBlock({ + className, + children, + ...props +}: React.HTMLAttributes & { inline?: boolean }) { + const match = /language-(\w+)/.exec(className || ''); + const isInline = !match && !String(children).includes('\n'); + + if (isInline) { + return ( + + {children} + + ); + } + + return ( +
+ {match && ( +
+ {match[1]} +
+ )} +
+        
+          {children}
+        
+      
+
+ ); +} diff --git a/src/pages/views/workspace/ConversationList.tsx b/src/pages/views/workspace/ConversationList.tsx new file mode 100644 index 0000000..bf9c57b --- /dev/null +++ b/src/pages/views/workspace/ConversationList.tsx @@ -0,0 +1,256 @@ +/** + * ConversationList -- Shows all workspace workflows/conversations. + * + * Loads conversations from the workspace API, displays them sorted by + * last activity. Names are auto-generated ("Chat N") and editable inline. + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import api from '../../../api'; + +interface Conversation { + id: string; + name: string; + status: string; + startedAt?: number; + lastActivity?: number; +} + +interface ConversationListProps { + instanceId: string; + activeWorkflowId: string | null; + onSelect: (workflowId: string) => void; +} + +export const ConversationList: React.FC = ({ + instanceId, + activeWorkflowId, + onSelect, +}) => { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [filterQuery, setFilterQuery] = useState(''); + const inputRef = useRef(null); + + const _loadConversations = useCallback(() => { + if (!instanceId) return; + setLoading(true); + api.get(`/api/workspace/${instanceId}/workflows`) + .then(res => { + const items = (res.data.workflows || res.data || []) + .map((w: any) => ({ + id: w.id, + name: w.name || w.label || 'Untitled', + status: w.status || 'unknown', + startedAt: w.startedAt || w.createdAt, + lastActivity: w.lastActivity || w.updatedAt || w.startedAt, + })) + .sort((a: Conversation, b: Conversation) => + (b.lastActivity || 0) - (a.lastActivity || 0), + ); + setConversations(items); + }) + .catch(() => setConversations([])) + .finally(() => setLoading(false)); + }, [instanceId]); + + useEffect(() => { + _loadConversations(); + }, [_loadConversations]); + + useEffect(() => { + if (editingId && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editingId]); + + const _formatTime = (ts?: number): string => { + if (!ts) return ''; + const d = new Date(ts * 1000); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + }; + + const _formatDate = (ts?: number): string => { + if (!ts) return ''; + const d = new Date(ts * 1000); + return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' }) + + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const _startEditing = (conv: Conversation) => { + setEditingId(conv.id); + setEditName(conv.name); + }; + + const _commitRename = (convId: string) => { + const trimmed = editName.trim(); + if (!trimmed) { + setEditingId(null); + return; + } + setConversations(prev => + prev.map(c => c.id === convId ? { ...c, name: trimmed } : c), + ); + setEditingId(null); + api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed }) + .catch(() => _loadConversations()); + }; + + const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => { + if (e.key === 'Enter') { + e.preventDefault(); + _commitRename(convId); + } else if (e.key === 'Escape') { + setEditingId(null); + } + }; + + const _filtered = (items: Conversation[], query: string): Conversation[] => { + if (!query.trim()) return items; + const q = query.toLowerCase(); + return items.filter(c => + c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q), + ); + }; + + return ( +
+
+ Conversations + +
+ + {conversations.length > 0 && ( + setFilterQuery(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, + border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', + }} + /> + )} + + {conversations.length === 0 && !loading && ( +
+ No conversations yet. Send a message to start. +
+ )} + + {_filtered(conversations, filterQuery).map(conv => { + const isActive = conv.id === activeWorkflowId; + const isEditing = editingId === conv.id; + return ( +
{ if (!isEditing) onSelect(conv.id); }} + style={{ + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + cursor: isEditing ? 'default' : 'pointer', + background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent', + border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent', + transition: 'background 0.15s', + }} + onMouseEnter={e => { + if (!isActive) e.currentTarget.style.background = '#f5f5f5'; + }} + onMouseLeave={e => { + if (!isActive) e.currentTarget.style.background = 'transparent'; + }} + > + {/* Name row */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={() => _commitRename(conv.id)} + onKeyDown={e => _handleKeyDown(e, conv.id)} + onClick={e => e.stopPropagation()} + style={{ + flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, + padding: '1px 4px', borderRadius: 3, + border: '1px solid var(--primary-color, #1976d2)', + outline: 'none', background: '#fff', + }} + /> + ) : ( + { e.stopPropagation(); _startEditing(conv); }} + title="Double-click to rename" + > + {conv.name} + + )} + {!isEditing && ( + + )} +
+ + {/* Status + last activity */} +
+ + {conv.status === 'active' && ( + {'\u25CF'} active + )} + {conv.status === 'completed' && ( + {'\u25CF'} completed + )} + {conv.status !== 'active' && conv.status !== 'completed' && ( + {conv.status} + )} + + + {_formatTime(conv.lastActivity)} + +
+
+ ); + })} +
+ ); +}; diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx new file mode 100644 index 0000000..4cea053 --- /dev/null +++ b/src/pages/views/workspace/DataSourcePanel.tsx @@ -0,0 +1,436 @@ +/** + * DataSourcePanel -- Browse external data sources as a lazy-loading tree. + * + * Tree structure: + * UserConnection (Level 1, loaded on mount) + * └─ Service (Level 2, loaded when connection expanded) + * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) + * + * Each folder node can be added as a DataSource for this workspace instance. + */ + +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import api from '../../../api'; +import type { DataSource } from './useWorkspace'; + +/* ─── Types ─────────────────────────────────────────────────────────── */ + +interface TreeNode { + key: string; + label: string; + icon: string; + type: 'connection' | 'service' | 'folder' | 'file'; + expanded: boolean; + loading: boolean; + children: TreeNode[] | null; + connectionId: string; + service?: string; + path?: string; + authority?: string; +} + +interface DataSourcePanelProps { + instanceId: string; + dataSources: DataSource[]; + onRefresh: () => void; +} + +/* ─── Icons ─────────────────────────────────────────────────────────── */ + +const _AUTHORITY_ICONS: Record = { + msft: '\uD83D\uDFE6', + google: '\uD83D\uDFE9', + 'local:ftp': '\uD83D\uDD17', + 'local:jira': '\uD83D\uDD27', +}; + +const _SERVICE_ICONS: Record = { + sharepoint: '\uD83D\uDCC1', + onedrive: '\u2601\uFE0F', + outlook: '\uD83D\uDCE7', + teams: '\uD83D\uDCAC', + drive: '\uD83D\uDCC2', + gmail: '\uD83D\uDCE8', + files: '\uD83D\uDCC2', +}; + +/* ─── Component ─────────────────────────────────────────────────────── */ + +export const DataSourcePanel: React.FC = ({ + instanceId, + dataSources, + onRefresh, +}) => { + const [tree, setTree] = useState([]); + const [loadingRoot, setLoadingRoot] = useState(false); + const [addingPath, setAddingPath] = useState(null); + const mountedRef = useRef(true); + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); + + /* ── Load Level 1: UserConnections ── */ + const _loadConnections = useCallback(() => { + if (!instanceId) return; + setLoadingRoot(true); + api.get(`/api/workspace/${instanceId}/connections`) + .then(res => { + if (!mountedRef.current) return; + const conns = res.data.connections || []; + const nodes: TreeNode[] = conns + .filter((c: any) => c.status === 'active') + .map((c: any) => ({ + key: `conn-${c.id}`, + label: c.externalEmail || c.externalUsername || c.authority, + icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', + type: 'connection' as const, + expanded: false, + loading: false, + children: null, + connectionId: c.id, + authority: c.authority, + })); + setTree(nodes); + }) + .catch(() => { if (mountedRef.current) setTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); + }, [instanceId]); + + useEffect(() => { _loadConnections(); }, [_loadConnections]); + + /* ── Generic tree update helper ── */ + const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { + setTree(prev => _mapTree(prev, key, updater)); + }, []); + + /* ── Toggle expand/collapse ── */ + const _toggleNode = useCallback(async (node: TreeNode) => { + if (node.expanded) { + _updateNode(node.key, n => ({ ...n, expanded: false })); + return; + } + + if (node.children !== null) { + _updateNode(node.key, n => ({ ...n, expanded: true })); + return; + } + + _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); + + try { + let children: TreeNode[] = []; + + if (node.type === 'connection') { + children = await _loadServices(instanceId, node.connectionId); + } else if (node.type === 'service' || node.type === 'folder') { + children = await _browseService(instanceId, node.connectionId, node.service!, node.path || '/'); + } + + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children })); + } + } catch { + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); + } + } + }, [instanceId, _updateNode]); + + /* ── Add as DataSource ── */ + const _addAsDataSource = useCallback(async (node: TreeNode) => { + if (!node.service || !node.connectionId) return; + setAddingPath(node.key); + try { + const sourceTypeMap: Record = { + sharepoint: 'sharepointFolder', + onedrive: 'onedriveFolder', + outlook: 'outlookFolder', + drive: 'googleDriveFolder', + gmail: 'gmailFolder', + files: 'ftpFolder', + }; + await api.post(`/api/workspace/${instanceId}/datasources`, { + connectionId: node.connectionId, + sourceType: sourceTypeMap[node.service] || node.service, + path: node.path || '/', + label: node.label, + }); + onRefresh(); + } catch (err) { + console.error('Failed to add data source:', err); + } finally { + if (mountedRef.current) setAddingPath(null); + } + }, [instanceId, onRefresh]); + + /* ── Remove DataSource ── */ + const _removeDatasource = useCallback(async (dsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); + onRefresh(); + } catch (err) { + console.error('Failed to remove data source:', err); + } + }, [instanceId, onRefresh]); + + /* ── Check if a path is already added ── */ + const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => { + return dataSources.some(ds => + ds.connectionId === connectionId && ds.path === (path || '/'), + ); + }, [dataSources]); + + return ( +
+ {/* Active DataSources */} + {dataSources.length > 0 && ( +
+
+ Active Sources +
+ {dataSources.map(ds => ( +
+ {'\u25CF'} + + {ds.label} + + +
+ ))} +
+
+ )} + + {/* Tree header */} +
+ + Browse Sources + + +
+ + {/* Tree */} + {loadingRoot && tree.length === 0 && ( +
+ Loading connections... +
+ )} + + {!loadingRoot && tree.length === 0 && ( +
+ No active connections found. +
+ )} + + {tree.map(node => ( + <_TreeNodeView + key={node.key} + node={node} + depth={0} + onToggle={_toggleNode} + onAdd={_addAsDataSource} + isAdded={_isAdded} + addingPath={addingPath} + /> + ))} +
+ ); +}; + +/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */ + +interface TreeNodeViewProps { + node: TreeNode; + depth: number; + onToggle: (node: TreeNode) => void; + onAdd: (node: TreeNode) => void; + isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; + addingPath: string | null; +} + +const _TreeNodeView: React.FC = ({ + node, depth, onToggle, onAdd, isAdded, addingPath, +}) => { + const [hovered, setHovered] = useState(false); + const hasChildren = node.type !== 'file'; + const chevron = hasChildren + ? (node.expanded ? '\u25BE' : '\u25B8') + : '\u00A0\u00A0'; + const canAdd = node.type === 'folder' || node.type === 'service'; + const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); + const isAdding = addingPath === node.key; + + return ( +
+
{ if (hasChildren) onToggle(node); }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + paddingLeft: depth * 16 + 4, + paddingRight: 4, + paddingTop: 3, + paddingBottom: 3, + cursor: hasChildren ? 'pointer' : 'default', + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', + userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + {node.icon} + + {node.label} + + {canAdd && hovered && !alreadyAdded && ( + + )} + {canAdd && alreadyAdded && ( + + {'\u2713'} + + )} +
+ + {/* Children */} + {node.expanded && node.children && node.children.length > 0 && ( +
+ {node.children.map(child => ( + <_TreeNodeView + key={child.key} + node={child} + depth={depth + 1} + onToggle={onToggle} + onAdd={onAdd} + isAdded={isAdded} + addingPath={addingPath} + /> + ))} +
+ )} + + {node.expanded && node.children && node.children.length === 0 && !node.loading && ( +
+ (empty) +
+ )} +
+ ); +}; + +/* ─── Spinner (inline) ──────────────────────────────────────────────── */ + +function _Spinner(): React.ReactElement { + return ( + + ); +} + +/* ─── Data fetching ─────────────────────────────────────────────────── */ + +async function _loadServices(instanceId: string, connectionId: string): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); + const services = res.data.services || []; + return services.map((s: any) => ({ + key: `svc-${connectionId}-${s.service}`, + label: s.label || s.service, + icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', + type: 'service' as const, + expanded: false, + loading: false, + children: null, + connectionId, + service: s.service, + path: '/', + })); +} + +async function _browseService( + instanceId: string, connectionId: string, service: string, path: string, +): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { + params: { service, path }, + }); + const items = res.data.items || []; + return items.map((entry: any, idx: number) => ({ + key: `item-${connectionId}-${service}-${entry.path || idx}`, + label: entry.name, + icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), + type: entry.isFolder ? 'folder' as const : 'file' as const, + expanded: false, + loading: false, + children: entry.isFolder ? null : [], + connectionId, + service, + path: entry.path, + })); +} + +function _fileIcon(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', + xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', + ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', + txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', + png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', + zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', + mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', + mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', + }; + return map[ext] || '\uD83D\uDCC4'; +} + +/* ─── Tree map utility ──────────────────────────────────────────────── */ + +function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { + return nodes.map(n => { + if (n.key === key) return updater(n); + if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; + return n; + }); +} diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx new file mode 100644 index 0000000..21e5f33 --- /dev/null +++ b/src/pages/views/workspace/FileBrowser.tsx @@ -0,0 +1,241 @@ +/** + * FileBrowser -- Folder + file browser panel with tags, search, and drag-and-drop upload. + */ + +import React, { useState, useCallback, useRef } from 'react'; +import api from '../../../api'; +import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace'; + +interface FileBrowserProps { + instanceId: string; + files: WorkspaceFile[]; + folders: WorkspaceFolder[]; + onRefresh: () => void; + onFileSelect?: (fileId: string) => void; +} + +export const FileBrowser: React.FC = ({ + instanceId, + files, + folders, + onRefresh, + onFileSelect, +}) => { + const [currentFolderId, setCurrentFolderId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const currentFolders = folders.filter(f => + currentFolderId ? f.parentId === currentFolderId : !f.parentId, + ); + const currentFiles = files.filter(f => { + const inFolder = currentFolderId ? f.folderId === currentFolderId : !f.folderId; + const matchesSearch = !searchQuery + || f.fileName.toLowerCase().includes(searchQuery.toLowerCase()) + || (f.tags || []).some(t => t.toLowerCase().includes(searchQuery.toLowerCase())); + return inFolder && matchesSearch; + }); + + const _navigateUp = () => { + if (!currentFolderId) return; + const folder = folders.find(f => f.id === currentFolderId); + setCurrentFolderId(folder?.parentId || null); + }; + + const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { + if (!instanceId || uploading) return; + setUploading(true); + try { + for (const file of Array.from(fileList)) { + const formData = new FormData(); + formData.append('file', file); + if (currentFolderId) { + formData.append('folderId', currentFolderId); + } + await api.post('/api/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + onRefresh(); + } catch (err) { + console.error('File upload failed:', err); + } finally { + setUploading(false); + } + }, [instanceId, uploading, currentFolderId, onRefresh]); + + const _handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const _handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const _handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + if (e.dataTransfer.files.length > 0) { + _uploadFiles(e.dataTransfer.files); + } + }, [_uploadFiles]); + + const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + _uploadFiles(e.target.files); + e.target.value = ''; + } + }, [_uploadFiles]); + + return ( +
+ {/* Drag overlay */} + {isDragOver && ( +
+ Drop files to upload +
+ )} + + {/* Header */} +
+ Files +
+ + +
+
+ + + + {/* Search */} + setSearchQuery(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, + border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', + }} + /> + + {/* Breadcrumb */} + {currentFolderId && ( +
+ ← Back +
+ )} + + {/* Folders */} + {currentFolders.map(folder => ( +
setCurrentFolderId(folder.id)} + style={{ + padding: '6px 8px', cursor: 'pointer', fontSize: 13, + display: 'flex', alignItems: 'center', gap: 6, + borderRadius: 4, + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={e => (e.currentTarget.style.background = '')} + > + 📁 + {folder.name} +
+ ))} + + {/* Files */} + {currentFiles.map(file => ( +
onFileSelect?.(file.id)} + style={{ + padding: '6px 8px', fontSize: 13, + display: 'flex', alignItems: 'center', gap: 6, + borderRadius: 4, + cursor: onFileSelect ? 'pointer' : 'default', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={e => (e.currentTarget.style.background = '')} + > + {_fileIcon(file.mimeType)} +
+
+ {file.fileName} +
+ {file.tags && file.tags.length > 0 && ( +
+ {file.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ + {(file.fileSize / 1024).toFixed(0)}K + +
+ ))} + + {currentFiles.length === 0 && currentFolders.length === 0 && ( +
+ {searchQuery ? 'No files match your search' : 'No files. Drag & drop to upload.'} +
+ )} +
+ ); +}; + +function _fileIcon(mime: string): string { + if (mime.startsWith('image/')) return '🖼️'; + if (mime.includes('pdf')) return '📕'; + if (mime.includes('word') || mime.includes('docx')) return '📘'; + if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '📊'; + if (mime.includes('presentation') || mime.includes('pptx')) return '📙'; + if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '📦'; + if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '📝'; + return '📄'; +} diff --git a/src/pages/views/workspace/FilePreview.tsx b/src/pages/views/workspace/FilePreview.tsx new file mode 100644 index 0000000..20ec03a --- /dev/null +++ b/src/pages/views/workspace/FilePreview.tsx @@ -0,0 +1,153 @@ +/** + * FilePreview -- File preview / editor panel in the right sidebar. + * + * Displays content preview for selected files based on their MIME type: + * - Text files: rendered as text with optional editing + * - Images: rendered as preview + * - PDFs: link to download + * - Other: metadata display + */ + +import React, { useState, useEffect } from 'react'; +import api from '../../../api'; +import type { WorkspaceFile } from './useWorkspace'; + +interface FilePreviewProps { + instanceId: string; + fileId: string | null; + files: WorkspaceFile[]; +} + +export const FilePreview: React.FC = ({ instanceId, fileId, files }) => { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + + const file = fileId ? files.find(f => f.id === fileId) : null; + + useEffect(() => { + setContent(null); + setPreviewUrl(null); + if (!file || !instanceId) return; + + const isText = _isTextMime(file.mimeType); + const isImage = file.mimeType.startsWith('image/'); + + if (isText && file.fileSize < 500_000) { + setLoading(true); + api.get(`/api/files/${file.id}/download`, { responseType: 'text' }) + .then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2))) + .catch(() => setContent(null)) + .finally(() => setLoading(false)); + } else if (isImage) { + const baseUrl = api.defaults.baseURL || ''; + setPreviewUrl(`${baseUrl}/api/files/${file.id}/download`); + } + }, [file, instanceId]); + + if (!file) { + return ( +
+ Select a file to preview +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {file.fileName} +
+
+ {file.mimeType} + {_formatFileSize(file.fileSize)} + {file.status && {file.status}} +
+ {file.description && ( +
{file.description}
+ )} + {file.tags && file.tags.length > 0 && ( +
+ {file.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ + {/* Content area */} +
+ {loading && ( +
Loading...
+ )} + + {content !== null && !loading && ( +
+            {content}
+          
+ )} + + {previewUrl && ( +
+ {file.fileName} setPreviewUrl(null)} + /> +
+ )} + + {!loading && content === null && !previewUrl && ( +
+ {file.fileSize > 500_000 + ? 'File too large for inline preview' + : `No preview available for ${file.mimeType}`} +
+ )} +
+
+ ); +}; + +function _isTextMime(mime: string): boolean { + if (mime.startsWith('text/')) return true; + const textTypes = [ + 'application/json', + 'application/xml', + 'application/javascript', + 'application/typescript', + 'application/x-python', + 'application/x-yaml', + 'application/yaml', + 'application/sql', + 'application/csv', + ]; + return textTypes.includes(mime); +} + +function _formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/pages/views/workspace/ToolActivityLog.tsx b/src/pages/views/workspace/ToolActivityLog.tsx new file mode 100644 index 0000000..3ee1130 --- /dev/null +++ b/src/pages/views/workspace/ToolActivityLog.tsx @@ -0,0 +1,83 @@ +/** + * ToolActivityLog -- Real-time tool call activity display. + */ + +import React from 'react'; +import type { ToolActivity } from './useWorkspace'; + +interface ToolActivityLogProps { + activities: ToolActivity[]; +} + +export const ToolActivityLog: React.FC = ({ activities }) => { + if (!activities.length) { + return ( +
+ No tool activity yet +
+ ); + } + + return ( +
+ {activities.map(activity => ( +
+
+ {activity.toolName} + + {activity.status} + +
+ {activity.args && Object.keys(activity.args).length > 0 && ( +
+ {Object.entries(activity.args) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`) + .join(', ')} +
+ )} + {activity.result && ( +
+ {activity.result.slice(0, 200)} + {activity.result.length > 200 && '...'} +
+ )} + {activity.error && ( +
+ {activity.error} +
+ )} +
+ ))} +
+ ); +}; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx new file mode 100644 index 0000000..f91f450 --- /dev/null +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -0,0 +1,409 @@ +/** + * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, + * voice toggle, and data source selection. + */ + +import React, { useState, useCallback, useRef } from 'react'; +import { ProviderMultiSelect } from '../../../components/ProviderSelector'; +import type { WorkspaceFile, DataSource } from './useWorkspace'; + +interface PendingFile { + fileId: string; + fileName: string; +} + +interface WorkspaceInputProps { + instanceId: string; + onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[]) => void; + isProcessing: boolean; + onStop: () => void; + files: WorkspaceFile[]; + dataSources: DataSource[]; + pendingFiles?: PendingFile[]; + onRemovePendingFile?: (fileId: string) => void; + onFileUploadClick?: () => void; + uploading?: boolean; + selectedProviders?: string[]; + onProvidersChange?: (providers: string[]) => void; +} + +export const WorkspaceInput: React.FC = ({ + instanceId, + onSend, + isProcessing, + onStop, + files, + dataSources, + pendingFiles = [], + onRemovePendingFile, + onFileUploadClick, + uploading = false, + selectedProviders = [], + onProvidersChange, +}) => { + const [prompt, setPrompt] = useState(''); + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteFilter, setAutocompleteFilter] = useState(''); + const [voiceActive, setVoiceActive] = useState(false); + const [attachedFileIds, setAttachedFileIds] = useState([]); + const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); + const textareaRef = useRef(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + + const _extractFileRefs = useCallback( + (text: string): string[] => { + const pattern = /@([\w.\-]+)/g; + const matched: string[] = []; + let match; + while ((match = pattern.exec(text)) !== null) { + const ref = match[1]; + const file = files.find( + f => f.fileName === ref || f.fileName.toLowerCase() === ref.toLowerCase(), + ); + if (file && !matched.includes(file.id)) { + matched.push(file.id); + } + } + return matched; + }, + [files], + ); + + const _handleSend = useCallback(() => { + const trimmed = prompt.trim(); + if (!trimmed || isProcessing) return; + const inlineFileIds = _extractFileRefs(trimmed); + const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; + onSend(trimmed, allFileIds, attachedDataSourceIds); + setPrompt(''); + setShowAutocomplete(false); + setAttachedFileIds([]); + setAttachedDataSourceIds([]); + }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, onSend]); + + const _handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + _handleSend(); + } + }, + [_handleSend], + ); + + const _handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setPrompt(value); + const cursorPos = e.target.selectionStart; + const textBeforeCursor = value.slice(0, cursorPos); + const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/); + if (atMatch) { + setAutocompleteFilter(atMatch[1].toLowerCase()); + setShowAutocomplete(true); + } else { + setShowAutocomplete(false); + } + }, + [], + ); + + const _insertFileRef = useCallback( + (fileName: string) => { + const textarea = textareaRef.current; + if (!textarea) return; + const cursorPos = textarea.selectionStart; + const textBefore = prompt.slice(0, cursorPos); + const textAfter = prompt.slice(cursorPos); + const atStart = textBefore.lastIndexOf('@'); + const newText = textBefore.slice(0, atStart) + `@${fileName} ` + textAfter; + setPrompt(newText); + setShowAutocomplete(false); + textarea.focus(); + }, + [prompt], + ); + + const _removeAttachedFile = useCallback((fileId: string) => { + setAttachedFileIds(prev => prev.filter(id => id !== fileId)); + }, []); + + const _removeAttachedDataSource = useCallback((dsId: string) => { + setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId)); + }, []); + + const _toggleVoice = useCallback(async () => { + if (voiceActive) { + mediaRecorderRef.current?.stop(); + setVoiceActive(false); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + recorder.ondataavailable = (e) => chunksRef.current.push(e.data); + recorder.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); + try { + const formData = new FormData(); + formData.append('audio', blob, 'voice.webm'); + const res = await fetch(`/api/workspace/${instanceId}/voice/transcribe`, { + method: 'POST', + body: formData, + }); + const data = await res.json(); + if (data.text) { + setPrompt(prev => prev + (prev ? ' ' : '') + data.text); + } + } catch (err) { + console.error('Voice transcription failed:', err); + } + }; + recorder.start(); + mediaRecorderRef.current = recorder; + setVoiceActive(true); + } catch (err) { + console.error('Microphone access denied:', err); + } + }, [voiceActive, instanceId]); + + const filteredFiles = showAutocomplete + ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) + : []; + + const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0; + + return ( +
+ {/* Pending uploaded files */} + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map(pf => ( + + 📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName} + {onRemovePendingFile && ( + + )} + + ))} +
+ )} + + {/* Attachment bar */} + {hasAttachments && ( +
+ {attachedFileIds.map(fId => { + const file = files.find(f => f.id === fId); + return ( + + 📄 {file?.fileName || fId} + + + ); + })} + {attachedDataSourceIds.map(dsId => { + const ds = dataSources.find(d => d.id === dsId); + return ( + + 🔗 {ds?.label || dsId} + + + ); + })} +
+ )} + + {/* Autocomplete dropdown */} + {showAutocomplete && filteredFiles.length > 0 && ( +
+ {filteredFiles.slice(0, 10).map(f => ( +
_insertFileRef(f.fileName)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + fontSize: 13, + borderBottom: '1px solid #f0f0f0', + }} + onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={e => (e.currentTarget.style.background = '')} + > + @{f.fileName} + + {f.mimeType} · {(f.fileSize / 1024).toFixed(1)}KB + +
+ ))} +
+ )} + + {/* Main input row */} +
+