This commit is contained in:
从何开始123
2026-01-08 02:16:42 +08:00
parent 83b4df1167
commit 54e9bf5906
31 changed files with 2201 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View File

View 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;

View 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;