450 lines
20 KiB
TypeScript
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;
|