[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
|
||||
export { default as SignalExecutionPanel } 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