mono/packages/ui/src/modules/ai/components/ChatSidebar.tsx
2026-03-21 20:18:25 +01:00

306 lines
15 KiB
TypeScript

/**
* ChatSidebar — settings panel with collapsible sections for
* sessions, provider, system prompt, tools, stats, and logs.
*/
import React, { useState, useCallback } from 'react';
import { Plus, X, Copy, Check, Braces } from 'lucide-react';
import { T, translate } from '@/i18n';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ProviderSelector } from '@/components/filters/ProviderSelector';
import CollapsibleSection from '@/components/CollapsibleSection';
import ChatLogBrowser, { CompactTreeView } from '@/components/ChatLogBrowser';
import FileBrowser from '@/apps/filebrowser/FileBrowser';
import ToolSections from './ToolSections';
import type { ChatMessage, FileContext } from '../types';
import type { INode } from '@/modules/storage/types';
import { getMimeCategory } from '@/modules/storage/helpers';
import type { LogEntry } from '@/contexts/LogContext';
import type { ChatSession } from '../chatSessions';
interface ChatSidebarProps {
provider: string;
model: string;
onProviderChange: (p: string) => void;
onModelChange: (m: string) => void;
systemPrompt: string;
onSystemPromptChange: (v: string) => void;
toolsEnabled: boolean;
onToolsEnabledChange: (v: boolean) => void;
pageToolsEnabled: boolean;
onPageToolsEnabledChange: (v: boolean) => void;
imageToolsEnabled: boolean;
onImageToolsEnabledChange: (v: boolean) => void;
imageModel: string;
onImageModelChange: (model: string) => void;
vfsToolsEnabled: boolean;
onVfsToolsEnabledChange: (v: boolean) => void;
webSearchEnabled: boolean;
onWebSearchEnabledChange: (v: boolean) => void;
extraToggles?: React.ReactNode;
isGenerating: boolean;
messages: ChatMessage[];
user: any;
chatLogs: LogEntry[];
onClearLogs: () => void;
lastApiMessages: any[];
// Sessions
sessionId: string;
sessions: Omit<ChatSession, 'messages'>[];
onNewSession: () => void;
onLoadSession: (id: string) => void;
onDeleteSession: (id: string) => void;
// File context
fileContexts?: FileContext[];
addFileContext?: (path: string, mount?: string) => void;
removeFileContext?: (path: string) => void;
}
const ChatSidebar: React.FC<ChatSidebarProps> = ({
provider, model, onProviderChange, onModelChange,
systemPrompt, onSystemPromptChange,
toolsEnabled, onToolsEnabledChange,
pageToolsEnabled, onPageToolsEnabledChange,
imageToolsEnabled, onImageToolsEnabledChange,
imageModel, onImageModelChange,
vfsToolsEnabled, onVfsToolsEnabledChange,
webSearchEnabled, onWebSearchEnabledChange,
extraToggles,
isGenerating, messages, user, chatLogs, onClearLogs,
lastApiMessages,
sessionId, sessions, onNewSession, onLoadSession, onDeleteSession,
fileContexts, addFileContext, removeFileContext,
}) => {
const [copied, setCopied] = useState(false);
const [copiedLogs, setCopiedLogs] = useState(false);
const [selectedNode, setSelectedNode] = useState<INode | null>(null);
const [selectedMount, setSelectedMount] = useState<string>('home');
const handleFileSelect = useCallback((node: INode | null, mount?: string) => {
setSelectedNode(node);
if (mount) setSelectedMount(mount);
}, []);
const handleCopyPayload = useCallback(() => {
if (!lastApiMessages) return;
navigator.clipboard.writeText(JSON.stringify(lastApiMessages, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [lastApiMessages]);
const handleCopyLogs = useCallback(() => {
if (!chatLogs.length) return;
const logsJson = JSON.stringify(chatLogs.map(l => ({
level: l.level,
message: l.message,
timestamp: l.timestamp instanceof Date ? l.timestamp.toISOString() : l.timestamp,
...(l.data !== undefined ? { data: l.data } : {}),
})), null, 2);
navigator.clipboard.writeText(logsJson);
setCopiedLogs(true);
setTimeout(() => setCopiedLogs(false), 2000);
}, [chatLogs]);
return (
<div className="flex-shrink-0 space-y-2 overflow-y-auto w-full h-full p-2">
{/* Sessions */}
<CollapsibleSection title={translate("Sessions")} storageKey="chat-section-sessions" minimal>
<div className="space-y-0.5">
<div
className="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer hover:bg-muted/50 transition-colors text-xs text-muted-foreground hover:text-foreground"
onClick={onNewSession}
>
<Plus className="h-3.5 w-3.5" />
<span><T>New Chat</T></span>
</div>
<div className="max-h-48 overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground text-center"><T>No saved sessions</T></div>
) : (
sessions.map(s => (
<div
key={s.id}
className={`flex items-center gap-1.5 px-2 py-1 cursor-pointer transition-colors group hover:bg-muted/50
${s.id === sessionId ? 'bg-primary/10 border-l-2 border-l-primary' : ''}`}
onClick={() => onLoadSession(s.id)}
>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate leading-tight">{s.title}</div>
<div className="text-[10px] text-muted-foreground leading-tight">
{new Date(s.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
{' '}
{new Date(s.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); onDeleteSession(s.id); }}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all flex-shrink-0"
title={translate("Delete")}
>
<X className="h-3 w-3" />
</button>
</div>
))
)}
</div>
</div>
</CollapsibleSection>
{/* Provider */}
<CollapsibleSection title={translate("Provider & Model")} storageKey="chat-section-provider" minimal>
<div className="p-3 space-y-3">
<ProviderSelector
provider={provider}
model={model}
onProviderChange={onProviderChange}
onModelChange={onModelChange}
disabled={isGenerating}
showManagement={true}
/>
</div>
</CollapsibleSection>
{/* System prompt */}
<CollapsibleSection title={translate("System Prompt")} storageKey="chat-section-system" minimal initiallyOpen={false}>
<div className="p-3">
<Textarea
value={systemPrompt}
onChange={e => onSystemPromptChange(e.target.value)}
placeholder={translate("You are a helpful assistant...")}
rows={4}
className="resize-none text-sm"
disabled={isGenerating}
data-testid="chat-system-prompt"
/>
</div>
</CollapsibleSection>
{/* Tools — individual collapsible sections */}
<CollapsibleSection title={translate("Tools")} storageKey="chat-section-tools" minimal>
<ToolSections
storagePrefix="chat-tool"
toolsEnabled={toolsEnabled}
onToolsEnabledChange={onToolsEnabledChange}
pageToolsEnabled={pageToolsEnabled}
onPageToolsEnabledChange={onPageToolsEnabledChange}
imageToolsEnabled={imageToolsEnabled}
onImageToolsEnabledChange={onImageToolsEnabledChange}
imageModel={imageModel}
onImageModelChange={onImageModelChange}
vfsToolsEnabled={vfsToolsEnabled}
onVfsToolsEnabledChange={onVfsToolsEnabledChange}
webSearchEnabled={webSearchEnabled}
onWebSearchEnabledChange={onWebSearchEnabledChange}
isGenerating={isGenerating}
extraToggles={extraToggles}
/>
</CollapsibleSection>
{/* Files */}
<CollapsibleSection title={translate("Files")} storageKey="chat-section-files" minimal initiallyOpen={false}>
<div style={{ height: 'min(500px, 60vh)', display: 'flex', flexDirection: 'column' }}>
<FileBrowser disableRoutingSync={true}
allowPanels={false}
mode='simple'
onSelect={handleFileSelect} />
</div>
{selectedNode && getMimeCategory(selectedNode) !== 'dir' && addFileContext && (
<div className="px-2 py-1.5 border-t border-border flex items-center gap-2">
<span className="text-xs font-mono text-muted-foreground truncate flex-1">{selectedNode.name}</span>
<button
onClick={() => { addFileContext(selectedNode.path, selectedMount); setSelectedNode(null); }}
className="text-[11px] px-2 py-0.5 rounded bg-teal-600/20 text-teal-400 hover:bg-teal-600/30 border border-teal-600/30 flex-shrink-0"
>
<T>Attach</T>
</button>
</div>
)}
{fileContexts && fileContexts.length > 0 && (
<div className="p-2 border-t border-border space-y-1">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider"><T>Attached</T> ({fileContexts.length})</div>
{fileContexts.map(fc => (
<div key={`${fc.mount}:${fc.path}`} className="flex items-center gap-1 text-xs font-mono text-teal-400 group">
<span className="truncate flex-1">{fc.path}</span>
{removeFileContext && (
<button
onClick={() => removeFileContext(fc.path)}
className="p-0.5 rounded hover:bg-destructive/20 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
</CollapsibleSection>
{/* Prompt Payload */}
{lastApiMessages && lastApiMessages.length > 0 && (
<CollapsibleSection
title={`${translate("Prompt Payload")} (${lastApiMessages.length})`}
storageKey="chat-section-payload"
minimal
initiallyOpen={false}
headerContent={
<button
onClick={(e) => { e.stopPropagation(); handleCopyPayload(); }}
className="p-0.5 rounded hover:bg-muted transition-colors"
title={translate("Copy payload JSON")}
>
{copied
? <Check className="h-3 w-3 text-green-500" />
: <Copy className="h-3 w-3 text-muted-foreground" />
}
</button>
}
>
<div className="min-h-[180px]" style={{ height: 'clamp(180px, 35vh, 400px)' }}>
<CompactTreeView
data={lastApiMessages}
onExit={() => { }}
header={
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-muted/20 flex-shrink-0">
<Braces className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground">
{lastApiMessages.length} {translate("messages in context")}
</span>
</div>
}
/>
</div>
</CollapsibleSection>
)}
{/* Logs */}
<CollapsibleSection
title={translate("Chat Logs")}
storageKey="chat-section-logs"
minimal
headerContent={
chatLogs.length > 0 ? (
<button
onClick={(e) => { e.stopPropagation(); handleCopyLogs(); }}
className="p-0.5 rounded hover:bg-muted transition-colors"
title={translate("Copy logs JSON")}
>
{copiedLogs
? <Check className="h-3 w-3 text-green-500" />
: <Copy className="h-3 w-3 text-muted-foreground" />
}
</button>
) : undefined
}
>
<div className="min-h-[180px]" style={{ height: 'clamp(180px, 35vh, 400px)' }}>
<ChatLogBrowser logs={chatLogs} clearLogs={onClearLogs} title={translate("Chat Logs")} />
</div>
</CollapsibleSection>
</div>
);
};
export default ChatSidebar;