Files
gooseek/backend/webui/src/components/ChatMessage.tsx
home 06fe57c765 feat: Go backend, enhanced search, new widgets, Docker deploy
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
2026-02-27 04:15:32 +03:00

276 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}