mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-01 11:56:25 +02:00
feat(crypto): add SurfSense 2.0 Crypto Co-Pilot UI components
Frontend - Web Dashboard: - Add crypto dashboard page with Watchlist, Alerts, Market, Profile tabs - Add 11 tool-ui components for inline chat display - Add crypto components (ChainIcon, SafetyBadge, PriceDisplay, etc.) - Add modals (AddTokenModal, CreateAlertModal) - Add mock data for development Frontend - Browser Extension: - Add shared components (ChainIcon, RiskBadge, PriceDisplay, SuggestionCard) - Add crypto components (SafetyScoreDisplay, WatchlistPanel, AlertConfigModal) - Add chat enhancements (WelcomeScreen, ThinkingStepsDisplay) - Add widget components for inline display - Enhance TokenInfoCard, ChatHeader, ChatInput, ChatInterface Documentation: - Add conversational UX specification - Add UX analysis report - Update extension UX design This implements the Conversational UX paradigm where crypto features are AI-callable tools that render inline in the chat interface.
This commit is contained in:
parent
ad795eb830
commit
e4d020799b
58 changed files with 11315 additions and 661 deletions
|
|
@ -1,24 +1,239 @@
|
|||
import { Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
User,
|
||||
LogOut,
|
||||
ExternalLink,
|
||||
Star,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Plug
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/routes/ui/popover";
|
||||
|
||||
export interface SearchSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
/** Available search spaces */
|
||||
searchSpaces?: SearchSpace[];
|
||||
/** Currently selected search space */
|
||||
selectedSpace?: SearchSpace;
|
||||
/** Callback when search space is changed */
|
||||
onSpaceChange?: (space: SearchSpace) => void;
|
||||
/** User display name */
|
||||
userName?: string;
|
||||
/** User avatar URL */
|
||||
userAvatar?: string;
|
||||
/** Callback when logout is clicked */
|
||||
onLogout?: () => void;
|
||||
/** Callback when settings item is clicked */
|
||||
onSettingsClick?: (item: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat header with branding and settings
|
||||
* Enhanced Chat header with branding, space selector, settings, and user menu
|
||||
*
|
||||
* Features:
|
||||
* - Search space selector dropdown
|
||||
* - Settings dropdown with full menu
|
||||
* - User avatar with logout option
|
||||
*/
|
||||
export function ChatHeader() {
|
||||
export function ChatHeader({
|
||||
searchSpaces = [],
|
||||
selectedSpace,
|
||||
onSpaceChange,
|
||||
userName,
|
||||
userAvatar,
|
||||
onLogout,
|
||||
onSettingsClick,
|
||||
}: ChatHeaderProps) {
|
||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const defaultSpaces: SearchSpace[] = [
|
||||
{ id: "crypto", name: "Crypto", icon: "🪙" },
|
||||
{ id: "general", name: "General", icon: "📚" },
|
||||
{ id: "research", name: "Research", icon: "🔬" },
|
||||
];
|
||||
|
||||
const spaces = searchSpaces.length > 0 ? searchSpaces : defaultSpaces;
|
||||
const currentSpace = selectedSpace || spaces[0];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
{/* Logo and brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/assets/icon.png"
|
||||
alt="SurfSense"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<h1 className="font-semibold text-lg">SurfSense</h1>
|
||||
<h1 className="font-semibold text-base">SurfSense</h1>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Search Space Selector */}
|
||||
<Popover open={spaceOpen} onOpenChange={setSpaceOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2"
|
||||
>
|
||||
<span>{currentSpace.icon}</span>
|
||||
<span className="max-w-[80px] truncate">{currentSpace.name}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="center">
|
||||
<div className="space-y-0.5">
|
||||
{spaces.map((space) => (
|
||||
<button
|
||||
key={space.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors",
|
||||
currentSpace.id === space.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSpaceChange?.(space);
|
||||
setSpaceOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{space.icon}</span>
|
||||
<span>{space.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Settings Dropdown */}
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="end">
|
||||
<SettingsMenu
|
||||
onItemClick={(item) => {
|
||||
onSettingsClick?.(item);
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* User Avatar */}
|
||||
<UserAvatar
|
||||
name={userName}
|
||||
avatarUrl={userAvatar}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings menu items
|
||||
*/
|
||||
function SettingsMenu({
|
||||
onItemClick,
|
||||
onLogout,
|
||||
}: {
|
||||
onItemClick?: (item: string) => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const menuItems = [
|
||||
{ id: "connectors", label: "Manage Connectors", icon: Plug },
|
||||
{ id: "chats", label: "View All Chats", icon: MessageSquare },
|
||||
{ id: "watchlist", label: "Manage Watchlist", icon: Star },
|
||||
{ id: "alerts", label: "Alert History", icon: Bell },
|
||||
{ id: "settings", label: "Full Settings", icon: Settings, external: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
onClick={() => onItemClick?.(item.id)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
{item.external && <ExternalLink className="h-3 w-3 opacity-50" />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1 border-t" />
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User avatar component
|
||||
*/
|
||||
function UserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
onLogout,
|
||||
}: {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const initials = name
|
||||
? name.split(" ").map(n => n[0]).join("").toUpperCase().slice(0, 2)
|
||||
: "U";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/20 transition-all">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt={name || "User"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs font-medium text-primary">{initials}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-2" align="end">
|
||||
<div className="text-center pb-2 border-b mb-2">
|
||||
<p className="font-medium text-sm">{name || "User"}</p>
|
||||
</div>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-destructive/10 text-destructive transition-colors"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,256 @@
|
|||
import { useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export interface AttachedFile {
|
||||
/** File ID */
|
||||
id: string;
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File type */
|
||||
type: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** File object */
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
/** Callback when message is sent */
|
||||
onSend: (content: string, attachments?: AttachedFile[]) => void;
|
||||
/** Whether input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Whether to show attachment button */
|
||||
showAttachment?: boolean;
|
||||
/** Accepted file types */
|
||||
acceptedFileTypes?: string;
|
||||
/** Max file size in bytes (default 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Quick action suggestions */
|
||||
suggestions?: string[];
|
||||
/** Callback when suggestion is clicked */
|
||||
onSuggestionClick?: (suggestion: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat input component with send button
|
||||
* Enhanced chat input with attachment support and suggestions
|
||||
*
|
||||
* Features:
|
||||
* - Text input with send button
|
||||
* - File attachment button
|
||||
* - Attached files preview
|
||||
* - Quick action suggestions
|
||||
* - Keyboard shortcuts (Enter to send)
|
||||
*/
|
||||
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
showAttachment = true,
|
||||
acceptedFileTypes = ".pdf,.txt,.md,.json,.csv,image/*",
|
||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||
suggestions = [],
|
||||
onSuggestionClick,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !disabled) {
|
||||
onSend(input.trim());
|
||||
if ((input.trim() || attachments.length > 0) && !disabled) {
|
||||
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const newAttachments: AttachedFile[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
console.warn(`File ${file.name} exceeds max size of ${maxFileSize / 1024 / 1024}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
newAttachments.push({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
});
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith("image/")) return Image;
|
||||
if (type.includes("pdf") || type.includes("text")) return FileText;
|
||||
return File;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={disabled || !input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="border-t">
|
||||
{/* Quick suggestions */}
|
||||
{suggestions.length > 0 && input.length === 0 && attachments.length === 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{suggestions.slice(0, 3).map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="text-xs px-2 py-1 rounded-full bg-muted hover:bg-muted/80 text-muted-foreground transition-colors"
|
||||
onClick={() => onSuggestionClick?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached files preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{attachments.map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.type);
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-sm group"
|
||||
>
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatFileSize(attachment.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
className="ml-1 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn(
|
||||
"p-3 transition-colors",
|
||||
dragOver && "bg-primary/5"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment button */}
|
||||
{showAttachment && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptedFileTypes}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md bg-background text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
"resize-none min-h-[38px] max-h-[120px]",
|
||||
"scrollbar-thin scrollbar-thumb-muted"
|
||||
)}
|
||||
style={{
|
||||
height: "auto",
|
||||
minHeight: "38px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
disabled={disabled || (!input.trim() && attachments.length === 0)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-md border-2 border-dashed border-primary pointer-events-none">
|
||||
<p className="text-sm text-primary font-medium">Drop files here</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,78 +2,472 @@ import { useState } from "react";
|
|||
import { usePageContext } from "../context/PageContextProvider";
|
||||
import { TokenInfoCard } from "../dexscreener/TokenInfoCard";
|
||||
import { QuickCapture } from "./QuickCapture";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatHeader, type SearchSpace } from "./ChatHeader";
|
||||
import { ChatMessages, type Message, type MessageWidget } from "./ChatMessages";
|
||||
import { ChatInput, type AttachedFile } from "./ChatInput";
|
||||
import { ThinkingStepsDisplay, type ThinkingStep } from "./ThinkingStepsDisplay";
|
||||
import {
|
||||
MOCK_MODE,
|
||||
MOCK_SEARCH_SPACES,
|
||||
MOCK_WATCHLIST_TOKENS,
|
||||
MOCK_WATCHLIST_ALERTS,
|
||||
MOCK_SAFETY_SCORE,
|
||||
MOCK_SAFETY_FACTORS,
|
||||
MOCK_SAFETY_SOURCES,
|
||||
} from "../mock/mockData";
|
||||
import { SafetyScoreDisplay } from "../crypto/SafetyScoreDisplay";
|
||||
import { WatchlistPanel } from "../crypto/WatchlistPanel";
|
||||
import { AlertConfigModal } from "../crypto/AlertConfigModal";
|
||||
import type { WatchlistItem } from "../widgets";
|
||||
|
||||
type ViewMode = "chat" | "watchlist" | "safety";
|
||||
|
||||
/**
|
||||
* Natural language command patterns for conversational UX
|
||||
*/
|
||||
const COMMAND_PATTERNS = {
|
||||
ADD_WATCHLIST: /add\s+(\w+)\s+to\s+(my\s+)?watchlist/i,
|
||||
REMOVE_WATCHLIST: /remove\s+(\w+)\s+from\s+(my\s+)?watchlist/i,
|
||||
SHOW_WATCHLIST: /(show|display|view)\s+(my\s+)?watchlist/i,
|
||||
SET_ALERT: /set\s+alert\s+(if|when)\s+(\w+)\s+(drops?|pumps?|reaches?|changes?)\s+(\d+)%?/i,
|
||||
ANALYZE_TOKEN: /(analyze|research|check)\s+(\w+)/i,
|
||||
SAFETY_CHECK: /(is\s+)?(\w+)\s+(safe|risky|rug)/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* Main chat interface for side panel
|
||||
* Adapts UI based on page context (e.g., shows token card on DexScreener)
|
||||
*
|
||||
* Features:
|
||||
* - Context-aware UI (DexScreener token detection)
|
||||
* - Welcome screen for new users
|
||||
* - Thinking steps visualization
|
||||
* - File attachments support
|
||||
* - Search space selection
|
||||
* - Watchlist panel
|
||||
* - Safety analysis view
|
||||
*/
|
||||
export function ChatInterface() {
|
||||
const { context } = usePageContext();
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const { context, isMockMode } = usePageContext();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [thinkingSteps, setThinkingSteps] = useState<ThinkingStep[]>([]);
|
||||
const [selectedSpace, setSelectedSpace] = useState<SearchSpace>(
|
||||
MOCK_SEARCH_SPACES[0]
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("chat");
|
||||
const [showAlertModal, setShowAlertModal] = useState(false);
|
||||
const [selectedTokenForAlert, setSelectedTokenForAlert] = useState<string | null>(null);
|
||||
const [watchlistTokens, setWatchlistTokens] = useState(MOCK_WATCHLIST_TOKENS);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
// TODO: Implement message sending with backend API
|
||||
console.log("Sending message:", content);
|
||||
// Mock user data - in production, this would come from auth context
|
||||
const userName = "Crypto Trader";
|
||||
|
||||
const handleSendMessage = async (content: string, attachments?: AttachedFile[]) => {
|
||||
console.log("Sending message:", content, attachments);
|
||||
setIsStreaming(true);
|
||||
setViewMode("chat");
|
||||
|
||||
// Add user message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
const userMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Simulate thinking steps
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isActive: true },
|
||||
]);
|
||||
|
||||
// TODO: Stream response from backend
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: "This is a placeholder response. Backend integration coming soon!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching", title: "Searching knowledge base...", isActive: true },
|
||||
]);
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([
|
||||
{ id: "1", type: "thinking", title: "Understanding your question...", isComplete: true },
|
||||
{ id: "2", type: "searching", title: "Searching knowledge base...", isComplete: true },
|
||||
{ id: "3", type: "analyzing", title: "Analyzing results...", isActive: true },
|
||||
]);
|
||||
setIsStreaming(false);
|
||||
}, 1000);
|
||||
|
||||
// Generate response based on content - with embedded widgets
|
||||
setTimeout(() => {
|
||||
setThinkingSteps([]);
|
||||
|
||||
let responseContent = "";
|
||||
let widget: MessageWidget | undefined;
|
||||
const tokenSymbol = context?.tokenData?.tokenSymbol || "BULLA";
|
||||
|
||||
// Check for natural language commands
|
||||
const addWatchlistMatch = content.match(COMMAND_PATTERNS.ADD_WATCHLIST);
|
||||
const showWatchlistMatch = content.match(COMMAND_PATTERNS.SHOW_WATCHLIST);
|
||||
const setAlertMatch = content.match(COMMAND_PATTERNS.SET_ALERT);
|
||||
|
||||
if (addWatchlistMatch || content.toLowerCase().includes("add") && content.toLowerCase().includes("watchlist")) {
|
||||
// Add to watchlist command
|
||||
const token = addWatchlistMatch?.[1] || tokenSymbol;
|
||||
responseContent = `Done! ✅\n\nI've added ${token} to your watchlist.`;
|
||||
widget = {
|
||||
type: "action_confirmation",
|
||||
actionType: "watchlist_add",
|
||||
tokenSymbol: token,
|
||||
details: [
|
||||
"Price change ±20%",
|
||||
"Liquidity drop >10%",
|
||||
"Whale movement >$50K",
|
||||
],
|
||||
};
|
||||
// Actually add to watchlist
|
||||
if (!watchlistTokens.find(t => t.symbol === token)) {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: token,
|
||||
name: token + " Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
contractAddress: context?.tokenData?.pairAddress || "unknown",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
hasAlerts: true,
|
||||
alertCount: 3,
|
||||
};
|
||||
setWatchlistTokens(prev => [...prev, newToken]);
|
||||
setIsInWatchlist(true);
|
||||
}
|
||||
} else if (showWatchlistMatch || content.toLowerCase().includes("watchlist") && (content.toLowerCase().includes("show") || content.toLowerCase().includes("view"))) {
|
||||
// Show watchlist command
|
||||
responseContent = `Here's your watchlist:`;
|
||||
const watchlistItems: WatchlistItem[] = watchlistTokens.map(t => ({
|
||||
id: t.id,
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
chain: t.chain,
|
||||
price: t.price,
|
||||
priceChange24h: t.priceChange24h,
|
||||
alertCount: t.alertCount,
|
||||
}));
|
||||
widget = {
|
||||
type: "watchlist",
|
||||
tokens: watchlistItems,
|
||||
};
|
||||
if (watchlistTokens.length > 0) {
|
||||
const bestPerformer = watchlistTokens.reduce((a, b) =>
|
||||
a.priceChange24h > b.priceChange24h ? a : b
|
||||
);
|
||||
responseContent += `\n\n${bestPerformer.symbol} is up ${bestPerformer.priceChange24h.toFixed(1)}% - your best performer! Want me to analyze if it's time to take profits?`;
|
||||
}
|
||||
} else if (setAlertMatch || content.toLowerCase().includes("alert") && (content.toLowerCase().includes("set") || content.toLowerCase().includes("notify"))) {
|
||||
// Set alert command
|
||||
const match = content.match(/(\d+)%/);
|
||||
const percentage = match ? match[1] : "20";
|
||||
const direction = content.toLowerCase().includes("drop") ? "drops" : "changes";
|
||||
responseContent = `I'll set that up for you:`;
|
||||
widget = {
|
||||
type: "alert_config",
|
||||
config: {
|
||||
tokenSymbol: tokenSymbol,
|
||||
condition: `Price ${direction} ${percentage}%`,
|
||||
currentPrice: context?.tokenData?.price || "$0.00001234",
|
||||
triggerPrice: "$0.00000987",
|
||||
channels: {
|
||||
browser: true,
|
||||
inApp: true,
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
isNew: true,
|
||||
};
|
||||
responseContent += `\n\nDone! I'll notify you if ${tokenSymbol} ${direction} ${percentage}% from current price. Want to set any other alerts?`;
|
||||
} else if (content.toLowerCase().includes("safe") || content.toLowerCase().includes("rug") || content.toLowerCase().includes("analyze") || content.toLowerCase().includes("research")) {
|
||||
// Token analysis with embedded widget
|
||||
responseContent = `Here's my analysis of ${tokenSymbol}:`;
|
||||
widget = {
|
||||
type: "token_analysis",
|
||||
data: {
|
||||
symbol: tokenSymbol,
|
||||
name: context?.tokenData?.tokenName || "Bulla Token",
|
||||
chain: context?.tokenData?.chain || "solana",
|
||||
price: context?.tokenData?.price || "$0.00001234",
|
||||
priceChange24h: 156.7,
|
||||
marketCap: "$2.1M",
|
||||
volume24h: "$1.2M",
|
||||
liquidity: "$450K",
|
||||
safetyScore: MOCK_SAFETY_SCORE,
|
||||
holderCount: 12456,
|
||||
top10HolderPercent: 35,
|
||||
},
|
||||
isInWatchlist: isInWatchlist,
|
||||
};
|
||||
responseContent += `\n\nBased on your moderate risk profile, suggested allocation: 2-5% of portfolio. The safety score of ${MOCK_SAFETY_SCORE}/100 indicates medium risk - proceed with caution.`;
|
||||
} else if (content.toLowerCase().includes("holder")) {
|
||||
responseContent = `**Holder Analysis for ${tokenSymbol}:**
|
||||
|
||||
📊 **Distribution:**
|
||||
- Total Holders: 12,456
|
||||
- Top 10 Holders: 35% of supply
|
||||
- Top 50 Holders: 52% of supply
|
||||
|
||||
🐋 **Whale Activity (24h):**
|
||||
- 3 large buys (>$10K each)
|
||||
- 1 large sell ($25K)
|
||||
- Net flow: +$15K
|
||||
|
||||
⚠️ **Concentration Risk:** Medium
|
||||
The top holder owns 8.5% which is relatively high.`;
|
||||
} else {
|
||||
responseContent = `I can help you with crypto analysis! Try these commands:
|
||||
|
||||
• **"Add BULLA to my watchlist"** - Track tokens
|
||||
• **"Show my watchlist"** - View tracked tokens
|
||||
• **"Set alert if BULLA drops 20%"** - Price alerts
|
||||
• **"Analyze BULLA"** - Full token analysis
|
||||
• **"Is BULLA safe?"** - Safety check
|
||||
|
||||
What would you like to know?`;
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: responseContent,
|
||||
timestamp: new Date(),
|
||||
widget,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsStreaming(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (text: string) => {
|
||||
handleSendMessage(text);
|
||||
};
|
||||
|
||||
const handleSpaceChange = (space: SearchSpace) => {
|
||||
setSelectedSpace(space);
|
||||
};
|
||||
|
||||
const handleSettingsClick = (item: string) => {
|
||||
console.log("Settings item clicked:", item);
|
||||
if (item === "watchlist") {
|
||||
setViewMode("watchlist");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
console.log("Logout clicked");
|
||||
};
|
||||
|
||||
const handleSafetyCheck = () => {
|
||||
setViewMode("safety");
|
||||
};
|
||||
|
||||
const handleAddToWatchlist = () => {
|
||||
setIsInWatchlist(!isInWatchlist);
|
||||
if (!isInWatchlist && context?.tokenData) {
|
||||
const newToken = {
|
||||
id: `token-${Date.now()}`,
|
||||
symbol: context.tokenData.tokenSymbol || "TOKEN",
|
||||
name: context.tokenData.tokenName || "Unknown Token",
|
||||
chain: context.tokenData.chain,
|
||||
contractAddress: context.tokenData.pairAddress,
|
||||
price: context.tokenData.price || "$0",
|
||||
priceChange24h: context.tokenData.priceChange24h || 0,
|
||||
hasAlerts: false,
|
||||
};
|
||||
setWatchlistTokens(prev => [...prev, newToken]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureAlerts = (tokenSymbol: string) => {
|
||||
setSelectedTokenForAlert(tokenSymbol);
|
||||
setShowAlertModal(true);
|
||||
};
|
||||
|
||||
const handleRugCheck = () => {
|
||||
handleSendMessage("Check this token for rug pull risks");
|
||||
};
|
||||
|
||||
// Handle widget actions from embedded widgets in chat
|
||||
const handleWidgetAction = (action: string, data?: unknown) => {
|
||||
console.log("Widget action:", action, data);
|
||||
switch (action) {
|
||||
case "view_watchlist":
|
||||
handleSendMessage("Show my watchlist");
|
||||
break;
|
||||
case "edit_alerts":
|
||||
if (typeof data === "string") {
|
||||
handleConfigureAlerts(data);
|
||||
}
|
||||
break;
|
||||
case "analyze_token":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Analyze ${(data as { symbol: string }).symbol}`);
|
||||
}
|
||||
break;
|
||||
case "remove_from_watchlist":
|
||||
if (typeof data === "string") {
|
||||
setWatchlistTokens(prev => prev.filter(t => t.id !== data));
|
||||
}
|
||||
break;
|
||||
case "add_to_watchlist":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Add ${(data as { symbol: string }).symbol} to my watchlist`);
|
||||
}
|
||||
break;
|
||||
case "set_alert":
|
||||
if (typeof data === "string") {
|
||||
handleConfigureAlerts(data);
|
||||
}
|
||||
break;
|
||||
case "analyze_further":
|
||||
if (data && typeof data === "object" && "symbol" in data) {
|
||||
handleSendMessage(`Tell me more about ${(data as { symbol: string }).symbol} holders and whale activity`);
|
||||
}
|
||||
break;
|
||||
case "tell_more":
|
||||
if (data && typeof data === "object" && "tokenSymbol" in data) {
|
||||
handleSendMessage(`Tell me more about ${(data as { tokenSymbol: string }).tokenSymbol}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log("Unhandled widget action:", action);
|
||||
}
|
||||
};
|
||||
|
||||
// Quick suggestions based on context
|
||||
const quickSuggestions = context?.pageType === "dexscreener"
|
||||
? ["Add to watchlist", "Is this safe?", "Set price alert"]
|
||||
: ["Show my watchlist", "What's trending?", "Analyze BULLA"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<ChatHeader />
|
||||
{/* Header with space selector and settings */}
|
||||
<ChatHeader
|
||||
searchSpaces={MOCK_SEARCH_SPACES}
|
||||
selectedSpace={selectedSpace}
|
||||
onSpaceChange={handleSpaceChange}
|
||||
userName={userName}
|
||||
onSettingsClick={handleSettingsClick}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
{/* Token info card (only on DexScreener) */}
|
||||
{context?.pageType === "dexscreener" && context.tokenData && (
|
||||
<TokenInfoCard tokenData={context.tokenData} />
|
||||
{context?.pageType === "dexscreener" && context.tokenData && viewMode === "chat" && (
|
||||
<TokenInfoCard
|
||||
tokenData={context.tokenData}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onAddToWatchlist={handleAddToWatchlist}
|
||||
onSafetyCheck={handleSafetyCheck}
|
||||
onRugCheck={handleRugCheck}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChatMessages messages={messages} />
|
||||
{viewMode === "chat" && (
|
||||
<>
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
userName={userName}
|
||||
onWidgetAction={handleWidgetAction}
|
||||
/>
|
||||
|
||||
{/* Thinking steps (shown during streaming) */}
|
||||
{isStreaming && thinkingSteps.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<ThinkingStepsDisplay
|
||||
steps={thinkingSteps}
|
||||
isThinking={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === "watchlist" && (
|
||||
<WatchlistPanel
|
||||
tokens={watchlistTokens}
|
||||
recentAlerts={MOCK_WATCHLIST_ALERTS}
|
||||
onTokenClick={(token) => console.log("Token clicked:", token)}
|
||||
onRemoveToken={(id) => setWatchlistTokens(prev => prev.filter(t => t.id !== id))}
|
||||
onAddToken={() => console.log("Add token clicked")}
|
||||
onConfigureAlerts={(token) => handleConfigureAlerts(token.symbol)}
|
||||
onAlertClick={(alert) => console.log("Alert clicked:", alert)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === "safety" && (
|
||||
<div className="p-4">
|
||||
<SafetyScoreDisplay
|
||||
score={MOCK_SAFETY_SCORE}
|
||||
factors={MOCK_SAFETY_FACTORS}
|
||||
sources={MOCK_SAFETY_SOURCES}
|
||||
timestamp={new Date()}
|
||||
tokenSymbol={context?.tokenData?.tokenSymbol}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onAddToWatchlist={handleAddToWatchlist}
|
||||
onSetAlert={() => handleConfigureAlerts(context?.tokenData?.tokenSymbol || "TOKEN")}
|
||||
/>
|
||||
<button
|
||||
className="mt-4 text-sm text-primary hover:underline"
|
||||
onClick={() => setViewMode("chat")}
|
||||
>
|
||||
← Back to chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat input */}
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isStreaming}
|
||||
placeholder={
|
||||
context?.pageType === "dexscreener"
|
||||
? "Ask about this token..."
|
||||
: "Ask me anything..."
|
||||
}
|
||||
/>
|
||||
{/* Chat input (only in chat mode) */}
|
||||
{viewMode === "chat" && (
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isStreaming}
|
||||
placeholder={
|
||||
context?.pageType === "dexscreener"
|
||||
? `Ask about ${context.tokenData?.tokenSymbol || "this token"}...`
|
||||
: "Ask me anything..."
|
||||
}
|
||||
suggestions={messages.length === 0 ? [] : quickSuggestions}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Back to chat button for other views */}
|
||||
{viewMode !== "chat" && (
|
||||
<div className="border-t p-3">
|
||||
<button
|
||||
className="w-full py-2 text-sm text-center text-primary hover:bg-primary/5 rounded-md transition-colors"
|
||||
onClick={() => setViewMode("chat")}
|
||||
>
|
||||
← Back to Chat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick capture button */}
|
||||
<QuickCapture />
|
||||
|
||||
{/* Alert configuration modal */}
|
||||
<AlertConfigModal
|
||||
open={showAlertModal}
|
||||
onOpenChange={setShowAlertModal}
|
||||
tokenSymbol={selectedTokenForAlert || "TOKEN"}
|
||||
currentPrice={context?.tokenData?.price}
|
||||
onSave={(alerts) => {
|
||||
console.log("Alerts saved:", alerts);
|
||||
setShowAlertModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,169 @@
|
|||
import { WelcomeScreen } from "./WelcomeScreen";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ActionConfirmationWidget,
|
||||
ProactiveAlertCard,
|
||||
WatchlistWidget,
|
||||
AlertWidget,
|
||||
TokenAnalysisWidget,
|
||||
type ProactiveAlertData,
|
||||
type WatchlistItem,
|
||||
type AlertConfigData,
|
||||
type TokenAnalysisData,
|
||||
} from "../widgets";
|
||||
|
||||
// Widget types that can be embedded in messages
|
||||
export type MessageWidget =
|
||||
| { type: "action_confirmation"; actionType: "watchlist_add" | "watchlist_remove" | "alert_set" | "alert_delete"; tokenSymbol: string; details?: string[] }
|
||||
| { type: "proactive_alert"; alert: ProactiveAlertData; recommendation?: string }
|
||||
| { type: "watchlist"; tokens: WatchlistItem[] }
|
||||
| { type: "alert_config"; config: AlertConfigData; isNew?: boolean }
|
||||
| { type: "token_analysis"; data: TokenAnalysisData; isInWatchlist?: boolean };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
isStreaming?: boolean;
|
||||
/** Embedded widget to display with this message */
|
||||
widget?: MessageWidget;
|
||||
}
|
||||
|
||||
export interface ChatMessagesProps {
|
||||
messages: Message[];
|
||||
onSuggestionClick?: (text: string) => void;
|
||||
userName?: string;
|
||||
/** Callbacks for widget interactions */
|
||||
onWidgetAction?: (action: string, data?: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat messages display component
|
||||
* Chat messages display component with embedded widget support
|
||||
* Shows WelcomeScreen when no messages, otherwise displays conversation
|
||||
*
|
||||
* Supports embedded widgets for conversational UX:
|
||||
* - ActionConfirmationWidget: Shows action confirmations
|
||||
* - ProactiveAlertCard: AI-initiated alerts
|
||||
* - WatchlistWidget: Inline watchlist display
|
||||
* - AlertWidget: Alert configuration display
|
||||
* - TokenAnalysisWidget: Full token analysis
|
||||
*/
|
||||
export function ChatMessages({ messages }: { messages: any[] }) {
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
onSuggestionClick,
|
||||
userName,
|
||||
onWidgetAction,
|
||||
}: ChatMessagesProps) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p>Start a conversation...</p>
|
||||
</div>
|
||||
<WelcomeScreen
|
||||
userName={userName}
|
||||
onSuggestionClick={onSuggestionClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleWidgetAction = (action: string, data?: unknown) => {
|
||||
onWidgetAction?.(action, data);
|
||||
};
|
||||
|
||||
const renderWidget = (widget: MessageWidget) => {
|
||||
switch (widget.type) {
|
||||
case "action_confirmation":
|
||||
return (
|
||||
<ActionConfirmationWidget
|
||||
actionType={widget.actionType}
|
||||
tokenSymbol={widget.tokenSymbol}
|
||||
details={widget.details}
|
||||
onViewWatchlist={() => handleWidgetAction("view_watchlist")}
|
||||
onEditAlerts={() => handleWidgetAction("edit_alerts", widget.tokenSymbol)}
|
||||
/>
|
||||
);
|
||||
case "proactive_alert":
|
||||
return (
|
||||
<ProactiveAlertCard
|
||||
alert={widget.alert}
|
||||
recommendation={widget.recommendation}
|
||||
onViewDetails={() => handleWidgetAction("view_alert_details", widget.alert)}
|
||||
onDismiss={() => handleWidgetAction("dismiss_alert", widget.alert.id)}
|
||||
onSetAlert={() => handleWidgetAction("set_alert", widget.alert.tokenSymbol)}
|
||||
onTellMore={() => handleWidgetAction("tell_more", widget.alert)}
|
||||
/>
|
||||
);
|
||||
case "watchlist":
|
||||
return (
|
||||
<WatchlistWidget
|
||||
tokens={widget.tokens}
|
||||
onAnalyze={(token) => handleWidgetAction("analyze_token", token)}
|
||||
onRemove={(id) => handleWidgetAction("remove_from_watchlist", id)}
|
||||
onAddToken={() => handleWidgetAction("add_token")}
|
||||
onClearAll={() => handleWidgetAction("clear_watchlist")}
|
||||
/>
|
||||
);
|
||||
case "alert_config":
|
||||
return (
|
||||
<AlertWidget
|
||||
config={widget.config}
|
||||
isNew={widget.isNew}
|
||||
onEdit={() => handleWidgetAction("edit_alert", widget.config)}
|
||||
onDelete={() => handleWidgetAction("delete_alert", widget.config)}
|
||||
onAddAnother={() => handleWidgetAction("add_another_alert")}
|
||||
onViewAll={() => handleWidgetAction("view_all_alerts")}
|
||||
/>
|
||||
);
|
||||
case "token_analysis":
|
||||
return (
|
||||
<TokenAnalysisWidget
|
||||
data={widget.data}
|
||||
isInWatchlist={widget.isInWatchlist}
|
||||
onAddToWatchlist={() => handleWidgetAction("add_to_watchlist", widget.data)}
|
||||
onSetAlert={() => handleWidgetAction("set_alert", widget.data.symbol)}
|
||||
onAnalyzeFurther={() => handleWidgetAction("analyze_further", widget.data)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
message.role === "user" ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
{/* Message bubble */}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${message.role === "user"
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg p-3",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
: "bg-muted",
|
||||
message.isStreaming && "animate-pulse"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
{message.timestamp && (
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Embedded widget (for assistant messages) */}
|
||||
{message.role === "assistant" && message.widget && (
|
||||
<div className="w-full max-w-[95%] mt-2">
|
||||
{renderWidget(message.widget)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Brain,
|
||||
Search,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
CheckCircle,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
|
||||
export type ThinkingStepType = "thinking" | "searching" | "reading" | "analyzing" | "complete";
|
||||
|
||||
export interface ThinkingStep {
|
||||
/** Step ID */
|
||||
id: string;
|
||||
/** Step type for icon selection */
|
||||
type: ThinkingStepType;
|
||||
/** Step title/label */
|
||||
title: string;
|
||||
/** Step description or content */
|
||||
content?: string;
|
||||
/** Whether step is currently active */
|
||||
isActive?: boolean;
|
||||
/** Whether step is complete */
|
||||
isComplete?: boolean;
|
||||
/** Timestamp */
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface ThinkingStepsDisplayProps {
|
||||
/** List of thinking steps */
|
||||
steps: ThinkingStep[];
|
||||
/** Whether AI is currently thinking */
|
||||
isThinking?: boolean;
|
||||
/** Whether to show expanded by default */
|
||||
defaultExpanded?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP_ICONS: Record<ThinkingStepType, typeof Brain> = {
|
||||
thinking: Brain,
|
||||
searching: Search,
|
||||
reading: FileText,
|
||||
analyzing: Lightbulb,
|
||||
complete: CheckCircle,
|
||||
};
|
||||
|
||||
const STEP_COLORS: Record<ThinkingStepType, string> = {
|
||||
thinking: "text-purple-500",
|
||||
searching: "text-blue-500",
|
||||
reading: "text-green-500",
|
||||
analyzing: "text-orange-500",
|
||||
complete: "text-green-600",
|
||||
};
|
||||
|
||||
/**
|
||||
* ThinkingStepsDisplay - Shows AI reasoning process
|
||||
*
|
||||
* Features:
|
||||
* - Collapsible thinking steps
|
||||
* - Step-specific icons and colors
|
||||
* - Active step animation
|
||||
* - Expandable step details
|
||||
*/
|
||||
export function ThinkingStepsDisplay({
|
||||
steps,
|
||||
isThinking = false,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
}: ThinkingStepsDisplayProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
if (steps.length === 0 && !isThinking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeStep = steps.find(s => s.isActive);
|
||||
const completedSteps = steps.filter(s => s.isComplete).length;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-muted/30", className)}>
|
||||
{/* Header - clickable to expand/collapse */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<Brain className={cn(
|
||||
"h-4 w-4",
|
||||
isThinking ? "text-purple-500 animate-pulse" : "text-muted-foreground"
|
||||
)} />
|
||||
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{isThinking ? "Thinking..." : "Thought Process"}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedSteps}/{steps.length} steps
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Steps list */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<StepItem key={step.id} step={step} index={index} />
|
||||
))}
|
||||
|
||||
{/* Active thinking indicator */}
|
||||
{isThinking && !activeStep && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-purple-500/10">
|
||||
<Loader2 className="h-4 w-4 text-purple-500 animate-spin" />
|
||||
<span className="text-sm text-purple-600 dark:text-purple-400">
|
||||
Processing...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual step item
|
||||
*/
|
||||
function StepItem({ step, index }: { step: ThinkingStep; index: number }) {
|
||||
const [isDetailExpanded, setIsDetailExpanded] = useState(false);
|
||||
const Icon = STEP_ICONS[step.type];
|
||||
const colorClass = STEP_COLORS[step.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
step.isActive && "bg-primary/5 ring-1 ring-primary/20",
|
||||
step.isComplete && "opacity-80"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 p-2 cursor-pointer"
|
||||
onClick={() => step.content && setIsDetailExpanded(!isDetailExpanded)}
|
||||
>
|
||||
{/* Step number or icon */}
|
||||
<div className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
step.isActive ? "bg-primary/10" : "bg-muted"
|
||||
)}>
|
||||
{step.isActive ? (
|
||||
<Loader2 className={cn("h-3 w-3 animate-spin", colorClass)} />
|
||||
) : step.isComplete ? (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Icon className={cn("h-3 w-3", colorClass)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
"text-sm",
|
||||
step.isActive && "font-medium"
|
||||
)}>
|
||||
{step.title}
|
||||
</p>
|
||||
|
||||
{/* Expandable detail */}
|
||||
{step.content && isDetailExpanded && (
|
||||
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
|
||||
{step.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand indicator */}
|
||||
{step.content && (
|
||||
<ChevronRight className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
isDetailExpanded && "rotate-90"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
surfsense_browser_extension/sidepanel/chat/WelcomeScreen.tsx
Normal file
113
surfsense_browser_extension/sidepanel/chat/WelcomeScreen.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useMemo } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { SuggestionCard, DEFAULT_CRYPTO_SUGGESTIONS } from "../components/shared";
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
/** User's display name for personalized greeting */
|
||||
userName?: string;
|
||||
/** Callback when a suggestion is clicked */
|
||||
onSuggestionClick?: (text: string) => void;
|
||||
/** Custom suggestions (overrides defaults) */
|
||||
suggestions?: Array<{ text: string; type: "general" | "safety" | "trending" | "wallet" | "custom" }>;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time-based greeting message
|
||||
*/
|
||||
function getTimeBasedGreeting(userName?: string): string {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
// Greeting variations for each time period
|
||||
const morningGreetings = ["Good morning", "Fresh start today", "Morning"];
|
||||
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there"];
|
||||
const eveningGreetings = ["Good evening", "Evening", "Hey there"];
|
||||
const nightGreetings = ["Good night", "Evening", "Winding down"];
|
||||
const lateNightGreetings = ["Still up?", "Night owl mode", "Burning the midnight oil"];
|
||||
|
||||
let greeting: string;
|
||||
if (hour < 5) {
|
||||
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
||||
} else if (hour < 12) {
|
||||
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
||||
} else if (hour < 18) {
|
||||
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
||||
} else if (hour < 22) {
|
||||
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
||||
} else {
|
||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
||||
}
|
||||
|
||||
// Add personalization with name if available
|
||||
if (userName) {
|
||||
const firstName = userName.split(/\s+/)[0];
|
||||
return `${greeting}, ${firstName}!`;
|
||||
}
|
||||
|
||||
return `${greeting}!`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WelcomeScreen - Displays greeting and suggestion cards for new conversations
|
||||
*
|
||||
* Features:
|
||||
* - Time-based personalized greeting
|
||||
* - Crypto-specific suggestion cards
|
||||
* - Animated entrance
|
||||
* - Accessible keyboard navigation
|
||||
*/
|
||||
export function WelcomeScreen({
|
||||
userName,
|
||||
onSuggestionClick,
|
||||
suggestions = DEFAULT_CRYPTO_SUGGESTIONS,
|
||||
className,
|
||||
}: WelcomeScreenProps) {
|
||||
// Memoize greeting so it doesn't change on re-renders
|
||||
const greeting = useMemo(() => getTimeBasedGreeting(userName), [userName]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center h-full p-4",
|
||||
"animate-in fade-in slide-in-from-bottom-4 duration-500",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Logo and Greeting */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">🌊</div>
|
||||
<h1 className="text-2xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-100">
|
||||
{greeting}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
|
||||
Your AI co-pilot for crypto research and analysis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestion Cards */}
|
||||
<div className="w-full max-w-sm space-y-2 animate-in fade-in slide-in-from-bottom-3 duration-500 delay-300">
|
||||
<p className="text-xs text-muted-foreground mb-3 flex items-center gap-1">
|
||||
<span>💡</span>
|
||||
<span>Try asking:</span>
|
||||
</p>
|
||||
{suggestions.slice(0, 4).map((suggestion, index) => (
|
||||
<SuggestionCard
|
||||
key={index}
|
||||
text={suggestion.text}
|
||||
type={suggestion.type}
|
||||
onClick={onSuggestionClick}
|
||||
className="animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
style={{ animationDelay: `${400 + index * 100}ms` } as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="text-xs text-muted-foreground mt-6 animate-in fade-in duration-500 delay-700">
|
||||
Press <kbd className="px-1.5 py-0.5 rounded bg-muted text-xs">⌘K</kbd> for quick actions
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue