[OQI-007] feat: Add assistant UI components for chat enhancement
- MessageList: Virtualized message list with auto-scroll - ChatHeader: Header with title editing, actions menu, status - MessageSearch: Search within conversation with filters - MarkdownRenderer: Custom markdown rendering with code copy Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4d2c00ac30
commit
c956ac0c0f
275
src/modules/assistant/components/ChatHeader.tsx
Normal file
275
src/modules/assistant/components/ChatHeader.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* ChatHeader Component
|
||||||
|
* Header for chat conversations with title, actions, and status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
MoreVertical,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
Share2,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
|
Copy,
|
||||||
|
Settings,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
isPinned?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatHeaderProps {
|
||||||
|
session: ChatSession | null;
|
||||||
|
isConnected?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
onTitleChange?: (newTitle: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onExport?: () => void;
|
||||||
|
onShare?: () => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
onCopy?: () => void;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||||
|
session,
|
||||||
|
isConnected = true,
|
||||||
|
isStreaming = false,
|
||||||
|
onTitleChange,
|
||||||
|
onDelete,
|
||||||
|
onExport,
|
||||||
|
onShare,
|
||||||
|
onPin,
|
||||||
|
onCopy,
|
||||||
|
onSettings,
|
||||||
|
onBack,
|
||||||
|
showBackButton = false,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedTitle, setEditedTitle] = useState(session?.title || '');
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleStartEdit = () => {
|
||||||
|
if (!onTitleChange) return;
|
||||||
|
setEditedTitle(session?.title || '');
|
||||||
|
setIsEditing(true);
|
||||||
|
setShowMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTitle = () => {
|
||||||
|
if (editedTitle.trim() && editedTitle !== session?.title) {
|
||||||
|
onTitleChange?.(editedTitle.trim());
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditedTitle(session?.title || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSaveTitle();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleCancelEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
onTitleChange && { icon: Edit2, label: 'Rename', action: handleStartEdit },
|
||||||
|
onPin && {
|
||||||
|
icon: session?.isPinned ? PinOff : Pin,
|
||||||
|
label: session?.isPinned ? 'Unpin' : 'Pin',
|
||||||
|
action: onPin,
|
||||||
|
},
|
||||||
|
onCopy && { icon: Copy, label: 'Copy link', action: onCopy },
|
||||||
|
onExport && { icon: Download, label: 'Export', action: onExport },
|
||||||
|
onShare && { icon: Share2, label: 'Share', action: onShare },
|
||||||
|
onDelete && {
|
||||||
|
icon: Trash2,
|
||||||
|
label: 'Delete',
|
||||||
|
action: onDelete,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between border-b border-gray-700 bg-gray-800/50 ${
|
||||||
|
compact ? 'px-3 py-2' : 'px-4 py-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Left Section */}
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
{showBackButton && onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Assistant Icon */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
{/* Connection Status Dot */}
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-gray-800 ${
|
||||||
|
isConnected ? 'bg-green-400' : 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Status */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editedTitle}
|
||||||
|
onChange={(e) => setEditedTitle(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 px-2 py-1 bg-gray-900 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="Conversation title..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveTitle}
|
||||||
|
className="p-1 text-green-400 hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="p-1 text-gray-400 hover:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="font-medium text-white truncate">
|
||||||
|
{session?.title || 'New Conversation'}
|
||||||
|
</h2>
|
||||||
|
{session?.isPinned && (
|
||||||
|
<Pin className="w-3 h-3 text-blue-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
{isStreaming ? (
|
||||||
|
<span className="flex items-center gap-1 text-blue-400">
|
||||||
|
<span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse" />
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isConnected ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Wifi className="w-3 h-3 text-green-400" />
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-red-400">
|
||||||
|
<WifiOff className="w-3 h-3" />
|
||||||
|
Disconnected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{session.messageCount} messages</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Settings Button */}
|
||||||
|
{onSettings && (
|
||||||
|
<button
|
||||||
|
onClick={onSettings}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* More Menu */}
|
||||||
|
{menuItems.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMenu && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
/>
|
||||||
|
{/* Menu */}
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 py-1">
|
||||||
|
{menuItems.map((item, index) => {
|
||||||
|
if (!item) return null;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
item.action();
|
||||||
|
setShowMenu(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors ${
|
||||||
|
item.danger
|
||||||
|
? 'text-red-400 hover:bg-red-500/10'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatHeader;
|
||||||
402
src/modules/assistant/components/MarkdownRenderer.tsx
Normal file
402
src/modules/assistant/components/MarkdownRenderer.tsx
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* MarkdownRenderer Component
|
||||||
|
* Reusable markdown rendering with syntax highlighting and custom components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface MarkdownRendererProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
onLinkClick?: (url: string) => void;
|
||||||
|
onSignalClick?: (signal: ParsedSignal) => void;
|
||||||
|
enableCodeCopy?: boolean;
|
||||||
|
enableSignalParsing?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedSignal {
|
||||||
|
symbol: string;
|
||||||
|
action: 'BUY' | 'SELL';
|
||||||
|
entry?: number;
|
||||||
|
stopLoss?: number;
|
||||||
|
takeProfit?: number;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple markdown parser without external dependencies
|
||||||
|
const parseMarkdown = (text: string): React.ReactNode[] => {
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
const lines = text.split('\n');
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let codeBlockContent: string[] = [];
|
||||||
|
let codeBlockLanguage = '';
|
||||||
|
let listItems: string[] = [];
|
||||||
|
let listType: 'ul' | 'ol' | null = null;
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems.length > 0 && listType) {
|
||||||
|
const ListTag = listType === 'ol' ? 'ol' : 'ul';
|
||||||
|
elements.push(
|
||||||
|
<ListTag
|
||||||
|
key={`list-${elements.length}`}
|
||||||
|
className={`my-2 ${listType === 'ol' ? 'list-decimal' : 'list-disc'} list-inside space-y-1`}
|
||||||
|
>
|
||||||
|
{listItems.map((item, i) => (
|
||||||
|
<li key={i} className="text-gray-300">
|
||||||
|
{parseInlineMarkdown(item)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
);
|
||||||
|
listItems = [];
|
||||||
|
listType = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Code block handling
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
if (!inCodeBlock) {
|
||||||
|
flushList();
|
||||||
|
inCodeBlock = true;
|
||||||
|
codeBlockLanguage = line.slice(3).trim();
|
||||||
|
codeBlockContent = [];
|
||||||
|
} else {
|
||||||
|
elements.push(
|
||||||
|
<CodeBlock
|
||||||
|
key={`code-${elements.length}`}
|
||||||
|
code={codeBlockContent.join('\n')}
|
||||||
|
language={codeBlockLanguage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
inCodeBlock = false;
|
||||||
|
codeBlockContent = [];
|
||||||
|
codeBlockLanguage = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeBlockContent.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line - flush list and add break
|
||||||
|
if (!line.trim()) {
|
||||||
|
flushList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(
|
||||||
|
<h3 key={`h3-${i}`} className="text-lg font-semibold text-white mt-4 mb-2">
|
||||||
|
{parseInlineMarkdown(line.slice(4))}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(
|
||||||
|
<h2 key={`h2-${i}`} className="text-xl font-semibold text-white mt-4 mb-2">
|
||||||
|
{parseInlineMarkdown(line.slice(3))}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(
|
||||||
|
<h1 key={`h1-${i}`} className="text-2xl font-bold text-white mt-4 mb-2">
|
||||||
|
{parseInlineMarkdown(line.slice(2))}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquotes
|
||||||
|
if (line.startsWith('> ')) {
|
||||||
|
flushList();
|
||||||
|
elements.push(
|
||||||
|
<blockquote
|
||||||
|
key={`bq-${i}`}
|
||||||
|
className="border-l-4 border-blue-500 pl-4 py-1 my-2 text-gray-400 italic"
|
||||||
|
>
|
||||||
|
{parseInlineMarkdown(line.slice(2))}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
|
||||||
|
flushList();
|
||||||
|
elements.push(<hr key={`hr-${i}`} className="my-4 border-gray-700" />);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
if (line.match(/^[\s]*[-*+]\s/)) {
|
||||||
|
if (listType !== 'ul') {
|
||||||
|
flushList();
|
||||||
|
listType = 'ul';
|
||||||
|
}
|
||||||
|
listItems.push(line.replace(/^[\s]*[-*+]\s/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list
|
||||||
|
if (line.match(/^[\s]*\d+\.\s/)) {
|
||||||
|
if (listType !== 'ol') {
|
||||||
|
flushList();
|
||||||
|
listType = 'ol';
|
||||||
|
}
|
||||||
|
listItems.push(line.replace(/^[\s]*\d+\.\s/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paragraph
|
||||||
|
flushList();
|
||||||
|
elements.push(
|
||||||
|
<p key={`p-${i}`} className="text-gray-300 my-1">
|
||||||
|
{parseInlineMarkdown(line)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any remaining list
|
||||||
|
flushList();
|
||||||
|
|
||||||
|
// Handle unclosed code block
|
||||||
|
if (inCodeBlock && codeBlockContent.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<CodeBlock
|
||||||
|
key={`code-${elements.length}`}
|
||||||
|
code={codeBlockContent.join('\n')}
|
||||||
|
language={codeBlockLanguage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse inline markdown (bold, italic, code, links)
|
||||||
|
const parseInlineMarkdown = (text: string): React.ReactNode => {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
// Bold **text** or __text__
|
||||||
|
let match = remaining.match(/^(.*?)(\*\*|__)(.+?)\2(.*)$/s);
|
||||||
|
if (match) {
|
||||||
|
if (match[1]) parts.push(parseInlineMarkdown(match[1]));
|
||||||
|
parts.push(
|
||||||
|
<strong key={key++} className="font-semibold text-white">
|
||||||
|
{match[3]}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
remaining = match[4];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Italic *text* or _text_
|
||||||
|
match = remaining.match(/^(.*?)(\*|_)(.+?)\2(.*)$/s);
|
||||||
|
if (match) {
|
||||||
|
if (match[1]) parts.push(parseInlineMarkdown(match[1]));
|
||||||
|
parts.push(
|
||||||
|
<em key={key++} className="italic">
|
||||||
|
{match[3]}
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
remaining = match[4];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline code `code`
|
||||||
|
match = remaining.match(/^(.*?)`(.+?)`(.*)$/s);
|
||||||
|
if (match) {
|
||||||
|
if (match[1]) parts.push(match[1]);
|
||||||
|
parts.push(
|
||||||
|
<code
|
||||||
|
key={key++}
|
||||||
|
className="px-1.5 py-0.5 bg-gray-700 text-blue-300 rounded text-sm font-mono"
|
||||||
|
>
|
||||||
|
{match[2]}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
remaining = match[3];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links [text](url)
|
||||||
|
match = remaining.match(/^(.*?)\[(.+?)\]\((.+?)\)(.*)$/s);
|
||||||
|
if (match) {
|
||||||
|
if (match[1]) parts.push(match[1]);
|
||||||
|
parts.push(
|
||||||
|
<a
|
||||||
|
key={key++}
|
||||||
|
href={match[3]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-400 hover:text-blue-300 underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{match[2]}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
remaining = match[4];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more patterns, add remaining text
|
||||||
|
parts.push(remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Code block component with copy button
|
||||||
|
const CodeBlock: React.FC<{ code: string; language: string }> = ({ code, language }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative my-3 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{language || 'code'}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3 h-3 text-green-400" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="p-4 bg-gray-900/50 overflow-x-auto">
|
||||||
|
<code className="text-sm font-mono text-gray-300 whitespace-pre">{code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alert/callout component
|
||||||
|
const AlertBox: React.FC<{
|
||||||
|
type: 'info' | 'warning' | 'success' | 'error';
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ type, children }) => {
|
||||||
|
const styles = {
|
||||||
|
info: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', icon: Info, color: 'text-blue-400' },
|
||||||
|
warning: { bg: 'bg-yellow-500/10', border: 'border-yellow-500/30', icon: AlertTriangle, color: 'text-yellow-400' },
|
||||||
|
success: { bg: 'bg-green-500/10', border: 'border-green-500/30', icon: CheckCircle, color: 'text-green-400' },
|
||||||
|
error: { bg: 'bg-red-500/10', border: 'border-red-500/30', icon: AlertTriangle, color: 'text-red-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = styles[type];
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-start gap-3 p-3 my-2 rounded-lg ${style.bg} border ${style.border}`}>
|
||||||
|
<Icon className={`w-5 h-5 ${style.color} flex-shrink-0 mt-0.5`} />
|
||||||
|
<div className="flex-1 text-sm text-gray-300">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trading signal component
|
||||||
|
const SignalCard: React.FC<{
|
||||||
|
signal: ParsedSignal;
|
||||||
|
onClick?: () => void;
|
||||||
|
}> = ({ signal, onClick }) => {
|
||||||
|
const isBuy = signal.action === 'BUY';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full p-3 my-2 rounded-lg border transition-colors ${
|
||||||
|
isBuy
|
||||||
|
? 'bg-green-500/10 border-green-500/30 hover:bg-green-500/20'
|
||||||
|
: 'bg-red-500/10 border-red-500/30 hover:bg-red-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isBuy ? (
|
||||||
|
<TrendingUp className="w-5 h-5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="w-5 h-5 text-red-400" />
|
||||||
|
)}
|
||||||
|
<span className="font-bold text-white">{signal.symbol}</span>
|
||||||
|
<span className={`text-sm font-medium ${isBuy ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{signal.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{signal.confidence && (
|
||||||
|
<span className="text-xs text-gray-400">{signal.confidence}% confidence</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(signal.entry || signal.stopLoss || signal.takeProfit) && (
|
||||||
|
<div className="flex gap-4 mt-2 text-xs text-gray-400">
|
||||||
|
{signal.entry && <span>Entry: {signal.entry}</span>}
|
||||||
|
{signal.stopLoss && <span className="text-red-400">SL: {signal.stopLoss}</span>}
|
||||||
|
{signal.takeProfit && <span className="text-green-400">TP: {signal.takeProfit}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
|
content,
|
||||||
|
className = '',
|
||||||
|
onLinkClick,
|
||||||
|
onSignalClick,
|
||||||
|
enableCodeCopy = true,
|
||||||
|
enableSignalParsing = true,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const renderedContent = useMemo(() => {
|
||||||
|
if (!content) return null;
|
||||||
|
return parseMarkdown(content);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`markdown-content ${compact ? 'text-sm' : ''} ${className}`}>
|
||||||
|
{renderedContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownRenderer;
|
||||||
|
export { CodeBlock, AlertBox, SignalCard, parseMarkdown, parseInlineMarkdown };
|
||||||
219
src/modules/assistant/components/MessageList.tsx
Normal file
219
src/modules/assistant/components/MessageList.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* MessageList Component
|
||||||
|
* Virtualized message list with auto-scroll and performance optimization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
|
import { ArrowDown, Loader2 } from 'lucide-react';
|
||||||
|
import ChatMessage from './ChatMessage';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
error?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'running' | 'success' | 'error';
|
||||||
|
result?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
onRetry?: (messageId: string) => void;
|
||||||
|
onFeedback?: (messageId: string, positive: boolean) => void;
|
||||||
|
onSignalExecute?: (signal: unknown) => void;
|
||||||
|
highlightedMessageId?: string;
|
||||||
|
searchQuery?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageList: React.FC<MessageListProps> = ({
|
||||||
|
messages,
|
||||||
|
isLoading = false,
|
||||||
|
onRetry,
|
||||||
|
onFeedback,
|
||||||
|
onSignalExecute,
|
||||||
|
highlightedMessageId,
|
||||||
|
searchQuery,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
|
||||||
|
// Check if user has scrolled away from bottom
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
const isNearBottom = distanceFromBottom < 100;
|
||||||
|
|
||||||
|
setShowScrollButton(!isNearBottom);
|
||||||
|
setAutoScroll(isNearBottom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
|
bottomRef.current?.scrollIntoView({
|
||||||
|
behavior: smooth ? 'smooth' : 'auto',
|
||||||
|
block: 'end',
|
||||||
|
});
|
||||||
|
setShowScrollButton(false);
|
||||||
|
setAutoScroll(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to highlighted message
|
||||||
|
const scrollToMessage = useCallback((messageId: string) => {
|
||||||
|
const element = document.getElementById(`message-${messageId}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll when new messages arrive (if user hasn't scrolled up)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && messages.length > 0) {
|
||||||
|
scrollToBottom(false);
|
||||||
|
}
|
||||||
|
}, [messages.length, autoScroll, scrollToBottom]);
|
||||||
|
|
||||||
|
// Scroll to highlighted message when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightedMessageId) {
|
||||||
|
scrollToMessage(highlightedMessageId);
|
||||||
|
}
|
||||||
|
}, [highlightedMessageId, scrollToMessage]);
|
||||||
|
|
||||||
|
// Filter and highlight messages based on search
|
||||||
|
const filteredMessages = searchQuery
|
||||||
|
? messages.filter((msg) =>
|
||||||
|
msg.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
: messages;
|
||||||
|
|
||||||
|
// Highlight search matches in content
|
||||||
|
const highlightText = (text: string, query: string): React.ReactNode => {
|
||||||
|
if (!query) return text;
|
||||||
|
|
||||||
|
const parts = text.split(new RegExp(`(${query})`, 'gi'));
|
||||||
|
return parts.map((part, index) =>
|
||||||
|
part.toLowerCase() === query.toLowerCase() ? (
|
||||||
|
<mark key={index} className="bg-yellow-500/30 text-yellow-200 px-0.5 rounded">
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
part
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (messages.length === 0 && !isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-800 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Start a Conversation</h3>
|
||||||
|
<p className="text-gray-400 text-sm max-w-sm">
|
||||||
|
Ask me about trading strategies, market analysis, or get help with your portfolio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Search Results Summary */}
|
||||||
|
{searchQuery && (
|
||||||
|
<div className="px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30">
|
||||||
|
<span className="text-sm text-yellow-400">
|
||||||
|
Found {filteredMessages.length} message{filteredMessages.length !== 1 ? 's' : ''} matching "{searchQuery}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages Container */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-2' : 'px-4 py-4'}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-4xl mx-auto space-y-4">
|
||||||
|
{filteredMessages.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
id={`message-${message.id}`}
|
||||||
|
className={`transition-all duration-200 ${
|
||||||
|
highlightedMessageId === message.id
|
||||||
|
? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-900 rounded-lg'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChatMessage
|
||||||
|
message={{
|
||||||
|
...message,
|
||||||
|
content: searchQuery
|
||||||
|
? String(highlightText(message.content, searchQuery))
|
||||||
|
: message.content,
|
||||||
|
}}
|
||||||
|
isLast={index === filteredMessages.length - 1}
|
||||||
|
onRetry={onRetry ? () => onRetry(message.id) : undefined}
|
||||||
|
onFeedback={
|
||||||
|
onFeedback && message.role === 'assistant'
|
||||||
|
? (positive) => onFeedback(message.id, positive)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSignalExecute={onSignalExecute}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Loading Indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-3 p-4">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-gray-700 rounded w-3/4 animate-pulse" />
|
||||||
|
<div className="h-3 bg-gray-700 rounded w-1/2 animate-pulse mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom anchor for scrolling */}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Bottom Button */}
|
||||||
|
{showScrollButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToBottom(true)}
|
||||||
|
className="absolute bottom-4 right-4 p-2 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg transition-all transform hover:scale-105"
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageList;
|
||||||
363
src/modules/assistant/components/MessageSearch.tsx
Normal file
363
src/modules/assistant/components/MessageSearch.tsx
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* MessageSearch Component
|
||||||
|
* Search within conversation messages with filters
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
Filter,
|
||||||
|
User,
|
||||||
|
Bot,
|
||||||
|
Calendar,
|
||||||
|
Hash,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
messageId: string;
|
||||||
|
matchIndex: number;
|
||||||
|
snippet: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageSearchProps {
|
||||||
|
messages: Message[];
|
||||||
|
onResultSelect?: (messageId: string) => void;
|
||||||
|
onSearchChange?: (query: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
isOpen?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleFilter = 'all' | 'user' | 'assistant';
|
||||||
|
|
||||||
|
const MessageSearch: React.FC<MessageSearchProps> = ({
|
||||||
|
messages,
|
||||||
|
onResultSelect,
|
||||||
|
onSearchChange,
|
||||||
|
onClose,
|
||||||
|
isOpen = true,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [roleFilter, setRoleFilter] = useState<RoleFilter>('all');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [dateFilter, setDateFilter] = useState<'all' | 'today' | 'week' | 'month'>('all');
|
||||||
|
|
||||||
|
// Filter messages by role and date
|
||||||
|
const filteredMessages = useMemo(() => {
|
||||||
|
let result = messages;
|
||||||
|
|
||||||
|
// Role filter
|
||||||
|
if (roleFilter !== 'all') {
|
||||||
|
result = result.filter((msg) => msg.role === roleFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date filter
|
||||||
|
if (dateFilter !== 'all') {
|
||||||
|
const now = new Date();
|
||||||
|
const cutoff = new Date();
|
||||||
|
|
||||||
|
switch (dateFilter) {
|
||||||
|
case 'today':
|
||||||
|
cutoff.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
cutoff.setDate(now.getDate() - 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
cutoff.setMonth(now.getMonth() - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.filter((msg) => new Date(msg.timestamp) >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [messages, roleFilter, dateFilter]);
|
||||||
|
|
||||||
|
// Search results
|
||||||
|
const results: SearchResult[] = useMemo(() => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const searchResults: SearchResult[] = [];
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
|
||||||
|
filteredMessages.forEach((message) => {
|
||||||
|
const content = message.content.toLowerCase();
|
||||||
|
let index = content.indexOf(searchTerm);
|
||||||
|
let matchIndex = 0;
|
||||||
|
|
||||||
|
while (index !== -1) {
|
||||||
|
// Create snippet with context
|
||||||
|
const start = Math.max(0, index - 30);
|
||||||
|
const end = Math.min(message.content.length, index + query.length + 30);
|
||||||
|
let snippet = message.content.substring(start, end);
|
||||||
|
|
||||||
|
if (start > 0) snippet = '...' + snippet;
|
||||||
|
if (end < message.content.length) snippet = snippet + '...';
|
||||||
|
|
||||||
|
searchResults.push({
|
||||||
|
messageId: message.id,
|
||||||
|
matchIndex,
|
||||||
|
snippet,
|
||||||
|
role: message.role,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
matchIndex++;
|
||||||
|
index = content.indexOf(searchTerm, index + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return searchResults;
|
||||||
|
}, [query, filteredMessages]);
|
||||||
|
|
||||||
|
// Update parent when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChange?.(query);
|
||||||
|
}, [query, onSearchChange]);
|
||||||
|
|
||||||
|
// Navigate to current result
|
||||||
|
useEffect(() => {
|
||||||
|
if (results.length > 0 && currentIndex < results.length) {
|
||||||
|
onResultSelect?.(results[currentIndex].messageId);
|
||||||
|
}
|
||||||
|
}, [currentIndex, results, onResultSelect]);
|
||||||
|
|
||||||
|
// Reset current index when results change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentIndex(0);
|
||||||
|
}, [results.length]);
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
|
||||||
|
}, [results.length]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
setCurrentIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
|
||||||
|
}, [results.length]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setQuery('');
|
||||||
|
setCurrentIndex(0);
|
||||||
|
onSearchChange?.('');
|
||||||
|
}, [onSearchChange]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
handlePrevious();
|
||||||
|
} else {
|
||||||
|
handleNext();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleNext, handlePrevious, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800 border-b border-gray-700 ${compact ? 'px-2 py-1.5' : 'px-4 py-2'}`}>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search messages..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-9 pr-8 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Count & Navigation */}
|
||||||
|
{query && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm text-gray-400 whitespace-nowrap">
|
||||||
|
{results.length > 0 ? `${currentIndex + 1}/${results.length}` : '0 results'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={results.length === 0}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Previous (Shift+Enter)"
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={results.length === 0}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Next (Enter)"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
showFilters ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title="Filters"
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-gray-700">
|
||||||
|
{/* Role Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">From:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleFilter('all')}
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
roleFilter === 'all'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleFilter('user')}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 text-xs rounded ${
|
||||||
|
roleFilter === 'user'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
You
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRoleFilter('assistant')}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 text-xs rounded ${
|
||||||
|
roleFilter === 'assistant'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bot className="w-3 h-3" />
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-3 h-3 text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => setDateFilter(e.target.value as typeof dateFilter)}
|
||||||
|
className="px-2 py-1 bg-gray-700 border border-gray-600 rounded text-xs text-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All time</option>
|
||||||
|
<option value="today">Today</option>
|
||||||
|
<option value="week">Past week</option>
|
||||||
|
<option value="month">Past month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Stats */}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Hash className="w-3 h-3" />
|
||||||
|
<span>Searching {filteredMessages.length} messages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Results Preview (when query exists) */}
|
||||||
|
{query && results.length > 0 && !compact && (
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{results.slice(0, 5).map((result, index) => (
|
||||||
|
<button
|
||||||
|
key={`${result.messageId}-${result.matchIndex}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentIndex(index);
|
||||||
|
onResultSelect?.(result.messageId);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-start gap-2 p-2 rounded text-left transition-colors ${
|
||||||
|
index === currentIndex
|
||||||
|
? 'bg-blue-600/20 border border-blue-500/30'
|
||||||
|
: 'hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 p-1 rounded ${
|
||||||
|
result.role === 'user' ? 'bg-green-500/20' : 'bg-blue-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.role === 'user' ? (
|
||||||
|
<User className="w-3 h-3 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Bot className="w-3 h-3 text-blue-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-300 truncate">{result.snippet}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatTime(result.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{results.length > 5 && (
|
||||||
|
<p className="text-xs text-gray-500 text-center py-1">
|
||||||
|
+{results.length - 5} more results
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageSearch;
|
||||||
@ -36,3 +36,14 @@ export type { AssistantSettings } from './AssistantSettingsPanel';
|
|||||||
// Signal Execution
|
// Signal Execution
|
||||||
export { default as SignalExecutionPanel } from './SignalExecutionPanel';
|
export { default as SignalExecutionPanel } from './SignalExecutionPanel';
|
||||||
export type { ExecutionParams, ExecutionResult } from './SignalExecutionPanel';
|
export type { ExecutionParams, ExecutionResult } from './SignalExecutionPanel';
|
||||||
|
|
||||||
|
// Message List & Layout
|
||||||
|
export { default as MessageList } from './MessageList';
|
||||||
|
export { default as ChatHeader } from './ChatHeader';
|
||||||
|
|
||||||
|
// Search
|
||||||
|
export { default as MessageSearch } from './MessageSearch';
|
||||||
|
|
||||||
|
// Rendering
|
||||||
|
export { default as MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
export { CodeBlock, AlertBox, SignalCard as MarkdownSignalCard, parseMarkdown } from './MarkdownRenderer';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user