diff --git a/CONTINUE.md b/CONTINUE.md
index a005550..bb3b77c 100644
--- a/CONTINUE.md
+++ b/CONTINUE.md
@@ -1,144 +1,141 @@
# Недоделки — начать отсюда
+## Последнее исправление (28.02.2026)
+
+### Fix: auth-svc не собирался в Docker
+- Добавлена строка сборки `auth-svc` в `Dockerfile.all`
+- Удалён дубликат `ratelimit_tiered.go` (конфликт типов с Redis-версией)
+- Добавлен REDIS_URL в api-gateway для rate limiting
+- Пересоздан тестовый пользователь через API
+
+---
+
## Последнее изменение (28.02.2026)
-**Создан полный UI для авторизации:**
-- Модальные окна login/register
-- Страницы /login, /register, /forgot-password, /reset-password
-- UserMenu в сайдбаре
-- AuthContext для управления состоянием
-## Сделано (полностью)
+**Обновлён дизайн страниц авторизации:**
-### 1. Auth UI (NEW)
+### Сделано (полностью)
-**Новые файлы:**
+#### Страницы авторизации в стиле сайта
+- `/login` — переделана с градиентным фоном, фирменным логотипом, анимациями
+- `/register` — аналогично, единый стиль с login
+- `/forgot-password` — сброс пароля в новом дизайне
+- `/reset-password` — установка нового пароля в новом дизайне
-**API клиент:**
-- `backend/webui/src/lib/auth.ts` — функции для работы с auth API, хранение токенов
+Изменения:
+- `bg-gradient-main` вместо простого `bg-base`
+- Фирменный логотип GooSeek (font-black italic + иконка Sparkles)
+- Gradient blur декорации на фоне
+- Карточка с градиентной рамкой и backdrop-blur
+- Анимации появления (framer-motion)
+- Footer с копирайтом
-**Контекст:**
-- `backend/webui/src/lib/contexts/AuthContext.tsx` — React контекст для auth состояния
+---
-**Компоненты:**
-- `backend/webui/src/components/auth/AuthModal.tsx` — модальное окно
-- `backend/webui/src/components/auth/LoginForm.tsx` — форма входа
-- `backend/webui/src/components/auth/RegisterForm.tsx` — форма регистрации
-- `backend/webui/src/components/auth/ForgotPasswordForm.tsx` — сброс пароля
-- `backend/webui/src/components/auth/UserMenu.tsx` — меню пользователя
-- `backend/webui/src/components/auth/index.ts` — экспорты
+**Ранее: Spaces как у Perplexity + коллаборация:**
-**Страницы:**
-- `backend/webui/src/app/(auth)/login/page.tsx`
-- `backend/webui/src/app/(auth)/register/page.tsx`
-- `backend/webui/src/app/(auth)/forgot-password/page.tsx`
-- `backend/webui/src/app/(auth)/reset-password/page.tsx`
-- `backend/webui/src/app/(auth)/layout.tsx`
+### Сделано (полностью)
-**Интеграция:**
-- `providers.tsx` — добавлен AuthProvider и AuthModal
-- `Sidebar.tsx` — кнопки входа/регистрации для гостей, профиль для авторизованных
+#### 1. Backend - Space Members
+- Добавлена таблица `space_members` (space_id, user_id, role)
+- Добавлена таблица `space_invites` (приглашения по токену)
+- Методы: `GetMembers`, `AddMember`, `RemoveMember`, `IsMember`
+- Методы: `CreateInvite`, `GetInviteByToken`, `DeleteInvite`
+- Обновлён `GetByMemberID` - возвращает пространства где пользователь участник
-**Функционал:**
-- Модальные окна для быстрого входа без перехода на страницу
-- Отдельные страницы для полноценного входа
-- Валидация паролей в реальном времени
-- Показ/скрытие пароля
-- Запоминание пользователя
-- Сброс пароля по email
-- Автоматический refresh токенов
-- Отображение tier (Free/Pro/Business)
+#### 2. Frontend - Types & API
+- Добавлены типы: `SpaceMember`, `SpaceInvite`
+- Обновлён тип `Space` с полями `members`, `memberCount`, `userId`
+- API методы: `fetchSpaceMembers`, `inviteToSpace`, `removeSpaceMember`, `acceptSpaceInvite`, `fetchSpaceThreads`
-### 2. Auth Service (Backend)
+#### 3. Страница /spaces - Новый дизайн
+- Карточки пространств с градиентным фоном
+- Показывает: название, описание, кол-во участников, кол-во тредов
+- Аватары участников на карточке
+- Поиск по пространствам
+- Требует авторизации
-**Файлы:**
-- `backend/internal/auth/types.go`
-- `backend/internal/auth/repository.go`
-- `backend/cmd/auth-svc/main.go`
+#### 4. Страница /spaces/[id] - Детали пространства
+- Заголовок с названием и описанием
+- Табы: Треды / Участники
+- Список тредов пространства с датами
+- Список участников с ролями (owner/admin/member)
+- Кнопка "Начать новый тред"
+- Модалка приглашения участников по email
+- Удаление участников (для admin/owner)
-**Эндпоинты:**
-| Method | Path | Описание |
-|--------|------|----------|
-| POST | `/api/v1/auth/register` | Регистрация |
-| POST | `/api/v1/auth/login` | Вход |
-| POST | `/api/v1/auth/refresh` | Обновление токена |
-| POST | `/api/v1/auth/logout` | Выход |
-| POST | `/api/v1/auth/logout-all` | Выход со всех устройств |
-| GET | `/api/v1/auth/validate` | Валидация токена |
-| GET | `/api/v1/auth/me` | Профиль |
-| PUT | `/api/v1/auth/me` | Обновить профиль |
-| POST | `/api/v1/auth/change-password` | Смена пароля |
-| POST | `/api/v1/auth/forgot-password` | Запрос сброса |
-| POST | `/api/v1/auth/reset-password` | Сброс пароля |
+#### 5. Страница /spaces/new - Создание
+- Превью карточки в реальном времени
+- Выбор иконки (эмодзи)
+- Выбор цвета (6 вариантов)
+- Переключатель приватности
+- AI инструкции для пространства
-### 3. Ранее — Аудит безопасности
+#### 6. Ранее добавлено
+- Селектор модели (Auto/GooSeek 1.0)
+- Ollama клиент для бесплатной модели
+- Обновлённая кнопка "Войти" в сайдбаре
-- Репозитории с фильтрацией по user_id
-- JWT middleware во всех сервисах
-- Tiered rate limiting (free/pro/business)
-- Usage tracking
-- LLM limits
+### Файлы изменены/созданы
+
+```
+backend/internal/db/
+└── space_repo.go (UPDATED - members, invites)
+
+backend/webui/src/
+├── lib/
+│ ├── types.ts (UPDATED - SpaceMember, SpaceInvite)
+│ └── api.ts (UPDATED - space members API)
+├── app/(main)/spaces/
+│ ├── page.tsx (REWRITTEN - новый дизайн)
+│ ├── new/page.tsx (REWRITTEN - с превью)
+│ └── [id]/page.tsx (NEW - детали пространства)
+└── components/
+ ├── Sidebar.tsx (UPDATED - кнопка войти)
+ └── ChatInput.tsx (UPDATED - селектор модели)
+
+backend/internal/llm/
+├── ollama.go (NEW)
+└── client.go (UPDATED)
+
+backend/pkg/config/config.go (UPDATED - Ollama config)
+backend/cmd/agent-svc/main.go (UPDATED - Ollama support)
+```
## Осталось сделать
### Высокий приоритет:
-1. **Проверить компиляцию** — `cd backend && go build ./...`
-2. **Протестировать auth flow** — регистрация → логин → refresh → logout
-3. **Добавить billing-svc** — интеграция с ЮKassa
+1. **Backend API endpoints** — добавить API для:
+ - `GET /api/v1/spaces/:id/members`
+ - `POST /api/v1/spaces/:id/invite`
+ - `DELETE /api/v1/spaces/:id/members/:userId`
+ - `POST /api/v1/spaces/invite/:token/accept`
+ - `GET /api/v1/spaces/:id/threads`
+
+2. **Email отправка** — отправлять email с приглашением
+
+3. **Ollama в docker-compose** — добавить сервис ollama
### Средний приоритет:
4. **OAuth провайдеры** — Google, GitHub, Yandex
-5. **Email уведомления** — подтверждение email, сброс пароля
-6. **Страница настроек профиля** — редактирование name, avatar
-7. **Страница подписки** — /settings/billing с выбором тарифа
+5. **Подтверждение email** — отправка письма при регистрации
+6. **Real-time обновления** — WebSocket для тредов в пространстве
+7. **Уведомления** — когда кто-то добавляет сообщение в тред
### Низкий приоритет:
-8. **2FA** — TOTP аутентификация
-9. **Session management** — список активных сессий
-10. **Account deletion** — удаление аккаунта
+8. **Интеграция оплаты** — ЮKassa для пополнения баланса
+9. **2FA** — TOTP аутентификация
+10. **Экспорт тредов** — PDF/Markdown
## Контекст
-### Новые файлы UI:
-```
-backend/webui/src/
-├── lib/
-│ ├── auth.ts (NEW)
-│ └── contexts/
-│ └── AuthContext.tsx (NEW)
-├── components/
-│ └── auth/
-│ ├── AuthModal.tsx (NEW)
-│ ├── LoginForm.tsx (NEW)
-│ ├── RegisterForm.tsx (NEW)
-│ ├── ForgotPasswordForm.tsx (NEW)
-│ ├── UserMenu.tsx (NEW)
-│ └── index.ts (NEW)
-└── app/
- └── (auth)/
- ├── layout.tsx (NEW)
- ├── login/page.tsx (NEW)
- ├── register/page.tsx (NEW)
- ├── forgot-password/page.tsx (NEW)
- └── reset-password/page.tsx (NEW)
-```
+### Модели:
+| ID | Provider | Тариф |
+|----|----------|-------|
+| auto | ollama | Бесплатно |
+| gooseek-1.0 | timeweb | По тарифу |
-### Обновлённые файлы:
-```
-backend/webui/src/app/providers.tsx — AuthProvider + AuthModal
-backend/webui/src/components/Sidebar.tsx — кнопки auth + профиль
-```
-
-### Хранение токенов:
-```
-localStorage:
-- token: JWT access token
-- refreshToken: refresh token
-- user: JSON с данными пользователя
-```
-
-### Auth flow:
-1. Гость видит кнопки "Войти" / "Регистрация" в сайдбаре
-2. Клик открывает модальное окно (или переход на страницу)
-3. После успешного входа — сохранение токенов, обновление UI
-4. При истечении access token — автоматический refresh
-5. При logout — очистка localStorage
+### Роли в пространстве:
+- `owner` — создатель, полные права
+- `admin` — может приглашать/удалять участников
+- `member` — может создавать треды
diff --git a/backend/cmd/agent-svc/main.go b/backend/cmd/agent-svc/main.go
index 5aeb85d..4c062b5 100644
--- a/backend/cmd/agent-svc/main.go
+++ b/backend/cmd/agent-svc/main.go
@@ -93,15 +93,23 @@ func main() {
providerID = "timeweb"
modelKey = "gpt-4o"
} else if providerID == "" {
- providerID = "openai"
- modelKey = "gpt-4o-mini"
+ providerID = "ollama"
+ modelKey = cfg.OllamaModelKey
+ }
+
+ baseURL := cfg.TimewebAPIBaseURL
+ if providerID == "ollama" {
+ baseURL = cfg.OllamaBaseURL
+ if modelKey == "" {
+ modelKey = cfg.OllamaModelKey
+ }
}
llmClient, err := llm.NewClient(llm.ProviderConfig{
ProviderID: providerID,
ModelKey: modelKey,
APIKey: getAPIKey(cfg, providerID),
- BaseURL: cfg.TimewebAPIBaseURL,
+ BaseURL: baseURL,
AgentAccessID: cfg.TimewebAgentAccessID,
})
if err != nil {
@@ -200,6 +208,8 @@ func main() {
func getAPIKey(cfg *config.Config, providerID string) string {
switch providerID {
+ case "ollama":
+ return ""
case "timeweb":
return cfg.TimewebAPIKey
case "openai":
diff --git a/backend/cmd/api-gateway/main.go b/backend/cmd/api-gateway/main.go
index 668999a..02c179c 100644
--- a/backend/cmd/api-gateway/main.go
+++ b/backend/cmd/api-gateway/main.go
@@ -2,6 +2,7 @@ package main
import (
"bufio"
+ "context"
"fmt"
"io"
"log"
@@ -15,6 +16,7 @@ import (
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/middleware"
+ "github.com/redis/go-redis/v9"
)
var svcURLs map[string]string
@@ -25,6 +27,19 @@ func main() {
log.Fatal("Failed to load config:", err)
}
+ opt, err := redis.ParseURL(cfg.RedisURL)
+ if err != nil {
+ log.Printf("Warning: failed to parse Redis URL, rate limiting will be disabled: %v", err)
+ }
+ var redisClient *redis.Client
+ if opt != nil {
+ redisClient = redis.NewClient(opt)
+ if _, err := redisClient.Ping(context.Background()).Result(); err != nil {
+ log.Printf("Warning: Redis not available, rate limiting will be disabled: %v", err)
+ redisClient = nil
+ }
+ }
+
svcURLs = map[string]string{
"auth": cfg.AuthSvcURL,
"chat": cfg.ChatSvcURL,
@@ -62,14 +77,17 @@ func main() {
AllowGuest: true,
}))
- app.Use(middleware.TieredRateLimit(middleware.TieredRateLimitConfig{
- Tiers: map[string]middleware.TierConfig{
- "free": {Max: 60, Window: time.Minute},
- "pro": {Max: 300, Window: time.Minute},
- "business": {Max: 1000, Window: time.Minute},
- },
- DefaultTier: "free",
- }))
+ if redisClient != nil {
+ app.Use(middleware.TieredRateLimit(middleware.TieredRateLimitConfig{
+ RedisClient: redisClient,
+ Tiers: map[string]middleware.TierConfig{
+ "free": {Max: 60, Window: time.Minute},
+ "pro": {Max: 300, Window: time.Minute},
+ "business": {Max: 1000, Window: time.Minute},
+ },
+ DefaultTier: "free",
+ }))
+ }
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
diff --git a/backend/deploy/docker/Dockerfile.all b/backend/deploy/docker/Dockerfile.all
index b155475..71b55e2 100644
--- a/backend/deploy/docker/Dockerfile.all
+++ b/backend/deploy/docker/Dockerfile.all
@@ -12,6 +12,7 @@ COPY . .
# Build all services
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api-gateway ./cmd/api-gateway
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/auth-svc ./cmd/auth-svc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/agent-svc ./cmd/agent-svc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/chat-svc ./cmd/chat-svc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/search-svc ./cmd/search-svc
diff --git a/backend/deploy/docker/docker-compose.yml b/backend/deploy/docker/docker-compose.yml
index bc3f3cd..675040c 100644
--- a/backend/deploy/docker/docker-compose.yml
+++ b/backend/deploy/docker/docker-compose.yml
@@ -41,6 +41,7 @@ services:
- LEARNING_SVC_URL=http://learning-svc:3034
- ADMIN_SVC_URL=http://admin-svc:3040
- JWT_SECRET=${JWT_SECRET}
+ - REDIS_URL=redis://redis:6379
ports:
- "3015:3015"
depends_on:
@@ -49,6 +50,7 @@ services:
- agent-svc
- thread-svc
- admin-svc
+ - redis
networks:
- gooseek
diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go
index 02932f7..d8f118f 100644
--- a/backend/internal/auth/repository.go
+++ b/backend/internal/auth/repository.go
@@ -38,6 +38,7 @@ func (r *Repository) RunMigrations(ctx context.Context) error {
avatar TEXT,
role VARCHAR(50) DEFAULT 'user',
tier VARCHAR(50) DEFAULT 'free',
+ balance DECIMAL(12,2) DEFAULT 0,
email_verified BOOLEAN DEFAULT FALSE,
provider VARCHAR(50) DEFAULT 'local',
provider_id VARCHAR(255),
@@ -69,6 +70,8 @@ func (r *Repository) RunMigrations(ctx context.Context) error {
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_password_reset_tokens ON password_reset_tokens(token)`,
+
+ `ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS balance DECIMAL(12,2) DEFAULT 0`,
}
for _, m := range migrations {
@@ -125,18 +128,19 @@ func (r *Repository) CreateUser(ctx context.Context, email, password, name strin
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*User, error) {
query := `
- SELECT id, email, password_hash, name, avatar, role, tier, email_verified,
+ SELECT id, email, password_hash, name, avatar, role, tier, balance, email_verified,
provider, provider_id, last_login_at, created_at, updated_at
FROM auth_users WHERE email = $1
`
user := &User{}
- var lastLogin, avatar, providerID sql.NullString
+ var avatar, providerID sql.NullString
var lastLoginTime sql.NullTime
+ var balance sql.NullFloat64
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID, &user.Email, &user.PasswordHash, &user.Name, &avatar,
- &user.Role, &user.Tier, &user.EmailVerified, &user.Provider,
+ &user.Role, &user.Tier, &balance, &user.EmailVerified, &user.Provider,
&providerID, &lastLoginTime, &user.CreatedAt, &user.UpdatedAt,
)
@@ -156,14 +160,16 @@ func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*User, e
if lastLoginTime.Valid {
user.LastLoginAt = lastLoginTime.Time
}
- _ = lastLogin
+ if balance.Valid {
+ user.Balance = balance.Float64
+ }
return user, nil
}
func (r *Repository) GetUserByID(ctx context.Context, id string) (*User, error) {
query := `
- SELECT id, email, password_hash, name, avatar, role, tier, email_verified,
+ SELECT id, email, password_hash, name, avatar, role, tier, balance, email_verified,
provider, provider_id, last_login_at, created_at, updated_at
FROM auth_users WHERE id = $1
`
@@ -171,10 +177,11 @@ func (r *Repository) GetUserByID(ctx context.Context, id string) (*User, error)
user := &User{}
var avatar, providerID sql.NullString
var lastLoginTime sql.NullTime
+ var balance sql.NullFloat64
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.Email, &user.PasswordHash, &user.Name, &avatar,
- &user.Role, &user.Tier, &user.EmailVerified, &user.Provider,
+ &user.Role, &user.Tier, &balance, &user.EmailVerified, &user.Provider,
&providerID, &lastLoginTime, &user.CreatedAt, &user.UpdatedAt,
)
@@ -194,6 +201,9 @@ func (r *Repository) GetUserByID(ctx context.Context, id string) (*User, error)
if lastLoginTime.Valid {
user.LastLoginAt = lastLoginTime.Time
}
+ if balance.Valid {
+ user.Balance = balance.Float64
+ }
return user, nil
}
@@ -254,6 +264,22 @@ func (r *Repository) UpdateRole(ctx context.Context, userID string, role UserRol
return err
}
+func (r *Repository) UpdateBalance(ctx context.Context, userID string, amount float64) error {
+ _, err := r.db.ExecContext(ctx,
+ "UPDATE auth_users SET balance = balance + $2, updated_at = NOW() WHERE id = $1",
+ userID, amount,
+ )
+ return err
+}
+
+func (r *Repository) SetBalance(ctx context.Context, userID string, balance float64) error {
+ _, err := r.db.ExecContext(ctx,
+ "UPDATE auth_users SET balance = $2, updated_at = NOW() WHERE id = $1",
+ userID, balance,
+ )
+ return err
+}
+
func (r *Repository) CreateRefreshToken(ctx context.Context, userID, userAgent, ip string, duration time.Duration) (*RefreshToken, error) {
token := generateSecureToken(32)
diff --git a/backend/internal/auth/types.go b/backend/internal/auth/types.go
index fa32b54..ef60b58 100644
--- a/backend/internal/auth/types.go
+++ b/backend/internal/auth/types.go
@@ -12,6 +12,7 @@ type User struct {
Avatar string `json:"avatar,omitempty"`
Role string `json:"role"`
Tier string `json:"tier"`
+ Balance float64 `json:"balance"`
EmailVerified bool `json:"emailVerified"`
Provider string `json:"provider"`
ProviderID string `json:"providerId,omitempty"`
diff --git a/backend/internal/db/space_repo.go b/backend/internal/db/space_repo.go
index fba4999..3fbb9e4 100644
--- a/backend/internal/db/space_repo.go
+++ b/backend/internal/db/space_repo.go
@@ -22,6 +22,30 @@ type Space struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ThreadCount int `json:"threadCount,omitempty"`
+ Members []*SpaceMember `json:"members,omitempty"`
+ MemberCount int `json:"memberCount,omitempty"`
+}
+
+type SpaceMember struct {
+ ID string `json:"id"`
+ SpaceID string `json:"spaceId"`
+ UserID string `json:"userId"`
+ Role string `json:"role"`
+ Email string `json:"email,omitempty"`
+ Name string `json:"name,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+ JoinedAt time.Time `json:"joinedAt"`
+}
+
+type SpaceInvite struct {
+ ID string `json:"id"`
+ SpaceID string `json:"spaceId"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ InvitedBy string `json:"invitedBy"`
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expiresAt"`
+ CreatedAt time.Time `json:"createdAt"`
}
type SpaceRepository struct {
@@ -50,6 +74,28 @@ func (r *SpaceRepository) RunMigrations(ctx context.Context) error {
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_spaces_user ON spaces(user_id)`,
+ `CREATE TABLE IF NOT EXISTS space_members (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL,
+ role VARCHAR(20) DEFAULT 'member',
+ joined_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(space_id, user_id)
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_space_members_space ON space_members(space_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members(user_id)`,
+ `CREATE TABLE IF NOT EXISTS space_invites (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ email VARCHAR(255) NOT NULL,
+ role VARCHAR(20) DEFAULT 'member',
+ invited_by UUID NOT NULL,
+ token VARCHAR(64) NOT NULL UNIQUE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_space_invites_token ON space_invites(token)`,
+ `CREATE INDEX IF NOT EXISTS idx_space_invites_email ON space_invites(email)`,
}
for _, m := range migrations {
@@ -175,3 +221,176 @@ func (r *SpaceRepository) Delete(ctx context.Context, id, userID string) error {
}
return nil
}
+
+func (r *SpaceRepository) GetMembers(ctx context.Context, spaceID string) ([]*SpaceMember, error) {
+ query := `
+ SELECT sm.id, sm.space_id, sm.user_id, sm.role, sm.joined_at,
+ COALESCE(u.email, '') as email,
+ COALESCE(u.name, '') as name,
+ COALESCE(u.avatar, '') as avatar
+ FROM space_members sm
+ LEFT JOIN auth_users u ON sm.user_id = u.id
+ WHERE sm.space_id = $1
+ ORDER BY sm.joined_at ASC
+ `
+
+ rows, err := r.db.db.QueryContext(ctx, query, spaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var members []*SpaceMember
+ for rows.Next() {
+ var m SpaceMember
+ if err := rows.Scan(&m.ID, &m.SpaceID, &m.UserID, &m.Role, &m.JoinedAt, &m.Email, &m.Name, &m.Avatar); err != nil {
+ return nil, err
+ }
+ members = append(members, &m)
+ }
+ return members, nil
+}
+
+func (r *SpaceRepository) AddMember(ctx context.Context, spaceID, userID, role string) error {
+ query := `
+ INSERT INTO space_members (space_id, user_id, role)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (space_id, user_id) DO NOTHING
+ `
+ _, err := r.db.db.ExecContext(ctx, query, spaceID, userID, role)
+ return err
+}
+
+func (r *SpaceRepository) RemoveMember(ctx context.Context, spaceID, userID string) error {
+ result, err := r.db.db.ExecContext(ctx, "DELETE FROM space_members WHERE space_id = $1 AND user_id = $2", spaceID, userID)
+ if err != nil {
+ return err
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (r *SpaceRepository) UpdateMemberRole(ctx context.Context, spaceID, userID, role string) error {
+ result, err := r.db.db.ExecContext(ctx, "UPDATE space_members SET role = $3 WHERE space_id = $1 AND user_id = $2", spaceID, userID, role)
+ if err != nil {
+ return err
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (r *SpaceRepository) IsMember(ctx context.Context, spaceID, userID string) (bool, string, error) {
+ var role string
+ err := r.db.db.QueryRowContext(ctx, "SELECT role FROM space_members WHERE space_id = $1 AND user_id = $2", spaceID, userID).Scan(&role)
+ if err == sql.ErrNoRows {
+ return false, "", nil
+ }
+ if err != nil {
+ return false, "", err
+ }
+ return true, role, nil
+}
+
+func (r *SpaceRepository) GetByMemberID(ctx context.Context, userID string) ([]*Space, error) {
+ query := `
+ SELECT DISTINCT s.id, s.user_id, s.name, s.description, s.icon, s.color,
+ s.custom_instructions, s.default_focus_mode, s.default_model,
+ s.is_public, s.settings, s.created_at, s.updated_at,
+ (SELECT COUNT(*) FROM threads WHERE space_id = s.id) as thread_count,
+ (SELECT COUNT(*) FROM space_members WHERE space_id = s.id) as member_count
+ FROM spaces s
+ LEFT JOIN space_members sm ON s.id = sm.space_id
+ WHERE s.user_id = $1 OR sm.user_id = $1
+ ORDER BY s.updated_at DESC
+ `
+
+ rows, err := r.db.db.QueryContext(ctx, query, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var spaces []*Space
+ for rows.Next() {
+ var s Space
+ var settingsJSON []byte
+
+ if err := rows.Scan(
+ &s.ID, &s.UserID, &s.Name, &s.Description, &s.Icon, &s.Color,
+ &s.CustomInstructions, &s.DefaultFocusMode, &s.DefaultModel,
+ &s.IsPublic, &settingsJSON, &s.CreatedAt, &s.UpdatedAt, &s.ThreadCount, &s.MemberCount,
+ ); err != nil {
+ return nil, err
+ }
+
+ json.Unmarshal(settingsJSON, &s.Settings)
+ spaces = append(spaces, &s)
+ }
+
+ return spaces, nil
+}
+
+func (r *SpaceRepository) CreateInvite(ctx context.Context, invite *SpaceInvite) error {
+ query := `
+ INSERT INTO space_invites (space_id, email, role, invited_by, token, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, created_at
+ `
+ return r.db.db.QueryRowContext(ctx, query,
+ invite.SpaceID, invite.Email, invite.Role, invite.InvitedBy, invite.Token, invite.ExpiresAt,
+ ).Scan(&invite.ID, &invite.CreatedAt)
+}
+
+func (r *SpaceRepository) GetInviteByToken(ctx context.Context, token string) (*SpaceInvite, error) {
+ query := `
+ SELECT id, space_id, email, role, invited_by, token, expires_at, created_at
+ FROM space_invites
+ WHERE token = $1 AND expires_at > NOW()
+ `
+ var inv SpaceInvite
+ err := r.db.db.QueryRowContext(ctx, query, token).Scan(
+ &inv.ID, &inv.SpaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &inv, nil
+}
+
+func (r *SpaceRepository) DeleteInvite(ctx context.Context, id string) error {
+ _, err := r.db.db.ExecContext(ctx, "DELETE FROM space_invites WHERE id = $1", id)
+ return err
+}
+
+func (r *SpaceRepository) GetInvitesBySpace(ctx context.Context, spaceID string) ([]*SpaceInvite, error) {
+ query := `
+ SELECT id, space_id, email, role, invited_by, token, expires_at, created_at
+ FROM space_invites
+ WHERE space_id = $1 AND expires_at > NOW()
+ ORDER BY created_at DESC
+ `
+ rows, err := r.db.db.QueryContext(ctx, query, spaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var invites []*SpaceInvite
+ for rows.Next() {
+ var inv SpaceInvite
+ if err := rows.Scan(&inv.ID, &inv.SpaceID, &inv.Email, &inv.Role, &inv.InvitedBy, &inv.Token, &inv.ExpiresAt, &inv.CreatedAt); err != nil {
+ return nil, err
+ }
+ invites = append(invites, &inv)
+ }
+ return invites, nil
+}
diff --git a/backend/internal/llm/client.go b/backend/internal/llm/client.go
index 74d7807..deb7b71 100644
--- a/backend/internal/llm/client.go
+++ b/backend/internal/llm/client.go
@@ -79,6 +79,11 @@ type ProviderConfig struct {
func NewClient(cfg ProviderConfig) (Client, error) {
switch cfg.ProviderID {
+ case "ollama":
+ return NewOllamaClient(OllamaConfig{
+ BaseURL: cfg.BaseURL,
+ ModelKey: cfg.ModelKey,
+ })
case "timeweb":
return NewTimewebClient(TimewebConfig{
BaseURL: cfg.BaseURL,
diff --git a/backend/internal/llm/ollama.go b/backend/internal/llm/ollama.go
new file mode 100644
index 0000000..5b19927
--- /dev/null
+++ b/backend/internal/llm/ollama.go
@@ -0,0 +1,233 @@
+package llm
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type OllamaClient struct {
+ baseClient
+ httpClient *http.Client
+ baseURL string
+}
+
+type OllamaConfig struct {
+ BaseURL string
+ ModelKey string
+}
+
+func NewOllamaClient(cfg OllamaConfig) (*OllamaClient, error) {
+ baseURL := cfg.BaseURL
+ if baseURL == "" {
+ baseURL = "http://ollama:11434"
+ }
+
+ modelKey := cfg.ModelKey
+ if modelKey == "" {
+ modelKey = "llama3.2"
+ }
+
+ return &OllamaClient{
+ baseClient: baseClient{
+ providerID: "ollama",
+ modelKey: modelKey,
+ },
+ httpClient: &http.Client{
+ Timeout: 300 * time.Second,
+ },
+ baseURL: baseURL,
+ }, nil
+}
+
+type ollamaChatRequest struct {
+ Model string `json:"model"`
+ Messages []ollamaMessage `json:"messages"`
+ Stream bool `json:"stream"`
+ Options *ollamaOptions `json:"options,omitempty"`
+}
+
+type ollamaMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type ollamaOptions struct {
+ Temperature float64 `json:"temperature,omitempty"`
+ NumPredict int `json:"num_predict,omitempty"`
+ TopP float64 `json:"top_p,omitempty"`
+ Stop []string `json:"stop,omitempty"`
+}
+
+type ollamaChatResponse struct {
+ Model string `json:"model"`
+ CreatedAt string `json:"created_at"`
+ Message ollamaMessage `json:"message"`
+ Done bool `json:"done"`
+}
+
+func (c *OllamaClient) StreamText(ctx context.Context, req StreamRequest) (<-chan StreamChunk, error) {
+ messages := make([]ollamaMessage, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ messages = append(messages, ollamaMessage{
+ Role: string(m.Role),
+ Content: m.Content,
+ })
+ }
+
+ chatReq := ollamaChatRequest{
+ Model: c.modelKey,
+ Messages: messages,
+ Stream: true,
+ }
+
+ if req.Options.MaxTokens > 0 || req.Options.Temperature > 0 || req.Options.TopP > 0 || len(req.Options.StopWords) > 0 {
+ chatReq.Options = &ollamaOptions{}
+ if req.Options.MaxTokens > 0 {
+ chatReq.Options.NumPredict = req.Options.MaxTokens
+ }
+ if req.Options.Temperature > 0 {
+ chatReq.Options.Temperature = req.Options.Temperature
+ }
+ if req.Options.TopP > 0 {
+ chatReq.Options.TopP = req.Options.TopP
+ }
+ if len(req.Options.StopWords) > 0 {
+ chatReq.Options.Stop = req.Options.StopWords
+ }
+ }
+
+ body, err := json.Marshal(chatReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/api/chat", c.baseURL)
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("Ollama API error: status %d, body: %s", resp.StatusCode, string(body))
+ }
+
+ ch := make(chan StreamChunk, 100)
+ go func() {
+ defer close(ch)
+ defer resp.Body.Close()
+
+ reader := bufio.NewReader(resp.Body)
+
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err != io.EOF {
+ return
+ }
+ return
+ }
+
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ var streamResp ollamaChatResponse
+ if err := json.Unmarshal([]byte(line), &streamResp); err != nil {
+ continue
+ }
+
+ if streamResp.Message.Content != "" {
+ ch <- StreamChunk{ContentChunk: streamResp.Message.Content}
+ }
+
+ if streamResp.Done {
+ ch <- StreamChunk{FinishReason: "stop"}
+ return
+ }
+ }
+ }()
+
+ return ch, nil
+}
+
+func (c *OllamaClient) GenerateText(ctx context.Context, req StreamRequest) (string, error) {
+ messages := make([]ollamaMessage, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ messages = append(messages, ollamaMessage{
+ Role: string(m.Role),
+ Content: m.Content,
+ })
+ }
+
+ chatReq := ollamaChatRequest{
+ Model: c.modelKey,
+ Messages: messages,
+ Stream: false,
+ }
+
+ if req.Options.MaxTokens > 0 || req.Options.Temperature > 0 || req.Options.TopP > 0 {
+ chatReq.Options = &ollamaOptions{}
+ if req.Options.MaxTokens > 0 {
+ chatReq.Options.NumPredict = req.Options.MaxTokens
+ }
+ if req.Options.Temperature > 0 {
+ chatReq.Options.Temperature = req.Options.Temperature
+ }
+ if req.Options.TopP > 0 {
+ chatReq.Options.TopP = req.Options.TopP
+ }
+ }
+
+ body, err := json.Marshal(chatReq)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/api/chat", c.baseURL)
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("Ollama API error: status %d, body: %s", resp.StatusCode, string(body))
+ }
+
+ var chatResp ollamaChatResponse
+ if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
+ return "", fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ if chatResp.Message.Content == "" {
+ return "", errors.New("empty response from Ollama")
+ }
+
+ return chatResp.Message.Content, nil
+}
diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go
index 7a44148..9faa62f 100644
--- a/backend/pkg/config/config.go
+++ b/backend/pkg/config/config.go
@@ -70,6 +70,10 @@ type Config struct {
TimewebAPIKey string
TimewebProxySource string
+ // Ollama (local LLM)
+ OllamaBaseURL string
+ OllamaModelKey string
+
// Timeouts
HTTPTimeout time.Duration
LLMTimeout time.Duration
@@ -141,6 +145,9 @@ func Load() (*Config, error) {
TimewebAPIKey: getEnv("TIMEWEB_API_KEY", ""),
TimewebProxySource: getEnv("TIMEWEB_X_PROXY_SOURCE", "gooseek"),
+ OllamaBaseURL: getEnv("OLLAMA_BASE_URL", "http://ollama:11434"),
+ OllamaModelKey: getEnv("OLLAMA_MODEL", "llama3.2"),
+
HTTPTimeout: time.Duration(getEnvInt("HTTP_TIMEOUT_MS", 60000)) * time.Millisecond,
LLMTimeout: time.Duration(getEnvInt("LLM_TIMEOUT_MS", 120000)) * time.Millisecond,
ScrapeTimeout: time.Duration(getEnvInt("SCRAPE_TIMEOUT_MS", 25000)) * time.Millisecond,
diff --git a/backend/pkg/middleware/ratelimit_redis.go b/backend/pkg/middleware/ratelimit_redis.go
index b5846e3..3662b03 100644
--- a/backend/pkg/middleware/ratelimit_redis.go
+++ b/backend/pkg/middleware/ratelimit_redis.go
@@ -144,6 +144,7 @@ type TieredRateLimitConfig struct {
RedisClient *redis.Client
KeyPrefix string
Tiers map[string]TierConfig
+ DefaultTier string
GetTierFunc func(*fiber.Ctx) string
KeyFunc func(*fiber.Ctx) string
}
@@ -157,16 +158,31 @@ func TieredRateLimit(cfg TieredRateLimitConfig) fiber.Handler {
if cfg.KeyPrefix == "" {
cfg.KeyPrefix = "ratelimit:tiered"
}
+ if cfg.DefaultTier == "" {
+ cfg.DefaultTier = "free"
+ }
if cfg.GetTierFunc == nil {
- cfg.GetTierFunc = func(c *fiber.Ctx) string { return "default" }
+ cfg.GetTierFunc = func(c *fiber.Ctx) string {
+ tier := GetUserTier(c)
+ if tier == "" {
+ return cfg.DefaultTier
+ }
+ return tier
+ }
}
if cfg.KeyFunc == nil {
- cfg.KeyFunc = func(c *fiber.Ctx) string { return c.IP() }
+ cfg.KeyFunc = func(c *fiber.Ctx) string {
+ userID := GetUserID(c)
+ if userID != "" {
+ return "user:" + userID
+ }
+ return "ip:" + c.IP()
+ }
}
- defaultTier := TierConfig{Max: 60, Window: time.Minute}
- if _, ok := cfg.Tiers["default"]; !ok {
- cfg.Tiers["default"] = defaultTier
+ defaultTierCfg := TierConfig{Max: 60, Window: time.Minute}
+ if _, ok := cfg.Tiers[cfg.DefaultTier]; !ok {
+ cfg.Tiers[cfg.DefaultTier] = defaultTierCfg
}
return func(c *fiber.Ctx) error {
@@ -174,7 +190,7 @@ func TieredRateLimit(cfg TieredRateLimitConfig) fiber.Handler {
tier := cfg.GetTierFunc(c)
tierCfg, ok := cfg.Tiers[tier]
if !ok {
- tierCfg = cfg.Tiers["default"]
+ tierCfg = cfg.Tiers[cfg.DefaultTier]
}
key := fmt.Sprintf("%s:%s:%s", cfg.KeyPrefix, tier, cfg.KeyFunc(c))
diff --git a/backend/pkg/middleware/ratelimit_tiered.go b/backend/pkg/middleware/ratelimit_tiered.go
deleted file mode 100644
index da008c7..0000000
--- a/backend/pkg/middleware/ratelimit_tiered.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package middleware
-
-import (
- "sync"
- "time"
-
- "github.com/gofiber/fiber/v2"
-)
-
-type TierConfig struct {
- Max int
- Window time.Duration
-}
-
-type TieredRateLimitConfig struct {
- Tiers map[string]TierConfig
- DefaultTier string
- KeyFunc func(*fiber.Ctx) string
- GetTierFunc func(*fiber.Ctx) string
-}
-
-type tieredRateLimiter struct {
- requests map[string][]time.Time
- mu sync.RWMutex
- tiers map[string]TierConfig
-}
-
-func newTieredRateLimiter(tiers map[string]TierConfig) *tieredRateLimiter {
- rl := &tieredRateLimiter{
- requests: make(map[string][]time.Time),
- tiers: tiers,
- }
- go rl.cleanup()
- return rl
-}
-
-func (rl *tieredRateLimiter) cleanup() {
- ticker := time.NewTicker(time.Minute)
- for range ticker.C {
- rl.mu.Lock()
- now := time.Now()
- for key, times := range rl.requests {
- var valid []time.Time
- for _, t := range times {
- if now.Sub(t) < 5*time.Minute {
- valid = append(valid, t)
- }
- }
- if len(valid) == 0 {
- delete(rl.requests, key)
- } else {
- rl.requests[key] = valid
- }
- }
- rl.mu.Unlock()
- }
-}
-
-func (rl *tieredRateLimiter) allow(key string, tier string) (bool, int, int) {
- rl.mu.Lock()
- defer rl.mu.Unlock()
-
- cfg, ok := rl.tiers[tier]
- if !ok {
- cfg = rl.tiers["free"]
- }
-
- now := time.Now()
- windowStart := now.Add(-cfg.Window)
-
- times := rl.requests[key]
- var valid []time.Time
- for _, t := range times {
- if t.After(windowStart) {
- valid = append(valid, t)
- }
- }
-
- remaining := cfg.Max - len(valid)
- if remaining <= 0 {
- rl.requests[key] = valid
- return false, 0, cfg.Max
- }
-
- rl.requests[key] = append(valid, now)
- return true, remaining - 1, cfg.Max
-}
-
-func TieredRateLimit(config TieredRateLimitConfig) fiber.Handler {
- if config.Tiers == nil {
- config.Tiers = map[string]TierConfig{
- "free": {Max: 60, Window: time.Minute},
- "pro": {Max: 300, Window: time.Minute},
- "business": {Max: 1000, Window: time.Minute},
- }
- }
- if config.DefaultTier == "" {
- config.DefaultTier = "free"
- }
- if config.KeyFunc == nil {
- config.KeyFunc = func(c *fiber.Ctx) string {
- userID := GetUserID(c)
- if userID != "" {
- return "user:" + userID
- }
- return "ip:" + c.IP()
- }
- }
- if config.GetTierFunc == nil {
- config.GetTierFunc = func(c *fiber.Ctx) string {
- tier := GetUserTier(c)
- if tier == "" {
- return config.DefaultTier
- }
- return tier
- }
- }
-
- limiter := newTieredRateLimiter(config.Tiers)
-
- return func(c *fiber.Ctx) error {
- key := config.KeyFunc(c)
- tier := config.GetTierFunc(c)
-
- allowed, remaining, limit := limiter.allow(key, tier)
-
- c.Set("X-RateLimit-Limit", formatInt(limit))
- c.Set("X-RateLimit-Remaining", formatInt(remaining))
- c.Set("X-RateLimit-Tier", tier)
-
- if !allowed {
- c.Set("Retry-After", "60")
- return c.Status(429).JSON(fiber.Map{
- "error": "Rate limit exceeded",
- "tier": tier,
- "limit": limit,
- "retryAfter": 60,
- })
- }
-
- return c.Next()
- }
-}
-
-func formatInt(n int) string {
- if n < 0 {
- n = 0
- }
- s := ""
- if n == 0 {
- return "0"
- }
- for n > 0 {
- s = string(rune('0'+n%10)) + s
- n /= 10
- }
- return s
-}
diff --git a/backend/webui/next.config.mjs b/backend/webui/next.config.mjs
index 88f4a36..7058e66 100644
--- a/backend/webui/next.config.mjs
+++ b/backend/webui/next.config.mjs
@@ -3,13 +3,14 @@ const nextConfig = {
output: 'standalone',
reactStrictMode: true,
env: {
- API_URL: process.env.API_URL || 'http://api-gateway:3015',
+ API_URL: process.env.API_URL || 'http://localhost:3015',
},
async rewrites() {
+ const apiUrl = process.env.API_URL || 'http://localhost:3015';
return [
{
source: '/api/:path*',
- destination: `${process.env.API_URL || 'http://api-gateway:3015'}/api/:path*`,
+ destination: `${apiUrl}/api/:path*`,
},
];
},
diff --git a/backend/webui/src/app/(auth)/forgot-password/page.tsx b/backend/webui/src/app/(auth)/forgot-password/page.tsx
index 5ed4e53..9acb2f1 100644
--- a/backend/webui/src/app/(auth)/forgot-password/page.tsx
+++ b/backend/webui/src/app/(auth)/forgot-password/page.tsx
@@ -2,32 +2,61 @@
import { useRouter } from 'next/navigation';
import Link from 'next/link';
+import { motion } from 'framer-motion';
+import { Sparkles } from 'lucide-react';
import { ForgotPasswordForm } from '@/components/auth';
export default function ForgotPasswordPage() {
const router = useRouter();
return (
-
-
-
-
-
G
+
+ {/* Background decorations */}
+
+
+ {/* Header */}
+
+
+
+
- GooSeek
+
+ GooSeek
+
-
-
-
-
router.push('/login')}
- />
+ {/* Main content */}
+
+
+ {/* Card with gradient border */}
+
+
+
+ router.push('/login')}
+ />
+
-
+
+
+ {/* Footer */}
+
);
}
diff --git a/backend/webui/src/app/(auth)/login/page.tsx b/backend/webui/src/app/(auth)/login/page.tsx
index 0a399ae..da03a60 100644
--- a/backend/webui/src/app/(auth)/login/page.tsx
+++ b/backend/webui/src/app/(auth)/login/page.tsx
@@ -3,6 +3,8 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
+import { motion } from 'framer-motion';
+import { Sparkles } from 'lucide-react';
import { LoginForm } from '@/components/auth';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -18,45 +20,78 @@ export default function LoginPage() {
if (isLoading) {
return (
-
+
);
}
return (
-
-
-
-
-
G
+
+ {/* Background decorations */}
+
+
+ {/* Header */}
+
+
+
+
- GooSeek
+
+ GooSeek
+
-
-
-
-
router.push('/')}
- onSwitchToRegister={() => router.push('/register')}
- />
+ {/* Main content */}
+
+
+ {/* Card with gradient border */}
+
+
+
+ router.push('/')}
+ onSwitchToRegister={() => router.push('/register')}
+ />
+
-
+ {/* Footer text */}
+
Продолжая, вы соглашаетесь с{' '}
-
+
условиями
{' '}
и{' '}
-
+
политикой конфиденциальности
-
-
+
+
+
+ {/* Footer */}
+
);
}
diff --git a/backend/webui/src/app/(auth)/register/page.tsx b/backend/webui/src/app/(auth)/register/page.tsx
index 29b7604..6c1247c 100644
--- a/backend/webui/src/app/(auth)/register/page.tsx
+++ b/backend/webui/src/app/(auth)/register/page.tsx
@@ -3,6 +3,8 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
+import { motion } from 'framer-motion';
+import { Sparkles } from 'lucide-react';
import { RegisterForm } from '@/components/auth';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -18,41 +20,74 @@ export default function RegisterPage() {
if (isLoading) {
return (
-
+
);
}
return (
-
-
-
-
-
G
+
+ {/* Background decorations */}
+
+
+ {/* Header */}
+
+
+
+
- GooSeek
+
+ GooSeek
+
-
-
-
-
router.push('/')}
- onSwitchToLogin={() => router.push('/login')}
- />
+ {/* Main content */}
+
+
+ {/* Card with gradient border */}
+
+
+
+ router.push('/')}
+ onSwitchToLogin={() => router.push('/login')}
+ />
+
-
+ {/* Footer text */}
+
Уже есть аккаунт?{' '}
-
+
Войти
-
-
+
+
+
+ {/* Footer */}
+
);
}
diff --git a/backend/webui/src/app/(auth)/reset-password/page.tsx b/backend/webui/src/app/(auth)/reset-password/page.tsx
index 819bc41..7f6d50d 100644
--- a/backend/webui/src/app/(auth)/reset-password/page.tsx
+++ b/backend/webui/src/app/(auth)/reset-password/page.tsx
@@ -3,7 +3,8 @@
import { useState, FormEvent, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
-import { Lock, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
+import { motion } from 'framer-motion';
+import { Lock, Loader2, Eye, EyeOff, CheckCircle, AlertCircle, Sparkles, Check, X } from 'lucide-react';
import { resetPassword } from '@/lib/auth';
function ResetPasswordContent() {
@@ -18,6 +19,13 @@ function ResetPasswordContent() {
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
+ const passwordRequirements = [
+ { met: password.length >= 8, text: 'Минимум 8 символов' },
+ { met: /[A-Z]/.test(password), text: 'Заглавная буква' },
+ { met: /[a-z]/.test(password), text: 'Строчная буква' },
+ { met: /[0-9]/.test(password), text: 'Цифра' },
+ ];
+
const isPasswordValid = password.length >= 8;
const doPasswordsMatch = password === confirmPassword && confirmPassword.length > 0;
@@ -64,7 +72,7 @@ function ResetPasswordContent() {
Запросить новую ссылку
@@ -74,7 +82,12 @@ function ResetPasswordContent() {
if (isSuccess) {
return (
-
+
@@ -84,11 +97,11 @@ function ResetPasswordContent() {
-
+
);
}
@@ -100,9 +113,13 @@ function ResetPasswordContent() {
{error && (
-
+
{error}
-
+
)}
@@ -133,6 +150,22 @@ function ResetPasswordContent() {
{showPassword ? : }
+
+ {password.length > 0 && (
+
+ {passwordRequirements.map((req, i) => (
+
+ {req.met ? : }
+ {req.text}
+
+ ))}
+
+ )}
@@ -149,11 +182,26 @@ function ResetPasswordContent() {
placeholder="••••••••"
required
autoComplete="new-password"
- className="w-full pl-11 pr-4 py-3 bg-surface/50 border border-border rounded-xl
+ className={`w-full pl-11 pr-11 py-3 bg-surface/50 border rounded-xl
text-primary placeholder:text-muted
- focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20
- transition-all duration-200"
+ focus:outline-none focus:ring-1 transition-all duration-200
+ ${
+ confirmPassword.length > 0
+ ? doPasswordsMatch
+ ? 'border-success/50 focus:border-success/50 focus:ring-success/20'
+ : 'border-error/50 focus:border-error/50 focus:ring-error/20'
+ : 'border-border focus:border-accent/50 focus:ring-accent/20'
+ }`}
/>
+ {confirmPassword.length > 0 && (
+
+ {doPasswordsMatch ? (
+
+ ) : (
+
+ )}
+
+ )}
@@ -180,29 +228,56 @@ function ResetPasswordContent() {
export default function ResetPasswordPage() {
return (
-
-
-
-
-
G
+
+ {/* Background decorations */}
+
+
+ {/* Header */}
+
+
+
+
- GooSeek
+
+ GooSeek
+
-
-
-
-
-
-
- }>
-
-
+ {/* Main content */}
+
+
+ {/* Card with gradient border */}
+
-
+
+
+ {/* Footer */}
+
);
}
diff --git a/backend/webui/src/app/(main)/layout.tsx b/backend/webui/src/app/(main)/layout.tsx
index 8beed32..61e2afb 100644
--- a/backend/webui/src/app/(main)/layout.tsx
+++ b/backend/webui/src/app/(main)/layout.tsx
@@ -60,11 +60,11 @@ export default function MainLayout({ children }: { children: React.ReactNode })
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm"
/>
setSidebarOpen(false)} />
diff --git a/backend/webui/src/app/(main)/settings/page.tsx b/backend/webui/src/app/(main)/settings/page.tsx
index 2d9adf2..fe73210 100644
--- a/backend/webui/src/app/(main)/settings/page.tsx
+++ b/backend/webui/src/app/(main)/settings/page.tsx
@@ -1,209 +1,124 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
-import { Shield, Zap, Scale, Sparkles, Download, Trash2, Bell, Eye, Languages, Plug, ChevronDown } from 'lucide-react';
-import * as Switch from '@radix-ui/react-switch';
-import { useLanguage, SupportedLanguage } from '@/lib/contexts/LanguageContext';
-import { ConnectorsSettings } from '@/components/settings/ConnectorsSettings';
+import { User, CreditCard, Settings, LogIn } from 'lucide-react';
+import { useAuth } from '@/lib/contexts/AuthContext';
+import { AccountTab } from '@/components/settings/AccountTab';
+import { BillingTab } from '@/components/settings/BillingTab';
+import { PreferencesTab } from '@/components/settings/PreferencesTab';
+
+type TabId = 'account' | 'billing' | 'preferences';
+
+interface Tab {
+ id: TabId;
+ label: string;
+ icon: React.ElementType;
+ requiresAuth: boolean;
+}
+
+const tabs: Tab[] = [
+ { id: 'account', label: 'Аккаунт', icon: User, requiresAuth: true },
+ { id: 'billing', label: 'Оплата', icon: CreditCard, requiresAuth: true },
+ { id: 'preferences', label: 'Настройки', icon: Settings, requiresAuth: false },
+];
export default function SettingsPage() {
- const [mode, setMode] = useState('balanced');
- const [saveHistory, setSaveHistory] = useState(true);
- const [personalization, setPersonalization] = useState(true);
- const [notifications, setNotifications] = useState(false);
- const [showConnectors, setShowConnectors] = useState(false);
- const { language, setLanguage } = useLanguage();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const { user, isAuthenticated, isLoading, showAuthModal } = useAuth();
+
+ const tabParam = searchParams.get('tab') as TabId | null;
+ const [activeTab, setActiveTab] = useState(tabParam && tabs.some(t => t.id === tabParam) ? tabParam : 'preferences');
+
+ useEffect(() => {
+ if (tabParam && tabs.some(t => t.id === tabParam)) {
+ setActiveTab(tabParam);
+ }
+ }, [tabParam]);
+
+ const handleTabChange = (tabId: TabId) => {
+ const tab = tabs.find(t => t.id === tabId);
+ if (tab?.requiresAuth && !isAuthenticated) {
+ showAuthModal('login');
+ return;
+ }
+ setActiveTab(tabId);
+ router.push(`/settings?tab=${tabId}`, { scroll: false });
+ };
+
+ const currentTab = tabs.find(t => t.id === activeTab);
+ const showAuthPrompt = currentTab?.requiresAuth && !isAuthenticated && !isLoading;
return (
-
+
{/* Header */}
-
-
Настройки
-
Персонализируйте ваш опыт
+
+
+ {activeTab === 'account' ? 'Аккаунт' : activeTab === 'billing' ? 'Оплата' : 'Настройки'}
+
+
+ {activeTab === 'account'
+ ? 'Управление профилем и безопасностью'
+ : activeTab === 'billing'
+ ? 'Баланс, тарифы и история платежей'
+ : 'Персонализируйте ваш опыт'}
+
-
- {/* Default Mode */}
-
-
- {[
- { id: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' },
- { id: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' },
- { id: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
- ].map((m) => (
-
- ))}
-
-
+ {/* Tabs */}
+
+ {tabs.map((tab) => (
+
+ ))}
+
- {/* Privacy & Data */}
-
-
- {/* Language */}
-
-
- {[
- { id: 'ru', label: 'Русский', flag: '🇷🇺' },
- { id: 'en', label: 'English', flag: '🇺🇸' },
- ].map((lang) => (
-
- ))}
-
-
-
- {/* Connectors */}
-
-
-
- Подключите источники данных и каналы уведомлений для автономных задач в Лаптопе.
+ {/* Content */}
+
+ {showAuthPrompt ? (
+
+
+
+
+
Требуется авторизация
+
+ Войдите в аккаунт, чтобы получить доступ к {activeTab === 'account' ? 'настройкам профиля' : 'платежам и балансу'}
-
- {showConnectors && (
-
-
-
- )}
-
-
-
- {/* Data Management */}
-
-
-
-
-
-
- {/* About */}
-
-
-
GooSeek v1.0.0
-
AI-поиск нового поколения
-
-
-
+ ) : (
+ <>
+ {activeTab === 'account' && }
+ {activeTab === 'billing' && }
+ {activeTab === 'preferences' && }
+ >
+ )}
+
);
}
-
-function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
- return (
-
-
-
-
- {title}
-
-
- {children}
-
- );
-}
-
-interface ToggleRowProps {
- icon: React.ElementType;
- label: string;
- description: string;
- checked: boolean;
- onChange: (v: boolean) => void;
-}
-
-function ToggleRow({ icon: Icon, label, description, checked, onChange }: ToggleRowProps) {
- return (
-
-
-
-
-
-
{label}
-
{description}
-
-
-
-
-
- );
-}
diff --git a/backend/webui/src/app/(main)/spaces/[id]/page.tsx b/backend/webui/src/app/(main)/spaces/[id]/page.tsx
new file mode 100644
index 0000000..f632b19
--- /dev/null
+++ b/backend/webui/src/app/(main)/spaces/[id]/page.tsx
@@ -0,0 +1,466 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ ArrowLeft,
+ FolderOpen,
+ Plus,
+ Users,
+ MessageSquare,
+ Settings,
+ UserPlus,
+ Loader2,
+ Clock,
+ MoreHorizontal,
+ Trash2,
+ Crown,
+ Shield,
+ User,
+ Copy,
+ Check,
+ X,
+ Send,
+} from 'lucide-react';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import * as Dialog from '@radix-ui/react-dialog';
+import { fetchSpace, fetchSpaceMembers, fetchSpaceThreads, inviteToSpace, removeSpaceMember } from '@/lib/api';
+import type { Space, SpaceMember, Thread } from '@/lib/types';
+import { useAuth } from '@/lib/contexts/AuthContext';
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return 'Сегодня';
+ if (days === 1) return 'Вчера';
+ if (days < 7) return `${days} дн. назад`;
+
+ return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
+}
+
+function getRoleIcon(role: string) {
+ switch (role) {
+ case 'owner': return Crown;
+ case 'admin': return Shield;
+ default: return User;
+ }
+}
+
+function getRoleLabel(role: string) {
+ switch (role) {
+ case 'owner': return 'Владелец';
+ case 'admin': return 'Админ';
+ default: return 'Участник';
+ }
+}
+
+export default function SpaceDetailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const { user } = useAuth();
+ const spaceId = params.id as string;
+
+ const [space, setSpace] = useState
(null);
+ const [members, setMembers] = useState([]);
+ const [threads, setThreads] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState<'threads' | 'members'>('threads');
+ const [showInviteModal, setShowInviteModal] = useState(false);
+ const [inviteEmail, setInviteEmail] = useState('');
+ const [inviteLoading, setInviteLoading] = useState(false);
+ const [inviteError, setInviteError] = useState('');
+ const [inviteSuccess, setInviteSuccess] = useState('');
+
+ const isOwner = space?.userId === user?.id;
+ const currentMember = members.find(m => m.userId === user?.id);
+ const isAdmin = isOwner || currentMember?.role === 'admin';
+
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const [spaceData, membersData, threadsData] = await Promise.all([
+ fetchSpace(spaceId),
+ fetchSpaceMembers(spaceId),
+ fetchSpaceThreads(spaceId),
+ ]);
+ setSpace(spaceData);
+ setMembers(membersData);
+ setThreads(threadsData);
+ } catch (err) {
+ console.error('Failed to load space:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [spaceId]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ const handleInvite = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!inviteEmail.trim() || inviteLoading) return;
+
+ setInviteLoading(true);
+ setInviteError('');
+ setInviteSuccess('');
+
+ try {
+ await inviteToSpace(spaceId, inviteEmail.trim());
+ setInviteSuccess(`Приглашение отправлено на ${inviteEmail}`);
+ setInviteEmail('');
+ setTimeout(() => setShowInviteModal(false), 2000);
+ } catch (err) {
+ setInviteError(err instanceof Error ? err.message : 'Не удалось отправить приглашение');
+ } finally {
+ setInviteLoading(false);
+ }
+ };
+
+ const handleRemoveMember = async (memberId: string, userId: string) => {
+ if (!confirm('Удалить участника из пространства?')) return;
+
+ try {
+ await removeSpaceMember(spaceId, userId);
+ setMembers(prev => prev.filter(m => m.id !== memberId));
+ } catch (err) {
+ console.error('Failed to remove member:', err);
+ }
+ };
+
+ const startNewThread = () => {
+ router.push(`/?space=${spaceId}`);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
Загрузка пространства...
+
+ );
+ }
+
+ if (!space) {
+ return (
+
+
+
Пространство не найдено
+
Возможно, оно было удалено или у вас нет доступа
+
+
К списку пространств
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
{space.name}
+ {space.description && (
+
{space.description}
+ )}
+
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+
+ {/* Stats */}
+
+
+
+ {members.length} участник{members.length === 1 ? '' : members.length < 5 ? 'а' : 'ов'}
+
+
+
+ {threads.length} тред{threads.length === 1 ? '' : threads.length < 5 ? 'а' : 'ов'}
+
+
+
+
+
+ {/* Tabs */}
+
+
+
+
+
+ {/* Content */}
+
+ {activeTab === 'threads' ? (
+
+ {/* New Thread Button */}
+
+
+ {/* Threads List */}
+ {threads.length > 0 ? (
+
+ {threads.map((thread, i) => (
+
+
+
+ {thread.title || 'Без названия'}
+
+
+
+
+ {formatDate(thread.updatedAt)}
+
+ {thread.messages?.length || 0} сообщений
+
+
+
+ ))}
+
+ ) : (
+
+
+
Пока нет тредов
+
Начните новый тред для обсуждения
+
+ )}
+
+ ) : (
+
+ {/* Members List */}
+
+ {members.map((member, i) => {
+ const RoleIcon = getRoleIcon(member.role);
+ const canRemove = isAdmin && member.userId !== user?.id && member.role !== 'owner';
+
+ return (
+
+
+ {member.avatar ? (
+

+ ) : (
+
+ {(member.name || member.email || '?').charAt(0).toUpperCase()}
+
+ )}
+
+
+
+
+
+ {member.name || member.email}
+
+
+
+ {getRoleLabel(member.role)}
+
+
+ {member.email && member.name && (
+
{member.email}
+ )}
+
+
+ {canRemove && (
+
+
+
+
+
+
+ handleRemoveMember(member.id, member.userId)}
+ className="flex items-center gap-2 px-3 py-2.5 text-sm text-error rounded-lg cursor-pointer hover:bg-error/10 outline-none transition-colors"
+ >
+
+ Удалить
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+ {/* Invite Button */}
+ {isAdmin && (
+
+ )}
+
+ )}
+
+
+ {/* Invite Modal */}
+
+
+
+
+
+
+ Пригласить участника
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/backend/webui/src/app/(main)/spaces/new/page.tsx b/backend/webui/src/app/(main)/spaces/new/page.tsx
index e12961e..d5fc36f 100644
--- a/backend/webui/src/app/(main)/spaces/new/page.tsx
+++ b/backend/webui/src/app/(main)/spaces/new/page.tsx
@@ -3,19 +3,28 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
-import { ArrowLeft, Loader2, Globe, BookOpen, Code2, Newspaper, TrendingUp, Youtube } from 'lucide-react';
+import { motion } from 'framer-motion';
+import {
+ ArrowLeft,
+ Loader2,
+ Globe,
+ Lock,
+ FolderOpen,
+ Palette,
+} from 'lucide-react';
import { createSpace } from '@/lib/api';
-import type { FocusMode } from '@/lib/types';
-const focusModes: { value: FocusMode; label: string; icon: React.ElementType }[] = [
- { value: 'all', label: 'Все источники', icon: Globe },
- { value: 'academic', label: 'Академический', icon: BookOpen },
- { value: 'code', label: 'Код', icon: Code2 },
- { value: 'news', label: 'Новости', icon: Newspaper },
- { value: 'finance', label: 'Финансы', icon: TrendingUp },
- { value: 'youtube', label: 'YouTube', icon: Youtube },
+const spaceColors = [
+ { id: 'violet', class: 'bg-violet-500', label: 'Фиолетовый' },
+ { id: 'blue', class: 'bg-blue-500', label: 'Синий' },
+ { id: 'emerald', class: 'bg-emerald-500', label: 'Изумрудный' },
+ { id: 'orange', class: 'bg-orange-500', label: 'Оранжевый' },
+ { id: 'pink', class: 'bg-pink-500', label: 'Розовый' },
+ { id: 'indigo', class: 'bg-indigo-500', label: 'Индиго' },
];
+const spaceIcons = ['📁', '🔬', '💡', '📊', '🎯', '🚀', '💼', '📚'];
+
export default function NewSpacePage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -25,7 +34,9 @@ export default function NewSpacePage() {
name: '',
description: '',
instructions: '',
- focusMode: 'all' as FocusMode,
+ icon: '📁',
+ color: 'violet',
+ isPublic: false,
});
const handleCreate = async (e: React.FormEvent) => {
@@ -36,13 +47,12 @@ export default function NewSpacePage() {
setError(null);
try {
- await createSpace({
+ const space = await createSpace({
name: formData.name.trim(),
description: formData.description.trim() || undefined,
instructions: formData.instructions.trim() || undefined,
- focusMode: formData.focusMode,
});
- router.push('/spaces');
+ router.push(`/spaces/${space.id}`);
} catch (err) {
console.error('Failed to create space:', err);
setError('Не удалось создать пространство. Попробуйте позже.');
@@ -53,9 +63,9 @@ export default function NewSpacePage() {
return (
-
+
{/* Header */}
-
+
-
Новое пространство
-
Организуйте исследования по темам
+
Новое пространство
+
Создайте место для исследований и коллаборации
{error && (
-
+
{error}
-
+
)}
{/* Form */}
-