new ai agent

This commit is contained in:
ValueOn AG 2026-03-15 23:38:44 +01:00
parent 869d1f24c3
commit 04b6841c51
16 changed files with 3011 additions and 1 deletions

View file

@ -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 />,
};
// =============================================================================

View file

@ -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);

View file

@ -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}
/>
</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>
);

View file

@ -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<string, Record<string, ViewComponent>> = {
editor: CodeEditorPage,
workflows: CodeEditorWorkflowsPage,
},
workspace: {
dashboard: WorkspacePage,
},
teamsbot: {
dashboard: TeamsbotDashboardView,
sessions: TeamsbotSessionView,
@ -199,6 +205,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <AccessDenied />;
}
// Workspace is rendered persistently by WorkspaceKeepAlive at MainLayout level
if (featureCode === 'workspace') {
return null;
}
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {

View file

@ -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<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),
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>
)}
</div>
)}
</div>
))}
{/* File edit proposals */}
{pendingEdits.filter(e => e.status === 'pending').map((edit) => (
<div
key={edit.id}
style={{
flexShrink: 0,
padding: 12,
borderRadius: 8,
border: '1px solid var(--warning-color, #ff9800)',
background: 'var(--edit-bg, #fff8e1)',
alignSelf: 'flex-start',
maxWidth: '85%',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#ff9800' }}></span>
File Edit Proposal: {edit.fileName}
</div>
<pre style={{
fontSize: 12,
maxHeight: 160,
overflow: 'auto',
margin: 0,
padding: 8,
background: '#1e1e1e',
color: '#d4d4d4',
borderRadius: 4,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{edit.newContent?.slice(0, 800)}
{(edit.newContent?.length || 0) > 800 && '\n...'}
</pre>
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
<button
onClick={() => onAcceptEdit(edit.id)}
style={{
padding: '4px 14px', borderRadius: 4, border: 'none',
background: 'var(--success-color, #4caf50)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Accept
</button>
<button
onClick={() => onRejectEdit(edit.id)}
style={{
padding: '4px 14px', borderRadius: 4,
border: '1px solid var(--border-color, #ccc)',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}
>
Reject
</button>
</div>
</div>
))}
{/* Agent progress */}
{isProcessing && agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
background: 'var(--progress-bg, #e8f5e9)',
border: '1px solid var(--progress-border, #c8e6c9)',
alignSelf: 'flex-start',
display: 'flex', gap: 12, alignItems: 'center',
}}>
<span style={{ fontWeight: 600 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
</span>
<span>{agentProgress.totalToolCalls} tools</span>
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
</div>
)}
{isProcessing && !agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
color: '#666', alignSelf: 'flex-start', fontStyle: 'italic',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span className="workspace-spinner" style={{
display: 'inline-block', width: 12, height: 12,
border: '2px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%', animation: 'workspace-spin 0.8s linear infinite',
}} />
Processing...
</div>
)}
<div ref={bottomRef} />
<style>{`
@keyframes workspace-spin {
to { transform: rotate(360deg); }
}
.workspace-markdown p { margin: 4px 0; }
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
.workspace-markdown blockquote {
margin: 8px 0; padding: 4px 12px;
border-left: 3px solid #ddd; color: #666;
}
.workspace-markdown h1, .workspace-markdown h2, .workspace-markdown h3 {
margin: 8px 0 4px; line-height: 1.3;
}
.workspace-markdown img { max-width: 100%; border-radius: 4px; }
.workspace-markdown hr { border: none; border-top: 1px solid #e0e0e0; margin: 8px 0; }
`}</style>
</div>
);
};
function _getBubbleBackground(role: string): string {
switch (role) {
case 'user': return 'var(--primary-light, #e3f2fd)';
case 'status': return 'var(--status-bg, #fff3e0)';
case 'system': return 'var(--system-bg, #f5f5f5)';
default: return 'var(--assistant-bg, #ffffff)';
}
}
function _FileCard({ doc }: { doc: MessageDocument }) {
const _handleDownload = useCallback(async () => {
try {
const res = await api.get(`/api/files/${doc.fileId}/download`, {
responseType: 'blob',
});
const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.fileName || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
}, [doc]);
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext);
const sizeLabel = doc.fileSize
? doc.fileSize > 1024 * 1024
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
: `${(doc.fileSize / 1024).toFixed(1)} KB`
: '';
return (
<div
onClick={_handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--file-card-bg, #f8f9fa)',
cursor: 'pointer',
transition: 'background 0.15s',
maxWidth: 340,
}}
title={`Download ${doc.fileName}`}
onMouseEnter={e => (e.currentTarget.style.background = '#e8f0fe')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')}
>
<span style={{ fontSize: 22 }}>{icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{doc.fileName}
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
</div>
</div>
<span style={{ fontSize: 14, color: '#1976d2' }} title="Download">&#x2B07;</span>
</div>
);
}
function _getFileIcon(ext: string): string {
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA',
doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB',
html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC',
py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6',
pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC',
eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7',
};
return map[ext] || '\uD83D\uDCC4';
}
function _CodeBlock({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
if (isInline) {
return (
<code
style={{
background: '#f0f0f0',
padding: '1px 5px',
borderRadius: 3,
fontSize: '0.9em',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
}}
{...props}
>
{children}
</code>
);
}
return (
<div style={{ position: 'relative', margin: '8px 0' }}>
{match && (
<div style={{
position: 'absolute', top: 0, right: 0,
padding: '2px 8px', fontSize: 10, color: '#888',
background: '#2d2d2d', borderBottomLeftRadius: 4,
}}>
{match[1]}
</div>
)}
<pre style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: '12px 14px',
borderRadius: 6,
overflow: 'auto',
fontSize: 13,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: 1.5,
margin: 0,
}}>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}

View file

@ -0,0 +1,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<ConversationListProps> = ({
instanceId,
activeWorkflowId,
onSelect,
}) => {
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 inputRef = useRef<HTMLInputElement>(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 (
<div style={{ padding: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
<button
onClick={_loadConversations}
disabled={loading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loading ? '...' : '\u21BB'}
</button>
</div>
{conversations.length > 0 && (
<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',
}}
/>
)}
{conversations.length === 0 && !loading && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No conversations yet. Send a message to start.
</div>
)}
{_filtered(conversations, filterQuery).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',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
{/* 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="Double-click to rename"
>
{conv.name}
</span>
)}
{!isEditing && (
<button
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0,
opacity: 0.6,
}}
title="Rename"
>
&#x270E;
</button>
)}
</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'} active</span>
)}
{conv.status === 'completed' && (
<span style={{ color: '#888' }}>{'\u25CF'} completed</span>
)}
{conv.status !== 'active' && conv.status !== 'completed' && (
<span>{conv.status}</span>
)}
</span>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
</div>
</div>
);
})}
</div>
);
};

View file

@ -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<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',
};
/* ─── 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 => (
<div key={ds.id} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: 'var(--primary-light, #e3f2fd)',
fontSize: 12,
}}>
<span style={{ color: '#4caf50', fontSize: 10 }}>{'\u25CF'}</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="Remove"
>
{'\u2715'}
</button>
</div>
))}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div>
)}
{/* Tree header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Browse Sources
</span>
<button
onClick={_loadConnections}
disabled={loadingRoot}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loadingRoot ? '...' : '\u21BB'}
</button>
</div>
{/* Tree */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading connections...
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No active connections found.
</div>
)}
{tree.map(node => (
<_TreeNodeView
key={node.key}
node={node}
depth={0}
onToggle={_toggleNode}
onAdd={_addAsDataSource}
isAdded={_isAdded}
addingPath={addingPath}
/>
))}
</div>
);
};
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
interface TreeNodeViewProps {
node: TreeNode;
depth: number;
onToggle: (node: TreeNode) => void;
onAdd: (node: TreeNode) => void;
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
addingPath: string | null;
}
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
node, depth, onToggle, onAdd, isAdded, addingPath,
}) => {
const [hovered, setHovered] = useState(false);
const hasChildren = node.type !== 'file';
const chevron = hasChildren
? (node.expanded ? '\u25BE' : '\u25B8')
: '\u00A0\u00A0';
const canAdd = node.type === 'folder' || node.type === 'service';
const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path);
const isAdding = addingPath === node.key;
return (
<div>
<div
onClick={() => { if (hasChildren) onToggle(node); }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
paddingLeft: depth * 16 + 4,
paddingRight: 4,
paddingTop: 3,
paddingBottom: 3,
cursor: hasChildren ? 'pointer' : 'default',
borderRadius: 3,
background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
transition: 'background 0.1s',
userSelect: 'none',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12,
fontWeight: node.type === 'connection' ? 600 : 400,
}}>
{node.label}
</span>
{canAdd && hovered && !alreadyAdded && (
<button
onClick={e => { e.stopPropagation(); onAdd(node); }}
disabled={isAdding}
style={{
background: 'none', border: '1px solid #1976d2', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
fontSize: 10, color: '#1976d2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
title="Add as data source"
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{canAdd && alreadyAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
{'\u2713'}
</span>
)}
</div>
{/* Children */}
{node.expanded && node.children && node.children.length > 0 && (
<div>
{node.children.map(child => (
<_TreeNodeView
key={child.key}
node={child}
depth={depth + 1}
onToggle={onToggle}
onAdd={onAdd}
isAdded={isAdded}
addingPath={addingPath}
/>
))}
</div>
)}
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
(empty)
</div>
)}
</div>
);
};
/* ─── Spinner (inline) ──────────────────────────────────────────────── */
function _Spinner(): React.ReactElement {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Data fetching ─────────────────────────────────────────────────── */
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
const services = res.data.services || [];
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
type: 'service' as const,
expanded: false,
loading: false,
children: null,
connectionId,
service: s.service,
path: '/',
}));
}
async function _browseService(
instanceId: string, connectionId: string, service: string, path: string,
): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
params: { service, path },
});
const items = res.data.items || [];
return items.map((entry: any, idx: number) => ({
key: `item-${connectionId}-${service}-${entry.path || idx}`,
label: entry.name,
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
type: entry.isFolder ? 'folder' as const : 'file' as const,
expanded: false,
loading: false,
children: entry.isFolder ? null : [],
connectionId,
service,
path: entry.path,
}));
}
function _fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
};
return map[ext] || '\uD83D\uDCC4';
}
/* ─── Tree map utility ──────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
return nodes.map(n => {
if (n.key === key) return updater(n);
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
return n;
});
}

View file

@ -0,0 +1,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<FileBrowserProps> = ({
instanceId,
files,
folders,
onRefresh,
onFileSelect,
}) => {
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<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}
>
{/* Drag overlay */}
{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',
}}>
Drop files to upload
</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' }}></button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={_handleFileInputChange}
/>
{/* Search */}
<input
type="text"
placeholder="Search files or tags..."
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',
}}
/>
{/* Breadcrumb */}
{currentFolderId && (
<div
onClick={_navigateUp}
style={{ padding: '4px 8px', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
Back
</div>
)}
{/* Folders */}
{currentFolders.map(folder => (
<div
key={folder.id}
onClick={() => 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 = '')}
>
<span>📁</span>
<span>{folder.name}</span>
</div>
))}
{/* Files */}
{currentFiles.map(file => (
<div
key={file.id}
onClick={() => 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 = '')}
>
<span style={{ fontSize: 11 }}>{_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>
))}
{currentFiles.length === 0 && currentFolders.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'No files match your search' : 'No files. Drag & drop to upload.'}
</div>
)}
</div>
);
};
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 '📄';
}

View file

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

View file

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

View file

@ -0,0 +1,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<WorkspaceInputProps> = ({
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<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
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<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 _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 (
<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>
{onProvidersChange && (
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
excludeByDefault={['privatellm']}
disabled={isProcessing}
/>
)}
<button
onClick={_toggleVoice}
title={voiceActive ? 'Stop recording' : 'Voice input'}
style={{
width: 40, height: 40, borderRadius: 8, border: 'none',
background: voiceActive ? '#f44336' : 'var(--secondary-bg, #f5f5f5)',
color: voiceActive ? '#fff' : '#666',
cursor: 'pointer', fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{voiceActive ? '■' : '🎤'}
</button>
{isProcessing ? (
<button
onClick={onStop}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600,
}}
>
Stop
</button>
) : (
<button
onClick={_handleSend}
disabled={!prompt.trim()}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: prompt.trim() ? 'var(--primary-color, #1976d2)' : '#ccc',
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
}}
>
Send
</button>
)}
</div>
</div>
);
};

View file

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

View file

@ -0,0 +1,284 @@
/**
* 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';
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 [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);
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]);
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: 280,
minWidth: 280,
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<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}
/>
)}
{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>
)}
{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 sidebar */}
{!rightCollapsed && (
<aside style={{
width: 320,
minWidth: 320,
borderLeft: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button style={tabButtonStyle(rightTab === 'activity')} onClick={() => setRightTab('activity')}>Activity</button>
<button style={tabButtonStyle(rightTab === 'preview')} onClick={() => setRightTab('preview')}>Preview</button>
</div>
<button onClick={() => setRightCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{rightTab === 'activity' && (
<ToolActivityLog activities={workspace.toolActivities} />
)}
{rightTab === 'preview' && (
<FilePreview
instanceId={instanceId}
fileId={selectedFileId}
files={workspace.files}
/>
)}
</div>
</aside>
)}
{rightCollapsed && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderLeft: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setRightCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
</div>
);
};
export default WorkspacePage;

View file

@ -0,0 +1,500 @@
/**
* 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;
}
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;
files: WorkspaceFile[];
folders: WorkspaceFolder[];
dataSources: DataSource[];
agentProgress: AgentProgress | null;
toolActivities: ToolActivity[];
pendingEdits: FileEditProposal[];
acceptEdit: (editId: string) => void;
rejectEdit: (editId: string) => void;
workflowId: string | null;
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 [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 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) => {
_handleVoiceResponse(event);
},
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,
files,
folders,
dataSources,
agentProgress,
toolActivities,
pendingEdits,
acceptEdit,
rejectEdit,
workflowId,
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 _handleVoiceResponse(event: SseEvent) {
const audioData = event.item?.audio || event.data?.audio;
if (!audioData) return;
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' });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play().catch(err => console.error('TTS playback failed:', err));
audio.onended = () => URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to decode voice response:', err);
}
}

View file

@ -301,6 +301,14 @@ 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' },
]
},
};
// =============================================================================

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

@ -0,0 +1,176 @@
/**
* 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;
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',
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;
}