new ai agent
This commit is contained in:
parent
869d1f24c3
commit
04b6841c51
16 changed files with 3011 additions and 1 deletions
|
|
@ -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 />,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
/* Let child components handle their own scrolling for sticky headers */
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import { Outlet, useLocation } from 'react-router-dom';
|
|||
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||
import { UserSection } from '../components/Navigation/UserSection';
|
||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||
import styles from './MainLayout.module.css';
|
||||
|
||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+/;
|
||||
|
||||
// =============================================================================
|
||||
// INNER LAYOUT (mit Zugriff auf Store)
|
||||
// =============================================================================
|
||||
|
|
@ -101,7 +104,12 @@ const MainLayoutInner: React.FC = () => {
|
|||
className={styles.mobileLogo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
||||
|
||||
<div style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : 'contents' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,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) {
|
||||
|
|
|
|||
392
src/pages/views/workspace/ChatStream.tsx
Normal file
392
src/pages/views/workspace/ChatStream.tsx
Normal 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">⬇</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>
|
||||
);
|
||||
}
|
||||
256
src/pages/views/workspace/ConversationList.tsx
Normal file
256
src/pages/views/workspace/ConversationList.tsx
Normal 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"
|
||||
>
|
||||
✎
|
||||
</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>
|
||||
);
|
||||
};
|
||||
436
src/pages/views/workspace/DataSourcePanel.tsx
Normal file
436
src/pages/views/workspace/DataSourcePanel.tsx
Normal 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;
|
||||
});
|
||||
}
|
||||
241
src/pages/views/workspace/FileBrowser.tsx
Normal file
241
src/pages/views/workspace/FileBrowser.tsx
Normal 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 '📄';
|
||||
}
|
||||
153
src/pages/views/workspace/FilePreview.tsx
Normal file
153
src/pages/views/workspace/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* FilePreview -- File preview / editor panel in the right sidebar.
|
||||
*
|
||||
* Displays content preview for selected files based on their MIME type:
|
||||
* - Text files: rendered as text with optional editing
|
||||
* - Images: rendered as preview
|
||||
* - PDFs: link to download
|
||||
* - Other: metadata display
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
import type { WorkspaceFile } from './useWorkspace';
|
||||
|
||||
interface FilePreviewProps {
|
||||
instanceId: string;
|
||||
fileId: string | null;
|
||||
files: WorkspaceFile[];
|
||||
}
|
||||
|
||||
export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, files }) => {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const file = fileId ? files.find(f => f.id === fileId) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setContent(null);
|
||||
setPreviewUrl(null);
|
||||
if (!file || !instanceId) return;
|
||||
|
||||
const isText = _isTextMime(file.mimeType);
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
|
||||
if (isText && file.fileSize < 500_000) {
|
||||
setLoading(true);
|
||||
api.get(`/api/files/${file.id}/download`, { responseType: 'text' })
|
||||
.then(res => setContent(typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)))
|
||||
.catch(() => setContent(null))
|
||||
.finally(() => setLoading(false));
|
||||
} else if (isImage) {
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
setPreviewUrl(`${baseUrl}/api/files/${file.id}/download`);
|
||||
}
|
||||
}, [file, instanceId]);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#999', fontSize: 13 }}>
|
||||
Select a file to preview
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{file.fileName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
||||
<span>{file.mimeType}</span>
|
||||
<span>{_formatFileSize(file.fileSize)}</span>
|
||||
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
||||
</div>
|
||||
{file.description && (
|
||||
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>{file.description}</div>
|
||||
)}
|
||||
{file.tags && file.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
{file.tags.map(tag => (
|
||||
<span key={tag} style={{ fontSize: 10, padding: '1px 6px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>Loading...</div>
|
||||
)}
|
||||
|
||||
{content !== null && !loading && (
|
||||
<pre style={{
|
||||
fontSize: 12,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.5,
|
||||
padding: 8,
|
||||
background: '#f8f9fa',
|
||||
borderRadius: 4,
|
||||
margin: 0,
|
||||
maxHeight: '100%',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{previewUrl && (
|
||||
<div style={{ textAlign: 'center', padding: 8 }}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.fileName}
|
||||
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 4, objectFit: 'contain' }}
|
||||
onError={() => setPreviewUrl(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && content === null && !previewUrl && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
{file.fileSize > 500_000
|
||||
? 'File too large for inline preview'
|
||||
: `No preview available for ${file.mimeType}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function _isTextMime(mime: string): boolean {
|
||||
if (mime.startsWith('text/')) return true;
|
||||
const textTypes = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/javascript',
|
||||
'application/typescript',
|
||||
'application/x-python',
|
||||
'application/x-yaml',
|
||||
'application/yaml',
|
||||
'application/sql',
|
||||
'application/csv',
|
||||
];
|
||||
return textTypes.includes(mime);
|
||||
}
|
||||
|
||||
function _formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
83
src/pages/views/workspace/ToolActivityLog.tsx
Normal file
83
src/pages/views/workspace/ToolActivityLog.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* ToolActivityLog -- Real-time tool call activity display.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ToolActivity } from './useWorkspace';
|
||||
|
||||
interface ToolActivityLogProps {
|
||||
activities: ToolActivity[];
|
||||
}
|
||||
|
||||
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
|
||||
if (!activities.length) {
|
||||
return (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
No tool activity yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
{activities.map(activity => (
|
||||
<div
|
||||
key={activity.id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
border: `1px solid ${
|
||||
activity.status === 'calling'
|
||||
? '#ffc107'
|
||||
: activity.status === 'success'
|
||||
? '#4caf50'
|
||||
: '#f44336'
|
||||
}30`,
|
||||
background: activity.status === 'calling'
|
||||
? '#fff8e1'
|
||||
: activity.status === 'success'
|
||||
? '#e8f5e9'
|
||||
: '#ffebee',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600 }}>{activity.toolName}</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
background: activity.status === 'calling'
|
||||
? '#ffc107'
|
||||
: activity.status === 'success'
|
||||
? '#4caf50'
|
||||
: '#f44336',
|
||||
color: '#fff',
|
||||
}}>
|
||||
{activity.status}
|
||||
</span>
|
||||
</div>
|
||||
{activity.args && Object.keys(activity.args).length > 0 && (
|
||||
<div style={{ marginTop: 4, color: '#666', fontSize: 11 }}>
|
||||
{Object.entries(activity.args)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{activity.result && (
|
||||
<div style={{ marginTop: 4, color: '#388e3c', fontSize: 11, maxHeight: 60, overflow: 'hidden' }}>
|
||||
{activity.result.slice(0, 200)}
|
||||
{activity.result.length > 200 && '...'}
|
||||
</div>
|
||||
)}
|
||||
{activity.error && (
|
||||
<div style={{ marginTop: 4, color: '#c62828', fontSize: 11 }}>
|
||||
{activity.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
409
src/pages/views/workspace/WorkspaceInput.tsx
Normal file
409
src/pages/views/workspace/WorkspaceInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/pages/views/workspace/WorkspaceKeepAlive.tsx
Normal file
48
src/pages/views/workspace/WorkspaceKeepAlive.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* WorkspaceKeepAlive
|
||||
*
|
||||
* Renders the WorkspacePage permanently at the MainLayout level so it
|
||||
* survives route changes. Visibility is toggled via CSS `display`
|
||||
* instead of mount / unmount, preserving messages, SSE connections,
|
||||
* files, and all other workspace state.
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { WorkspacePage } from './WorkspacePage';
|
||||
|
||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
|
||||
|
||||
interface WorkspaceKeepAliveProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
|
||||
const location = useLocation();
|
||||
const cachedInstanceIdRef = useRef<string>('');
|
||||
|
||||
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
|
||||
if (match?.[2]) {
|
||||
cachedInstanceIdRef.current = match[2];
|
||||
}
|
||||
|
||||
const instanceId = cachedInstanceIdRef.current;
|
||||
if (!instanceId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: isVisible ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<WorkspacePage persistentInstanceId={instanceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
284
src/pages/views/workspace/WorkspacePage.tsx
Normal file
284
src/pages/views/workspace/WorkspacePage.tsx
Normal 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;
|
||||
500
src/pages/views/workspace/useWorkspace.ts
Normal file
500
src/pages/views/workspace/useWorkspace.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
176
src/utils/sseClient.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue