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:
home
2026-02-27 05:17:42 +03:00
parent 06fe57c765
commit 120fbbaafb
19 changed files with 391 additions and 126 deletions

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
))}

View File

@@ -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>
))}

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>
))}

View File

@@ -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 && (

View File

@@ -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>
);

View File

@@ -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) =>

View File

@@ -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 {