fix(computer-svc): stream для завершенных задач + Timeweb env vars
- Исправлен Stream() в computer.go: для completed/failed/cancelled задач сразу отправляется финальное событие и канал закрывается (ранее соединение зависало с socket hang up) - Добавлены TIMEWEB_* переменные в docker-compose.yml для computer-svc (LLM через Timeweb Cloud AI для России) - UI компоненты webui обновлены Made-with: Cursor
This commit is contained in:
@@ -200,6 +200,9 @@ services:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
- TIMEWEB_API_BASE_URL=${TIMEWEB_API_BASE_URL}
|
||||
- TIMEWEB_AGENT_ACCESS_ID=${TIMEWEB_AGENT_ACCESS_ID}
|
||||
- TIMEWEB_API_KEY=${TIMEWEB_API_KEY}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- SANDBOX_IMAGE=python:3.11-slim
|
||||
- BROWSER_SVC_URL=http://browser-svc:3050
|
||||
|
||||
@@ -645,6 +645,31 @@ func (c *Computer) GetUserTasks(ctx context.Context, userID string, limit, offse
|
||||
}
|
||||
|
||||
func (c *Computer) Stream(ctx context.Context, taskID string) (<-chan TaskEvent, error) {
|
||||
task, err := c.taskRepo.GetByID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task not found: %w", err)
|
||||
}
|
||||
|
||||
if task.Status == StatusCompleted || task.Status == StatusFailed || task.Status == StatusCancelled {
|
||||
ch := make(chan TaskEvent, 1)
|
||||
go func() {
|
||||
eventType := EventTaskCompleted
|
||||
if task.Status == StatusFailed {
|
||||
eventType = EventTaskFailed
|
||||
}
|
||||
ch <- TaskEvent{
|
||||
TaskID: taskID,
|
||||
Type: eventType,
|
||||
Status: task.Status,
|
||||
Progress: task.Progress,
|
||||
Message: task.Message,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
return c.eventBus.Subscribe(taskID), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,9 @@ const statusConfig: Record<ComputerTaskStatus, { color: string; bg: string; icon
|
||||
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: 'Запланировано' },
|
||||
scheduled: { color: 'text-cyan-400', bg: 'bg-cyan-400/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: 'Чекпоинт' },
|
||||
checkpoint: { color: 'text-gradient', bg: 'active-gradient', icon: RefreshCw, label: 'Чекпоинт' },
|
||||
};
|
||||
|
||||
const artifactIcons: Record<string, React.ElementType> = {
|
||||
@@ -294,7 +294,7 @@ export default function ComputerPage() {
|
||||
>
|
||||
<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" />
|
||||
<Cpu className="w-5 h-5 text-gradient" />
|
||||
Задачи
|
||||
</h2>
|
||||
<button
|
||||
@@ -321,7 +321,7 @@ export default function ComputerPage() {
|
||||
<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" />
|
||||
<Cpu className="w-5 h-5 text-gradient" />
|
||||
Computer
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-1">Автономные AI-задачи</p>
|
||||
@@ -398,12 +398,12 @@ export default function ComputerPage() {
|
||||
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"
|
||||
className="h-full progress-gradient 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>
|
||||
<span className="text-xs text-gradient">{selectedTask.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -484,11 +484,11 @@ export default function ComputerPage() {
|
||||
return (
|
||||
<div
|
||||
key={artifact.id}
|
||||
className="p-4 rounded-lg bg-elevated/40 border border-border/40 hover:border-accent/30 transition-colors"
|
||||
className="p-4 rounded-lg bg-elevated/40 border border-border/40 hover-gradient 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 className="p-2 rounded-lg icon-gradient flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-gradient" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary truncate">{artifact.name}</p>
|
||||
@@ -559,7 +559,7 @@ export default function ComputerPage() {
|
||||
}}
|
||||
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"
|
||||
className="w-full px-4 py-4 pr-14 rounded-xl bg-elevated/60 border border-border text-primary placeholder-muted focus:outline-none input-gradient resize-none transition-all text-base"
|
||||
/>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
@@ -597,16 +597,16 @@ export default function ComputerPage() {
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setDurationMode(mode.value)}
|
||||
className={`p-2 sm:p-3 rounded-xl border text-center transition-all ${
|
||||
className={`p-2 sm:p-3 rounded-xl 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'
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border 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'
|
||||
durationMode === mode.value ? 'text-gradient' : 'text-muted'
|
||||
}`} />
|
||||
<div className="text-xs sm:text-sm font-medium">{mode.label}</div>
|
||||
<div className={`text-xs sm:text-sm font-medium ${durationMode === mode.value ? 'text-gradient' : ''}`}>{mode.label}</div>
|
||||
<div className="text-xs text-muted mt-0.5 hidden sm:block">{mode.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -633,9 +633,9 @@ export default function ComputerPage() {
|
||||
].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"
|
||||
className="p-3 rounded-lg bg-surface/40 border border-border/50 hover-gradient transition-colors text-left"
|
||||
>
|
||||
<conn.icon className="w-4 h-4 text-accent mb-1.5" />
|
||||
<conn.icon className="w-4 h-4 text-gradient 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>
|
||||
@@ -657,16 +657,16 @@ export default function ComputerPage() {
|
||||
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"
|
||||
className="group relative p-3 sm:p-4 rounded-xl bg-elevated/40 border border-border/40 hover-gradient 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>
|
||||
<span className="text-sm font-medium text-primary group-hover:text-gradient 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" />
|
||||
<ArrowRight className="absolute bottom-3 right-3 w-4 h-4 text-transparent group-hover:text-gradient opacity-60 transition-all transform translate-x-2 group-hover:translate-x-0" />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
@@ -694,7 +694,7 @@ function TaskList({ tasks, selectedTask, isLoading, onSelect, isTaskActive, stat
|
||||
<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" />
|
||||
<Loader2 className="w-6 h-6 loader-gradient animate-spin" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted text-sm">
|
||||
@@ -710,10 +710,10 @@ function TaskList({ tasks, selectedTask, isLoading, onSelect, isTaskActive, stat
|
||||
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 ${
|
||||
className={`w-full text-left p-3 rounded-xl transition-all ${
|
||||
selectedTask?.id === task.id
|
||||
? 'bg-elevated/60 border-accent/30'
|
||||
: 'bg-elevated/40 border-border/40 hover:border-border'
|
||||
? 'bg-elevated/60 active-gradient'
|
||||
: 'bg-elevated/40 border border-border/40 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -737,7 +737,7 @@ function TaskList({ tasks, selectedTask, isLoading, onSelect, isTaskActive, stat
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${task.progress}%` }}
|
||||
className="h-full bg-gradient-to-r from-accent to-accent-hover"
|
||||
className="h-full progress-gradient"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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';
|
||||
@@ -14,10 +15,16 @@ const topics = [
|
||||
] as const;
|
||||
|
||||
export default function DiscoverPage() {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<DiscoverItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [topic, setTopic] = useState<string>('tech');
|
||||
|
||||
const handleSummarize = useCallback((url: string) => {
|
||||
const query = encodeURIComponent(`Summary: ${url}`);
|
||||
router.push(`/?q=${query}`);
|
||||
}, [router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -46,7 +53,7 @@ export default function DiscoverPage() {
|
||||
<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"
|
||||
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-gradient rounded-xl transition-all w-fit"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">Обновить</span>
|
||||
@@ -60,7 +67,7 @@ export default function DiscoverPage() {
|
||||
<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"
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:active-gradient 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}
|
||||
@@ -73,8 +80,8 @@ export default function DiscoverPage() {
|
||||
{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" />
|
||||
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
|
||||
<div className="absolute inset-0 blur-xl progress-gradient opacity-20" />
|
||||
</div>
|
||||
<p className="text-sm text-muted mt-4">Загрузка новостей...</p>
|
||||
</div>
|
||||
@@ -91,7 +98,7 @@ export default function DiscoverPage() {
|
||||
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"
|
||||
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-gradient hover:bg-elevated/60 transition-all duration-200"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{item.thumbnail && (
|
||||
@@ -113,7 +120,7 @@ export default function DiscoverPage() {
|
||||
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"
|
||||
className="text-sm font-medium text-primary line-clamp-2 group-hover:text-gradient transition-colors mb-2"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
@@ -132,7 +139,10 @@ export default function DiscoverPage() {
|
||||
<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">
|
||||
<button
|
||||
onClick={() => handleSummarize(item.url)}
|
||||
className="flex items-center gap-1.5 text-xs text-gradient hover:opacity-80 transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
AI Саммари
|
||||
</button>
|
||||
|
||||
@@ -47,7 +47,7 @@ function StockRow({ stock, market, delay }: { stock: FinanceStock; market: strin
|
||||
<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">
|
||||
<p className="text-sm font-medium text-primary truncate group-hover:text-gradient transition-colors">
|
||||
{stock.name}
|
||||
</p>
|
||||
{stock.sector && (
|
||||
@@ -150,7 +150,7 @@ export default function FinancePage() {
|
||||
<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"
|
||||
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-gradient rounded-xl transition-all w-fit"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">Обновить</span>
|
||||
@@ -164,7 +164,7 @@ export default function FinancePage() {
|
||||
<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"
|
||||
className="px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:active-gradient data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
|
||||
>
|
||||
{m.name}
|
||||
</Tabs.Trigger>
|
||||
@@ -180,7 +180,7 @@ export default function FinancePage() {
|
||||
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'
|
||||
? 'active-gradient text-gradient'
|
||||
: 'text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -192,7 +192,7 @@ export default function FinancePage() {
|
||||
{/* 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" />
|
||||
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка данных...</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -218,7 +218,7 @@ export default function FinancePage() {
|
||||
stocks={moversData.mostActive}
|
||||
market={currentMarket}
|
||||
icon={Activity}
|
||||
color="bg-accent/10 text-accent"
|
||||
color="active-gradient text-gradient"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function HistoryPage() {
|
||||
<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"
|
||||
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-gradient rounded-xl transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
@@ -152,14 +152,14 @@ export default function HistoryPage() {
|
||||
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"
|
||||
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 input-gradient 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" />
|
||||
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка истории...</p>
|
||||
</div>
|
||||
) : Object.keys(grouped).length > 0 ? (
|
||||
@@ -177,13 +177,13 @@ export default function HistoryPage() {
|
||||
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"
|
||||
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-gradient 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">
|
||||
<p className="text-sm text-primary truncate group-hover:text-gradient transition-colors">
|
||||
{thread.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function NewLessonPage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -109,11 +109,11 @@ export default function NewLessonPage() {
|
||||
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'
|
||||
? 'active-gradient 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' : ''}`} />
|
||||
<d.icon className={`w-4 h-4 ${formData.difficulty === d.value ? 'text-gradient' : ''}`} />
|
||||
<span className="text-sm">{d.label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -127,7 +127,7 @@ export default function NewLessonPage() {
|
||||
<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"
|
||||
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary focus:outline-none input-gradient transition-colors"
|
||||
>
|
||||
{modes.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
@@ -145,7 +145,7 @@ export default function NewLessonPage() {
|
||||
<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'
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
@@ -155,13 +155,13 @@ export default function NewLessonPage() {
|
||||
onChange={(e) => setFormData((f) => ({ ...f, includeCode: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-accent' : ''}`} />
|
||||
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-gradient' : ''}`} />
|
||||
<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'
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
@@ -171,7 +171,7 @@ export default function NewLessonPage() {
|
||||
onChange={(e) => setFormData((f) => ({ ...f, includeQuiz: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-accent' : ''}`} />
|
||||
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-gradient' : ''}`} />
|
||||
<span className="text-sm">Тесты</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function LearningPage() {
|
||||
<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"
|
||||
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-gradient rounded-xl transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
@@ -96,8 +96,8 @@ export default function LearningPage() {
|
||||
<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>
|
||||
<div className="p-3 sm:p-4 active-gradient rounded-xl text-center">
|
||||
<p className="text-xl sm:text-2xl font-semibold text-gradient">{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">
|
||||
@@ -109,7 +109,7 @@ export default function LearningPage() {
|
||||
{/* 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" />
|
||||
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка уроков...</p>
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
@@ -125,7 +125,7 @@ export default function LearningPage() {
|
||||
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"
|
||||
className="group p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover-gradient cursor-pointer transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div
|
||||
@@ -133,21 +133,21 @@ export default function LearningPage() {
|
||||
done
|
||||
? 'bg-success/10 border border-success/25'
|
||||
: inProgress
|
||||
? 'bg-accent/10 border border-accent/25'
|
||||
? 'icon-gradient'
|
||||
: '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" />
|
||||
<Play className="w-4 h-4 sm:w-5 sm:h-5 text-gradient" />
|
||||
) : (
|
||||
<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">
|
||||
<p className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
|
||||
{lesson.title || lesson.topic}
|
||||
</p>
|
||||
<button
|
||||
@@ -182,7 +182,7 @@ export default function LearningPage() {
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className={`h-full rounded-full ${
|
||||
done ? 'bg-success' : 'bg-accent'
|
||||
done ? 'bg-success' : 'progress-gradient'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useRef, useEffect, useState, Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
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() {
|
||||
function HomePageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { messages, isLoading, sendMessage, stopGeneration } = useChat();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [showWelcome, setShowWelcome] = useState(true);
|
||||
const initialQueryProcessed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
@@ -17,6 +21,15 @@ export default function HomePage() {
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchParams.get('q');
|
||||
if (query && !initialQueryProcessed.current && !isLoading) {
|
||||
initialQueryProcessed.current = true;
|
||||
router.replace('/', { scroll: false });
|
||||
sendMessage(decodeURIComponent(query));
|
||||
}
|
||||
}, [searchParams, isLoading, sendMessage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
@@ -106,3 +119,11 @@ export default function HomePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex-1" />}>
|
||||
<HomePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ export default function SettingsPage() {
|
||||
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'
|
||||
? 'active-gradient 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' : ''}`} />
|
||||
<m.icon className={`w-4 h-4 sm:w-5 sm:h-5 ${mode === m.id ? 'text-gradient' : ''}`} />
|
||||
<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>
|
||||
@@ -86,7 +86,7 @@ export default function SettingsPage() {
|
||||
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'
|
||||
? 'active-gradient text-primary'
|
||||
: 'bg-surface/30 border-border/30 text-muted hover:border-border hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -163,9 +163,9 @@ function ToggleRow({ icon: Icon, label, description, checked, onChange }: Toggle
|
||||
<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"
|
||||
className="w-10 h-[22px] sm:w-11 sm:h-6 bg-surface/80 rounded-full relative transition-colors data-[state=checked]:active-gradient border border-border 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.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]:progress-gradient" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function EditSpacePage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||||
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export default function EditSpacePage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function EditSpacePage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -181,7 +181,7 @@ export default function EditSpacePage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -197,11 +197,11 @@ export default function EditSpacePage() {
|
||||
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'
|
||||
? 'active-gradient 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' : ''}`} />
|
||||
<mode.icon className={`w-4 h-4 ${formData.focusMode === mode.value ? 'text-gradient' : ''}`} />
|
||||
<span className="text-xs text-center">{mode.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function NewSpacePage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ export default function NewSpacePage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function NewSpacePage() {
|
||||
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"
|
||||
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 input-gradient transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -127,11 +127,11 @@ export default function NewSpacePage() {
|
||||
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'
|
||||
? 'active-gradient 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' : ''}`} />
|
||||
<mode.icon className={`w-4 h-4 ${formData.focusMode === mode.value ? 'text-gradient' : ''}`} />
|
||||
<span className="text-xs text-center">{mode.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function SpacesPage() {
|
||||
<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"
|
||||
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-gradient rounded-xl transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
@@ -103,14 +103,14 @@ export default function SpacesPage() {
|
||||
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"
|
||||
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 input-gradient 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" />
|
||||
<Loader2 className="w-8 h-8 animate-spin loader-gradient" />
|
||||
<p className="text-sm text-muted mt-4">Загрузка пространств...</p>
|
||||
</div>
|
||||
) : filtered.length > 0 ? (
|
||||
@@ -124,13 +124,13 @@ export default function SpacesPage() {
|
||||
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"
|
||||
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-gradient 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 className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl icon-gradient flex items-center justify-center flex-shrink-0">
|
||||
<FolderOpen className="w-4 h-4 sm:w-5 sm:h-5 text-gradient" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-primary group-hover:text-accent transition-colors truncate">
|
||||
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
|
||||
{space.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
|
||||
@@ -237,6 +237,164 @@ body {
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Gradient active state for selectable items */
|
||||
.active-gradient {
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.1) 0%,
|
||||
hsl(260 90% 75% / 0.08) 50%,
|
||||
hsl(187 85% 65% / 0.06) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.active-gradient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.4) 0%,
|
||||
hsl(260 90% 75% / 0.3) 50%,
|
||||
hsl(187 85% 65% / 0.2) 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;
|
||||
}
|
||||
|
||||
/* Gradient border for cards on hover */
|
||||
.hover-gradient:hover {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hover-gradient:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.3) 0%,
|
||||
hsl(260 90% 75% / 0.2) 50%,
|
||||
hsl(187 85% 65% / 0.15) 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;
|
||||
}
|
||||
|
||||
/* Gradient icon wrapper */
|
||||
.icon-gradient {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.15) 0%,
|
||||
hsl(260 90% 75% / 0.1) 50%,
|
||||
hsl(187 85% 65% / 0.08) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-gradient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.25) 0%,
|
||||
hsl(260 90% 75% / 0.2) 50%,
|
||||
hsl(187 85% 65% / 0.15) 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;
|
||||
}
|
||||
|
||||
/* Gradient focus state for inputs */
|
||||
.input-gradient:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 1px hsl(239 84% 74% / 0.4),
|
||||
0 0 0 3px hsl(239 84% 74% / 0.1);
|
||||
}
|
||||
|
||||
/* Gradient loader */
|
||||
.loader-gradient {
|
||||
color: hsl(239 84% 74%);
|
||||
filter: drop-shadow(0 0 8px hsl(239 84% 74% / 0.3));
|
||||
}
|
||||
|
||||
/* Progress bar gradient */
|
||||
.progress-gradient {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Stat card gradient */
|
||||
.stat-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.08) 0%,
|
||||
hsl(260 90% 75% / 0.05) 100%
|
||||
);
|
||||
border: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(239 84% 74% / 0.25) 0%,
|
||||
hsl(187 85% 65% / 0.15) 100%
|
||||
) 1;
|
||||
}
|
||||
|
||||
/* Gradient glow effect */
|
||||
.glow-gradient {
|
||||
box-shadow: 0 0 20px hsl(239 84% 74% / 0.15),
|
||||
0 0 40px hsl(260 90% 75% / 0.1),
|
||||
0 0 60px hsl(187 85% 65% / 0.05);
|
||||
}
|
||||
|
||||
/* Border left gradient indicator */
|
||||
.border-l-gradient {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.border-l-gradient::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(239 84% 74%) 0%,
|
||||
hsl(260 90% 75%) 50%,
|
||||
hsl(187 85% 65%) 100%
|
||||
);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Modern thin scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
|
||||
<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" />
|
||||
<currentMode.icon className="w-4 h-4 text-gradient" />
|
||||
<span className="font-medium">{currentMode.label}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
|
||||
</button>
|
||||
@@ -146,13 +146,13 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }:
|
||||
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'
|
||||
? 'active-gradient 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>
|
||||
<m.icon className={`w-4 h-4 ${mode === m.value ? 'text-gradient' : 'text-muted'}`} />
|
||||
<span className={`flex-1 font-medium ${mode === m.value ? 'text-gradient' : ''}`}>{m.label}</span>
|
||||
<span className="text-xs text-muted">{m.desc}</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
|
||||
@@ -31,8 +31,8 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
>
|
||||
{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 className="w-9 h-9 rounded-xl icon-gradient flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-gradient" />
|
||||
</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>
|
||||
@@ -42,8 +42,8 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
<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 className="w-9 h-9 rounded-xl icon-gradient flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-gradient" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-secondary">GooSeek</span>
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
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"
|
||||
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(239_84%_74%/0.3)] hover:decoration-[hsl(239_84%_74%/0.5)] transition-all"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
@@ -109,7 +109,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
),
|
||||
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="text-gradient opacity-60 select-none mt-0.5">•</span>
|
||||
<span className="leading-[1.6]">{children}</span>
|
||||
</li>
|
||||
),
|
||||
@@ -129,7 +129,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
</h3>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-accent/40 pl-4 my-5 text-secondary italic">
|
||||
<blockquote className="border-l-gradient pl-4 my-5 text-secondary italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
@@ -143,7 +143,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
|
||||
{/* Streaming Cursor */}
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block w-2 h-5 bg-accent/60 rounded-sm animate-pulse-soft ml-1" />
|
||||
<span className="inline-block w-2 h-5 progress-gradient rounded-sm animate-pulse-soft ml-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -199,12 +199,12 @@ function ActionButton({ icon: Icon, label, onClick, active }: ActionButtonProps)
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${active
|
||||
? 'text-accent bg-accent/10'
|
||||
? 'active-gradient'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<Icon className={`w-4 h-4 ${active ? 'text-gradient' : ''}`} />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
@@ -230,9 +230,9 @@ function CitationBadge({ citation }: { citation: CitationType }) {
|
||||
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"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 bg-surface/40 hover:bg-surface/60 border border-border/50 hover-gradient 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">
|
||||
<span className="w-5 h-5 rounded-md icon-gradient text-gradient flex items-center justify-center text-xs font-semibold">
|
||||
{citation.index}
|
||||
</span>
|
||||
{citation.favicon && (
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
FolderOpen,
|
||||
Clock,
|
||||
Settings,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
@@ -68,7 +67,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<span className="font-bold italic text-primary tracking-tight text-lg">GooSeek</span>
|
||||
<span className="font-black italic text-primary tracking-tight text-3xl">GooSeek</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -94,26 +93,6 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
)}
|
||||
</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) => (
|
||||
@@ -184,14 +163,14 @@ function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLin
|
||||
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'
|
||||
? 'active-gradient text-primary border-l-gradient ml-0'
|
||||
: 'text-secondary hover:text-primary hover:bg-surface/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-accent' : ''}`} />
|
||||
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-medium truncate">{label}</span>
|
||||
<span className={`text-sm font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -84,6 +84,59 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
// Handle new block format from agent-svc
|
||||
if (event.block && typeof event.block === 'object') {
|
||||
const block = event.block as { type: string; data: unknown };
|
||||
if (block.type === 'text' && typeof block.data === 'string') {
|
||||
fullContent = block.data;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: fullContent }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (block.type === 'widget' && block.data) {
|
||||
const widget = block.data as Widget;
|
||||
collectedWidgets.push(widget);
|
||||
setWidgets([...collectedWidgets]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'textChunk':
|
||||
// Handle streaming text chunks
|
||||
if (event.chunk && typeof event.chunk === 'string') {
|
||||
fullContent += event.chunk;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessage.id
|
||||
? { ...m, content: fullContent }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'updateBlock':
|
||||
// Handle block updates (final text)
|
||||
if (event.patch && Array.isArray(event.patch)) {
|
||||
for (const p of event.patch) {
|
||||
if (p.op === 'replace' && p.path === '/data' && typeof p.value === 'string') {
|
||||
fullContent = p.value;
|
||||
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<{
|
||||
@@ -112,6 +165,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'researchComplete':
|
||||
// Research phase complete, text generation starting
|
||||
break;
|
||||
|
||||
case 'messageEnd':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
|
||||
@@ -117,6 +117,18 @@ export interface SearchResult {
|
||||
export interface StreamEvent {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
block?: {
|
||||
id: string;
|
||||
type: string;
|
||||
data: unknown;
|
||||
};
|
||||
blockId?: string;
|
||||
chunk?: string;
|
||||
patch?: Array<{
|
||||
op: string;
|
||||
path: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
|
||||
Reference in New Issue
Block a user