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
This commit is contained in:
208
backend/webui/src/components/ChatInput.tsx
Normal file
208
backend/webui/src/components/ChatInput.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Send,
|
||||
Paperclip,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Scale,
|
||||
ChevronDown,
|
||||
Square,
|
||||
Globe,
|
||||
Image as ImageIcon,
|
||||
ArrowUp,
|
||||
} from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
|
||||
type Mode = 'speed' | 'balanced' | 'quality';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, mode: Mode) => void;
|
||||
onStop?: () => void;
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] = [
|
||||
{ value: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' },
|
||||
{ value: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' },
|
||||
{ value: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
|
||||
];
|
||||
|
||||
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }: ChatInputProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [mode, setMode] = useState<Mode>('balanced');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (message.trim() && !isLoading) {
|
||||
onSend(message.trim(), mode);
|
||||
setMessage('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
}, [message, mode, isLoading, onSend]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 180)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const currentMode = modes.find((m) => m.value === mode)!;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<motion.div
|
||||
animate={{
|
||||
borderColor: isFocused ? 'hsl(239 84% 67% / 0.4)' : 'hsl(240 4% 16% / 0.8)',
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`
|
||||
relative bg-elevated/60 backdrop-blur-xl border rounded-2xl overflow-hidden
|
||||
shadow-card transition-shadow duration-200
|
||||
${isFocused ? 'shadow-glow-sm' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Main Input Area */}
|
||||
<div className="flex items-end gap-3 p-4 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder || 'Задайте любой вопрос...'}
|
||||
autoFocus={autoFocus}
|
||||
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[180px] placeholder:text-muted leading-relaxed"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30 hover:bg-error/20 transition-all"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim()}
|
||||
className={`
|
||||
w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200
|
||||
${message.trim()
|
||||
? 'btn-gradient'
|
||||
: 'bg-surface/50 text-muted border border-border/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<ArrowUp className={`w-5 h-5 ${message.trim() ? 'btn-gradient-text' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Toolbar */}
|
||||
<div className="px-4 pb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mode Selector */}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button className="flex items-center gap-2 h-8 px-3 text-xs text-secondary hover:text-primary rounded-lg hover:bg-surface/50 transition-all">
|
||||
<currentMode.icon className="w-4 h-4 text-accent-muted" />
|
||||
<span className="font-medium">{currentMode.label}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="min-w-[180px] bg-surface/95 backdrop-blur-xl border border-border rounded-xl p-1.5 shadow-dropdown z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
{modes.map((m) => (
|
||||
<DropdownMenu.Item
|
||||
key={m.value}
|
||||
onClick={() => setMode(m.value)}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg cursor-pointer outline-none transition-colors
|
||||
${mode === m.value
|
||||
? 'bg-accent/10 text-primary'
|
||||
: 'text-secondary hover:bg-elevated/80 hover:text-primary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<m.icon className={`w-4 h-4 ${mode === m.value ? 'text-accent' : 'text-muted'}`} />
|
||||
<span className="flex-1 font-medium">{m.label}</span>
|
||||
<span className="text-xs text-muted">{m.desc}</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border/50 mx-1" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
className="btn-icon w-8 h-8"
|
||||
title="Веб-поиск"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon w-8 h-8"
|
||||
title="Прикрепить файл"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon w-8 h-8"
|
||||
title="Изображение"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-faint">
|
||||
Enter для отправки · Shift+Enter для новой строки
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Focus Glow Effect */}
|
||||
<AnimatePresence>
|
||||
{isFocused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute -inset-0.5 bg-gradient-to-r from-accent/10 via-accent/20 to-accent/10 rounded-2xl -z-10 blur-xl"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
backend/webui/src/components/ChatMessage.tsx
Normal file
275
backend/webui/src/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
146
backend/webui/src/components/Citation.tsx
Normal file
146
backend/webui/src/components/Citation.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import type { Citation as CitationType } from '@/lib/types';
|
||||
|
||||
interface CitationProps {
|
||||
citation: CitationType;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function Citation({ citation, compact }: CitationProps) {
|
||||
if (compact) {
|
||||
return (
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-cream-300/10 hover:bg-cream-300/20 text-cream-300 border border-cream-400/20 rounded transition-colors"
|
||||
>
|
||||
{citation.index}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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-2.5 py-1.5 bg-navy-800/40 hover:bg-navy-800/60 border border-navy-700/30 hover:border-cream-400/20 rounded-lg transition-all group"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium">
|
||||
{citation.index}
|
||||
</span>
|
||||
{citation.favicon && (
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
className="w-3.5 h-3.5 rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-cream-400/80 group-hover:text-cream-200 max-w-[120px] truncate transition-colors">
|
||||
{citation.domain}
|
||||
</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 text-cream-500/50 group-hover:text-cream-400/70 transition-colors" />
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className="max-w-[300px] p-4 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p className="font-medium text-sm text-cream-100 line-clamp-2 mb-2">
|
||||
{citation.title}
|
||||
</p>
|
||||
{citation.snippet && (
|
||||
<p className="text-xs text-cream-400/70 line-clamp-3 mb-3">
|
||||
{citation.snippet}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-2xs text-cream-500/60">
|
||||
{citation.favicon && (
|
||||
<img
|
||||
src={citation.favicon}
|
||||
alt=""
|
||||
className="w-3 h-3 rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{citation.domain}</span>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-navy-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface CitationListProps {
|
||||
citations: CitationType[];
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
|
||||
if (!citations || citations.length === 0) return null;
|
||||
|
||||
const visible = citations.slice(0, maxVisible);
|
||||
const remaining = citations.length - maxVisible;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.map((citation) => (
|
||||
<Citation key={citation.index} citation={citation} />
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button className="text-xs text-cream-500/70 hover:text-cream-300 px-2.5 py-1.5 rounded-lg hover:bg-navy-800/30 transition-colors">
|
||||
+{remaining} ещё
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className="max-w-[320px] p-3 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{citations.slice(maxVisible).map((citation) => (
|
||||
<a
|
||||
key={citation.index}
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-navy-700/50 transition-colors"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium flex-shrink-0">
|
||||
{citation.index}
|
||||
</span>
|
||||
<span className="text-xs text-cream-200 truncate">
|
||||
{citation.title}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-navy-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
backend/webui/src/components/DiscoverCard.tsx
Normal file
152
backend/webui/src/components/DiscoverCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, FileText, Sparkles, Clock } from 'lucide-react';
|
||||
import type { DiscoverItem } from '@/lib/types';
|
||||
|
||||
interface DiscoverCardProps {
|
||||
item: DiscoverItem;
|
||||
variant?: 'large' | 'medium' | 'small';
|
||||
onSummarize?: (url: string) => void;
|
||||
}
|
||||
|
||||
export function DiscoverCard({ item, variant = 'medium', onSummarize }: DiscoverCardProps) {
|
||||
const domain = item.url ? new URL(item.url).hostname.replace('www.', '') : '';
|
||||
|
||||
if (variant === 'large') {
|
||||
return (
|
||||
<article className="group relative overflow-hidden rounded-2xl bg-navy-900/40 border border-navy-700/20 hover:border-cream-400/15 transition-all duration-300">
|
||||
{item.thumbnail && (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-500/60">{domain}</span>
|
||||
{item.sourcesCount && item.sourcesCount > 1 && (
|
||||
<span className="text-xs text-cream-600/40">
|
||||
• {item.sourcesCount} источников
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-cream-100 mb-3 line-clamp-2 group-hover:text-cream-50 transition-colors">
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="text-cream-400/70 text-sm line-clamp-3 mb-5">{item.content}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-cream-400/80 hover:text-cream-200 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Читать
|
||||
</a>
|
||||
{onSummarize && (
|
||||
<button
|
||||
onClick={() => onSummarize(item.url)}
|
||||
className="flex items-center gap-2 text-sm text-cream-300 hover:text-cream-100 transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI Саммари
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'small') {
|
||||
return (
|
||||
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-navy-800/30 transition-colors">
|
||||
{item.thumbnail && (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||
alt=""
|
||||
className="w-3 h-3 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-600/50 truncate">{domain}</span>
|
||||
</div>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-cream-200 group-hover:text-cream-100 line-clamp-2 transition-colors"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="group p-4 bg-navy-900/30 border border-navy-700/20 rounded-xl hover:border-cream-400/15 hover:bg-navy-900/50 transition-all duration-200">
|
||||
{item.thumbnail && (
|
||||
<div className="aspect-video rounded-lg overflow-hidden mb-4 -mx-1 -mt-1">
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<span className="text-xs text-cream-600/50">{domain}</span>
|
||||
</div>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-medium text-cream-100 mb-2 line-clamp-2 group-hover:text-cream-50 transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
</a>
|
||||
<p className="text-sm text-cream-500/60 line-clamp-2">{item.content}</p>
|
||||
{onSummarize && (
|
||||
<button
|
||||
onClick={() => onSummarize(item.url)}
|
||||
className="flex items-center gap-2 mt-3 text-xs text-cream-400 hover:text-cream-200 transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Саммари
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
198
backend/webui/src/components/Sidebar.tsx
Normal file
198
backend/webui/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search,
|
||||
Compass,
|
||||
FolderOpen,
|
||||
Clock,
|
||||
Settings,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
BookOpen,
|
||||
Cpu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', icon: Search, label: 'Поиск' },
|
||||
{ href: '/discover', icon: Compass, label: 'Discover' },
|
||||
{ href: '/spaces', icon: FolderOpen, label: 'Пространства' },
|
||||
{ href: '/history', icon: Clock, label: 'История' },
|
||||
];
|
||||
|
||||
const toolItems = [
|
||||
{ href: '/computer', icon: Cpu, label: 'Computer' },
|
||||
{ href: '/finance', icon: TrendingUp, label: 'Финансы' },
|
||||
{ href: '/learning', icon: BookOpen, label: 'Обучение' },
|
||||
];
|
||||
|
||||
export function Sidebar({ onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const isMobile = !!onClose;
|
||||
|
||||
const toggleCollapse = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleNavClick = () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: isMobile ? 280 : collapsed ? 68 : 260 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="h-full flex flex-col bg-base border-r border-border/50"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-16 px-4 flex items-center justify-between">
|
||||
<AnimatePresence mode="wait">
|
||||
{(isMobile || !collapsed) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<span className="font-bold italic text-primary tracking-tight text-lg">GooSeek</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{isMobile ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-xl text-muted hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-muted hover:text-secondary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<div className="px-3 mb-4">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={handleNavClick}
|
||||
className={`
|
||||
w-full flex items-center gap-3 h-11 btn-gradient
|
||||
${isMobile || !collapsed ? 'px-3' : 'justify-center px-0'}
|
||||
`}
|
||||
>
|
||||
<Plus className="w-[18px] h-[18px] flex-shrink-0 btn-gradient-text" />
|
||||
{(isMobile || !collapsed) && (
|
||||
<span className="text-sm font-medium truncate btn-gradient-text">Новый чат</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-4 border-t border-border/50" />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
collapsed={!isMobile && collapsed}
|
||||
active={pathname === item.href}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Tools Section */}
|
||||
<div className="pt-6 pb-2">
|
||||
{(isMobile || !collapsed) && (
|
||||
<span className="px-3 text-[11px] font-semibold text-muted uppercase tracking-wider">
|
||||
Инструменты
|
||||
</span>
|
||||
)}
|
||||
{!isMobile && collapsed && <div className="h-px bg-border/30 mx-2" />}
|
||||
</div>
|
||||
|
||||
{toolItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
collapsed={!isMobile && collapsed}
|
||||
active={pathname === item.href}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-border/30">
|
||||
<NavLink
|
||||
href="/settings"
|
||||
icon={Settings}
|
||||
label="Настройки"
|
||||
collapsed={!isMobile && collapsed}
|
||||
active={pathname === '/settings'}
|
||||
onClick={handleNavClick}
|
||||
/>
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
collapsed: boolean;
|
||||
active: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-3 h-11 rounded-xl transition-all duration-150
|
||||
${collapsed ? 'justify-center px-0' : 'px-3'}
|
||||
${active
|
||||
? 'bg-accent/10 text-primary border-l-2 border-accent ml-0'
|
||||
: 'text-secondary hover:text-primary hover:bg-surface/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-accent' : ''}`} />
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-medium truncate">{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
5
backend/webui/src/components/index.ts
Normal file
5
backend/webui/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { ChatMessage } from './ChatMessage';
|
||||
export { Citation, CitationList } from './Citation';
|
||||
export { DiscoverCard } from './DiscoverCard';
|
||||
Reference in New Issue
Block a user