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:
7
backend/webui/.env.example
Normal file
7
backend/webui/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# GooSeek WebUI Configuration
|
||||
|
||||
# API Gateway URL (internal Docker network)
|
||||
API_URL=http://api-gateway:3015
|
||||
|
||||
# Public API URL (for browser requests)
|
||||
NEXT_PUBLIC_API_URL=
|
||||
44
backend/webui/Dockerfile
Normal file
44
backend/webui/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
5
backend/webui/next-env.d.ts
vendored
Normal file
5
backend/webui/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
18
backend/webui/next.config.mjs
Normal file
18
backend/webui/next.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
env: {
|
||||
API_URL: process.env.API_URL || 'http://api-gateway:3015',
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${process.env.API_URL || 'http://api-gateway:3015'}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
4408
backend/webui/package-lock.json
generated
Normal file
4408
backend/webui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
backend/webui/package.json
Normal file
38
backend/webui/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "gooseek-webui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.3",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^14.2.26",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
6
backend/webui/postcss.config.js
Normal file
6
backend/webui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
backend/webui/public/logo.svg
Normal file
12
backend/webui/public/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="10" fill="url(#gradient)"/>
|
||||
<path d="M20 10L26 16L20 22L14 16L20 10Z" fill="#0a1929"/>
|
||||
<path d="M14 20L20 26L26 20L20 14L14 20Z" fill="#0a1929" opacity="0.7"/>
|
||||
<path d="M20 22L26 28L20 34L14 28L20 22Z" fill="#0a1929" opacity="0.5"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="40" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#d4a373"/>
|
||||
<stop offset="1" stop-color="#cd7f32"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
752
backend/webui/src/app/(main)/computer/page.tsx
Normal file
752
backend/webui/src/app/(main)/computer/page.tsx
Normal file
@@ -0,0 +1,752 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Cpu,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Clock,
|
||||
Zap,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Database,
|
||||
Download,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Timer,
|
||||
DollarSign,
|
||||
Layers,
|
||||
Bot,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
Settings2,
|
||||
Send,
|
||||
Globe,
|
||||
Code2,
|
||||
BarChart3,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Webhook,
|
||||
HardDrive,
|
||||
Menu,
|
||||
ArrowLeft,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
ComputerTask,
|
||||
ComputerTaskStatus,
|
||||
DurationMode,
|
||||
Artifact,
|
||||
ComputerTaskEvent,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
executeComputerTask,
|
||||
fetchComputerTasks,
|
||||
cancelComputerTask,
|
||||
streamComputerTask,
|
||||
fetchComputerArtifacts,
|
||||
downloadArtifact,
|
||||
} from '@/lib/api';
|
||||
|
||||
const durationModes: { value: DurationMode; label: string; desc: string; icon: React.ElementType }[] = [
|
||||
{ value: 'short', label: '30 мин', desc: 'Быстрые', icon: Zap },
|
||||
{ value: 'medium', label: '4 часа', desc: 'Стандартные', icon: Clock },
|
||||
{ value: 'long', label: '24 часа', desc: 'Комплексные', icon: Calendar },
|
||||
{ value: 'extended', label: '7 дней', desc: 'Мониторинг', icon: Timer },
|
||||
{ value: 'unlimited', label: '∞', desc: 'Без лимита', icon: Sparkles },
|
||||
];
|
||||
|
||||
const taskExamples = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Исследование конкурентов',
|
||||
query: 'Проанализируй топ-5 конкурентов в сфере e-commerce в России',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
},
|
||||
{
|
||||
icon: Code2,
|
||||
title: 'Разработка дашборда',
|
||||
query: 'Создай дашборд для отслеживания курсов криптовалют',
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/10',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Мониторинг новостей',
|
||||
query: 'Мониторь новости по теме AI в медицине каждые 6 часов',
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Генерация отчёта',
|
||||
query: 'Исследуй рынок EdTech и создай PDF-отчёт с визуализациями',
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon: React.ElementType; label: string }> = {
|
||||
pending: { color: 'text-secondary', bg: 'bg-surface/60', icon: Clock, label: 'Ожидание' },
|
||||
planning: { color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Bot, label: 'Планирование' },
|
||||
executing: { color: 'text-success', bg: 'bg-success/10', icon: Play, label: 'Выполнение' },
|
||||
long_running: { color: 'text-success', bg: 'bg-success/10', icon: Loader2, label: 'Долгая задача' },
|
||||
waiting_user: { color: 'text-warning', bg: 'bg-warning/10', icon: AlertCircle, label: 'Ожидает ввода' },
|
||||
completed: { color: 'text-success', bg: 'bg-success/10', icon: CheckCircle2, label: 'Завершено' },
|
||||
failed: { color: 'text-error', bg: 'bg-error/10', icon: XCircle, label: 'Ошибка' },
|
||||
cancelled: { color: 'text-muted', bg: 'bg-surface/40', icon: Square, label: 'Отменено' },
|
||||
scheduled: { color: 'text-accent-secondary', bg: 'bg-accent-secondary/10', icon: Calendar, label: 'Запланировано' },
|
||||
paused: { color: 'text-warning', bg: 'bg-warning/10', icon: Pause, label: 'Пауза' },
|
||||
checkpoint: { color: 'text-accent', bg: 'bg-accent/10', icon: RefreshCw, label: 'Чекпоинт' },
|
||||
};
|
||||
|
||||
const artifactIcons: Record<string, React.ElementType> = {
|
||||
file: FileText,
|
||||
code: FileCode,
|
||||
report: FileText,
|
||||
deployment: Globe,
|
||||
image: ImageIcon,
|
||||
data: Database,
|
||||
};
|
||||
|
||||
export default function ComputerPage() {
|
||||
const [tasks, setTasks] = useState<ComputerTask[]>([]);
|
||||
const [selectedTask, setSelectedTask] = useState<ComputerTask | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [durationMode, setDurationMode] = useState<DurationMode>('medium');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||
const [events, setEvents] = useState<ComputerTaskEvent[]>([]);
|
||||
const [showTaskList, setShowTaskList] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const eventsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchComputerTasks(undefined, 50);
|
||||
setTasks(result.tasks || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
eventsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [events]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
setIsExecuting(true);
|
||||
setEvents([]);
|
||||
try {
|
||||
const task = await executeComputerTask({
|
||||
query: query.trim(),
|
||||
options: {
|
||||
async: true,
|
||||
durationMode,
|
||||
enableSandbox: true,
|
||||
enableBrowser: true,
|
||||
},
|
||||
});
|
||||
|
||||
setSelectedTask(task);
|
||||
setTasks((prev) => [task, ...prev]);
|
||||
setQuery('');
|
||||
setShowTaskList(false);
|
||||
|
||||
streamTaskEvents(task.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute task:', error);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const streamTaskEvents = async (taskId: string) => {
|
||||
try {
|
||||
for await (const event of streamComputerTask(taskId)) {
|
||||
setEvents((prev) => [...prev, event]);
|
||||
|
||||
if (event.status) {
|
||||
setSelectedTask((prev) =>
|
||||
prev?.id === taskId ? { ...prev, status: event.status!, progress: event.progress ?? prev.progress } : prev
|
||||
);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskId ? { ...t, status: event.status!, progress: event.progress ?? t.progress } : t
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'task_completed' || event.type === 'task_failed') {
|
||||
loadArtifacts(taskId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadArtifacts = async (taskId: string) => {
|
||||
try {
|
||||
const result = await fetchComputerArtifacts(taskId);
|
||||
setArtifacts(result.artifacts || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load artifacts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (taskId: string) => {
|
||||
try {
|
||||
await cancelComputerTask(taskId);
|
||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: 'cancelled' } : t)));
|
||||
if (selectedTask?.id === taskId) {
|
||||
setSelectedTask((prev) => (prev ? { ...prev, status: 'cancelled' } : null));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (artifact: Artifact) => {
|
||||
try {
|
||||
const blob = await downloadArtifact(artifact.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = artifact.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download artifact:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExample = (exampleQuery: string) => {
|
||||
setQuery(exampleQuery);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleSelectTask = (task: ComputerTask) => {
|
||||
setSelectedTask(task);
|
||||
setShowTaskList(false);
|
||||
if (isTaskActive(task.status)) {
|
||||
setEvents([]);
|
||||
streamTaskEvents(task.id);
|
||||
} else {
|
||||
loadArtifacts(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}мс`;
|
||||
if (ms < 60000) return `${Math.round(ms / 1000)}с`;
|
||||
if (ms < 3600000) return `${Math.round(ms / 60000)}м`;
|
||||
return `${Math.round(ms / 3600000)}ч`;
|
||||
};
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const isTaskActive = (status: ComputerTaskStatus): boolean => {
|
||||
return ['pending', 'planning', 'executing', 'long_running'].includes(status);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gradient-main relative">
|
||||
{/* Mobile Task List Overlay */}
|
||||
<AnimatePresence>
|
||||
{showTaskList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowTaskList(false)}
|
||||
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm md:hidden"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 z-50 w-[280px] bg-base border-r border-border/50 flex flex-col md:hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-border/50 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-primary flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-accent" />
|
||||
Задачи
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowTaskList(false)}
|
||||
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>
|
||||
</div>
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
selectedTask={selectedTask}
|
||||
isLoading={isLoading}
|
||||
onSelect={handleSelectTask}
|
||||
isTaskActive={isTaskActive}
|
||||
statusConfig={statusConfig}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop Left Panel - Task List */}
|
||||
<div className="hidden md:flex w-72 lg:w-80 border-r border-border/50 flex-col">
|
||||
<div className="p-4 border-b border-border/50">
|
||||
<h2 className="text-lg font-semibold text-primary flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-accent" />
|
||||
Computer
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-1">Автономные AI-задачи</p>
|
||||
</div>
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
selectedTask={selectedTask}
|
||||
isLoading={isLoading}
|
||||
onSelect={handleSelectTask}
|
||||
isTaskActive={isTaskActive}
|
||||
statusConfig={statusConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedTask ? (
|
||||
<motion.div
|
||||
key="task-detail"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Task Header */}
|
||||
<div className="p-4 sm:p-6 border-b border-border/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedTask(null)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base sm:text-lg text-primary font-medium line-clamp-2">{selectedTask.query}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
|
||||
{(() => {
|
||||
const config = statusConfig[selectedTask.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<span className={`flex items-center gap-1.5 text-sm ${config.color}`}>
|
||||
<StatusIcon className={`w-4 h-4 ${isTaskActive(selectedTask.status) ? 'animate-spin' : ''}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span className="text-sm text-muted flex items-center gap-1">
|
||||
<Timer className="w-3.5 h-3.5" />
|
||||
{formatDuration(selectedTask.totalRuntime)}
|
||||
</span>
|
||||
<span className="text-sm text-muted flex items-center gap-1">
|
||||
<DollarSign className="w-3.5 h-3.5" />
|
||||
{formatCost(selectedTask.totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isTaskActive(selectedTask.status) && (
|
||||
<button
|
||||
onClick={() => handleCancel(selectedTask.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-error/10 text-error border border-error/30 hover:bg-error/20 transition-colors text-sm flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Отменить</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isTaskActive(selectedTask.status) && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-surface/60 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${selectedTask.progress}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="h-full bg-gradient-to-r from-accent to-accent-hover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-xs text-muted">Прогресс</span>
|
||||
<span className="text-xs text-accent">{selectedTask.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
{/* Sub Tasks */}
|
||||
{selectedTask.subTasks && selectedTask.subTasks.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
Подзадачи ({selectedTask.subTasks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedTask.subTasks.map((subtask) => {
|
||||
const config = statusConfig[subtask.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="p-3 rounded-lg bg-elevated/40 border border-border/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 ${config.color} flex-shrink-0 ${
|
||||
isTaskActive(subtask.status) ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">{subtask.description}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-muted uppercase">{subtask.type}</span>
|
||||
{subtask.cost > 0 && (
|
||||
<span className="text-xs text-muted">{formatCost(subtask.cost)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events Log */}
|
||||
{events.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
События
|
||||
</h3>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto bg-base/50 rounded-lg p-3 border border-border/30">
|
||||
{events.map((event, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-faint font-mono whitespace-nowrap">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-secondary break-all">{event.message || event.type}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={eventsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artifacts */}
|
||||
{artifacts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
Артефакты ({artifacts.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{artifacts.map((artifact) => {
|
||||
const Icon = artifactIcons[artifact.type] || FileText;
|
||||
return (
|
||||
<div
|
||||
key={artifact.id}
|
||||
className="p-4 rounded-lg bg-elevated/40 border border-border/40 hover:border-accent/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-surface/60 flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">{artifact.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted">{artifact.type}</span>
|
||||
<span className="text-xs text-muted">
|
||||
{(artifact.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(artifact)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface/60 text-muted hover:text-secondary transition-colors flex-shrink-0"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="new-task"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8 pb-24">
|
||||
{/* Mobile task list button */}
|
||||
<div className="w-full max-w-2xl mb-6 md:hidden">
|
||||
<button
|
||||
onClick={() => setShowTaskList(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary bg-surface/40 border border-border/50 rounded-xl hover:border-border transition-all"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
Показать задачи ({tasks.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 sm:mb-10">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-primary mb-2 sm:mb-3">
|
||||
GooSeek <span className="text-gradient">Computer</span>
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-secondary max-w-lg leading-relaxed px-4">
|
||||
Автономный AI-агент для сложных задач: исследования, код, мониторинг.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task Input */}
|
||||
<div className="w-full max-w-2xl mb-6 sm:mb-8">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleExecute();
|
||||
}
|
||||
}}
|
||||
placeholder="Опишите задачу..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-4 pr-14 rounded-xl bg-elevated/60 border border-border text-primary placeholder-muted focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 resize-none transition-all text-base"
|
||||
/>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!query.trim() || isExecuting}
|
||||
className={`absolute right-3 bottom-3 p-2.5 rounded-lg transition-all ${
|
||||
query.trim()
|
||||
? 'btn-gradient'
|
||||
: 'bg-surface/50 text-muted'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin btn-gradient-text" />
|
||||
) : (
|
||||
<Send className={`w-5 h-5 ${query.trim() ? 'btn-gradient-text' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration Mode Selector */}
|
||||
<div className="w-full max-w-2xl mb-6 sm:mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-secondary">Режим выполнения</span>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-xs text-muted hover:text-secondary flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Расширенные</span>
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{durationModes.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setDurationMode(mode.value)}
|
||||
className={`p-2 sm:p-3 rounded-xl border text-center transition-all ${
|
||||
durationMode === mode.value
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-elevated/40 border-border/40 text-secondary hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<mode.icon className={`w-4 sm:w-5 h-4 sm:h-5 mx-auto mb-1 ${
|
||||
durationMode === mode.value ? 'text-accent' : 'text-muted'
|
||||
}`} />
|
||||
<div className="text-xs sm:text-sm font-medium">{mode.label}</div>
|
||||
<div className="text-xs text-muted mt-0.5 hidden sm:block">{mode.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<AnimatePresence>
|
||||
{showAdvanced && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="w-full max-w-2xl mb-6 sm:mb-8 overflow-hidden"
|
||||
>
|
||||
<div className="p-4 rounded-xl bg-elevated/40 border border-border/40">
|
||||
<h4 className="text-sm font-medium text-primary mb-3">Коннекторы</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ icon: Mail, label: 'Email', desc: 'Отправка' },
|
||||
{ icon: MessageCircle, label: 'Telegram', desc: 'Уведомления' },
|
||||
{ icon: Webhook, label: 'Webhook', desc: 'HTTP' },
|
||||
{ icon: HardDrive, label: 'Storage', desc: 'S3' },
|
||||
].map((conn) => (
|
||||
<button
|
||||
key={conn.label}
|
||||
className="p-3 rounded-lg bg-surface/40 border border-border/50 hover:border-accent/30 transition-colors text-left"
|
||||
>
|
||||
<conn.icon className="w-4 h-4 text-accent mb-1.5" />
|
||||
<div className="text-xs text-primary">{conn.label}</div>
|
||||
<div className="text-xs text-muted hidden sm:block">{conn.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Examples */}
|
||||
<div className="w-full max-w-2xl lg:max-w-3xl">
|
||||
<h3 className="text-sm text-muted mb-3 text-center">Примеры задач</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{taskExamples.map((example, i) => (
|
||||
<motion.button
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => handleExample(example.query)}
|
||||
className="group relative p-3 sm:p-4 rounded-xl bg-elevated/40 border border-border/40 hover:border-accent/30 text-left transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`w-8 h-8 rounded-lg ${example.bgColor} flex items-center justify-center flex-shrink-0`}>
|
||||
<example.icon className={`w-4 h-4 ${example.color}`} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">{example.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary line-clamp-2 leading-relaxed">{example.query}</p>
|
||||
<ArrowRight className="absolute bottom-3 right-3 w-4 h-4 text-transparent group-hover:text-accent/60 transition-all transform translate-x-2 group-hover:translate-x-0" />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: ComputerTask[];
|
||||
selectedTask: ComputerTask | null;
|
||||
isLoading: boolean;
|
||||
onSelect: (task: ComputerTask) => void;
|
||||
isTaskActive: (status: ComputerTaskStatus) => boolean;
|
||||
statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon: React.ElementType; label: string }>;
|
||||
}
|
||||
|
||||
function TaskList({ tasks, selectedTask, isLoading, onSelect, isTaskActive, statusConfig }: TaskListProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted text-sm">
|
||||
Нет активных задач
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const config = statusConfig[task.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<motion.button
|
||||
key={task.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={() => onSelect(task)}
|
||||
className={`w-full text-left p-3 rounded-xl border transition-all ${
|
||||
selectedTask?.id === task.id
|
||||
? 'bg-elevated/60 border-accent/30'
|
||||
: 'bg-elevated/40 border-border/40 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-1.5 rounded-lg ${config.bg} flex-shrink-0`}>
|
||||
<StatusIcon
|
||||
className={`w-4 h-4 ${config.color} ${
|
||||
isTaskActive(task.status) ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary line-clamp-2">{task.query}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`text-xs ${config.color}`}>{config.label}</span>
|
||||
{task.progress > 0 && task.progress < 100 && (
|
||||
<span className="text-xs text-muted">{task.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{isTaskActive(task.status) && task.progress > 0 && (
|
||||
<div className="mt-2 h-1 bg-surface/60 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${task.progress}%` }}
|
||||
className="h-full bg-gradient-to-r from-accent to-accent-hover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
backend/webui/src/app/(main)/discover/page.tsx
Normal file
153
backend/webui/src/app/(main)/discover/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RefreshCw, ExternalLink, Loader2, Sparkles, Globe, Cpu, DollarSign, Dumbbell } from 'lucide-react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { fetchDiscover } from '@/lib/api';
|
||||
import type { DiscoverItem } from '@/lib/types';
|
||||
|
||||
const topics = [
|
||||
{ id: 'tech', label: 'Технологии', icon: Cpu },
|
||||
{ id: 'finance', label: 'Финансы', icon: DollarSign },
|
||||
{ id: 'sports', label: 'Спорт', icon: Dumbbell },
|
||||
] as const;
|
||||
|
||||
export default function DiscoverPage() {
|
||||
const [items, setItems] = useState<DiscoverItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [topic, setTopic] = useState<string>('tech');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchDiscover(topic, 'russia');
|
||||
setItems(data);
|
||||
} catch {
|
||||
setItems([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [topic]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Discover</h1>
|
||||
<p className="text-sm text-secondary">Актуальные новости и события</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all w-fit"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">Обновить</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Topic Tabs */}
|
||||
<Tabs.Root value={topic} onValueChange={setTopic}>
|
||||
<Tabs.List className="flex gap-2 mb-6 sm:mb-8 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
{topics.map((t) => (
|
||||
<Tabs.Trigger
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:bg-accent/10 data-[state=active]:border-accent/30 data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
|
||||
>
|
||||
<t.icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
<div className="absolute inset-0 blur-xl bg-accent/20" />
|
||||
</div>
|
||||
<p className="text-sm text-muted mt-4">Загрузка новостей...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 sm:py-20">
|
||||
<Globe className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||||
<p className="text-secondary">Нет новостей по выбранной теме</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{items.map((item, i) => (
|
||||
<motion.article
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="group relative flex flex-col sm:flex-row gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:border-accent/25 hover:bg-elevated/60 transition-all duration-200"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{item.thumbnail && (
|
||||
<div className="relative w-full sm:w-28 h-40 sm:h-20 rounded-lg overflow-hidden flex-shrink-0 bg-surface/50">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-primary line-clamp-2 group-hover:text-accent transition-colors mb-2"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
<p className="text-xs text-muted line-clamp-2 mb-3">
|
||||
{item.content}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Читать
|
||||
</a>
|
||||
<button className="flex items-center gap-1.5 text-xs text-accent/80 hover:text-accent transition-colors">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
AI Саммари
|
||||
</button>
|
||||
{item.sourcesCount && item.sourcesCount > 1 && (
|
||||
<span className="text-xs text-faint">
|
||||
{item.sourcesCount} источников
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
backend/webui/src/app/(main)/finance/page.tsx
Normal file
265
backend/webui/src/app/(main)/finance/page.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TrendingUp, TrendingDown, RefreshCw, Loader2, ArrowUpRight, ArrowDownRight, Activity, BarChart3 } from 'lucide-react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { fetchMarkets, fetchHeatmap, fetchTopMovers } from '@/lib/api';
|
||||
import type { FinanceMarket, HeatmapData, TopMovers, FinanceStock } from '@/lib/types';
|
||||
|
||||
const defaultMarkets: FinanceMarket[] = [
|
||||
{ id: 'moex', name: 'MOEX', region: 'ru' },
|
||||
{ id: 'crypto', name: 'Крипто', region: 'global' },
|
||||
{ id: 'forex', name: 'Валюты', region: 'global' },
|
||||
];
|
||||
|
||||
const timeRanges = [
|
||||
{ id: '1d', label: '1Д' },
|
||||
{ id: '1w', label: '1Н' },
|
||||
{ id: '1m', label: '1М' },
|
||||
{ id: '3m', label: '3М' },
|
||||
{ id: '1y', label: '1Г' },
|
||||
];
|
||||
|
||||
function formatPrice(price: number, market: string): string {
|
||||
if (market === 'crypto' && price > 1000) {
|
||||
return price.toLocaleString('ru-RU', { maximumFractionDigits: 0 });
|
||||
}
|
||||
return price.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatChange(change: number): string {
|
||||
const prefix = change >= 0 ? '+' : '';
|
||||
return `${prefix}${change.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function StockRow({ stock, market, delay }: { stock: FinanceStock; market: string; delay: number }) {
|
||||
const isPositive = stock.change >= 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay }}
|
||||
className="flex items-center gap-3 sm:gap-4 p-2 sm:p-3 rounded-xl hover:bg-surface/40 transition-colors group"
|
||||
>
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] sm:text-xs font-semibold text-secondary">{stock.symbol.slice(0, 4)}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate group-hover:text-accent transition-colors">
|
||||
{stock.name}
|
||||
</p>
|
||||
{stock.sector && (
|
||||
<p className="text-xs text-muted truncate">{stock.sector}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-sm font-mono text-primary">{formatPrice(stock.price, market)}</p>
|
||||
<div className={`flex items-center justify-end gap-1 text-xs font-mono ${isPositive ? 'text-success' : 'text-error'}`}>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
)}
|
||||
{formatChange(stock.change)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoversSection({ title, stocks, market, icon: Icon, color }: {
|
||||
title: string;
|
||||
stocks: FinanceStock[];
|
||||
market: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}) {
|
||||
if (!stocks || stocks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-center gap-3 mb-3 sm:mb-4">
|
||||
<div className={`w-8 h-8 sm:w-9 sm:h-9 rounded-xl ${color} flex items-center justify-center`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1 bg-elevated/40 border border-border/40 rounded-xl p-2">
|
||||
{stocks.slice(0, 5).map((stock, i) => (
|
||||
<StockRow key={stock.symbol} stock={stock} market={market} delay={i * 0.05} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinancePage() {
|
||||
const [markets, setMarkets] = useState<FinanceMarket[]>(defaultMarkets);
|
||||
const [currentMarket, setCurrentMarket] = useState('moex');
|
||||
const [heatmapData, setHeatmapData] = useState<HeatmapData | null>(null);
|
||||
const [moversData, setMoversData] = useState<TopMovers | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState('1d');
|
||||
|
||||
const loadMarkets = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchMarkets();
|
||||
if (data && data.length > 0) {
|
||||
setMarkets(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load markets:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [heatmap, movers] = await Promise.all([
|
||||
fetchHeatmap(currentMarket, timeRange).catch(() => null),
|
||||
fetchTopMovers(currentMarket, 10).catch(() => null),
|
||||
]);
|
||||
setHeatmapData(heatmap);
|
||||
setMoversData(movers);
|
||||
} catch (err) {
|
||||
console.error('Failed to load finance data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentMarket, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMarkets();
|
||||
}, [loadMarkets]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Финансы</h1>
|
||||
<p className="text-sm text-secondary">Котировки и аналитика рынков</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all w-fit"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">Обновить</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Market Tabs */}
|
||||
<Tabs.Root value={currentMarket} onValueChange={setCurrentMarket}>
|
||||
<Tabs.List className="flex gap-2 mb-4 sm:mb-6 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
{markets.map((m) => (
|
||||
<Tabs.Trigger
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
className="px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:bg-accent/10 data-[state=active]:border-accent/30 data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
|
||||
>
|
||||
{m.name}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="flex gap-1 p-1.5 bg-elevated/40 border border-border/30 rounded-xl w-fit mb-6 sm:mb-8 overflow-x-auto">
|
||||
{timeRanges.map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => setTimeRange(range.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg transition-all whitespace-nowrap ${
|
||||
timeRange === range.id
|
||||
? 'bg-accent/15 text-accent border border-accent/30'
|
||||
: 'text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка данных...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{moversData && (
|
||||
<div className="grid gap-4 sm:gap-6">
|
||||
<MoversSection
|
||||
title="Лидеры роста"
|
||||
stocks={moversData.gainers}
|
||||
market={currentMarket}
|
||||
icon={ArrowUpRight}
|
||||
color="bg-success/10 text-success"
|
||||
/>
|
||||
<MoversSection
|
||||
title="Лидеры падения"
|
||||
stocks={moversData.losers}
|
||||
market={currentMarket}
|
||||
icon={ArrowDownRight}
|
||||
color="bg-error/10 text-error"
|
||||
/>
|
||||
<MoversSection
|
||||
title="Самые активные"
|
||||
stocks={moversData.mostActive}
|
||||
market={currentMarket}
|
||||
icon={Activity}
|
||||
color="bg-accent/10 text-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{heatmapData && heatmapData.sectors && heatmapData.sectors.length > 0 && (
|
||||
<div className="mt-6 sm:mt-8">
|
||||
<div className="flex items-center gap-3 mb-3 sm:mb-4">
|
||||
<div className="w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-surface/60 flex items-center justify-center">
|
||||
<BarChart3 className="w-4 h-4 text-secondary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-primary">По секторам</h3>
|
||||
</div>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{heatmapData.sectors.map((sector) => (
|
||||
<div key={sector.name} className="bg-elevated/40 border border-border/40 rounded-xl p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-primary">{sector.name}</span>
|
||||
<span className={`text-xs font-mono ${sector.change >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
{formatChange(sector.change)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sector.tickers.slice(0, 3).map((stock, i) => (
|
||||
<StockRow key={stock.symbol} stock={stock} market={currentMarket} delay={i * 0.02} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!moversData && !heatmapData && (
|
||||
<div className="text-center py-16 sm:py-20">
|
||||
<BarChart3 className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||||
<p className="text-secondary">Данные недоступны для выбранного рынка</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
backend/webui/src/app/(main)/history/page.tsx
Normal file
246
backend/webui/src/app/(main)/history/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, Search, ChevronRight, Trash2, RefreshCw, Loader2, Share2, MessageSquare, Check } from 'lucide-react';
|
||||
import { fetchThreads, deleteThread, shareThread } from '@/lib/api';
|
||||
import type { Thread } from '@/lib/types';
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (diff < dayMs && date.getDate() === now.getDate()) {
|
||||
return 'Сегодня';
|
||||
}
|
||||
if (diff < 2 * dayMs && date.getDate() === now.getDate() - 1) {
|
||||
return 'Вчера';
|
||||
}
|
||||
if (diff < 7 * dayMs) {
|
||||
return date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
}
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const router = useRouter();
|
||||
const [threads, setThreads] = useState<Thread[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchThreads(100, 0);
|
||||
setThreads(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load threads:', err);
|
||||
setThreads([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (deletingId) return;
|
||||
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await deleteThread(id);
|
||||
setThreads((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete thread:', err);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const result = await shareThread(id);
|
||||
const url = `${window.location.origin}${result.shareUrl}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to share thread:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm('Удалить всю историю? Это действие нельзя отменить.')) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.all(threads.map((t) => deleteThread(t.id)));
|
||||
setThreads([]);
|
||||
} catch (err) {
|
||||
console.error('Failed to clear history:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return threads;
|
||||
const q = search.toLowerCase();
|
||||
return threads.filter((t) => t.title.toLowerCase().includes(q));
|
||||
}, [threads, search]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return filtered.reduce((acc, thread) => {
|
||||
const date = formatDate(thread.createdAt);
|
||||
if (!acc[date]) acc[date] = [];
|
||||
acc[date].push(thread);
|
||||
return acc;
|
||||
}, {} as Record<string, Thread[]>);
|
||||
}, [filtered]);
|
||||
|
||||
const openThread = (id: string) => {
|
||||
router.push(`/?thread=${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">История</h1>
|
||||
<p className="text-sm text-secondary">Ваши предыдущие поисковые сессии</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{threads.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-sm text-error hover:text-error bg-error/5 border border-error/20 hover:border-error/30 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Очистить</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск в истории..."
|
||||
className="w-full pl-11 pr-4 py-3 text-sm bg-elevated/40 border border-border/50 rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка истории...</p>
|
||||
</div>
|
||||
) : Object.keys(grouped).length > 0 ? (
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
{Object.entries(grouped).map(([date, dateThreads]) => (
|
||||
<div key={date}>
|
||||
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider mb-3 pl-1">
|
||||
{date}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{dateThreads.map((thread, i) => (
|
||||
<motion.div
|
||||
key={thread.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
onClick={() => openThread(thread.id)}
|
||||
className="group flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl bg-elevated/40 border border-border/40 hover:bg-elevated/60 hover:border-accent/25 cursor-pointer transition-all duration-200"
|
||||
>
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
|
||||
<MessageSquare className="w-4 h-4 text-secondary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate group-hover:text-accent transition-colors">
|
||||
{thread.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{thread.focusMode && thread.focusMode !== 'all' && (
|
||||
<span className="text-xs text-muted bg-surface/60 px-2 py-0.5 rounded capitalize">
|
||||
{thread.focusMode}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-faint">
|
||||
{formatTime(thread.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => handleShare(thread.id, e)}
|
||||
className="p-2 rounded-lg hover:bg-surface/60 transition-colors"
|
||||
title="Поделиться"
|
||||
>
|
||||
{copiedId === thread.id ? (
|
||||
<Check className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Share2 className="w-4 h-4 text-muted hover:text-secondary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDelete(thread.id, e)}
|
||||
disabled={deletingId === thread.id}
|
||||
className="p-2 rounded-lg hover:bg-error/10 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
{deletingId === thread.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-error" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 text-error/60 hover:text-error" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-faint group-hover:text-secondary transition-colors hidden sm:block" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 sm:py-20">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||||
<p className="text-secondary mb-1">
|
||||
{search ? 'Ничего не найдено' : 'История пуста'}
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
{search ? 'Попробуйте изменить запрос' : 'Начните новый поиск'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
backend/webui/src/app/(main)/layout.tsx
Normal file
86
backend/webui/src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
if (!mobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] bg-gradient-main overflow-hidden">
|
||||
{/* Mobile Header */}
|
||||
{isMobile && (
|
||||
<div className="fixed top-0 left-0 right-0 z-40 h-14 bg-base/95 backdrop-blur-lg border-b border-border/50 flex items-center justify-between px-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-primary">GooSeek</span>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
{!isMobile && <Sidebar />}
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
<AnimatePresence>
|
||||
{isMobile && sidebarOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 bottom-0 z-50 w-[280px]"
|
||||
>
|
||||
<Sidebar onClose={() => setSidebarOpen(false)} />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Content */}
|
||||
<main
|
||||
className={`
|
||||
flex-1 min-w-0 overflow-auto
|
||||
${isMobile ? 'pt-14' : ''}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
backend/webui/src/app/(main)/learning/new/page.tsx
Normal file
201
backend/webui/src/app/(main)/learning/new/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Target, BookOpen, Brain, Sparkles, Code2, CheckCircle2 } from 'lucide-react';
|
||||
import { createLesson } from '@/lib/api';
|
||||
|
||||
const difficulties = [
|
||||
{ value: 'beginner', label: 'Начинающий', icon: Target },
|
||||
{ value: 'intermediate', label: 'Средний', icon: BookOpen },
|
||||
{ value: 'advanced', label: 'Продвинутый', icon: Brain },
|
||||
{ value: 'expert', label: 'Эксперт', icon: Sparkles },
|
||||
];
|
||||
|
||||
const modes = [
|
||||
{ value: 'explain', label: 'Объяснение' },
|
||||
{ value: 'guided', label: 'С наставником' },
|
||||
{ value: 'interactive', label: 'Интерактив' },
|
||||
{ value: 'practice', label: 'Практика' },
|
||||
{ value: 'quiz', label: 'Тест' },
|
||||
];
|
||||
|
||||
export default function NewLessonPage() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
topic: '',
|
||||
difficulty: 'beginner',
|
||||
mode: 'explain',
|
||||
includeCode: true,
|
||||
includeQuiz: true,
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.topic.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await createLesson({
|
||||
topic: formData.topic.trim(),
|
||||
difficulty: formData.difficulty,
|
||||
mode: formData.mode,
|
||||
includeCode: formData.includeCode,
|
||||
includeQuiz: formData.includeQuiz,
|
||||
locale: 'ru',
|
||||
});
|
||||
router.push('/learning');
|
||||
} catch (err) {
|
||||
console.error('Failed to create lesson:', err);
|
||||
setError('Не удалось создать урок. Попробуйте позже.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6 sm:mb-8">
|
||||
<Link
|
||||
href="/learning"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary">Новый урок</h1>
|
||||
<p className="text-sm text-secondary mt-0.5 hidden sm:block">Создайте интерактивный урок с AI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleCreate} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Тема урока <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={formData.topic}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, topic: e.target.value }))}
|
||||
placeholder="Например: Основы Python"
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-3">
|
||||
Уровень сложности
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{difficulties.map((d) => (
|
||||
<button
|
||||
key={d.value}
|
||||
type="button"
|
||||
onClick={() => setFormData((f) => ({ ...f, difficulty: d.value }))}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
|
||||
formData.difficulty === d.value
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<d.icon className={`w-4 h-4 ${formData.difficulty === d.value ? 'text-accent' : ''}`} />
|
||||
<span className="text-sm">{d.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Режим обучения
|
||||
</label>
|
||||
<select
|
||||
value={formData.mode}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, mode: e.target.value }))}
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary focus:outline-none focus:border-accent/40 transition-colors"
|
||||
>
|
||||
{modes.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-3">
|
||||
Дополнительные материалы
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<label
|
||||
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
formData.includeCode
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.includeCode}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, includeCode: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-accent' : ''}`} />
|
||||
<span className="text-sm">Примеры кода</span>
|
||||
</label>
|
||||
<label
|
||||
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
formData.includeQuiz
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.includeQuiz}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, includeQuiz: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-accent' : ''}`} />
|
||||
<span className="text-sm">Тесты</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
|
||||
<Link
|
||||
href="/learning"
|
||||
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
|
||||
>
|
||||
Отмена
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!formData.topic.trim() || isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
|
||||
<span className="btn-gradient-text">Создать урок</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
backend/webui/src/app/(main)/learning/page.tsx
Normal file
218
backend/webui/src/app/(main)/learning/page.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BookOpen, Play, CheckCircle2, Clock, Plus, Trash2, Loader2, RefreshCw, GraduationCap } from 'lucide-react';
|
||||
import { fetchLessons, deleteLesson } from '@/lib/api';
|
||||
import type { Lesson } from '@/lib/types';
|
||||
|
||||
function formatTime(time: string): string {
|
||||
return time || '~30 мин';
|
||||
}
|
||||
|
||||
function getProgressPercent(lesson: Lesson): number {
|
||||
if (!lesson.progress) return 0;
|
||||
const completed = lesson.progress.completedSteps?.length || 0;
|
||||
return Math.round((completed / lesson.stepsCount) * 100);
|
||||
}
|
||||
|
||||
export default function LearningPage() {
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchLessons(50, 0);
|
||||
setLessons(data.lessons || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load lessons:', err);
|
||||
setLessons([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (deletingId) return;
|
||||
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await deleteLesson(id);
|
||||
setLessons((prev) => prev.filter((l) => l.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete lesson:', err);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const completed = lessons.filter((l) => getProgressPercent(l) === 100).length;
|
||||
const inProgress = lessons.filter((l) => {
|
||||
const pct = getProgressPercent(l);
|
||||
return pct > 0 && pct < 100;
|
||||
}).length;
|
||||
return { completed, inProgress, total: lessons.length };
|
||||
}, [lessons]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Обучение</h1>
|
||||
<p className="text-sm text-secondary">Интерактивные уроки с AI-наставником</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<Link
|
||||
href="/learning/new"
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
|
||||
>
|
||||
<Plus className="w-4 h-4 btn-gradient-text" />
|
||||
<span className="hidden sm:inline btn-gradient-text">Новый урок</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:gap-4 mb-6 sm:mb-8">
|
||||
<div className="p-3 sm:p-4 bg-success/5 border border-success/20 rounded-xl text-center">
|
||||
<p className="text-xl sm:text-2xl font-semibold text-success">{stats.completed}</p>
|
||||
<p className="text-xs text-muted mt-1">Завершено</p>
|
||||
</div>
|
||||
<div className="p-3 sm:p-4 bg-accent/5 border border-accent/20 rounded-xl text-center">
|
||||
<p className="text-xl sm:text-2xl font-semibold text-accent">{stats.inProgress}</p>
|
||||
<p className="text-xs text-muted mt-1">В процессе</p>
|
||||
</div>
|
||||
<div className="p-3 sm:p-4 bg-surface/40 border border-border/40 rounded-xl text-center">
|
||||
<p className="text-xl sm:text-2xl font-semibold text-primary">{stats.total}</p>
|
||||
<p className="text-xs text-muted mt-1">Всего</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка уроков...</p>
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{lessons.map((lesson, i) => {
|
||||
const pct = getProgressPercent(lesson);
|
||||
const done = pct === 100;
|
||||
const inProgress = pct > 0 && pct < 100;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={lesson.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="group p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-accent/25 cursor-pointer transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div
|
||||
className={`w-10 h-10 sm:w-12 sm:h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
done
|
||||
? 'bg-success/10 border border-success/25'
|
||||
: inProgress
|
||||
? 'bg-accent/10 border border-accent/25'
|
||||
: 'bg-surface/60 border border-border/50'
|
||||
}`}
|
||||
>
|
||||
{done ? (
|
||||
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-success" />
|
||||
) : inProgress ? (
|
||||
<Play className="w-4 h-4 sm:w-5 sm:h-5 text-accent" />
|
||||
) : (
|
||||
<BookOpen className="w-4 h-4 sm:w-5 sm:h-5 text-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">
|
||||
{lesson.title || lesson.topic}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => handleDelete(lesson.id, e)}
|
||||
disabled={deletingId === lesson.id}
|
||||
className="p-1.5 hover:bg-error/10 rounded-lg transition-all flex-shrink-0 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
>
|
||||
{deletingId === lesson.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-error" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 text-error/60 hover:text-error" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-2">
|
||||
<span className="text-xs text-muted bg-surface/60 px-2 py-0.5 rounded capitalize">
|
||||
{lesson.difficulty}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-faint">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(lesson.estimatedTime)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-faint">
|
||||
<GraduationCap className="w-3 h-3" />
|
||||
{lesson.stepsCount} шагов
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className="flex-1 h-1.5 bg-surface/60 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className={`h-full rounded-full ${
|
||||
done ? 'bg-success' : 'bg-accent'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-faint tabular-nums">
|
||||
{lesson.progress?.completedSteps?.length || 0}/{lesson.stepsCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 sm:py-20">
|
||||
<GraduationCap className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||||
<p className="text-secondary mb-1">Пока нет уроков</p>
|
||||
<p className="text-sm text-muted mb-6">
|
||||
Создайте первый урок, чтобы начать обучение
|
||||
</p>
|
||||
<Link
|
||||
href="/learning/new"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
|
||||
>
|
||||
<Plus className="w-4 h-4 btn-gradient-text" />
|
||||
<span className="btn-gradient-text">Создать первый урок</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
backend/webui/src/app/(main)/page.tsx
Normal file
108
backend/webui/src/app/(main)/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { ChatMessage } from '@/components/ChatMessage';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
|
||||
export default function HomePage() {
|
||||
const { messages, isLoading, sendMessage, stopGeneration } = useChat();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [showWelcome, setShowWelcome] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
setShowWelcome(false);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gradient-main">
|
||||
<AnimatePresence mode="wait">
|
||||
{showWelcome ? (
|
||||
<motion.div
|
||||
key="welcome"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8">
|
||||
{/* Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
className="text-center mb-8 sm:mb-10"
|
||||
>
|
||||
<h1 className="text-2xl sm:text-4xl font-bold text-primary tracking-tight">
|
||||
Что вы хотите{' '}
|
||||
<span className="text-gradient">узнать?</span>
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* Search Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
className="w-full max-w-2xl px-2"
|
||||
>
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
onStop={stopGeneration}
|
||||
isLoading={isLoading}
|
||||
placeholder="Спросите что угодно..."
|
||||
autoFocus
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="chat"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
<div className="max-w-3xl mx-auto space-y-6 sm:space-y-8">
|
||||
{messages.map((message, i) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<ChatMessage message={message} />
|
||||
</motion.div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Input */}
|
||||
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 sm:pb-6 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
onStop={stopGeneration}
|
||||
isLoading={isLoading}
|
||||
placeholder="Продолжить диалог..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
backend/webui/src/app/(main)/settings/page.tsx
Normal file
172
backend/webui/src/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages } from 'lucide-react';
|
||||
import * as Switch from '@radix-ui/react-switch';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [mode, setMode] = useState('balanced');
|
||||
const [saveHistory, setSaveHistory] = useState(true);
|
||||
const [personalization, setPersonalization] = useState(true);
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
const [language, setLanguage] = useState('ru');
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Настройки</h1>
|
||||
<p className="text-sm text-secondary">Персонализируйте ваш опыт</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
{/* Default Mode */}
|
||||
<Section title="Режим по умолчанию" icon={Zap}>
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||
{[
|
||||
{ id: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' },
|
||||
{ id: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' },
|
||||
{ id: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setMode(m.id)}
|
||||
className={`flex flex-col items-center gap-1 sm:gap-2 p-3 sm:p-4 rounded-xl border transition-all ${
|
||||
mode === m.id
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-surface/30 border-border/30 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<m.icon className={`w-4 h-4 sm:w-5 sm:h-5 ${mode === m.id ? 'text-accent' : ''}`} />
|
||||
<span className="text-xs sm:text-sm font-medium">{m.label}</span>
|
||||
<span className="text-[10px] sm:text-xs text-faint">{m.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Privacy & Data */}
|
||||
<Section title="Приватность" icon={Shield}>
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
icon={Eye}
|
||||
label="Сохранять историю"
|
||||
description="Автоматическое сохранение сессий"
|
||||
checked={saveHistory}
|
||||
onChange={setSaveHistory}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={Sparkles}
|
||||
label="Персонализация"
|
||||
description="Улучшение на основе истории"
|
||||
checked={personalization}
|
||||
onChange={setPersonalization}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={Bell}
|
||||
label="Уведомления"
|
||||
description="Уведомления об обновлениях"
|
||||
checked={notifications}
|
||||
onChange={setNotifications}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Language */}
|
||||
<Section title="Язык интерфейса" icon={Languages}>
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
{[
|
||||
{ id: 'ru', label: 'Русский', flag: '🇷🇺' },
|
||||
{ id: 'en', label: 'English', flag: '🇺🇸' },
|
||||
].map((lang) => (
|
||||
<button
|
||||
key={lang.id}
|
||||
onClick={() => setLanguage(lang.id)}
|
||||
className={`flex items-center gap-3 p-3 sm:p-4 rounded-xl border transition-all ${
|
||||
language === lang.id
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-surface/30 border-border/30 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{lang.flag}</span>
|
||||
<span className="text-sm font-medium">{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Data Management */}
|
||||
<Section title="Управление данными" icon={Download}>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm bg-surface/40 border border-border/50 text-secondary rounded-xl hover:bg-surface/60 hover:border-border hover:text-primary transition-all">
|
||||
<Download className="w-4 h-4" />
|
||||
Экспорт данных
|
||||
</button>
|
||||
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm bg-error/5 border border-error/20 text-error rounded-xl hover:bg-error/10 hover:border-error/30 transition-all">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить всё
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<div className="pt-6 border-t border-border/30">
|
||||
<div className="text-center text-faint text-xs">
|
||||
<p className="mb-1">GooSeek v1.0.0</p>
|
||||
<p>AI-поиск нового поколения</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-3 sm:space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-muted" />
|
||||
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function ToggleRow({ icon: Icon, label, description, checked, onChange }: ToggleRowProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl">
|
||||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-4 h-4 text-secondary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">{label}</p>
|
||||
<p className="text-xs text-muted mt-0.5 truncate">{description}</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="w-10 h-[22px] sm:w-11 sm:h-6 bg-surface/80 rounded-full relative transition-colors data-[state=checked]:bg-accent/20 border border-border data-[state=checked]:border-accent/30 flex-shrink-0"
|
||||
>
|
||||
<Switch.Thumb className="block w-[18px] h-[18px] sm:w-5 sm:h-5 bg-secondary rounded-full transition-transform translate-x-0.5 data-[state=checked]:translate-x-[18px] sm:data-[state=checked]:translate-x-[22px] data-[state=checked]:bg-accent" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
backend/webui/src/app/(main)/spaces/[id]/edit/page.tsx
Normal file
248
backend/webui/src/app/(main)/spaces/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Globe, BookOpen, Code2, Newspaper, TrendingUp, Youtube, Trash2 } from 'lucide-react';
|
||||
import { fetchSpaces, updateSpace, deleteSpace } from '@/lib/api';
|
||||
import type { Space, FocusMode } from '@/lib/types';
|
||||
|
||||
const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
|
||||
{ value: 'all', label: 'Все источники', icon: Globe },
|
||||
{ value: 'academic', label: 'Академический', icon: BookOpen },
|
||||
{ value: 'code', label: 'Код', icon: Code2 },
|
||||
{ value: 'news', label: 'Новости', icon: Newspaper },
|
||||
{ value: 'finance', label: 'Финансы', icon: TrendingUp },
|
||||
{ value: 'youtube', label: 'YouTube', icon: Youtube },
|
||||
];
|
||||
|
||||
export default function EditSpacePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const spaceId = params.id as string;
|
||||
|
||||
const [space, setSpace] = useState<Space | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
focusMode: 'all' as FocusMode,
|
||||
});
|
||||
|
||||
const loadSpace = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const spaces = await fetchSpaces();
|
||||
const found = spaces.find((s: Space) => s.id === spaceId);
|
||||
if (found) {
|
||||
setSpace(found);
|
||||
setFormData({
|
||||
name: found.name,
|
||||
description: found.description || '',
|
||||
instructions: found.instructions || '',
|
||||
focusMode: found.focusMode || 'all',
|
||||
});
|
||||
} else {
|
||||
setError('Пространство не найдено');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load space:', err);
|
||||
setError('Не удалось загрузить пространство');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpace();
|
||||
}, [loadSpace]);
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateSpace(spaceId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
instructions: formData.instructions.trim() || undefined,
|
||||
focusMode: formData.focusMode,
|
||||
});
|
||||
router.push('/spaces');
|
||||
} catch (err) {
|
||||
console.error('Failed to update space:', err);
|
||||
setError('Не удалось обновить пространство. Попробуйте позже.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (isDeleting) return;
|
||||
if (!confirm('Удалить пространство? Все связанные треды останутся в истории.')) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteSpace(spaceId);
|
||||
router.push('/spaces');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete space:', err);
|
||||
setError('Не удалось удалить пространство');
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!space) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4">
|
||||
<p className="text-secondary">{error || 'Пространство не найдено'}</p>
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="px-4 py-2.5 text-sm btn-gradient"
|
||||
>
|
||||
<span className="btn-gradient-text">Вернуться к списку</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6 sm:mb-8">
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary truncate">Редактировать</h1>
|
||||
<p className="text-sm text-secondary mt-0.5 truncate">{space.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleUpdate} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Название <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Название пространства"
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Краткое описание"
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
AI инструкции
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.instructions}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, instructions: e.target.value }))}
|
||||
placeholder="Специальные инструкции для AI"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-3">
|
||||
Режим фокуса
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{focusModes.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setFormData((f) => ({ ...f, focusMode: mode.value }))}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
|
||||
formData.focusMode === mode.value
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<mode.icon className={`w-4 h-4 ${formData.focusMode === mode.value ? 'text-accent' : ''}`} />
|
||||
<span className="text-xs text-center">{mode.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3">
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
|
||||
>
|
||||
Отмена
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!formData.name.trim() || isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
|
||||
<span className="btn-gradient-text">Сохранить</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 text-sm text-error bg-error/5 border border-error/20 rounded-xl hover:bg-error/10 transition-all"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Удалить пространство
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
backend/webui/src/app/(main)/spaces/new/page.tsx
Normal file
162
backend/webui/src/app/(main)/spaces/new/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, Globe, BookOpen, Code2, Newspaper, TrendingUp, Youtube } from 'lucide-react';
|
||||
import { createSpace } from '@/lib/api';
|
||||
import type { FocusMode } from '@/lib/types';
|
||||
|
||||
const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
|
||||
{ value: 'all', label: 'Все источники', icon: Globe },
|
||||
{ value: 'academic', label: 'Академический', icon: BookOpen },
|
||||
{ value: 'code', label: 'Код', icon: Code2 },
|
||||
{ value: 'news', label: 'Новости', icon: Newspaper },
|
||||
{ value: 'finance', label: 'Финансы', icon: TrendingUp },
|
||||
{ value: 'youtube', label: 'YouTube', icon: Youtube },
|
||||
];
|
||||
|
||||
export default function NewSpacePage() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
focusMode: 'all' as FocusMode,
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await createSpace({
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
instructions: formData.instructions.trim() || undefined,
|
||||
focusMode: formData.focusMode,
|
||||
});
|
||||
router.push('/spaces');
|
||||
} catch (err) {
|
||||
console.error('Failed to create space:', err);
|
||||
setError('Не удалось создать пространство. Попробуйте позже.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6 sm:mb-8">
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary">Новое пространство</h1>
|
||||
<p className="text-sm text-secondary mt-0.5 hidden sm:block">Организуйте исследования по темам</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleCreate} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Название <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Например: Исследование рынка"
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Краткое описание пространства"
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
AI инструкции
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.instructions}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, instructions: e.target.value }))}
|
||||
placeholder="Специальные инструкции для AI в этом пространстве"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted resize-none focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-3">
|
||||
Режим фокуса
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{focusModes.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setFormData((f) => ({ ...f, focusMode: mode.value }))}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
|
||||
formData.focusMode === mode.value
|
||||
? 'bg-accent/10 border-accent/30 text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<mode.icon className={`w-4 h-4 ${formData.focusMode === mode.value ? 'text-accent' : ''}`} />
|
||||
<span className="text-xs text-center">{mode.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
|
||||
<Link
|
||||
href="/spaces"
|
||||
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
|
||||
>
|
||||
Отмена
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!formData.name.trim() || isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
|
||||
<span className="btn-gradient-text">Создать пространство</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
backend/webui/src/app/(main)/spaces/page.tsx
Normal file
205
backend/webui/src/app/(main)/spaces/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FolderOpen, Plus, MoreHorizontal, Search, Trash2, Loader2, Settings, RefreshCw, Globe, BookOpen, Code2, TrendingUp, Newspaper, Youtube } from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { fetchSpaces, deleteSpace } from '@/lib/api';
|
||||
import type { Space, FocusMode } from '@/lib/types';
|
||||
|
||||
const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
|
||||
{ value: 'all', label: 'Все источники', icon: Globe },
|
||||
{ value: 'academic', label: 'Академический', icon: BookOpen },
|
||||
{ value: 'code', label: 'Код', icon: Code2 },
|
||||
{ value: 'news', label: 'Новости', icon: Newspaper },
|
||||
{ value: 'finance', label: 'Финансы', icon: TrendingUp },
|
||||
{ value: 'youtube', label: 'YouTube', icon: Youtube },
|
||||
];
|
||||
|
||||
export default function SpacesPage() {
|
||||
const router = useRouter();
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchSpaces();
|
||||
setSpaces(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err);
|
||||
setSpaces([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Удалить пространство? Все связанные треды останутся в истории.')) return;
|
||||
|
||||
try {
|
||||
await deleteSpace(id);
|
||||
setSpaces((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete space:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return spaces;
|
||||
const q = search.toLowerCase();
|
||||
return spaces.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [spaces, search]);
|
||||
|
||||
const openSpace = (id: string) => {
|
||||
router.push(`/?space=${id}`);
|
||||
};
|
||||
|
||||
const getFocusModeIcon = (mode: FocusMode) => {
|
||||
const found = focusModes.find((m) => m.value === mode);
|
||||
return found?.icon || Globe;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Пространства</h1>
|
||||
<p className="text-sm text-secondary">Организуйте исследования по темам</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<Link
|
||||
href="/spaces/new"
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
|
||||
>
|
||||
<Plus className="w-4 h-4 btn-gradient-text" />
|
||||
<span className="hidden sm:inline btn-gradient-text">Создать</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск пространств..."
|
||||
className="w-full pl-11 pr-4 py-3 text-sm bg-elevated/40 border border-border/50 rounded-xl text-primary placeholder:text-muted focus:outline-none focus:border-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка пространств...</p>
|
||||
</div>
|
||||
) : filtered.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{filtered.map((space, i) => {
|
||||
const FocusIcon = getFocusModeIcon(space.focusMode || 'all');
|
||||
return (
|
||||
<motion.div
|
||||
key={space.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => openSpace(space.id)}
|
||||
className="group flex items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-accent/25 cursor-pointer transition-all duration-200"
|
||||
>
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<FolderOpen className="w-4 h-4 sm:w-5 sm:h-5 text-accent/70" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">
|
||||
{space.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<FocusIcon className="w-3 h-3 text-muted flex-shrink-0" />
|
||||
<span className="text-xs text-muted truncate">
|
||||
{space.description || focusModes.find((m) => m.value === space.focusMode)?.label || 'Все источники'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-surface/60 rounded-lg transition-all sm:opacity-0 sm:group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4 text-secondary" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="min-w-[160px] bg-surface/95 backdrop-blur-xl border border-border rounded-xl p-1.5 shadow-dropdown z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
href={`/spaces/${space.id}/edit`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-sm text-secondary rounded-lg cursor-pointer hover:bg-elevated/80 hover:text-primary outline-none transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Редактировать
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(space.id);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2.5 text-sm text-error rounded-lg cursor-pointer hover:bg-error/10 outline-none transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 sm:py-20">
|
||||
<FolderOpen className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||||
<p className="text-secondary mb-1">
|
||||
{search ? 'Ничего не найдено' : 'Нет пространств'}
|
||||
</p>
|
||||
<p className="text-sm text-muted mb-6">
|
||||
Создайте пространство для организации исследований
|
||||
</p>
|
||||
<Link
|
||||
href="/spaces/new"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm btn-gradient"
|
||||
>
|
||||
<Plus className="w-4 h-4 btn-gradient-text" />
|
||||
<span className="btn-gradient-text">Создать пространство</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
522
backend/webui/src/app/globals.css
Normal file
522
backend/webui/src/app/globals.css
Normal file
@@ -0,0 +1,522 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
/* Cursor IDE 2026 Dark Theme */
|
||||
/* Base backgrounds - very dark with slight blue tint */
|
||||
--bg-base: 240 6% 4%;
|
||||
--bg-elevated: 240 6% 8%;
|
||||
--bg-surface: 240 5% 11%;
|
||||
--bg-overlay: 240 5% 14%;
|
||||
--bg-muted: 240 4% 18%;
|
||||
|
||||
/* Text colors - zinc-based for readability */
|
||||
--text-primary: 240 5% 92%;
|
||||
--text-secondary: 240 4% 68%;
|
||||
--text-muted: 240 4% 48%;
|
||||
--text-faint: 240 3% 38%;
|
||||
|
||||
/* Accent colors - indigo/purple like Cursor */
|
||||
--accent: 239 84% 67%;
|
||||
--accent-hover: 239 84% 74%;
|
||||
--accent-muted: 239 60% 55%;
|
||||
--accent-subtle: 239 40% 25%;
|
||||
|
||||
/* Secondary accent - cyan for variety */
|
||||
--accent-secondary: 187 85% 55%;
|
||||
--accent-secondary-muted: 187 60% 40%;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 142 71% 45%;
|
||||
--success-muted: 142 50% 35%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-muted: 38 70% 40%;
|
||||
--error: 0 72% 51%;
|
||||
--error-muted: 0 60% 40%;
|
||||
|
||||
/* Border colors */
|
||||
--border: 240 4% 16%;
|
||||
--border-hover: 240 4% 22%;
|
||||
--border-focus: 239 60% 50%;
|
||||
|
||||
/* Legacy mappings for compatibility */
|
||||
--background: var(--bg-base);
|
||||
--foreground: var(--text-primary);
|
||||
--card: var(--bg-elevated);
|
||||
--card-foreground: var(--text-primary);
|
||||
--popover: var(--bg-surface);
|
||||
--popover-foreground: var(--text-primary);
|
||||
--primary: var(--accent);
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: var(--bg-surface);
|
||||
--secondary-foreground: var(--text-secondary);
|
||||
--muted: var(--bg-muted);
|
||||
--muted-foreground: var(--text-muted);
|
||||
--accent-color: var(--accent);
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: var(--error);
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--input: var(--bg-surface);
|
||||
--ring: var(--accent);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: hsl(var(--bg-base));
|
||||
color: hsl(var(--text-primary));
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.bg-gradient-main {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(240 6% 4%) 0%,
|
||||
hsl(240 6% 5%) 50%,
|
||||
hsl(240 5% 6%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-elevated {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(240 6% 9% / 0.9) 0%,
|
||||
hsl(240 6% 7% / 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(240 5% 11% / 0.6) 0%,
|
||||
hsl(240 5% 9% / 0.4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Accent gradient for special elements */
|
||||
.bg-gradient-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 67%) 0%,
|
||||
hsl(260 84% 67%) 50%,
|
||||
hsl(239 84% 67%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Gradient border button */
|
||||
.btn-gradient {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(239 84% 74%);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btn-gradient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-gradient:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.1) 0%,
|
||||
hsl(260 90% 75% / 0.1) 50%,
|
||||
hsl(187 85% 65% / 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.btn-gradient:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Gradient border - larger version */
|
||||
.btn-gradient-lg {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(239 84% 74%);
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btn-gradient-lg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 2px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-gradient-lg:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.1) 0%,
|
||||
hsl(260 90% 75% / 0.1) 50%,
|
||||
hsl(187 85% 65% / 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Gradient text for buttons */
|
||||
.btn-gradient-text {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Modern thin scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(240 4% 20%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(240 4% 28%);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(239 84% 67% / 0.3);
|
||||
}
|
||||
|
||||
/* Code font */
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass morphism card */
|
||||
.glass-card {
|
||||
@apply bg-surface/60 backdrop-blur-xl border border-border rounded-2xl;
|
||||
}
|
||||
|
||||
/* Surface card with hover */
|
||||
.surface-card {
|
||||
@apply bg-elevated/40 backdrop-blur-sm border border-border/50 rounded-xl;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.surface-card:hover {
|
||||
@apply border-border-hover bg-elevated/60;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input-cursor {
|
||||
@apply bg-surface/50 border border-border rounded-xl px-4 py-3;
|
||||
@apply text-primary placeholder:text-muted;
|
||||
@apply focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
/* Primary button - gradient accent */
|
||||
.btn-primary {
|
||||
@apply bg-accent/10 text-accent-foreground border border-accent/30;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
|
||||
@apply hover:bg-accent/20 hover:border-accent/50;
|
||||
@apply active:bg-accent/25;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-primary-solid {
|
||||
@apply bg-accent text-white border-none;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
|
||||
@apply hover:bg-accent-hover;
|
||||
@apply active:scale-[0.98];
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
@apply bg-surface/50 text-secondary border border-border;
|
||||
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
|
||||
@apply hover:bg-muted/50 hover:text-primary hover:border-border-hover;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
@apply text-secondary hover:text-primary;
|
||||
@apply hover:bg-surface/50 rounded-xl px-3 py-2;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.btn-icon {
|
||||
@apply w-9 h-9 flex items-center justify-center rounded-xl;
|
||||
@apply text-muted hover:text-secondary hover:bg-surface/50;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-icon-active {
|
||||
@apply bg-accent/15 text-accent;
|
||||
}
|
||||
|
||||
/* Navigation item */
|
||||
.nav-item {
|
||||
@apply flex items-center gap-3 px-3 py-2.5 rounded-xl;
|
||||
@apply text-secondary hover:text-primary;
|
||||
@apply hover:bg-surface/40 transition-all duration-150;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
@apply bg-accent/10 text-primary;
|
||||
@apply border-l-2 border-accent;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-elevated/40 backdrop-blur-sm;
|
||||
@apply border border-border/50 rounded-2xl;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-border bg-elevated/60;
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
@apply border-accent/30 shadow-lg shadow-accent/5;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.section-header {
|
||||
@apply text-xs font-semibold uppercase tracking-wider text-muted;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-medium;
|
||||
@apply bg-surface text-secondary border border-border/50;
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
@apply bg-accent/15 text-accent border-accent/30;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success/15 text-success border-success/30;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning/15 text-warning border-warning/30;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply bg-error/15 text-error border-error/30;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
@apply border-t border-border/50;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-accent {
|
||||
box-shadow: 0 0 20px hsl(239 84% 67% / 0.15),
|
||||
0 0 40px hsl(239 84% 67% / 0.1);
|
||||
}
|
||||
|
||||
.glow-accent-strong {
|
||||
box-shadow: 0 0 30px hsl(239 84% 67% / 0.25),
|
||||
0 0 60px hsl(239 84% 67% / 0.15);
|
||||
}
|
||||
|
||||
.glow-subtle {
|
||||
box-shadow: 0 4px 20px hsl(240 6% 4% / 0.5);
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-accent/50 focus:ring-offset-2 focus:ring-offset-base;
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.animate-delay-75 { animation-delay: 75ms; }
|
||||
.animate-delay-100 { animation-delay: 100ms; }
|
||||
.animate-delay-150 { animation-delay: 150ms; }
|
||||
.animate-delay-200 { animation-delay: 200ms; }
|
||||
.animate-delay-300 { animation-delay: 300ms; }
|
||||
.animate-delay-400 { animation-delay: 400ms; }
|
||||
.animate-delay-500 { animation-delay: 500ms; }
|
||||
}
|
||||
|
||||
/* Smooth animations */
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; transform: translateY(4px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
0% { opacity: 0; transform: translateX(-12px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
0% { opacity: 0; transform: translateX(12px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% { opacity: 0; transform: scale(0.95); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px hsl(239 84% 67% / 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px hsl(239 84% 67% / 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(240 5% 11%) 0%,
|
||||
hsl(240 5% 16%) 50%,
|
||||
hsl(240 5% 11%) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
19
backend/webui/src/app/layout.tsx
Normal file
19
backend/webui/src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GooSeek - AI Search',
|
||||
description: 'AI-поиск нового поколения',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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';
|
||||
579
backend/webui/src/lib/api.ts
Normal file
579
backend/webui/src/lib/api.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
import type {
|
||||
ChatRequest,
|
||||
DiscoverItem,
|
||||
StreamEvent,
|
||||
Thread,
|
||||
Space,
|
||||
FinanceMarket,
|
||||
HeatmapData,
|
||||
TopMovers,
|
||||
Lesson,
|
||||
LessonStep,
|
||||
ComputerTask,
|
||||
ComputerExecuteRequest,
|
||||
ComputerTaskEvent,
|
||||
ComputerModel,
|
||||
ComputerConnector,
|
||||
Artifact,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
const response = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chat request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(buffer) as StreamEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse final buffer:', buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDiscover(topic = 'tech', region = 'russia'): Promise<DiscoverItem[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/discover?topic=${encodeURIComponent(topic)}®ion=${encodeURIComponent(region)}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discover fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.blogs || [];
|
||||
}
|
||||
|
||||
export async function fetchConfig(): Promise<{ values: Record<string, unknown> }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/config`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Config fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
export async function fetchThreads(limit = 50, offset = 0): Promise<Thread[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/threads?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return [];
|
||||
throw new Error(`Threads fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.threads || [];
|
||||
}
|
||||
|
||||
export async function fetchThread(id: string): Promise<Thread | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/threads/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Thread fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createThread(data: { title?: string; focusMode?: string; spaceId?: string }): Promise<Thread> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/threads`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Thread create failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteThread(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/threads/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Thread delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareThread(id: string): Promise<{ shareId: string; shareUrl: string }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/threads/${id}/share`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Thread share failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchSpaces(): Promise<Space[]> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/spaces`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) return [];
|
||||
throw new Error(`Spaces fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.spaces || [];
|
||||
}
|
||||
|
||||
export async function fetchSpace(id: string): Promise<Space | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/spaces/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Space fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createSpace(data: { name: string; description?: string; instructions?: string; focusMode?: string }): Promise<Space> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/spaces`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Space create failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateSpace(id: string, data: Partial<Space>): Promise<Space> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/spaces/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Space update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteSpace(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/spaces/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Space delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMarkets(): Promise<FinanceMarket[]> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/markets`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Markets fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.markets || [];
|
||||
}
|
||||
|
||||
export async function fetchHeatmap(market: string, timeRange = '1d'): Promise<HeatmapData> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/heatmap/${market}?range=${timeRange}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Heatmap fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchTopMovers(market: string, count = 10): Promise<TopMovers> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/movers/${market}?count=${count}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Top movers fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLessons(limit = 20, offset = 0): Promise<{ lessons: Lesson[]; count: number }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/learning/lessons?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lessons fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchLesson(id: string): Promise<{ lesson: Lesson; steps: LessonStep[] } | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Lesson fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createLesson(data: {
|
||||
topic: string;
|
||||
query?: string;
|
||||
difficulty?: string;
|
||||
mode?: string;
|
||||
maxSteps?: number;
|
||||
locale?: string;
|
||||
includeCode?: boolean;
|
||||
includeQuiz?: boolean;
|
||||
}): Promise<Lesson> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/lesson`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
topic: data.topic,
|
||||
query: data.query || data.topic,
|
||||
difficulty: data.difficulty || 'beginner',
|
||||
mode: data.mode || 'explain',
|
||||
maxSteps: data.maxSteps || 5,
|
||||
locale: data.locale || 'ru',
|
||||
includeCode: data.includeCode ?? true,
|
||||
includeQuiz: data.includeQuiz ?? true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lesson create failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function completeStep(lessonId: string, stepIndex: number): Promise<{ success: boolean; progress: unknown }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${lessonId}/complete-step`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ stepIndex }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Step complete failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitQuizAnswer(
|
||||
lessonId: string,
|
||||
stepIndex: number,
|
||||
selectedOptions: string[]
|
||||
): Promise<{ correct: boolean; explanation?: string; progress: unknown }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${lessonId}/submit-answer`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ stepIndex, selectedOptions }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Quiz submit failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteLesson(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/learning/lessons/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lesson delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Computer API (Perplexity Computer style)
|
||||
// =====================
|
||||
|
||||
export async function executeComputerTask(request: ComputerExecuteRequest): Promise<ComputerTask> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/execute`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer execute failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchComputerTasks(
|
||||
userId?: string,
|
||||
limit = 20,
|
||||
offset = 0
|
||||
): Promise<{ tasks: ComputerTask[]; count: number }> {
|
||||
const params = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
});
|
||||
if (userId) {
|
||||
params.set('userId', userId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/tasks?${params}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer tasks fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchComputerTask(id: string): Promise<ComputerTask | null> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Computer task fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function* streamComputerTask(id: string): AsyncGenerator<ComputerTaskEvent> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}/stream`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer stream failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6)) as ComputerTaskEvent;
|
||||
yield event;
|
||||
} catch {
|
||||
console.warn('Failed to parse computer event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeComputerTask(id: string, userInput: string): Promise<{ status: string }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}/resume`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ userInput }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer resume failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function cancelComputerTask(id: string): Promise<{ status: string }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer cancel failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchComputerArtifacts(taskId: string): Promise<{ artifacts: Artifact[]; count: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/tasks/${taskId}/artifacts`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer artifacts fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function downloadArtifact(id: string): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/artifacts/${id}/download`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Artifact download failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function fetchComputerModels(): Promise<{ models: ComputerModel[]; count: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer models fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchComputerConnectors(): Promise<{ connectors: ComputerConnector[]; count: number }> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/connectors`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Computer connectors fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function executeConnectorAction(
|
||||
connectorId: string,
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/computer/connectors/${connectorId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ action, params }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Connector execute failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
177
backend/webui/src/lib/hooks/useChat.ts
Normal file
177
backend/webui/src/lib/hooks/useChat.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { Message, Citation, Widget, StreamEvent } from '../types';
|
||||
import { streamChat, generateId } from '../api';
|
||||
|
||||
interface UseChatOptions {
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useChat(options: UseChatOptions = {}) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
const [widgets, setWidgets] = useState<Widget[]>([]);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const sendMessage = useCallback(async (content: string, mode: 'speed' | 'balanced' | 'quality' = 'balanced') => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
citations: [],
|
||||
widgets: [],
|
||||
isStreaming: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
setIsLoading(true);
|
||||
setCitations([]);
|
||||
setWidgets([]);
|
||||
|
||||
const history: [string, string][] = messages
|
||||
.filter((m) => !m.isStreaming)
|
||||
.reduce((acc, m, i, arr) => {
|
||||
if (m.role === 'user' && arr[i + 1]?.role === 'assistant') {
|
||||
acc.push([m.content, arr[i + 1].content]);
|
||||
}
|
||||
return acc;
|
||||
}, [] as [string, string][]);
|
||||
|
||||
try {
|
||||
const stream = streamChat({
|
||||
message: {
|
||||
messageId: assistantMessage.id,
|
||||
chatId: userMessage.id,
|
||||
content: content.trim(),
|
||||
},
|
||||
optimizationMode: mode,
|
||||
history,
|
||||
locale: 'ru',
|
||||
});
|
||||
|
||||
let fullContent = '';
|
||||
const collectedCitations: Citation[] = [];
|
||||
const collectedWidgets: Widget[] = [];
|
||||
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case 'messageStart':
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
if (event.data && typeof event.data === 'object' && 'content' in event.data) {
|
||||
const chunk = (event.data as { content: string }).content;
|
||||
fullContent += chunk;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: fullContent }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sources':
|
||||
if (event.data && Array.isArray(event.data)) {
|
||||
const sources = event.data as Array<{
|
||||
url: string;
|
||||
title?: string;
|
||||
metadata?: { title?: string };
|
||||
}>;
|
||||
sources.forEach((source, idx) => {
|
||||
const citation: Citation = {
|
||||
index: idx + 1,
|
||||
url: source.url,
|
||||
title: source.title || source.metadata?.title || new URL(source.url).hostname,
|
||||
domain: new URL(source.url).hostname,
|
||||
};
|
||||
collectedCitations.push(citation);
|
||||
});
|
||||
setCitations([...collectedCitations]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'widget':
|
||||
if (event.data && typeof event.data === 'object') {
|
||||
const widget = event.data as Widget;
|
||||
collectedWidgets.push(widget);
|
||||
setWidgets([...collectedWidgets]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'messageEnd':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? {
|
||||
...m,
|
||||
content: fullContent,
|
||||
citations: collectedCitations,
|
||||
widgets: collectedWidgets,
|
||||
isStreaming: false,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
throw new Error(
|
||||
event.data && typeof event.data === 'object' && 'message' in event.data
|
||||
? String((event.data as { message: string }).message)
|
||||
: 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Ошибка при отправке сообщения';
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
|
||||
: m
|
||||
)
|
||||
);
|
||||
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, messages, options]);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCitations([]);
|
||||
setWidgets([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
citations,
|
||||
widgets,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
};
|
||||
}
|
||||
441
backend/webui/src/lib/types.ts
Normal file
441
backend/webui/src/lib/types.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
citations?: Citation[];
|
||||
widgets?: Widget[];
|
||||
isStreaming?: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
index: number;
|
||||
url: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
snippet?: string;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
export interface Widget {
|
||||
type: WidgetType;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WidgetType =
|
||||
| 'sources'
|
||||
| 'images'
|
||||
| 'videos'
|
||||
| 'products'
|
||||
| 'profiles'
|
||||
| 'promos'
|
||||
| 'knowledge_card'
|
||||
| 'image_gallery'
|
||||
| 'video_embed'
|
||||
| 'weather'
|
||||
| 'finance'
|
||||
| 'map';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DiscoverItem {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
thumbnail?: string;
|
||||
sourcesCount?: number;
|
||||
digestId?: string;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
focusMode?: FocusMode;
|
||||
isPublic?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type FocusMode =
|
||||
| 'all'
|
||||
| 'academic'
|
||||
| 'youtube'
|
||||
| 'reddit'
|
||||
| 'code'
|
||||
| 'news'
|
||||
| 'math'
|
||||
| 'finance';
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
focusMode: string;
|
||||
isPublic: boolean;
|
||||
shareId?: string;
|
||||
messages?: ThreadMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadMessage {
|
||||
id: string;
|
||||
threadId: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: ThreadSource[];
|
||||
widgets?: Record<string, unknown>[];
|
||||
relatedQuestions?: string[];
|
||||
model?: string;
|
||||
tokensUsed?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadSource {
|
||||
url: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
domain?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
message: {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
content: string;
|
||||
};
|
||||
optimizationMode: 'speed' | 'balanced' | 'quality';
|
||||
sources?: string[];
|
||||
history?: [string, string][];
|
||||
chatModel?: {
|
||||
providerId: string;
|
||||
key: string;
|
||||
};
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface ApiConfig {
|
||||
version: number;
|
||||
setupComplete: boolean;
|
||||
modelProviders: ModelProvider[];
|
||||
}
|
||||
|
||||
export interface ModelProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
models: Model[];
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
contextLength: number;
|
||||
}
|
||||
|
||||
export interface FinanceStock {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume?: number;
|
||||
marketCap?: number;
|
||||
sector?: string;
|
||||
}
|
||||
|
||||
export interface HeatmapData {
|
||||
market: string;
|
||||
timeRange: string;
|
||||
sectors: HeatmapSector[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HeatmapSector {
|
||||
name: string;
|
||||
change: number;
|
||||
tickers: FinanceStock[];
|
||||
}
|
||||
|
||||
export interface TopMovers {
|
||||
gainers: FinanceStock[];
|
||||
losers: FinanceStock[];
|
||||
mostActive: FinanceStock[];
|
||||
}
|
||||
|
||||
export interface FinanceMarket {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
topic: string;
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
mode: 'explain' | 'guided' | 'interactive' | 'practice' | 'quiz';
|
||||
stepsCount: number;
|
||||
estimatedTime: string;
|
||||
progress: LessonProgress;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LessonProgress {
|
||||
currentStep: number;
|
||||
completedSteps: number[];
|
||||
score: number;
|
||||
timeSpent: number;
|
||||
}
|
||||
|
||||
export interface LessonStep {
|
||||
index: number;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'explanation' | 'code' | 'visualization' | 'quiz' | 'practice';
|
||||
codeExample?: CodeExample;
|
||||
quiz?: QuizQuestion;
|
||||
practice?: PracticeExercise;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface CodeExample {
|
||||
language: string;
|
||||
code: string;
|
||||
explanation?: string;
|
||||
runnable?: boolean;
|
||||
}
|
||||
|
||||
export interface QuizQuestion {
|
||||
question: string;
|
||||
options: string[];
|
||||
correctAnswers: string[];
|
||||
explanation?: string;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export interface PracticeExercise {
|
||||
task: string;
|
||||
hints: string[];
|
||||
solution: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Computer Types (Perplexity Computer style)
|
||||
// =====================
|
||||
|
||||
export type ComputerTaskStatus =
|
||||
| 'pending'
|
||||
| 'planning'
|
||||
| 'executing'
|
||||
| 'waiting_user'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'scheduled'
|
||||
| 'paused'
|
||||
| 'checkpoint'
|
||||
| 'long_running';
|
||||
|
||||
export type ComputerTaskType =
|
||||
| 'research'
|
||||
| 'code'
|
||||
| 'analysis'
|
||||
| 'design'
|
||||
| 'deploy'
|
||||
| 'monitor'
|
||||
| 'report'
|
||||
| 'communicate'
|
||||
| 'schedule'
|
||||
| 'transform'
|
||||
| 'validate';
|
||||
|
||||
export type DurationMode = 'short' | 'medium' | 'long' | 'extended' | 'unlimited';
|
||||
|
||||
export type TaskPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
export interface ComputerTask {
|
||||
id: string;
|
||||
userId: string;
|
||||
query: string;
|
||||
status: ComputerTaskStatus;
|
||||
plan?: TaskPlan;
|
||||
subTasks?: SubTask[];
|
||||
artifacts?: Artifact[];
|
||||
memory?: Record<string, unknown>;
|
||||
progress: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
schedule?: ComputerSchedule;
|
||||
nextRunAt?: string;
|
||||
runCount: number;
|
||||
totalCost: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
durationMode: DurationMode;
|
||||
checkpoint?: Checkpoint;
|
||||
checkpoints?: Checkpoint[];
|
||||
maxDuration: number;
|
||||
estimatedEnd?: string;
|
||||
iterations: number;
|
||||
maxIterations: number;
|
||||
pausedAt?: string;
|
||||
resumedAt?: string;
|
||||
totalRuntime: number;
|
||||
heartbeatAt?: string;
|
||||
priority: TaskPriority;
|
||||
resourceLimits?: ResourceLimits;
|
||||
}
|
||||
|
||||
export interface TaskPlan {
|
||||
query: string;
|
||||
summary: string;
|
||||
subTasks: SubTask[];
|
||||
executionOrder: string[][];
|
||||
estimatedCost: number;
|
||||
estimatedTimeSeconds: number;
|
||||
}
|
||||
|
||||
export interface SubTask {
|
||||
id: string;
|
||||
type: ComputerTaskType;
|
||||
description: string;
|
||||
dependencies?: string[];
|
||||
modelId?: string;
|
||||
requiredCaps?: string[];
|
||||
input?: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
status: ComputerTaskStatus;
|
||||
progress: number;
|
||||
error?: string;
|
||||
cost: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
retries: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
taskId: string;
|
||||
type: 'file' | 'code' | 'report' | 'deployment' | 'image' | 'data';
|
||||
name: string;
|
||||
url?: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ComputerSchedule {
|
||||
type: 'once' | 'interval' | 'cron' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
cronExpr?: string;
|
||||
intervalSeconds?: number;
|
||||
nextRun: string;
|
||||
maxRuns: number;
|
||||
runCount: number;
|
||||
expiresAt?: string;
|
||||
enabled: boolean;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface Checkpoint {
|
||||
id: string;
|
||||
taskId: string;
|
||||
subTaskIndex: number;
|
||||
waveIndex: number;
|
||||
state: Record<string, unknown>;
|
||||
progress: number;
|
||||
artifacts: string[];
|
||||
memory: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
runtimeSoFar: number;
|
||||
costSoFar: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ResourceLimits {
|
||||
maxCpu: number;
|
||||
maxMemoryMb: number;
|
||||
maxDiskMb: number;
|
||||
maxNetworkMbps: number;
|
||||
maxCostPerHour: number;
|
||||
maxTotalCost: number;
|
||||
maxConcurrent: number;
|
||||
idleTimeoutMins: number;
|
||||
}
|
||||
|
||||
export interface ComputerTaskEvent {
|
||||
type: string;
|
||||
taskId: string;
|
||||
subTaskId?: string;
|
||||
status?: ComputerTaskStatus;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ComputerExecuteRequest {
|
||||
query: string;
|
||||
userId?: string;
|
||||
options?: {
|
||||
async?: boolean;
|
||||
maxCost?: number;
|
||||
timeoutSeconds?: number;
|
||||
enableSandbox?: boolean;
|
||||
schedule?: Partial<ComputerSchedule>;
|
||||
context?: Record<string, unknown>;
|
||||
durationMode?: DurationMode;
|
||||
priority?: TaskPriority;
|
||||
resourceLimits?: Partial<ResourceLimits>;
|
||||
resumeFromId?: string;
|
||||
enableBrowser?: boolean;
|
||||
notifyOnEvents?: string[];
|
||||
webhookUrl?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComputerModel {
|
||||
id: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
capabilities: string[];
|
||||
costPer1K: number;
|
||||
maxContext: number;
|
||||
maxTokens: number;
|
||||
priority: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ComputerConnector {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
actions: string[];
|
||||
enabled: boolean;
|
||||
configRequired: string[];
|
||||
}
|
||||
54
backend/webui/src/lib/utils.ts
Normal file
54
backend/webui/src/lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const now = new Date();
|
||||
const target = typeof date === 'string' ? new Date(date) : date;
|
||||
const diff = now.getTime() - target.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return 'только что';
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (hours < 24) return `${hours} ч назад`;
|
||||
if (days < 7) return `${days} дн назад`;
|
||||
|
||||
return target.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
export function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace('www.', '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
165
backend/webui/tailwind.config.ts
Normal file
165
backend/webui/tailwind.config.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
/* Cursor IDE 2026 Color Palette */
|
||||
base: 'hsl(var(--bg-base))',
|
||||
elevated: 'hsl(var(--bg-elevated))',
|
||||
surface: 'hsl(var(--bg-surface))',
|
||||
overlay: 'hsl(var(--bg-overlay))',
|
||||
|
||||
/* Text colors */
|
||||
primary: 'hsl(var(--text-primary))',
|
||||
secondary: 'hsl(var(--text-secondary))',
|
||||
muted: 'hsl(var(--text-muted))',
|
||||
faint: 'hsl(var(--text-faint))',
|
||||
|
||||
/* Accent colors */
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
hover: 'hsl(var(--accent-hover))',
|
||||
muted: 'hsl(var(--accent-muted))',
|
||||
subtle: 'hsl(var(--accent-subtle))',
|
||||
foreground: 'hsl(0 0% 100%)',
|
||||
},
|
||||
|
||||
'accent-secondary': {
|
||||
DEFAULT: 'hsl(var(--accent-secondary))',
|
||||
muted: 'hsl(var(--accent-secondary-muted))',
|
||||
},
|
||||
|
||||
/* Semantic colors */
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
muted: 'hsl(var(--success-muted))',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(var(--warning))',
|
||||
muted: 'hsl(var(--warning-muted))',
|
||||
},
|
||||
error: {
|
||||
DEFAULT: 'hsl(var(--error))',
|
||||
muted: 'hsl(var(--error-muted))',
|
||||
},
|
||||
|
||||
/* Border colors */
|
||||
border: {
|
||||
DEFAULT: 'hsl(var(--border))',
|
||||
hover: 'hsl(var(--border-hover))',
|
||||
focus: 'hsl(var(--border-focus))',
|
||||
},
|
||||
|
||||
/* Legacy mappings */
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
|
||||
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'base': ['0.9375rem', { lineHeight: '1.5rem' }],
|
||||
'lg': ['1.0625rem', { lineHeight: '1.625rem' }],
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
'26': '6.5rem',
|
||||
'30': '7.5rem',
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
boxShadow: {
|
||||
'glow-sm': '0 0 10px hsl(239 84% 67% / 0.1)',
|
||||
'glow-md': '0 0 20px hsl(239 84% 67% / 0.15)',
|
||||
'glow-lg': '0 0 40px hsl(239 84% 67% / 0.2)',
|
||||
'inner-glow': 'inset 0 0 20px hsl(240 6% 12% / 0.5)',
|
||||
'elevated': '0 4px 20px hsl(240 6% 4% / 0.4), 0 0 1px hsl(240 5% 20% / 0.5)',
|
||||
'card': '0 2px 8px hsl(240 6% 4% / 0.3)',
|
||||
'dropdown': '0 8px 32px hsl(240 6% 4% / 0.5), 0 0 1px hsl(240 5% 20% / 0.5)',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'fade-in-up': {
|
||||
'0%': { opacity: '0', transform: 'translateY(12px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-in-right': {
|
||||
'0%': { opacity: '0', transform: 'translateX(-12px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
'slide-in-left': {
|
||||
'0%': { opacity: '0', transform: 'translateX(12px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
'scale-in': {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
'pulse-soft': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { backgroundPosition: '-200% 0' },
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
'glow-pulse': {
|
||||
'0%, 100%': { boxShadow: '0 0 20px hsl(239 84% 67% / 0.15)' },
|
||||
'50%': { boxShadow: '0 0 30px hsl(239 84% 67% / 0.25)' },
|
||||
},
|
||||
'border-pulse': {
|
||||
'0%, 100%': { borderColor: 'hsl(239 84% 67% / 0.3)' },
|
||||
'50%': { borderColor: 'hsl(239 84% 67% / 0.5)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 0.2s ease-out forwards',
|
||||
'fade-in-up': 'fade-in-up 0.3s ease-out forwards',
|
||||
'slide-in-right': 'slide-in-right 0.25s ease-out forwards',
|
||||
'slide-in-left': 'slide-in-left 0.25s ease-out forwards',
|
||||
'scale-in': 'scale-in 0.2s ease-out forwards',
|
||||
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
|
||||
shimmer: 'shimmer 1.5s linear infinite',
|
||||
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
|
||||
'border-pulse': 'border-pulse 2s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
26
backend/webui/tsconfig.json
Normal file
26
backend/webui/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user