Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import { User, Bot, Copy, Check, ExternalLink, ThumbsUp, ThumbsDown, Share2, Sparkles } from 'lucide-react';
|
||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||
import type { Message, Citation as CitationType } from '@/lib/types';
|
||
|
||
interface ChatMessageProps {
|
||
message: Message;
|
||
}
|
||
|
||
export function ChatMessage({ message }: ChatMessageProps) {
|
||
const [copied, setCopied] = useState(false);
|
||
const [feedback, setFeedback] = useState<'up' | 'down' | null>(null);
|
||
const isUser = message.role === 'user';
|
||
|
||
const handleCopy = async () => {
|
||
await navigator.clipboard.writeText(message.content);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||
className={`group ${isUser ? 'flex justify-end' : ''}`}
|
||
>
|
||
{isUser ? (
|
||
<div className="max-w-[85%] flex items-start gap-3 flex-row-reverse">
|
||
<div className="w-9 h-9 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center flex-shrink-0">
|
||
<User className="w-4 h-4 text-accent" />
|
||
</div>
|
||
<div className="bg-surface/60 border border-border/50 rounded-2xl rounded-tr-sm px-4 py-3">
|
||
<p className="text-[15px] text-primary leading-relaxed">{message.content}</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="max-w-full">
|
||
{/* Assistant Header */}
|
||
<div className="flex items-center gap-2.5 mb-4">
|
||
<div className="w-9 h-9 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||
<Sparkles className="w-4 h-4 text-accent" />
|
||
</div>
|
||
<span className="text-sm font-medium text-secondary">GooSeek</span>
|
||
</div>
|
||
|
||
{/* Citations - Show at top like Perplexity */}
|
||
{message.citations && message.citations.length > 0 && (
|
||
<div className="mb-5">
|
||
<div className="flex flex-wrap gap-2">
|
||
{message.citations.slice(0, 6).map((citation) => (
|
||
<CitationBadge key={citation.index} citation={citation} />
|
||
))}
|
||
{message.citations.length > 6 && (
|
||
<button className="text-xs text-muted hover:text-secondary px-2.5 py-1.5 rounded-lg hover:bg-surface/40 transition-colors">
|
||
+{message.citations.length - 6} ещё
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Message Content */}
|
||
<div className="prose prose-invert max-w-none">
|
||
<ReactMarkdown
|
||
components={{
|
||
p: ({ children }) => (
|
||
<p className="mb-4 last:mb-0 text-[15px] text-primary/90 leading-[1.7]">
|
||
{children}
|
||
</p>
|
||
),
|
||
a: ({ href, children }) => (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/30 hover:decoration-accent/50 transition-colors"
|
||
>
|
||
{children}
|
||
</a>
|
||
),
|
||
code: ({ className, children }) => {
|
||
const isInline = !className;
|
||
if (isInline) {
|
||
return (
|
||
<code className="px-1.5 py-0.5 bg-surface/80 border border-border/50 rounded-md text-[13px] text-accent-secondary font-mono">
|
||
{children}
|
||
</code>
|
||
);
|
||
}
|
||
return <code className={className}>{children}</code>;
|
||
},
|
||
pre: ({ children }) => (
|
||
<pre className="bg-elevated/80 border border-border/50 p-4 rounded-xl overflow-x-auto text-[13px] my-5 font-mono">
|
||
{children}
|
||
</pre>
|
||
),
|
||
ul: ({ children }) => (
|
||
<ul className="list-none pl-0 my-4 space-y-2.5">{children}</ul>
|
||
),
|
||
ol: ({ children }) => (
|
||
<ol className="list-none pl-0 my-4 space-y-2.5 counter-reset-item">
|
||
{children}
|
||
</ol>
|
||
),
|
||
li: ({ children }) => (
|
||
<li className="flex gap-3 text-[15px] text-primary/85">
|
||
<span className="text-accent/60 select-none mt-0.5">•</span>
|
||
<span className="leading-[1.6]">{children}</span>
|
||
</li>
|
||
),
|
||
h1: ({ children }) => (
|
||
<h1 className="text-xl font-semibold text-primary mt-8 mb-4">
|
||
{children}
|
||
</h1>
|
||
),
|
||
h2: ({ children }) => (
|
||
<h2 className="text-lg font-semibold text-primary mt-6 mb-3">
|
||
{children}
|
||
</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<h3 className="text-base font-medium text-primary mt-5 mb-2">
|
||
{children}
|
||
</h3>
|
||
),
|
||
blockquote: ({ children }) => (
|
||
<blockquote className="border-l-2 border-accent/40 pl-4 my-5 text-secondary italic">
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
strong: ({ children }) => (
|
||
<strong className="font-semibold text-primary">{children}</strong>
|
||
),
|
||
}}
|
||
>
|
||
{message.content}
|
||
</ReactMarkdown>
|
||
|
||
{/* Streaming Cursor */}
|
||
{message.isStreaming && (
|
||
<span className="inline-block w-2 h-5 bg-accent/60 rounded-sm animate-pulse-soft ml-1" />
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
{!message.isStreaming && (
|
||
<div className="flex items-center gap-1 mt-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<ActionButton
|
||
icon={copied ? Check : Copy}
|
||
label={copied ? 'Скопировано' : 'Копировать'}
|
||
onClick={handleCopy}
|
||
active={copied}
|
||
/>
|
||
<ActionButton
|
||
icon={Share2}
|
||
label="Поделиться"
|
||
onClick={() => {}}
|
||
/>
|
||
<div className="w-px h-4 bg-border/50 mx-1.5" />
|
||
<ActionButton
|
||
icon={ThumbsUp}
|
||
label="Полезно"
|
||
onClick={() => setFeedback('up')}
|
||
active={feedback === 'up'}
|
||
/>
|
||
<ActionButton
|
||
icon={ThumbsDown}
|
||
label="Не полезно"
|
||
onClick={() => setFeedback('down')}
|
||
active={feedback === 'down'}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
interface ActionButtonProps {
|
||
icon: React.ElementType;
|
||
label: string;
|
||
onClick: () => void;
|
||
active?: boolean;
|
||
}
|
||
|
||
function ActionButton({ icon: Icon, label, onClick, active }: ActionButtonProps) {
|
||
return (
|
||
<Tooltip.Provider>
|
||
<Tooltip.Root>
|
||
<Tooltip.Trigger asChild>
|
||
<button
|
||
onClick={onClick}
|
||
className={`
|
||
p-2 rounded-lg transition-all
|
||
${active
|
||
? 'text-accent bg-accent/10'
|
||
: 'text-muted hover:text-secondary hover:bg-surface/50'
|
||
}
|
||
`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
</button>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Portal>
|
||
<Tooltip.Content
|
||
side="top"
|
||
className="px-2.5 py-1.5 text-xs text-primary bg-surface border border-border rounded-lg shadow-dropdown z-50"
|
||
sideOffset={4}
|
||
>
|
||
{label}
|
||
</Tooltip.Content>
|
||
</Tooltip.Portal>
|
||
</Tooltip.Root>
|
||
</Tooltip.Provider>
|
||
);
|
||
}
|
||
|
||
function CitationBadge({ citation }: { citation: CitationType }) {
|
||
return (
|
||
<Tooltip.Provider>
|
||
<Tooltip.Root>
|
||
<Tooltip.Trigger asChild>
|
||
<a
|
||
href={citation.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-3 py-2 bg-surface/40 hover:bg-surface/60 border border-border/50 hover:border-accent/30 rounded-xl transition-all group"
|
||
>
|
||
<span className="w-5 h-5 rounded-md bg-accent/15 text-accent flex items-center justify-center text-xs font-semibold">
|
||
{citation.index}
|
||
</span>
|
||
{citation.favicon && (
|
||
<img
|
||
src={citation.favicon}
|
||
alt=""
|
||
className="w-4 h-4 rounded"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
}}
|
||
/>
|
||
)}
|
||
<span className="text-sm text-secondary group-hover:text-primary max-w-[140px] truncate transition-colors">
|
||
{citation.domain}
|
||
</span>
|
||
<ExternalLink className="w-3 h-3 text-muted group-hover:text-secondary transition-colors" />
|
||
</a>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Portal>
|
||
<Tooltip.Content
|
||
side="top"
|
||
className="max-w-[300px] p-4 bg-surface/95 backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
|
||
sideOffset={8}
|
||
>
|
||
<p className="font-medium text-sm text-primary line-clamp-2 mb-2">
|
||
{citation.title}
|
||
</p>
|
||
{citation.snippet && (
|
||
<p className="text-xs text-secondary line-clamp-2 mb-2">
|
||
{citation.snippet}
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-muted truncate">{citation.url}</p>
|
||
<Tooltip.Arrow className="fill-surface" />
|
||
</Tooltip.Content>
|
||
</Tooltip.Portal>
|
||
</Tooltip.Root>
|
||
</Tooltip.Provider>
|
||
);
|
||
}
|