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

450 lines
20 KiB
TypeScript

/**
* ChatPanel — reusable AI chat component with preset modes.
*
* Presets:
* - simple: Chat only (messages + composer). No header, no sidebar.
* - standard: Chat + header. Sidebar toggle available.
* - developer: Full experience — header + sidebar (open by default, all sections).
*
* Embeddable anywhere. Owns its own useChatEngine instance.
* Parent can override individual section visibility via props.
*/
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Bot } from 'lucide-react';
import { useChatEngine } from '@/modules/ai/useChatEngine';
import ChatHeader from '@/modules/ai/components/ChatHeader';
import ChatSidebar from '@/modules/ai/components/ChatSidebar';
import ChatComposer from '@/modules/ai/components/ChatComposer';
import MessageBubble from '@/modules/ai/components/MessageBubble';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useIsMobile } from '@/hooks/use-mobile';
import { buildSupportPrompt, SupportContextKey } from '@/modules/ai/defaults';
// ── Types ────────────────────────────────────────────────────────────────
export type ChatPreset = 'simple' | 'standard' | 'developer' | 'support';
export interface ChatPanelProps {
/** Preset controls the default layout. Default: 'developer' */
preset?: ChatPreset;
/** Override: show/hide the header bar */
showHeader?: boolean;
/** Override: allow sidebar (settings toggle visible in header) */
showSidebar?: boolean;
/** Override: sidebar initially open */
sidebarOpen?: boolean;
/** CSS class on the outer container */
className?: string;
/** Unique layout persistence ID (defaults to 'chat-layout') */
layoutId?: string;
/**
* Dynamic context provider — called just before each send.
* Return a string to inject into the system prompt, or null to skip.
* Use this to inject page context, selection state, etc.
*/
getContext?: () => string | null;
/**
* Extra tools provider — called when assembling tools for each send.
* Return an array of OpenAI-compatible tool definitions (RunnableToolFunctionWithParse).
*/
extraTools?: () => any[];
/**
* Pre-wired engine instance. When provided, ChatPanel uses this engine
* instead of creating its own via useChatEngine(). The caller is
* responsible for wiring context/tools into the engine.
* getContext and extraTools props are ignored when engine is supplied.
*/
engine?: ReturnType<typeof useChatEngine>;
/**
* Extra toggles to render in the sidebar (for edit mode specific tools)
*/
extraToggles?: React.ReactNode;
/**
* Seed the engine with a fixed provider on first mount.
* Useful for support / embedded chat where the provider must not be changed by the user.
*/
initialProvider?: string;
/** Seed the engine with a fixed model on first mount. */
initialModel?: string;
/** Seed the engine with a system prompt on first mount. */
initialSystemPrompt?: string;
/**
* Named context additions from defaults.ts to append to the base support prompt.
* Used when initialSystemPrompt is NOT provided — the prompt is assembled via
* buildSupportPrompt() using the current UI language + these context keys.
* e.g. context={['shipping-rates']}
*/
context?: SupportContextKey[];
/** Seed search-tools enabled on first mount. */
initialSearchToolsEnabled?: boolean;
/** Seed page-tools enabled on first mount. */
initialPageToolsEnabled?: boolean;
/** Seed image-tools enabled on first mount. */
initialImageToolsEnabled?: boolean;
/** Seed VFS-tools enabled on first mount. */
initialVfsToolsEnabled?: boolean;
/**
* Portal target for the composer. If provided, the composer is rendered
* into this DOM element via createPortal instead of inside the messages panel.
* Use this to mount the chat input in a completely different part of the page.
*
* @example
* const composerTarget = useRef<HTMLDivElement>(null);
* <ChatPanel composerPortal={composerTarget} />
* <div ref={composerTarget} /> // composer renders here
*/
composerPortal?: React.RefObject<HTMLElement | null>;
/** Initial user input to populate in the text area when opened */
initialPrompt?: string;
/** If true, the initialPrompt will be submitted immediately */
initialAutoSend?: boolean;
}
// ── Preset defaults ──────────────────────────────────────────────────────
const PRESET_DEFAULTS: Record<ChatPreset, {
showHeader: boolean;
showSidebar: boolean;
sidebarOpen: boolean;
}> = {
simple: {
showHeader: false,
showSidebar: false,
sidebarOpen: false,
},
standard: {
showHeader: true,
showSidebar: true,
sidebarOpen: false,
},
developer: {
showHeader: true,
showSidebar: true,
sidebarOpen: true,
},
support: {
showHeader: false,
showSidebar: false,
sidebarOpen: false,
},
};
// ── Messages + Composer sub-component ─────────────────────────────────────
const ChatMessages: React.FC<{
engine: ReturnType<typeof useChatEngine>;
composerPortal?: React.RefObject<HTMLElement | null>;
layoutId: string;
}> = ({ engine, composerPortal, layoutId }) => {
const composer = (
<ChatComposer
input={engine.input}
onInputChange={engine.setInput}
attachments={engine.attachments}
isGenerating={engine.isGenerating}
isDragging={engine.isDragging}
canSend={engine.canSend}
showImagePicker={engine.showImagePicker}
isRecording={engine.isRecording}
isTranscribing={engine.isTranscribing}
composerRef={engine.composerRef}
inputRef={engine.inputRef}
fileInputRef={engine.fileInputRef}
promptHistory={engine.promptHistory}
historyIndex={engine.historyIndex}
navigateHistory={engine.navigateHistory}
onSend={engine.sendMessage}
onCancel={engine.handleCancel}
onKeyDown={engine.handleKeyDown}
onPaste={engine.handlePaste}
onRemoveAttachment={engine.removeAttachment}
onToggleRecording={engine.handleMicrophoneToggle}
onFileInputChange={engine.handleFileInputChange}
onDragEnter={engine.handleDragEnter}
onDragLeave={engine.handleDragLeave}
onDragOver={engine.handleDragOver}
onDrop={engine.handleDrop}
onOpenFilePicker={() => engine.fileInputRef.current?.click()}
onOpenGallery={() => engine.setShowImagePicker(true)}
onCloseGallery={() => engine.setShowImagePicker(false)}
onPickerSelect={engine.handlePickerSelect}
onPickerSelectSingle={(pic: any) => {
engine.handlePickerSelect([pic]);
}}
fileContexts={engine.fileContexts}
onRemoveFileContext={engine.removeFileContext}
/>
);
const bottomRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = engine.scrollRef;
// Interrupt auto-scroll if the user scrolls up manually
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const handleScroll = () => {
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
engine.isUserScrolledUpRef.current = !isAtBottom;
};
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [scrollContainerRef, engine.isUserScrolledUpRef]);
// Scroll to bottom when messages change or stream updates
useEffect(() => {
if (!engine.isUserScrolledUpRef.current) {
bottomRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
}
}, [engine.messages, engine.isUserScrolledUpRef]);
return (
<div className="h-full w-full overflow-hidden flex flex-col min-h-0 min-w-0 border rounded-lg bg-background">
<ResizablePanelGroup direction="vertical" autoSaveId={`${layoutId}-prompt-splitter`}>
{/* Messages */}
<ResizablePanel defaultSize={85} className="flex flex-col min-h-0">
<div
ref={scrollContainerRef}
className="flex-1 min-h-0 overflow-y-auto p-2 sm:p-4 space-y-3 sm:space-y-4"
data-testid="chat-messages"
>
{engine.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<Bot className="h-10 w-10 sm:h-12 sm:w-12 opacity-30" />
<p className="text-sm text-center px-4">Send a message to start chatting</p>
<p className="text-xs opacity-60 text-center px-4">
Using {engine.provider}/{engine.model} Enter to send
</p>
<p className="text-xs opacity-40 text-center">
Drop images or use 📎 to attach
</p>
</div>
) : (
engine.messages
.filter(msg => msg.role !== 'tool')
.map(msg => (
<MessageBubble
key={msg.id}
message={msg}
onCancel={msg.isStreaming ? engine.handleCancel : undefined}
/>
))
)}
<div ref={bottomRef} className="h-2 flex-shrink-0" />
</div>
{/* Mini-log: last 2 info-level entries for at-a-glance activity */}
{(() => {
const recent = engine.chatLogs
.filter(l => l.level === 'info')
.slice(-2);
if (!recent.length) return null;
return (
<div className="flex-shrink-0 border-t px-3 py-1 space-y-0.5 bg-muted/30 mt-auto">
{recent.map(l => (
<p key={l.id} className="text-[11px] text-muted-foreground truncate leading-tight" title={l.message}>
{l.message}
</p>
))}
</div>
);
})()}
</ResizablePanel>
{/* Composer — portaled if target exists, inline as a resizable panel otherwise */}
{composerPortal?.current ? (
createPortal(composer, composerPortal.current)
) : (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={15} minSize={10} className="flex flex-col min-h-0">
{composer}
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
);
};
// ── Sidebar props helper ─────────────────────────────────────────────────
export const sidebarProps = (engine: ReturnType<typeof useChatEngine>) => ({
provider: engine.provider,
model: engine.model,
onProviderChange: engine.setProvider,
onModelChange: engine.setModel,
systemPrompt: engine.systemPrompt,
onSystemPromptChange: engine.setSystemPrompt,
toolsEnabled: engine.toolsEnabled,
onToolsEnabledChange: engine.setToolsEnabled,
pageToolsEnabled: engine.pageToolsEnabled,
onPageToolsEnabledChange: engine.setPageToolsEnabled,
imageToolsEnabled: engine.imageToolsEnabled,
onImageToolsEnabledChange: engine.setImageToolsEnabled,
imageModel: engine.imageModel,
onImageModelChange: engine.setImageModel,
vfsToolsEnabled: engine.vfsToolsEnabled,
onVfsToolsEnabledChange: engine.setVfsToolsEnabled,
webSearchEnabled: engine.webSearchEnabled,
onWebSearchEnabledChange: engine.setWebSearchEnabled,
isGenerating: engine.isGenerating,
messages: engine.messages,
user: engine.user,
chatLogs: engine.chatLogs,
onClearLogs: () => engine.setChatLogs([]),
lastApiMessages: engine.lastApiMessages,
sessionId: engine.sessionId,
sessions: engine.sessions,
onNewSession: engine.handleNewSession,
onLoadSession: engine.handleLoadSession,
onDeleteSession: engine.handleDeleteSession,
fileContexts: engine.fileContexts,
addFileContext: engine.addFileContext,
removeFileContext: engine.removeFileContext,
});
// ── Main Component ───────────────────────────────────────────────────────
const ChatPanel: React.FC<ChatPanelProps> = ({
preset = 'developer',
showHeader: showHeaderProp,
showSidebar: showSidebarProp,
sidebarOpen: sidebarOpenProp,
className = '',
layoutId = 'chat-layout',
getContext,
extraTools,
extraToggles,
engine: externalEngine,
composerPortal,
initialProvider,
initialModel,
initialSystemPrompt,
context,
initialSearchToolsEnabled,
initialPageToolsEnabled,
initialImageToolsEnabled,
initialVfsToolsEnabled,
initialPrompt,
initialAutoSend,
}) => {
const defaults = PRESET_DEFAULTS[preset];
const hasHeader = showHeaderProp ?? defaults.showHeader;
const hasSidebar = showSidebarProp ?? defaults.showSidebar;
const ownEngine = useChatEngine(preset);
const engine = externalEngine || ownEngine;
const isMobile = useIsMobile();
// Wire external context and tools providers into the engine synchronously
// (skipped when an external engine is provided — caller handles wiring)
if (!externalEngine) {
engine.contextProviderRef.current = getContext ?? null;
engine.extraToolsRef.current = extraTools ?? null;
}
// One-shot: seed initial provider/model/systemPrompt/tools on first mount
useEffect(() => {
if (externalEngine) return;
if (initialProvider !== undefined) engine.setProvider(initialProvider);
if (initialModel !== undefined) engine.setModel(initialModel);
// System prompt: explicit prop wins; otherwise assemble from defaults + context
const resolvedPrompt = initialSystemPrompt ?? buildSupportPrompt(context);
engine.setSystemPrompt(resolvedPrompt);
if (initialSearchToolsEnabled !== undefined) engine.setToolsEnabled(initialSearchToolsEnabled);
if (initialPageToolsEnabled !== undefined) engine.setPageToolsEnabled(initialPageToolsEnabled);
if (initialImageToolsEnabled !== undefined) engine.setImageToolsEnabled(initialImageToolsEnabled);
if (initialVfsToolsEnabled !== undefined) engine.setVfsToolsEnabled(initialVfsToolsEnabled);
// Pre-fill the composer if a prompt is provided
if (initialPrompt) {
engine.setInput(initialPrompt);
if (initialAutoSend) {
setTimeout(() => {
engine.sendMessage(initialPrompt);
}, 100);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Override sidebar initial state based on preset (only on mount)
const sidebarInitial = sidebarOpenProp ?? defaults.sidebarOpen;
// The engine already persists showSettings — but for 'simple' we force it off
const showSettings = hasSidebar ? engine.showSettings : false;
return (
<div className={`flex flex-col h-full min-w-0 w-full ${className}`}>
{/* Header */}
{hasHeader && (
<div className="flex-shrink-0 pb-2">
<ChatHeader
provider={engine.provider}
model={engine.model}
messages={engine.messages}
attachments={engine.attachments}
showSettings={showSettings}
onToggleSettings={hasSidebar
? () => engine.setShowSettings(v => !v)
: () => { }
}
onExportJson={engine.handleExportJson}
onExportMarkdown={engine.handleExportMarkdown}
onClear={engine.handleClear}
onNewSession={engine.handleNewSession}
/>
</div>
)}
{/* Main area */}
<div className="flex flex-1 min-h-0 min-w-0 relative w-full">
{/* Mobile sidebar (Sheet) */}
{hasSidebar && isMobile && (
<div className="md:hidden">
<Sheet open={showSettings} onOpenChange={engine.setShowSettings}>
<SheetContent side="left" className="w-[85vw] max-w-[360px] p-0 sm:max-w-[400px]">
<ChatSidebar {...sidebarProps(engine)} extraToggles={extraToggles} />
</SheetContent>
</Sheet>
</div>
)}
{/* Desktop: resizable panel layout */}
<div className="hidden md:flex flex-1 min-h-0 min-w-0 w-full overflow-hidden">
<ResizablePanelGroup
direction="horizontal"
autoSaveId={layoutId}
className="h-full"
>
{hasSidebar && showSettings && !isMobile && (
<>
<ResizablePanel defaultSize={25} id="chat-sidebar" order={1}>
<ChatSidebar {...sidebarProps(engine)} extraToggles={extraToggles} />
</ResizablePanel>
<ResizableHandle withHandle />
</>
)}
<ResizablePanel defaultSize={75} id="chat-main" order={2}>
<ChatMessages engine={engine} composerPortal={composerPortal} layoutId={layoutId} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* Mobile: full-width chat */}
<div className="flex-1 flex flex-col min-h-0 min-w-0 w-full overflow-hidden md:hidden">
<ChatMessages engine={engine} composerPortal={composerPortal} layoutId={layoutId} />
</div>
</div>
</div>
);
};
export { ChatPanel, ChatMessages };
export default ChatPanel;