1
This commit is contained in:
90
prisma/components/ChatArea.tsx
Normal file
90
prisma/components/ChatArea.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { ChatMessage, AppState, AnalysisResult, ExpertResult } from '../types';
|
||||
import ChatMessageItem from './ChatMessage';
|
||||
import ProcessFlow from './ProcessFlow';
|
||||
|
||||
interface ChatAreaProps {
|
||||
messages: ChatMessage[];
|
||||
appState: AppState;
|
||||
managerAnalysis: AnalysisResult | null;
|
||||
experts: ExpertResult[];
|
||||
finalOutput: string;
|
||||
processStartTime: number | null;
|
||||
processEndTime: number | null;
|
||||
}
|
||||
|
||||
const ChatArea = ({
|
||||
messages,
|
||||
appState,
|
||||
managerAnalysis,
|
||||
experts,
|
||||
finalOutput,
|
||||
processStartTime,
|
||||
processEndTime
|
||||
}: ChatAreaProps) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar scroll-smooth">
|
||||
<div className="pb-40">
|
||||
{messages.length === 0 && appState === 'idle' && (
|
||||
<div className="h-full flex flex-col items-center justify-center pt-32 opacity-50 px-4 text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl mb-6 shadow-lg rotate-3 flex items-center justify-center text-white font-bold text-2xl">
|
||||
Pr
|
||||
</div>
|
||||
<p className="text-lg font-medium">Prisma</p>
|
||||
<p className="text-sm">Ask a complex question to start.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{messages.map((msg, idx) => (
|
||||
<ChatMessageItem
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLast={idx === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Active Generation (Ghost Message) */}
|
||||
{appState !== 'idle' && appState !== 'completed' && (
|
||||
<div className="group w-full bg-transparent text-slate-800">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8 flex gap-6">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-white border border-blue-200 shadow-sm flex items-center justify-center">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-slate-900 mb-2">Prisma</div>
|
||||
|
||||
{/* Active Thinking Process */}
|
||||
<div className="mb-4 bg-white border border-blue-100 rounded-xl p-4 shadow-sm">
|
||||
<ProcessFlow
|
||||
appState={appState}
|
||||
managerAnalysis={managerAnalysis}
|
||||
experts={experts}
|
||||
processStartTime={processStartTime}
|
||||
processEndTime={processEndTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streaming Output */}
|
||||
{finalOutput && (
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<ChatMessageItem
|
||||
message={{
|
||||
id: 'streaming',
|
||||
role: 'model',
|
||||
content: finalOutput,
|
||||
isThinking: false
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatArea;
|
||||
103
prisma/components/ChatMessage.tsx
Normal file
103
prisma/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import ProcessFlow from './ProcessFlow';
|
||||
import { ChatMessage } from '../types';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessage;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const ChatMessageItem = ({ message, isLast }: ChatMessageProps) => {
|
||||
const isUser = message.role === 'user';
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
|
||||
// Check if there is any thinking data to show
|
||||
const hasThinkingData = message.analysis || (message.experts && message.experts.length > 0);
|
||||
|
||||
return (
|
||||
<div className={`group w-full text-slate-800 ${isUser ? 'bg-transparent' : 'bg-transparent'}`}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8 flex gap-4 md:gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 flex flex-col relative items-end">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center border ${
|
||||
isUser
|
||||
? 'bg-slate-100 border-slate-200'
|
||||
: 'bg-white border-blue-100 shadow-sm'
|
||||
}`}>
|
||||
{isUser ? (
|
||||
<User size={16} className="text-slate-500" />
|
||||
) : (
|
||||
<Sparkles size={16} className="text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="font-semibold text-sm text-slate-900 mb-1">
|
||||
{isUser ? 'You' : 'Prisma'}
|
||||
</div>
|
||||
|
||||
{/* Thinking Process Accordion (Only for AI) */}
|
||||
{!isUser && hasThinkingData && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
className="flex items-center gap-2 text-xs font-medium text-slate-500 hover:text-slate-800 bg-slate-50 hover:bg-slate-100 border border-slate-200 rounded-lg px-3 py-2 transition-colors w-full md:w-auto"
|
||||
>
|
||||
<span>
|
||||
{message.isThinking
|
||||
? "Thinking..."
|
||||
: (message.totalDuration
|
||||
? `Thought for ${(message.totalDuration / 1000).toFixed(1)} seconds`
|
||||
: "Reasoning Process")
|
||||
}
|
||||
</span>
|
||||
{showThinking ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
|
||||
{showThinking && (
|
||||
<div className="mt-3 p-4 bg-white border border-slate-200 rounded-xl shadow-sm animate-in fade-in slide-in-from-top-2">
|
||||
<ProcessFlow
|
||||
appState={message.isThinking ? 'experts_working' : 'completed'} // Visual approximation for history
|
||||
managerAnalysis={message.analysis || null}
|
||||
experts={message.experts || []}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="prose prose-slate max-w-none prose-p:leading-7 prose-pre:bg-slate-900 prose-pre:text-slate-50">
|
||||
{message.content ? (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
) : (
|
||||
message.isThinking && <span className="inline-block w-2 h-4 bg-blue-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Internal Monologue (Synthesis Thoughts) - Optional Footer */}
|
||||
{!isUser && message.synthesisThoughts && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<details className="group/thoughts">
|
||||
<summary className="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1">
|
||||
<ChevronRight size={12} className="group-open/thoughts:rotate-90 transition-transform" />
|
||||
Show Internal Monologue
|
||||
</summary>
|
||||
<div className="mt-2 text-xs font-mono text-slate-500 bg-slate-50 p-3 rounded border border-slate-100 whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{message.synthesisThoughts}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageItem;
|
||||
68
prisma/components/Header.tsx
Normal file
68
prisma/components/Header.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Settings, ChevronDown, Menu, History } from 'lucide-react';
|
||||
import { MODELS } from '../config';
|
||||
import { ModelOption } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
selectedModel: ModelOption;
|
||||
setSelectedModel: (model: ModelOption) => void;
|
||||
onOpenSettings: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onNewChat: () => void;
|
||||
}
|
||||
|
||||
const Header = ({ selectedModel, setSelectedModel, onOpenSettings, onToggleSidebar, onNewChat }: HeaderProps) => {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-md">
|
||||
<div className="w-full px-4 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 -ml-2 text-slate-500 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Toggle History"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer group"
|
||||
onClick={onNewChat}
|
||||
title="Start New Chat"
|
||||
>
|
||||
<h1 className="font-bold text-lg tracking-tight text-slate-900 hidden sm:block group-hover:opacity-70 transition-opacity">
|
||||
Gemini <span className="text-blue-600 font-light">Prisma</span>
|
||||
</h1>
|
||||
<h1 className="font-bold text-lg tracking-tight text-slate-900 sm:hidden group-hover:opacity-70 transition-opacity">
|
||||
Prisma
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="relative group">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value as ModelOption)}
|
||||
className="relative bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-auto p-2.5 outline-none appearance-none cursor-pointer pl-3 pr-8 shadow-sm font-medium hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{MODELS.map(m => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-3 text-slate-400 pointer-events-none group-hover:text-slate-600 transition-colors" size={14} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2.5 rounded-lg bg-white border border-slate-200 hover:bg-slate-50 hover:border-slate-300 transition-colors text-slate-500 hover:text-slate-900 shadow-sm"
|
||||
title="Configuration"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
102
prisma/components/InputSection.tsx
Normal file
102
prisma/components/InputSection.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useRef, useLayoutEffect, useState } from 'react';
|
||||
import { ArrowUp, Square } from 'lucide-react';
|
||||
import { AppState } from '../types';
|
||||
|
||||
interface InputSectionProps {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
onRun: () => void;
|
||||
onStop: () => void;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const InputSection = ({ query, setQuery, onRun, onStop, appState }: InputSectionProps) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (textareaRef.current) {
|
||||
// Reset height to auto to allow shrinking when text is deleted
|
||||
textareaRef.current.style.height = 'auto';
|
||||
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const maxHeight = 200;
|
||||
|
||||
// Set new height based on scrollHeight, capped at 200px
|
||||
textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
|
||||
|
||||
// Only show scrollbar if we hit the max height limit
|
||||
if (scrollHeight > maxHeight) {
|
||||
textareaRef.current.style.overflowY = 'auto';
|
||||
} else {
|
||||
textareaRef.current.style.overflowY = 'hidden';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// useLayoutEffect prevents visual flickering by adjusting height before paint
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
}, [query]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// If user presses Enter without Shift
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// robust check for IME composition (e.g. Chinese/Japanese inputs)
|
||||
if (isComposing || (e.nativeEvent as any).isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (query.trim() && appState === 'idle') {
|
||||
onRun();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = appState !== 'idle';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Container: Flex items-end ensures button stays at bottom right as text grows */}
|
||||
<div className="w-full flex items-end p-2 bg-white/70 backdrop-blur-xl border border-slate-200/50 rounded-[26px] shadow-2xl focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:bg-white/90 transition-colors duration-200">
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
placeholder="Ask a complex question..."
|
||||
rows={1}
|
||||
className="flex-1 max-h-[200px] py-3 pl-4 pr-2 bg-transparent border-none focus:ring-0 resize-none outline-none text-slate-800 placeholder:text-slate-400 leading-relaxed custom-scrollbar text-base"
|
||||
style={{ minHeight: '48px' }}
|
||||
/>
|
||||
|
||||
<div className="flex-shrink-0 pb-0.5 pr-0.5">
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-slate-900 text-white hover:bg-slate-700 transition-colors shadow-md"
|
||||
>
|
||||
<Square size={14} className="fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (query.trim()) onRun();
|
||||
}}
|
||||
disabled={!query.trim()}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 transition-all shadow-md hover:scale-105 active:scale-95"
|
||||
>
|
||||
<ArrowUp size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputSection;
|
||||
86
prisma/components/MarkdownRenderer.tsx
Normal file
86
prisma/components/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Copy, Check, Terminal } from 'lucide-react';
|
||||
|
||||
const CodeBlock = ({ node, inline, className, children, ...props }: any) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
// Inline code (e.g. `const x = 1`)
|
||||
if (inline) {
|
||||
return (
|
||||
<code className={`${className} bg-slate-100 text-slate-800 px-1 py-0.5 rounded text-sm font-mono border border-slate-200`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const codeString = String(children).replace(/\n$/, '');
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(codeString);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-4 rounded-lg overflow-hidden border border-slate-200 bg-[#1e1e1e] shadow-sm">
|
||||
{/* Code Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[#252526] border-b border-[#333] text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={14} />
|
||||
<span className="font-mono text-slate-300">{language || 'text'}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
<span>{copied ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Syntax Highlighter */}
|
||||
<div className="overflow-x-auto">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.875rem', // text-sm
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { fontFamily: 'JetBrains Mono, monospace' }
|
||||
}}
|
||||
wrapLines={true}
|
||||
{...props}
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRenderer = ({ content, className }: { content: string, className?: string }) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: CodeBlock
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
0
prisma/components/OutputSection.tsx
Normal file
0
prisma/components/OutputSection.tsx
Normal file
155
prisma/components/ProcessFlow.tsx
Normal file
155
prisma/components/ProcessFlow.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Zap, Brain, Loader2, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { AppState, AnalysisResult, ExpertResult } from '../types';
|
||||
import ProcessNode from '../ProcessNode';
|
||||
import ExpertCard from '../ExpertCard';
|
||||
|
||||
interface ProcessFlowProps {
|
||||
appState: AppState;
|
||||
managerAnalysis: AnalysisResult | null;
|
||||
experts: ExpertResult[];
|
||||
defaultExpanded?: boolean;
|
||||
processStartTime?: number | null;
|
||||
processEndTime?: number | null;
|
||||
}
|
||||
|
||||
const GlobalTimer = ({ start, end, appState }: { start: number | null | undefined, end: number | null | undefined, appState: AppState }) => {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
const isRunning = appState !== 'idle' && appState !== 'completed' && start;
|
||||
|
||||
if (isRunning) {
|
||||
interval = setInterval(() => {
|
||||
setElapsed(Date.now() - (start || 0));
|
||||
}, 100);
|
||||
} else if (appState === 'completed' && start && end) {
|
||||
setElapsed(end - start);
|
||||
} else if (appState === 'idle') {
|
||||
setElapsed(0);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [appState, start, end]);
|
||||
|
||||
if (!start) return null;
|
||||
|
||||
const seconds = (elapsed / 1000).toFixed(1);
|
||||
return (
|
||||
<div className="absolute right-0 top-0 flex items-center gap-1.5 bg-slate-800 text-slate-100 text-xs font-mono py-1 px-2 rounded-lg shadow-sm">
|
||||
<Clock size={12} className="text-blue-400" />
|
||||
<span>{seconds}s</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProcessFlow = ({ appState, managerAnalysis, experts, defaultExpanded = true, processStartTime, processEndTime }: ProcessFlowProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
// Status computation helpers
|
||||
const isAnalysisDone = !!managerAnalysis;
|
||||
const isSynthesisActive = appState === 'synthesizing';
|
||||
const isComplete = appState === 'completed';
|
||||
|
||||
// Experts are active if ANY expert is currently thinking or pending
|
||||
// We use this logic instead of just `appState` because now experts run IN PARALLEL with analysis
|
||||
const hasExperts = experts.length > 0;
|
||||
const anyExpertWorking = experts.some(e => e.status === 'thinking' || e.status === 'pending');
|
||||
const allExpertsDone = experts.length > 0 && experts.every(e => e.status === 'completed' || e.status === 'error');
|
||||
|
||||
// Logic for Node Active States
|
||||
// 1. Manager: Active if analyzing, OR if we don't have analysis yet but experts have started (edge case), Completed if analysis exists.
|
||||
const managerStatus = (appState === 'analyzing' && !managerAnalysis) ? 'active' : (isAnalysisDone ? 'completed' : 'idle');
|
||||
|
||||
// 2. Experts: Active if any is working, Completed if all are done, Idle otherwise
|
||||
const expertsStatus = anyExpertWorking ? 'active' : (allExpertsDone ? 'completed' : 'idle');
|
||||
|
||||
return (
|
||||
<div className="relative space-y-4 pt-4">
|
||||
|
||||
{/* Global Timer Overlay */}
|
||||
<GlobalTimer start={processStartTime} end={processEndTime} appState={appState} />
|
||||
|
||||
<div className="relative space-y-2">
|
||||
{/* Connector Line */}
|
||||
<div className={`absolute left-8 top-2 bottom-2 w-0.5 bg-slate-100 transition-opacity duration-300 ${isExpanded ? 'opacity-100' : 'opacity-0'}`} />
|
||||
|
||||
{/* Node 1: Manager Analysis */}
|
||||
<ProcessNode
|
||||
icon={Users}
|
||||
title="Planning Strategy"
|
||||
status={managerStatus}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="space-y-3 pl-2">
|
||||
{managerAnalysis ? (
|
||||
<>
|
||||
<p className="text-sm text-slate-600 italic border-l-2 border-slate-300 pl-3">
|
||||
"{managerAnalysis.thought_process}"
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{managerAnalysis.experts?.map((exp, i) => (
|
||||
<span key={i} className="text-[10px] bg-slate-50 text-slate-600 px-2 py-1 rounded border border-slate-200 font-medium uppercase tracking-wide">
|
||||
{exp.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 text-slate-500 text-sm">
|
||||
<Loader2 size={14} className="animate-spin text-blue-500" />
|
||||
<span>Analyzing request...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProcessNode>
|
||||
|
||||
{/* Node 2: Expert Pool */}
|
||||
{hasExperts && (
|
||||
<ProcessNode
|
||||
icon={Zap}
|
||||
title="Expert Execution"
|
||||
status={expertsStatus}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 pt-2">
|
||||
{experts.map((expert) => (
|
||||
<ExpertCard key={expert.id} expert={expert} />
|
||||
))}
|
||||
</div>
|
||||
</ProcessNode>
|
||||
)}
|
||||
|
||||
{/* Node 3: Synthesis */}
|
||||
{(isSynthesisActive || isComplete) && (
|
||||
<ProcessNode
|
||||
icon={Brain}
|
||||
title="Final Synthesis"
|
||||
status={isSynthesisActive ? 'active' : (isComplete ? 'completed' : 'idle')}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="text-sm text-slate-600 pl-2">
|
||||
{isSynthesisActive ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin text-purple-600" size={14} />
|
||||
<span>Synthesizing final answer...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 size={14} />
|
||||
<span>Reasoning complete.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProcessNode>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessFlow;
|
||||
112
prisma/components/Sidebar.tsx
Normal file
112
prisma/components/Sidebar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { Plus, MessageSquare, Trash2, X, History } from 'lucide-react';
|
||||
import { ChatSession } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessions: ChatSession[];
|
||||
currentSessionId: string | null;
|
||||
onSelectSession: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
onDeleteSession: (id: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const Sidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
onNewChat,
|
||||
onDeleteSession
|
||||
}: SidebarProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-30 lg:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40
|
||||
w-[280px] bg-slate-50 border-r border-slate-200 transform transition-transform duration-300 ease-in-out
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0 lg:w-0 lg:border-r-0 lg:overflow-hidden'}
|
||||
flex flex-col h-full
|
||||
`}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-100 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2 text-slate-700 font-semibold">
|
||||
<History size={18} />
|
||||
<span>History</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="lg:hidden text-slate-400 hover:text-slate-600">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<div className="p-4 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
if (window.innerWidth < 1024) onClose();
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-lg transition-colors shadow-sm font-medium text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Session List */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar px-3 pb-4 space-y-1">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-10 text-slate-400 text-sm">
|
||||
<p>No chat history yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => {
|
||||
onSelectSession(session.id);
|
||||
if (window.innerWidth < 1024) onClose();
|
||||
}}
|
||||
className={`
|
||||
group relative flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all
|
||||
${currentSessionId === session.id
|
||||
? 'bg-white shadow-sm border border-slate-200 text-slate-900'
|
||||
: 'text-slate-600 hover:bg-slate-100 border border-transparent'}
|
||||
`}
|
||||
>
|
||||
<MessageSquare size={16} className={`shrink-0 ${currentSessionId === session.id ? 'text-blue-500' : 'text-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium truncate pr-6">{session.title}</h4>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{new Date(session.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => onDeleteSession(session.id, e)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-600 text-slate-400 transition-all"
|
||||
title="Delete Chat"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
Reference in New Issue
Block a user