From c2f646c1b658d041d8a379b9176af220041760ee Mon Sep 17 00:00:00 2001 From: KIRILL TRUBITSYN <502198t@gmail.com> Date: Fri, 27 Feb 2026 10:53:24 +0300 Subject: [PATCH 01/40] fix: wrap content in raw tags to prevent Liquid syntax error in audio-visualization.md --- .../remotion/rules/audio-visualization.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/bundled-skills/remotion/rules/audio-visualization.md b/bundled-skills/remotion/rules/audio-visualization.md index 733b0d3..0a9583c 100644 --- a/bundled-skills/remotion/rules/audio-visualization.md +++ b/bundled-skills/remotion/rules/audio-visualization.md @@ -4,7 +4,7 @@ description: Audio visualization patterns - spectrum bars, waveforms, bass-react metadata: tags: audio, visualization, spectrum, waveform, bass, music, audiogram, frequency --- - +{% raw %} # Audio Visualization in Remotion ## Prerequisites @@ -68,12 +68,7 @@ return ( {frequencies.map((v, i) => (
))}
@@ -131,7 +126,7 @@ const path = createSmoothSvgPath({ return ( - + ); ``` @@ -179,7 +174,10 @@ const waveform = getWaveformPortion({ // Returns array of { index, amplitude } objects (amplitude: 0-1) waveform.map((bar) => ( -
+
)); ``` @@ -196,3 +194,4 @@ const scaled = frequencies.map((value) => { return (db - minDb) / (maxDb - minDb); }); ``` +{% endraw %} From 64db59c22a51c67050f01912fd8305c87f83a659 Mon Sep 17 00:00:00 2001 From: KIRILL TRUBITSYN <502198t@gmail.com> Date: Fri, 27 Feb 2026 10:57:01 +0300 Subject: [PATCH 02/40] fix: add _config.yml to disable Liquid processing globally and fix all JSX syntax errors --- _config.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 _config.yml diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..7ded328 --- /dev/null +++ b/_config.yml @@ -0,0 +1,7 @@ +# Jekyll configuration +# Disable Liquid processing globally to prevent errors with JSX/template syntax in markdown files +defaults: + - scope: + path: "" + values: + render_with_liquid: false From 929535527cb299a7262c833564ffa6c690c8713b Mon Sep 17 00:00:00 2001 From: KIRILL TRUBITSYN <502198t@gmail.com> Date: Fri, 27 Feb 2026 11:00:12 +0300 Subject: [PATCH 03/40] fix: add liquid error_mode: lax to suppress JSX syntax errors in Jekyll build --- _config.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/_config.yml b/_config.yml index 7ded328..efc043b 100644 --- a/_config.yml +++ b/_config.yml @@ -1,5 +1,9 @@ # Jekyll configuration -# Disable Liquid processing globally to prevent errors with JSX/template syntax in markdown files +# Use lax error mode to prevent Liquid syntax errors from breaking the build +# This is needed because markdown files contain JSX code with {{ }} syntax +liquid: + error_mode: lax + defaults: - scope: path: "" From 3d32a079950eacd5b3d7f1dac502b0c586424a12 Mon Sep 17 00:00:00 2001 From: KIRILL TRUBITSYN <502198t@gmail.com> Date: Fri, 27 Feb 2026 11:04:05 +0300 Subject: [PATCH 04/40] fix: wrap charts.md content in raw tags to prevent Liquid syntax errors --- bundled-skills/remotion/rules/charts.md | 34 ++++++++++--------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/bundled-skills/remotion/rules/charts.md b/bundled-skills/remotion/rules/charts.md index f036485..a64a526 100644 --- a/bundled-skills/remotion/rules/charts.md +++ b/bundled-skills/remotion/rules/charts.md @@ -4,12 +4,12 @@ description: Chart and data visualization patterns for Remotion. Use when creati metadata: tags: charts, data, visualization, bar-chart, pie-chart, line-chart, stock-chart, svg-paths, graphs --- - +{% raw %} # Charts in Remotion Create charts using React code - HTML, SVG, and D3.js are all supported. -Disable all animations from third party libraries - they cause flickering. +Disable all animations from third party libraries - they cause flickering. Drive all animations from `useCurrentFrame()`. ## Bar Chart @@ -26,7 +26,7 @@ const bars = data.map((item, i) => { delay: i * STAGGER_DELAY, config: { damping: 200 }, }); - return
; + return
; }); ``` @@ -42,22 +42,21 @@ const offset = interpolate(progress, [0, 1], [segmentLength, 0]); ; ``` ## Line Chart / Path Animation Use `@remotion/paths` for animating SVG paths (line charts, stock graphs, signatures). - -Install: `npx remotion add @remotion/paths` +Install: `npx remotion add @remotion/paths` Docs: https://remotion.dev/docs/paths.md ### Convert data points to SVG path @@ -82,14 +81,13 @@ const progress = interpolate(frame, [0, 2 * fps], [0, 1], { extrapolateRight: "clamp", easing: Easing.out(Easing.quad), }); - const { strokeDasharray, strokeDashoffset } = evolvePath(progress, path); ; @@ -109,12 +107,8 @@ const point = getPointAtLength(path, progress * pathLength); const tangent = getTangentAtLength(path, progress * pathLength); const angle = Math.atan2(tangent.y, tangent.x); - - + + ; ``` +{% endraw %} From cc462df50a421a356a724e70942bc00a42b5f109 Mon Sep 17 00:00:00 2001 From: KIRILL TRUBITSYN <502198t@gmail.com> Date: Fri, 27 Feb 2026 11:05:19 +0300 Subject: [PATCH 05/40] fix: wrap charts.md content in raw tags to prevent Liquid syntax errors --- bundled-skills/remotion/rules/charts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundled-skills/remotion/rules/charts.md b/bundled-skills/remotion/rules/charts.md index a64a526..b81b8e5 100644 --- a/bundled-skills/remotion/rules/charts.md +++ b/bundled-skills/remotion/rules/charts.md @@ -4,7 +4,7 @@ description: Chart and data visualization patterns for Remotion. Use when creati metadata: tags: charts, data, visualization, bar-chart, pie-chart, line-chart, stock-chart, svg-paths, graphs --- -{% raw %} +{% raw %} # Charts in Remotion Create charts using React code - HTML, SVG, and D3.js are all supported. From 8bb55115f53cb2157b05867cec33aaca2e49d8bd Mon Sep 17 00:00:00 2001 From: KIRILL TRUBITSYN <502198t@gmail.com> Date: Fri, 27 Feb 2026 11:07:00 +0300 Subject: [PATCH 06/40] fix: wrap calculate-metadata.md content in raw tags to prevent Liquid syntax errors --- bundled-skills/remotion/rules/calculate-metadata.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bundled-skills/remotion/rules/calculate-metadata.md b/bundled-skills/remotion/rules/calculate-metadata.md index eef564f..12bc2ba 100644 --- a/bundled-skills/remotion/rules/calculate-metadata.md +++ b/bundled-skills/remotion/rules/calculate-metadata.md @@ -4,7 +4,7 @@ description: Dynamically set composition duration, dimensions, and props metadata: tags: calculateMetadata, duration, dimensions, props, dynamic --- - +{% raw %} # Using calculateMetadata Use `calculateMetadata` on a `` to dynamically set duration, dimensions, and transform props before rendering. @@ -34,7 +34,6 @@ const calculateMetadata: CalculateMetadataFunction = async ({ props, }) => { const durationInSeconds = await getVideoDuration(props.videoSrc); - return { durationInFrames: Math.ceil(durationInSeconds * 30), }; @@ -54,7 +53,6 @@ const calculateMetadata: CalculateMetadataFunction = async ({ props, }) => { const dimensions = await getVideoDimensions(props.videoSrc); - return { width: dimensions.width, height: dimensions.height, @@ -72,12 +70,10 @@ const calculateMetadata: CalculateMetadataFunction = async ({ getVideoDuration(video.src), ); const allMetadata = await Promise.all(metadataPromises); - const totalDuration = allMetadata.reduce( (sum, durationInSeconds) => sum + durationInSeconds, 0, ); - return { durationInFrames: Math.ceil(totalDuration * 30), }; @@ -109,7 +105,6 @@ const calculateMetadata: CalculateMetadataFunction = async ({ }) => { const response = await fetch(props.dataUrl, { signal: abortSignal }); const data = await response.json(); - return { props: { ...props, @@ -132,3 +127,4 @@ All fields are optional. Returned values override the `` props: - `props`: Transformed props passed to the component - `defaultOutName`: Default output filename - `defaultCodec`: Default codec for rendering +{% endraw %} From 4821bd84a21f24b30813584fe6910a8d741ce79d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 12:58:02 +0000 Subject: [PATCH 07/40] docs: add Eggent proposal for Railway deployment and multi-user system Comprehensive proposal document describing the idea of deploying Eggent on Railway with a multi-user system (roles, permissions, data isolation, Telegram per-user support, usage statistics). https://claude.ai/code/session_014UXjuRzQFgRp4Mm83PKqvt --- EGGENT_PROPOSAL.md | 370 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 EGGENT_PROPOSAL.md diff --git a/EGGENT_PROPOSAL.md b/EGGENT_PROPOSAL.md new file mode 100644 index 0000000..7b25ed3 --- /dev/null +++ b/EGGENT_PROPOSAL.md @@ -0,0 +1,370 @@ +# Eggent: Предложение по размещению и мультиюзер-системе + +## 1. Что такое Eggent + +Eggent — это self-hosted AI-воркспейс с локальным хранением данных. Приложение объединяет возможности ChatGPT-подобного интерфейса с продвинутыми инструментами: проектной организацией, семантической памятью, выполнением кода, автоматизацией задач и интеграциями. + +### Ключевые возможности + +| Возможность | Описание | +|-------------|----------| +| **Чат с AI** | Разговорный интерфейс с поддержкой стриминга, Markdown-рендеринг, история сообщений | +| **Проекты** | Изолированные рабочие пространства со своими инструкциями, навыками, памятью и базой знаний | +| **RAG / Knowledge** | Загрузка документов (PDF, DOCX, XLSX, TXT, MD, изображения через OCR) → разбивка на чанки → эмбеддинги → семантический поиск | +| **Семантическая память** | Векторная база (cosine similarity) с областями: main, fragments, solutions, instruments | +| **Выполнение кода** | Python 3, Node.js, терминал — с таймаутом 180 сек и лимитом вывода | +| **MCP-серверы** | Подключение внешних инструментов через Model Context Protocol (STDIO и HTTP) | +| **Cron-автоматизация** | Планирование задач: расписание cron, интервалы, абсолютное время. Глобально и per-project | +| **Telegram-бот** | Общение с AI через Telegram, отправка файлов, access-коды для авторизации | +| **Agent Skills** | 35+ встроенных навыков (GitHub, Discord, iMessage, Excalidraw и др.) | +| **Внешнее API** | Приём сообщений от внешних систем через токен-аутентификацию | + +### Технический стек + +- **Framework:** Next.js 15.5.4, React 19.1.2, TypeScript +- **AI SDK:** Vercel AI SDK 6.0 — единый интерфейс для OpenAI, Anthropic, Google, OpenRouter, Ollama +- **MCP:** @modelcontextprotocol/sdk 1.26.0 +- **Документы:** pdfjs-dist (PDF), mammoth (DOCX), xlsx (Excel), tesseract.js (OCR) +- **UI:** TailwindCSS 4, Radix UI (Shadcn), Lucide Icons +- **State:** Zustand 5 (клиент), file-based JSON (сервер) +- **Docker:** Node 22 (bookworm-slim), multi-stage build, Python 3 venv +- **Хранение:** Файловая система (`./data/`) — чаты, проекты, память, настройки + +--- + +## 2. Текущая архитектура + +### Хранение данных + +Все данные хранятся в JSON-файлах на диске: + +``` +data/ +├── chats/ # Чаты: {chatId}.json +├── projects/ # Проекты: {projectId}/.meta/ (skills, knowledge, MCP) +├── memory/ # Векторные эмбеддинги: {projectId}/vectors.json +├── settings/ # Глобальные настройки, Telegram, API-токены +├── external-sessions/ # Сессии API-клиентов и Telegram +└── cron/ # Cron-задачи и логи +``` + +### Аутентификация (текущая) + +**Однопользовательская система:** +- Один логин/пароль (`admin/admin` по умолчанию) +- Пароль хешируется bcrypt (`src/lib/auth/password.ts`) +- Сессия: HMAC-SHA256 токен в HTTP-only cookie (`eggent_auth`), TTL 7 дней +- При первом входе — принудительная смена пароля (`mustChangeCredentials`) +- Нет ролей, нет разделения данных между пользователями + +### Telegram-интеграция (текущая) + +- Webhook на `/api/integrations/telegram` +- Access-коды формата `EG-XXXXXX` с TTL +- Allowlist пользователей по Telegram ID +- Per-user сессии (activeProject, activeChat) +- Поддержка файлов: документы, изображения, аудио, видео (до 30 МБ) + +### Хорошая основа для расширения + +В текущем коде уже реализованы механизмы, которые можно переиспользовать: +- `password.ts` — хеширование/верификация паролей +- `session.ts` — генерация/проверка токенов с кастомным payload +- `mustChangeCredentials` — флоу принудительной смены пароля +- `telegram-integration-store.ts` — access-коды, сессии, allowlist +- `external-session-store.ts` — per-client state management + +--- + +## 3. Идея: Размещение на Railway + +### Зачем + +Хочу сделать Eggent доступным через интернет для 2-5 доверенных пользователей (семья, друзья) — с одним набором API-ключей. + +### Почему Railway, а не Vercel + +| Критерий | Vercel | Railway | +|----------|--------|---------| +| Архитектура | Serverless (Lambda) | Docker-контейнер | +| Долгие процессы | Макс. 60-300 сек | Без ограничений | +| Выполнение кода | Нет (read-only filesystem) | Python, Node.js, терминал | +| MCP-серверы (STDIO) | Невозможно | Полная поддержка | +| Cron с агентом | Ограничено | Полная поддержка | +| Persistent Volume | Нет | До 100 ГБ ($0.25/ГБ/мес) | +| HTTPS | Автоматический | Автоматический | +| Стоимость | $0 (Hobby) — $20/мес (Pro) | ~$5-20/мес | + +**Вывод:** Vercel не подходит для Eggent, потому что приложение требует долгие процессы (code execution, cron, MCP) и персистентное файловое хранилище. + +### Что нужно для деплоя + +1. **Railway проект** с подключенным Git-репозиторием +2. **Persistent Volume** → `/app/data` (5-10 ГБ достаточно для 2-5 пользователей) +3. **Environment variables:** + - `OPENROUTER_API_KEY` — ключ для AI-моделей + - `OPENAI_API_KEY` — ключ для эмбеддингов (text-embedding-3-large) + - `EGGENT_AUTH_SECRET` — секрет для подписи сессий + - `APP_BIND_HOST=0.0.0.0` — слушать на всех интерфейсах +4. **Dockerfile** уже готов — multi-stage build, Node 22, всё необходимое + +### Безопасность Dockerfile + +Единственное изменение: убрать строку с passwordless sudo для пользователя `node`: + +```dockerfile +# УБРАТЬ эту строку: +RUN echo "node ALL=(root) NOPASSWD: ALL" > /etc/sudoers.d/eggent-node +``` + +Для локальной разработки это удобно (AI может устанавливать пакеты через sudo), но для публичного деплоя — потенциальная уязвимость. + +### Стоимость Railway + +| Компонент | $/мес | +|-----------|-------| +| Контейнер (1 vCPU, 1 ГБ RAM) | $5-15 | +| Persistent Volume (5-10 ГБ) | $1.25-2.50 | +| **Итого Railway** | **~$6-17** | + +--- + +## 4. Идея: Мультиюзер-система + +### Зачем + +Сейчас Eggent — однопользовательский. Если несколько человек используют один экземпляр, все видят все чаты и проекты, нет разделения прав. Нужна система, где: + +- Каждый пользователь видит **только свои** чаты и проекты +- Admin управляет пользователями и видит статистику +- Permissions контролируют доступ к возможностям (code execution, image gen и т.д.) +- Telegram-бот изолирует данные per-user + +### Модель данных пользователей + +Новый файл: `data/settings/users.json` + +```json +{ + "users": [ + { + "id": "usr_abc123", + "username": "kirill", + "displayName": "Kirill", + "passwordHash": "scrypt$...", + "role": "admin", + "mustChangePassword": false, + "permissions": { + "chat": true, + "projects": true, + "knowledge": true, + "codeExecution": true, + "webSearch": true, + "fileUpload": true, + "imageGeneration": true, + "telegram": true + }, + "quotas": { + "dailyMessageLimit": 0, + "monthlyTokenLimit": 0 + }, + "telegramUserId": null, + "createdAt": "2026-02-27T...", + "lastLoginAt": null + } + ] +} +``` + +**Роли:** +- `admin` — полный доступ + управление пользователями + API Keys + статистика +- `user` — доступ только к разрешённым возможностям, свои чаты/проекты + +**Permissions** (admin назначает при создании пользователя): +- `chat` — общение с AI +- `projects` — создание проектов +- `knowledge` — загрузка документов в RAG +- `codeExecution` — выполнение кода (Python/Node/терминал) +- `webSearch` — поиск в интернете +- `fileUpload` — загрузка файлов +- `imageGeneration` — генерация изображений +- `telegram` — доступ через Telegram-бота + +**Quotas** (0 = без лимита): +- `dailyMessageLimit` — максимум сообщений в день +- `monthlyTokenLimit` — максимум токенов в месяц + +### Создание пользователей + +``` +Admin → Settings → Users → "Add User" + → username, displayName, временный пароль, role, permissions + → Сохраняется с mustChangePassword: true + → Admin передаёт логин/пароль (устно, мессенджер) + → Пользователь входит → форма «Установите постоянный пароль» + → После смены → mustChangePassword: false → полный доступ +``` + +Механизм `mustChangeCredentials` уже реализован в `session.ts` и `middleware.ts` — нужно только привязать его к `mustChangePassword` в users-store. + +### Изоляция данных + +**Сессии:** в JWT-токен добавляются `uid` (userId) и `r` (role). Middleware прокидывает их в headers для всех API-routes. + +**Чаты:** поле `userId` в каждом чате. User видит только свои, admin — все. + +**Проекты:** поле `ownerId` в метаданных. User видит свои + общие (без ownerId). Admin видит все. + +**Память/RAG:** уже привязана к проектам → если проекты изолированы, память изолирована автоматически. + +### Видимость настроек по роли + +| Раздел Settings | admin | user | +|----------------|-------|------| +| API Keys | Полный доступ | Скрыт | +| Models | Полный доступ | Только выбор модели в чате | +| Users | CRUD всех пользователей | Только свой профиль | +| Telegram | Настройка бота | Привязка своего аккаунта | +| Usage / Stats | Полный | Скрыт | + +### Telegram: per-user изоляция + +**Текущая система** — access-коды и allowlist — дополняется привязкой к Eggent-аккаунту: + +``` +Admin: Settings → Users → Marina → "Generate Telegram Code" + → access-код привязан к userId Marina +Marina в TG: /start → /code EG-A1B2C3 + → система привязывает её Telegram ID к Eggent-аккаунту + → "Привет, Marina!" +``` + +После привязки: +- Каждый видит **только свои** чаты через Telegram +- `/new` создаёт чат для своего аккаунта +- Файлы привязываются к пользователю +- Permissions и quotas применяются + +### Статистика использования (Admin Dashboard) + +Файл: `data/stats/usage.json` — ежедневная статистика по пользователям и моделям: + +```json +{ + "daily": { + "2026-02-27": { + "usr_abc": { + "messages": 42, + "tokensIn": 15000, + "tokensOut": 45000, + "cost": 0.85, + "byModel": { + "claude-opus-4-6": { "messages": 5, "cost": 0.65 }, + "claude-sonnet-4-6": { "messages": 35, "cost": 0.15 } + } + } + } + } +} +``` + +Сбор: после каждого ответа AI → userId + модель + токены (из response) + стоимость по прайсу. + +UI: вкладка "Usage" в Settings (admin) — таблица по пользователям, моделям, дням. + +### API для мультиюзера + +``` +# Управление пользователями (admin) +GET /api/auth/users — список +POST /api/auth/users — создать +PUT /api/auth/users/[id] — обновить +DELETE /api/auth/users/[id] — удалить +POST /api/auth/users/[id]/telegram-code — TG-код + +# Профиль (любой пользователь) +GET /api/auth/profile — свой профиль +PUT /api/auth/profile — обновить displayName / пароль + +# Статистика (admin) +GET /api/admin/stats?period=day|week|month&userId=... +``` + +--- + +## 5. Рекомендуемый стек моделей (OpenRouter) + +Все модели подключаются через один OpenRouter API-ключ. + +| Роль | Модель | Цена (in/out за 1M tokens) | Когда использовать | +|------|--------|---------------------------|-------------------| +| **Chat (основная)** | `anthropic/claude-opus-4-6` | $5 / $25 | Сложный анализ, агентные цепочки, длинный контекст | +| **Utility (быстрая)** | `anthropic/claude-sonnet-4-6` | $3 / $15 | 80% задач: быстрые ответы, код, повседневное | +| **Мультимедиа** | `google/gemini-3.1-pro` | $2 / $12 | Аудио (до 8.4ч), видео (до 1ч), генерация изображений | +| **Embeddings** | `openai/text-embedding-3-large` | ~$0.13 | RAG, knowledge base, семантическая память | + +На уровне проекта можно задать другую модель — медиа-проект на Gemini, кодинг на Opus. + +--- + +## 6. Итоговая оценка стоимости + +| Компонент | $/мес | +|-----------|-------| +| Railway (контейнер + volume) | $6-17 | +| Opus 4.6 (сложные задачи) | ~$20-60 | +| Sonnet 4.6 (повседневные) | ~$5-15 | +| Gemini 3.1 Pro (медиа) | ~$5-20 | +| Embeddings (text-embedding-3-large) | ~$1-5 | +| **Итого** | **~$37-117** | + +*При активном использовании 2-5 пользователями. Основные расходы — API моделей, Railway — минимум.* + +--- + +## 7. Список файлов для изменения (21 файл) + +| Файл | Действие | Описание | +|------|----------|----------| +| `Dockerfile` | Изменить | Убрать passwordless sudo | +| | | | +| **Хранилища** | | | +| `src/lib/storage/users-store.ts` | Создать | CRUD пользователей, roles, permissions | +| `src/lib/storage/usage-store.ts` | Создать | Статистика по пользователям/моделям | +| `src/lib/storage/chat-store.ts` | Изменить | Добавить userId в чатах | +| `src/lib/storage/project-store.ts` | Изменить | Добавить ownerId в проектах | +| `src/lib/storage/settings-store.ts` | Изменить | Убрать auth, ограничить по роли | +| `src/lib/storage/telegram-integration-store.ts` | Изменить | Access-коды с targetUserId | +| | | | +| **Авторизация** | | | +| `src/lib/auth/session.ts` | Изменить | Добавить uid + role в токен | +| `middleware.ts` | Изменить | userId/role → headers + проверка permissions | +| | | | +| **API** | | | +| `src/app/api/auth/login/route.ts` | Изменить | Логин по users-store | +| `src/app/api/auth/credentials/route.ts` | Изменить | Смена пароля через users-store | +| `src/app/api/auth/users/route.ts` | Создать | GET/POST пользователей (admin) | +| `src/app/api/auth/users/[id]/route.ts` | Создать | PUT/DELETE пользователя (admin) | +| `src/app/api/auth/users/[id]/telegram-code/route.ts` | Создать | TG-код для пользователя | +| `src/app/api/auth/profile/route.ts` | Создать | Профиль пользователя | +| `src/app/api/admin/stats/route.ts` | Создать | Статистика (admin) | +| `src/app/api/integrations/telegram/route.ts` | Изменить | TG userId → Eggent userId | +| | | | +| **UI** | | | +| `src/components/settings/users-*.tsx` | Создать | Управление пользователями (admin) | +| `src/components/settings/usage-*.tsx` | Создать | Статистика (admin) | +| `src/components/auth/change-password.tsx` | Создать | Форма смены временного пароля | + +--- + +## Резюме + +Eggent — мощный AI-воркспейс с отличной архитектурой. Для превращения его в мультиюзерный сервис, доступный через интернет, нужно: + +1. **Railway** для деплоя (Docker + persistent volume + HTTPS) +2. **Users-store** с ролями (admin/user), permissions и quotas +3. **Изоляция данных** — чаты и проекты привязаны к userId +4. **Telegram per-user** — через расширение существующих access-кодов +5. **Admin dashboard** — статистика использования по пользователям и моделям + +Все изменения совместимы с текущей архитектурой и не требуют миграции на базу данных — file-based хранение достаточно для 2-5 пользователей. From 944574446cce6dc7266d4dfcaff8d41250057127 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 13:34:39 +0000 Subject: [PATCH 08/40] Phase 1: Remove passwordless sudo from Dockerfile Remove the sudoers rule that granted the node user passwordless root access. This was convenient for local development but is a security vulnerability for public deployment. https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 63b21ad..3d03cc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,9 +42,6 @@ RUN apt-get update \ && "${PYTHON_VENV}/bin/python3" -m pip --version \ && rm -rf /var/lib/apt/lists/* -RUN echo "node ALL=(root) NOPASSWD: ALL" > /etc/sudoers.d/eggent-node \ - && chmod 440 /etc/sudoers.d/eggent-node - COPY package.json package-lock.json ./ RUN npm install --omit=dev --no-package-lock From 0e025ba89e2e5e3205af4713f43b84627f8e0254 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 13:45:58 +0000 Subject: [PATCH 09/40] Phase 2: Add users-store with CRUD, roles, permissions and quotas Create src/lib/storage/users-store.ts with: - User model: id, username, displayName, passwordHash, role (admin/user), mustChangePassword, permissions, quotas, telegramUserId - CRUD operations: getAllUsers, getUserById, getUserByUsername, getUserByTelegramId, createUser, updateUser, updateUserPassword, deleteUser - SafeUser type that strips passwordHash for API responses - Auto-migration from legacy settings.auth on first access - Input validation (username format, password length) - Protection against deleting the last admin - File storage at data/settings/users.json https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/lib/storage/users-store.ts | 383 +++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 src/lib/storage/users-store.ts diff --git a/src/lib/storage/users-store.ts b/src/lib/storage/users-store.ts new file mode 100644 index 0000000..3c0a00a --- /dev/null +++ b/src/lib/storage/users-store.ts @@ -0,0 +1,383 @@ +import fs from "fs/promises"; +import path from "path"; +import { randomBytes } from "node:crypto"; +import { hashPassword } from "@/lib/auth/password"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type UserRole = "admin" | "user"; + +export interface UserPermissions { + chat: boolean; + projects: boolean; + knowledge: boolean; + codeExecution: boolean; + webSearch: boolean; + fileUpload: boolean; + imageGeneration: boolean; + telegram: boolean; +} + +export interface UserQuotas { + /** 0 = unlimited */ + dailyMessageLimit: number; + /** 0 = unlimited */ + monthlyTokenLimit: number; +} + +export interface User { + id: string; + username: string; + displayName: string; + passwordHash: string; + role: UserRole; + mustChangePassword: boolean; + permissions: UserPermissions; + quotas: UserQuotas; + telegramUserId: string | null; + createdAt: string; + lastLoginAt: string | null; +} + +/** User object without the password hash — safe for API responses. */ +export type SafeUser = Omit; + +interface UsersFile { + users: User[]; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const DATA_DIR = path.join(process.cwd(), "data"); +const SETTINGS_DIR = path.join(DATA_DIR, "settings"); +const USERS_FILE = path.join(SETTINGS_DIR, "users.json"); + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +export const DEFAULT_PERMISSIONS: UserPermissions = { + chat: true, + projects: true, + knowledge: true, + codeExecution: true, + webSearch: true, + fileUpload: true, + imageGeneration: true, + telegram: true, +}; + +const DEFAULT_QUOTAS: UserQuotas = { + dailyMessageLimit: 0, + monthlyTokenLimit: 0, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function generateUserId(): string { + return `usr_${randomBytes(8).toString("hex")}`; +} + +async function ensureDir(dir: string) { + await fs.mkdir(dir, { recursive: true }); +} + +function stripPasswordHash(user: User): SafeUser { + const { passwordHash: _, ...safe } = user; + return safe; +} + +// --------------------------------------------------------------------------- +// Persistence (read / write) +// --------------------------------------------------------------------------- + +async function readUsersFile(): Promise { + await ensureDir(SETTINGS_DIR); + try { + const content = await fs.readFile(USERS_FILE, "utf-8"); + const parsed = JSON.parse(content) as unknown; + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + Array.isArray((parsed as UsersFile).users) + ) { + return parsed as UsersFile; + } + } catch { + // file missing or corrupt — fall through + } + return { users: [] }; +} + +async function writeUsersFile(data: UsersFile): Promise { + await ensureDir(SETTINGS_DIR); + await fs.writeFile(USERS_FILE, JSON.stringify(data, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Migration: bootstrap initial admin from legacy settings.auth +// --------------------------------------------------------------------------- + +let migrationDone = false; + +/** + * Ensures at least one admin user exists. On the very first call it reads + * the legacy `settings.json → auth` block and creates a matching admin user + * in the users store, preserving the existing passwordHash and + * mustChangeCredentials flag. + */ +async function ensureInitialized(): Promise { + if (migrationDone) return; + + const data = await readUsersFile(); + if (data.users.length > 0) { + migrationDone = true; + return; + } + + // Read legacy settings to migrate from + let legacyUsername = "admin"; + let legacyPasswordHash: string | undefined; + let legacyMustChange = true; + + try { + const settingsPath = path.join(SETTINGS_DIR, "settings.json"); + const raw = await fs.readFile(settingsPath, "utf-8"); + const settings = JSON.parse(raw) as { + auth?: { + username?: string; + passwordHash?: string; + mustChangeCredentials?: boolean; + }; + }; + + if (settings.auth) { + if (settings.auth.username) legacyUsername = settings.auth.username; + if (settings.auth.passwordHash) + legacyPasswordHash = settings.auth.passwordHash; + if (typeof settings.auth.mustChangeCredentials === "boolean") + legacyMustChange = settings.auth.mustChangeCredentials; + } + } catch { + // No settings file — use defaults + } + + const now = new Date().toISOString(); + const adminUser: User = { + id: generateUserId(), + username: legacyUsername, + displayName: legacyUsername.charAt(0).toUpperCase() + legacyUsername.slice(1), + passwordHash: legacyPasswordHash || hashPassword("admin"), + role: "admin", + mustChangePassword: legacyMustChange, + permissions: { ...DEFAULT_PERMISSIONS }, + quotas: { ...DEFAULT_QUOTAS }, + telegramUserId: null, + createdAt: now, + lastLoginAt: null, + }; + + data.users.push(adminUser); + await writeUsersFile(data); + migrationDone = true; +} + +// --------------------------------------------------------------------------- +// Public API — Read +// --------------------------------------------------------------------------- + +export async function getAllUsers(): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + return data.users.map(stripPasswordHash); +} + +export async function getUserById(id: string): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + return data.users.find((u) => u.id === id) ?? null; +} + +export async function getUserByUsername( + username: string +): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + const lower = username.toLowerCase(); + return data.users.find((u) => u.username.toLowerCase() === lower) ?? null; +} + +export async function getUserByTelegramId( + telegramUserId: string +): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + return ( + data.users.find((u) => u.telegramUserId === telegramUserId) ?? null + ); +} + +export async function getSafeUserById(id: string): Promise { + const user = await getUserById(id); + return user ? stripPasswordHash(user) : null; +} + +// --------------------------------------------------------------------------- +// Public API — Write +// --------------------------------------------------------------------------- + +export interface CreateUserInput { + username: string; + displayName?: string; + password: string; + role?: UserRole; + permissions?: Partial; + quotas?: Partial; +} + +export async function createUser(input: CreateUserInput): Promise { + await ensureInitialized(); + + const username = input.username.trim().toLowerCase(); + if (!username || username.length < 3 || username.length > 64) { + throw new Error("Username must be 3-64 characters"); + } + if (!/^[a-z0-9._-]+$/.test(username)) { + throw new Error( + "Username may only contain lowercase letters, numbers, dots, hyphens and underscores" + ); + } + + const password = input.password.trim(); + if (password.length < 8 || password.length > 128) { + throw new Error("Password must be 8-128 characters"); + } + + const data = await readUsersFile(); + if (data.users.some((u) => u.username.toLowerCase() === username)) { + throw new Error("Username already exists"); + } + + const now = new Date().toISOString(); + const user: User = { + id: generateUserId(), + username, + displayName: + input.displayName?.trim() || + username.charAt(0).toUpperCase() + username.slice(1), + passwordHash: hashPassword(password), + role: input.role ?? "user", + mustChangePassword: true, + permissions: { ...DEFAULT_PERMISSIONS, ...(input.permissions ?? {}) }, + quotas: { ...DEFAULT_QUOTAS, ...(input.quotas ?? {}) }, + telegramUserId: null, + createdAt: now, + lastLoginAt: null, + }; + + data.users.push(user); + await writeUsersFile(data); + return stripPasswordHash(user); +} + +export interface UpdateUserInput { + displayName?: string; + role?: UserRole; + permissions?: Partial; + quotas?: Partial; + mustChangePassword?: boolean; + telegramUserId?: string | null; +} + +export async function updateUser( + id: string, + input: UpdateUserInput +): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + const idx = data.users.findIndex((u) => u.id === id); + if (idx === -1) return null; + + const user = data.users[idx]; + + if (input.displayName !== undefined) + user.displayName = input.displayName.trim(); + if (input.role !== undefined) user.role = input.role; + if (input.mustChangePassword !== undefined) + user.mustChangePassword = input.mustChangePassword; + if (input.telegramUserId !== undefined) + user.telegramUserId = input.telegramUserId; + if (input.permissions) + user.permissions = { ...user.permissions, ...input.permissions }; + if (input.quotas) user.quotas = { ...user.quotas, ...input.quotas }; + + data.users[idx] = user; + await writeUsersFile(data); + return stripPasswordHash(user); +} + +export async function updateUserPassword( + id: string, + newPassword: string +): Promise { + const password = newPassword.trim(); + if (password.length < 8 || password.length > 128) { + throw new Error("Password must be 8-128 characters"); + } + + const data = await readUsersFile(); + const idx = data.users.findIndex((u) => u.id === id); + if (idx === -1) return false; + + data.users[idx].passwordHash = hashPassword(password); + data.users[idx].mustChangePassword = false; + await writeUsersFile(data); + return true; +} + +export async function updateLastLogin(id: string): Promise { + const data = await readUsersFile(); + const idx = data.users.findIndex((u) => u.id === id); + if (idx === -1) return; + + data.users[idx].lastLoginAt = new Date().toISOString(); + await writeUsersFile(data); +} + +export async function deleteUser(id: string): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + const idx = data.users.findIndex((u) => u.id === id); + if (idx === -1) return false; + + const user = data.users[idx]; + + // Prevent deleting the last admin + if (user.role === "admin") { + const adminCount = data.users.filter((u) => u.role === "admin").length; + if (adminCount <= 1) { + throw new Error("Cannot delete the last admin user"); + } + } + + data.users.splice(idx, 1); + await writeUsersFile(data); + return true; +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +export async function countAdmins(): Promise { + await ensureInitialized(); + const data = await readUsersFile(); + return data.users.filter((u) => u.role === "admin").length; +} From a6f8461968e5751c7c399ce13a21bdbe11b0a81d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:11:37 +0000 Subject: [PATCH 10/40] Phase 3: Per-user data isolation for chats and projects - Add userId field to Chat/ChatListItem types for chat ownership - Add ownerId/isShared fields to Project type for project ownership - Create getCurrentUser() helper to extract user from session cookie - Update chat-store to filter by userId (legacy chats without userId visible to all) - Update project-store to filter by ownerId (shared projects visible to all) - Update chat/history API to show only user's chats (admins see all) - Update chat API to set userId on new chats - Update projects API to set ownerId on new projects - Add ownership checks on project GET/PUT/DELETE operations https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/app/api/chat/history/route.ts | 15 ++++++++++- src/app/api/chat/route.ts | 41 ++++++++++++++++++++++++++++-- src/app/api/projects/[id]/route.ts | 25 ++++++++++++++++++ src/app/api/projects/route.ts | 12 +++++++-- src/lib/auth/get-current-user.ts | 19 ++++++++++++++ src/lib/storage/chat-store.ts | 10 ++++++-- src/lib/storage/project-store.ts | 13 ++++++++-- src/lib/types.ts | 7 +++++ 8 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 src/lib/auth/get-current-user.ts diff --git a/src/app/api/chat/history/route.ts b/src/app/api/chat/history/route.ts index 82810b8..eefef40 100644 --- a/src/app/api/chat/history/route.ts +++ b/src/app/api/chat/history/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { getAllChats, getChat, deleteChat } from "@/lib/storage/chat-store"; +import { getCurrentUser } from "@/lib/auth/get-current-user"; export async function GET(req: NextRequest) { const chatId = req.nextUrl.searchParams.get("id"); @@ -13,7 +14,10 @@ export async function GET(req: NextRequest) { } const projectId = req.nextUrl.searchParams.get("projectId"); - let chats = await getAllChats(); + const user = await getCurrentUser(); + // Admins see all chats; regular users see only their own + const filterUserId = user?.role === "admin" ? undefined : user?.id; + let chats = await getAllChats(filterUserId); // Filter by project: "none" means global chats (no project), // a project ID filters to that project's chats @@ -32,6 +36,15 @@ export async function DELETE(req: NextRequest) { return Response.json({ error: "Chat ID required" }, { status: 400 }); } + // Ownership check: non-admin users can only delete their own chats + const user = await getCurrentUser(); + if (user && user.role !== "admin") { + const chat = await getChat(chatId); + if (chat?.userId && chat.userId !== user.id) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + } + const deleted = await deleteChat(chatId); if (!deleted) { return Response.json({ error: "Chat not found" }, { status: 404 }); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2b36d0f..839a55a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,6 +2,12 @@ import { NextRequest } from "next/server"; import { runAgent } from "@/lib/agent/agent"; import { createChat, getChat } from "@/lib/storage/chat-store"; import { ensureCronSchedulerStarted } from "@/lib/cron/runtime"; +import { getCurrentUser } from "@/lib/auth/get-current-user"; +import { + checkDailyQuota, + checkMonthlyTokenQuota, + recordMessageUsage, +} from "@/lib/storage/usage-stats-store"; export const maxDuration = 300; // 5 min max for long agent runs @@ -36,15 +42,37 @@ export async function POST(req: NextRequest) { ); } + // Resolve current user for per-user data isolation + const user = await getCurrentUser(); + const userId = user?.id; + + // Quota checks + if (user && user.role !== "admin") { + const dailyOk = await checkDailyQuota(user.id, user.quotas.dailyMessageLimit); + if (!dailyOk) { + return Response.json( + { error: "Daily message limit reached. Try again tomorrow." }, + { status: 429 } + ); + } + const monthlyOk = await checkMonthlyTokenQuota(user.id, user.quotas.monthlyTokenLimit); + if (!monthlyOk) { + return Response.json( + { error: "Monthly token limit reached. Contact your administrator." }, + { status: 429 } + ); + } + } + // Create chat if needed let resolvedChatId = chatId; if (!resolvedChatId) { resolvedChatId = crypto.randomUUID(); - await createChat(resolvedChatId, "New Chat", projectId); + await createChat(resolvedChatId, "New Chat", projectId, userId); } else { const existing = await getChat(resolvedChatId); if (!existing) { - await createChat(resolvedChatId, "New Chat", projectId); + await createChat(resolvedChatId, "New Chat", projectId, userId); } } @@ -56,6 +84,15 @@ export async function POST(req: NextRequest) { currentPath: typeof currentPath === "string" ? currentPath : undefined, }); + // Record usage stats (fire-and-forget, don't block the response) + if (userId) { + recordMessageUsage({ + userId, + userMessageLength: message.length, + assistantMessageLength: 500, // estimate; actual length unknown at stream start + }).catch(() => {}); + } + return result.toUIMessageStreamResponse({ headers: { "X-Chat-Id": resolvedChatId, diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index 00f638b..1399b93 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -4,6 +4,17 @@ import { updateProject, deleteProject, } from "@/lib/storage/project-store"; +import { getCurrentUser } from "@/lib/auth/get-current-user"; + +/** Non-admin users can only access projects they own or shared projects. */ +async function checkProjectAccess(projectOwnerId?: string, projectIsShared?: boolean) { + const user = await getCurrentUser(); + if (!user || user.role === "admin") return null; // admins have full access + if (!projectOwnerId) return null; // legacy projects without ownerId are accessible + if (projectIsShared) return null; + if (projectOwnerId === user.id) return null; + return Response.json({ error: "Forbidden" }, { status: 403 }); +} export async function GET( _req: NextRequest, @@ -14,6 +25,8 @@ export async function GET( if (!project) { return Response.json({ error: "Project not found" }, { status: 404 }); } + const forbidden = await checkProjectAccess(project.ownerId, project.isShared); + if (forbidden) return forbidden; return Response.json(project); } @@ -22,6 +35,12 @@ export async function PUT( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const project = await getProject(id); + if (!project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + const forbidden = await checkProjectAccess(project.ownerId, project.isShared); + if (forbidden) return forbidden; const body = await req.json(); const updated = await updateProject(id, body); if (!updated) { @@ -35,6 +54,12 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const project = await getProject(id); + if (!project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + const forbidden = await checkProjectAccess(project.ownerId, project.isShared); + if (forbidden) return forbidden; const deleted = await deleteProject(id); if (!deleted) { return Response.json({ error: "Project not found" }, { status: 404 }); diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 8d84c56..be4afc0 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,15 +1,19 @@ import { NextRequest } from "next/server"; import { getAllProjects, createProject } from "@/lib/storage/project-store"; +import { getCurrentUser } from "@/lib/auth/get-current-user"; export async function GET() { - const projects = await getAllProjects(); + const user = await getCurrentUser(); + // Admins see all projects; regular users see only their own + shared + const filterUserId = user?.role === "admin" ? undefined : user?.id; + const projects = await getAllProjects(filterUserId); return Response.json(projects); } export async function POST(req: NextRequest) { try { const body = await req.json(); - const { name, description, instructions, memoryMode } = body; + const { name, description, instructions, memoryMode, isShared } = body; if (!name || typeof name !== "string") { return Response.json( @@ -25,12 +29,16 @@ export async function POST(req: NextRequest) { .replace(/^-|-$/g, "") || crypto.randomUUID().slice(0, 8); + const user = await getCurrentUser(); + const project = await createProject({ id, name, description: description || "", instructions: instructions || "", memoryMode: memoryMode || "global", + ownerId: user?.id, + isShared: user?.role === "admin" ? (isShared ?? false) : false, }); return Response.json(project, { status: 201 }); diff --git a/src/lib/auth/get-current-user.ts b/src/lib/auth/get-current-user.ts new file mode 100644 index 0000000..d26297f --- /dev/null +++ b/src/lib/auth/get-current-user.ts @@ -0,0 +1,19 @@ +import { cookies } from "next/headers"; +import { AUTH_COOKIE_NAME, verifySessionToken } from "@/lib/auth/session"; +import { getUserByUsername, type User } from "@/lib/storage/users-store"; + +/** + * Extract the current authenticated user from the session cookie. + * Returns null if no valid session or user not found. + * Intended for use inside API routes / server components. + */ +export async function getCurrentUser(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + if (!token) return null; + + const session = await verifySessionToken(token); + if (!session) return null; + + return getUserByUsername(session.username); +} diff --git a/src/lib/storage/chat-store.ts b/src/lib/storage/chat-store.ts index 767ed0a..90146aa 100644 --- a/src/lib/storage/chat-store.ts +++ b/src/lib/storage/chat-store.ts @@ -10,7 +10,7 @@ async function ensureDir(dir: string) { await fs.mkdir(dir, { recursive: true }); } -export async function getAllChats(): Promise { +export async function getAllChats(filterUserId?: string): Promise { await ensureDir(CHATS_DIR); const files = await fs.readdir(CHATS_DIR); const chats: ChatListItem[] = []; @@ -20,10 +20,14 @@ export async function getAllChats(): Promise { try { const content = await fs.readFile(path.join(CHATS_DIR, file), "utf-8"); const chat: Chat = JSON.parse(content); + // Per-user isolation: skip chats owned by other users. + // Chats without userId (legacy) are visible to everyone. + if (filterUserId && chat.userId && chat.userId !== filterUserId) continue; chats.push({ id: chat.id, title: chat.title, projectId: chat.projectId, + userId: chat.userId, createdAt: chat.createdAt, updatedAt: chat.updatedAt, messageCount: chat.messages.length, @@ -109,13 +113,15 @@ export async function deleteChatsByProjectId(projectId: string): Promise export async function createChat( id: string, title: string, - projectId?: string + projectId?: string, + userId?: string ): Promise { const now = new Date().toISOString(); const chat: Chat = { id, title, projectId, + userId, messages: [], createdAt: now, updatedAt: now, diff --git a/src/lib/storage/project-store.ts b/src/lib/storage/project-store.ts index 01717f1..eee6891 100644 --- a/src/lib/storage/project-store.ts +++ b/src/lib/storage/project-store.ts @@ -671,7 +671,12 @@ export async function loadProjectSkills( return skills; } -export async function getAllProjects(): Promise { +/** + * Return all projects. When filterUserId is provided, only return projects + * that belong to that user OR are shared. Legacy projects without ownerId + * are visible to everyone. + */ +export async function getAllProjects(filterUserId?: string): Promise { await ensureDir(PROJECTS_DIR); const entries = await fs.readdir(PROJECTS_DIR, { withFileTypes: true }); const projects: Project[] = []; @@ -681,7 +686,11 @@ export async function getAllProjects(): Promise { try { const metaFile = projectMetaFile(entry.name); const content = await fs.readFile(metaFile, "utf-8"); - projects.push(JSON.parse(content)); + const project: Project = JSON.parse(content); + if (filterUserId && project.ownerId && project.ownerId !== filterUserId && !project.isShared) { + continue; + } + projects.push(project); } catch { // skip projects without metadata } diff --git a/src/lib/types.ts b/src/lib/types.ts index 2c1b0ba..efbced2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -83,6 +83,8 @@ export interface Chat { id: string; title: string; projectId?: string; + /** Owner user ID — set on creation for per-user data isolation. */ + userId?: string; messages: ChatMessage[]; createdAt: string; updatedAt: string; @@ -92,6 +94,7 @@ export interface ChatListItem { id: string; title: string; projectId?: string; + userId?: string; createdAt: string; updatedAt: string; messageCount: number; @@ -114,6 +117,10 @@ export interface Project { description: string; instructions: string; memoryMode: "global" | "isolated"; + /** Owner user ID — set on creation for per-user data isolation. */ + ownerId?: string; + /** When true, all users can see this project (admin-created shared projects). */ + isShared?: boolean; createdAt: string; updatedAt: string; } From d72abe3764b2ee6f7bd9b2d0b89a5073ecc37811 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:11:54 +0000 Subject: [PATCH 11/40] Phase 4: Telegram per-user integration - Resolve Telegram userId to app User via getUserByTelegramId() - Check telegram permission before processing messages - Pass userId to chats created from Telegram sessions - Add quota checks (daily messages, monthly tokens) for Telegram users - Record usage stats after successful agent responses - Deny access with Russian-language messages when permissions/quotas fail https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/app/api/integrations/telegram/route.ts | 59 +++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/app/api/integrations/telegram/route.ts b/src/app/api/integrations/telegram/route.ts index 542835c..3de1276 100644 --- a/src/app/api/integrations/telegram/route.ts +++ b/src/app/api/integrations/telegram/route.ts @@ -21,6 +21,12 @@ import { } from "@/lib/storage/telegram-integration-store"; import { saveChatFile } from "@/lib/storage/chat-files-store"; import { createChat, getChat } from "@/lib/storage/chat-store"; +import { getUserByTelegramId } from "@/lib/storage/users-store"; +import { + checkDailyQuota, + checkMonthlyTokenQuota, + recordMessageUsage, +} from "@/lib/storage/usage-stats-store"; import { contextKey, type ExternalSession, @@ -160,6 +166,7 @@ function chatBelongsToProject( async function ensureTelegramExternalChatContext(params: { sessionId: string; defaultProjectId?: string; + userId?: string; }): Promise { const { session, resolvedProjectId } = await resolveTelegramProjectContext({ sessionId: params.sessionId, @@ -179,7 +186,8 @@ async function ensureTelegramExternalChatContext(params: { await createChat( resolvedChatId, `External session ${session.id}`, - resolvedProjectId + resolvedProjectId, + params.userId ); } @@ -595,6 +603,21 @@ export async function POST(req: NextRequest) { }); } + // Resolve app user linked to this Telegram account (if any) + const appUser = await getUserByTelegramId(fromUserId); + const appUserId = appUser?.id; + + // Check telegram permission if user is linked + if (appUser && !appUser.permissions.telegram) { + await sendTelegramMessage( + botToken, + chatId, + "Доступ к Telegram-боту отключён для вашего аккаунта. Обратитесь к администратору.", + messageId + ); + return Response.json({ ok: true, ignored: true, reason: "telegram_permission_denied" }); + } + let sessionId = await getTelegramChatSessionId(botId, chatId); if (!sessionId) { sessionId = createDefaultTelegramSessionId(botId, chatId); @@ -649,6 +672,7 @@ export async function POST(req: NextRequest) { externalContext = await ensureTelegramExternalChatContext({ sessionId, defaultProjectId, + userId: appUserId, }); const fileBuffer = await downloadTelegramFile(botToken, incomingFile.fileId); const saved = await saveChatFile( @@ -687,6 +711,30 @@ export async function POST(req: NextRequest) { return Response.json({ ok: true, ignored: true, reason: "non_text" }); } + // Quota check for linked app users + if (appUser && appUser.role !== "admin") { + const dailyOk = await checkDailyQuota(appUser.id, appUser.quotas.dailyMessageLimit); + if (!dailyOk) { + await sendTelegramMessage( + botToken, + chatId, + "Дневной лимит сообщений исчерпан. Попробуйте завтра.", + messageId + ); + return Response.json({ ok: true, ignored: true, reason: "daily_quota_exceeded" }); + } + const monthlyOk = await checkMonthlyTokenQuota(appUser.id, appUser.quotas.monthlyTokenLimit); + if (!monthlyOk) { + await sendTelegramMessage( + botToken, + chatId, + "Месячный лимит токенов исчерпан. Обратитесь к администратору.", + messageId + ); + return Response.json({ ok: true, ignored: true, reason: "monthly_quota_exceeded" }); + } + } + try { const result = await handleExternalMessage({ sessionId, @@ -705,6 +753,15 @@ export async function POST(req: NextRequest) { }, }); + // Record usage stats for linked app user + if (appUserId) { + recordMessageUsage({ + userId: appUserId, + userMessageLength: incomingText.length, + assistantMessageLength: result.reply.length, + }).catch(() => {}); + } + await sendTelegramMessage(botToken, chatId, result.reply, messageId); return Response.json({ ok: true }); } catch (error) { From 03438ee840b954524782a5514974fea44d81ec02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:12:12 +0000 Subject: [PATCH 12/40] Phase 5: Usage statistics with quota enforcement - Create usage-stats-store with per-user daily/monthly tracking - Track message count and estimated token usage per user per day - Auto-prune stats older than 90 days to keep storage compact - Add daily message quota and monthly token quota checking - Add GET /api/usage-stats endpoint (admins see all, users see own) - Integrate quota checks into chat API (returns 429 when exceeded) - Record usage stats fire-and-forget after agent responses https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/app/api/usage-stats/route.ts | 24 +++ src/lib/storage/usage-stats-store.ts | 213 +++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/app/api/usage-stats/route.ts create mode 100644 src/lib/storage/usage-stats-store.ts diff --git a/src/app/api/usage-stats/route.ts b/src/app/api/usage-stats/route.ts new file mode 100644 index 0000000..1b424a7 --- /dev/null +++ b/src/app/api/usage-stats/route.ts @@ -0,0 +1,24 @@ +import { getCurrentUser } from "@/lib/auth/get-current-user"; +import { + getUserUsageStats, + getAllUsageStats, +} from "@/lib/storage/usage-stats-store"; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Admins can see all users' stats + if (user.role === "admin") { + const allStats = await getAllUsageStats(); + return Response.json({ stats: allStats }); + } + + // Regular users see only their own stats + const stats = await getUserUsageStats(user.id); + return Response.json({ + stats: stats ? [stats] : [], + }); +} diff --git a/src/lib/storage/usage-stats-store.ts b/src/lib/storage/usage-stats-store.ts new file mode 100644 index 0000000..8695aaa --- /dev/null +++ b/src/lib/storage/usage-stats-store.ts @@ -0,0 +1,213 @@ +import fs from "fs/promises"; +import path from "path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface UserDailyStats { + /** ISO date string YYYY-MM-DD */ + date: string; + messageCount: number; + /** Estimated token usage (rough count based on message length / 4) */ + estimatedTokens: number; +} + +export interface UserUsageStats { + userId: string; + /** Daily stats keyed by date YYYY-MM-DD */ + daily: Record; + /** Lifetime totals */ + totalMessages: number; + totalEstimatedTokens: number; + updatedAt: string; +} + +interface UsageStatsFile { + users: Record; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const DATA_DIR = path.join(process.cwd(), "data"); +const STATS_FILE = path.join(DATA_DIR, "settings", "usage-stats.json"); + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +async function ensureDir() { + await fs.mkdir(path.dirname(STATS_FILE), { recursive: true }); +} + +async function readStatsFile(): Promise { + try { + const content = await fs.readFile(STATS_FILE, "utf-8"); + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const file = parsed as UsageStatsFile; + if (file.users && typeof file.users === "object") return file; + } + } catch { + // file missing or corrupt + } + return { users: {} }; +} + +async function writeStatsFile(data: UsageStatsFile): Promise { + await ensureDir(); + await fs.writeFile(STATS_FILE, JSON.stringify(data, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function todayKey(): string { + return new Date().toISOString().slice(0, 10); +} + +function estimateTokens(text: string): number { + // Rough estimate: ~4 characters per token + return Math.ceil(text.length / 4); +} + +/** Remove daily entries older than 90 days to keep the file compact. */ +function pruneOldEntries(stats: UserUsageStats): void { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + const cutoffStr = cutoff.toISOString().slice(0, 10); + for (const dateKey of Object.keys(stats.daily)) { + if (dateKey < cutoffStr) { + delete stats.daily[dateKey]; + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Record a message exchange for a user. Call this after a successful + * chat message + agent response cycle. + */ +export async function recordMessageUsage(params: { + userId: string; + userMessageLength: number; + assistantMessageLength: number; +}): Promise { + const { userId, userMessageLength, assistantMessageLength } = params; + const data = await readStatsFile(); + + if (!data.users[userId]) { + data.users[userId] = { + userId, + daily: {}, + totalMessages: 0, + totalEstimatedTokens: 0, + updatedAt: new Date().toISOString(), + }; + } + + const stats = data.users[userId]; + const today = todayKey(); + + if (!stats.daily[today]) { + stats.daily[today] = { + date: today, + messageCount: 0, + estimatedTokens: 0, + }; + } + + const tokens = estimateTokens( + " ".repeat(userMessageLength) + " ".repeat(assistantMessageLength) + ); + + stats.daily[today].messageCount += 1; + stats.daily[today].estimatedTokens += tokens; + stats.totalMessages += 1; + stats.totalEstimatedTokens += tokens; + stats.updatedAt = new Date().toISOString(); + + pruneOldEntries(stats); + data.users[userId] = stats; + await writeStatsFile(data); +} + +/** + * Get usage stats for a specific user. + */ +export async function getUserUsageStats( + userId: string +): Promise { + const data = await readStatsFile(); + return data.users[userId] ?? null; +} + +/** + * Get usage stats for all users (admin view). + */ +export async function getAllUsageStats(): Promise { + const data = await readStatsFile(); + return Object.values(data.users); +} + +/** + * Get today's message count for a user (for quota checking). + */ +export async function getTodayMessageCount(userId: string): Promise { + const data = await readStatsFile(); + const stats = data.users[userId]; + if (!stats) return 0; + const today = todayKey(); + return stats.daily[today]?.messageCount ?? 0; +} + +/** + * Get current month's estimated token usage for a user (for quota checking). + */ +export async function getCurrentMonthTokens(userId: string): Promise { + const data = await readStatsFile(); + const stats = data.users[userId]; + if (!stats) return 0; + + const now = new Date(); + const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + let total = 0; + for (const [dateKey, daily] of Object.entries(stats.daily)) { + if (dateKey.startsWith(monthPrefix)) { + total += daily.estimatedTokens; + } + } + return total; +} + +/** + * Check if a user has exceeded their daily message quota. + * Returns true if the user CAN send a message (under quota or unlimited). + */ +export async function checkDailyQuota( + userId: string, + dailyLimit: number +): Promise { + if (dailyLimit <= 0) return true; // 0 = unlimited + const count = await getTodayMessageCount(userId); + return count < dailyLimit; +} + +/** + * Check if a user has exceeded their monthly token quota. + * Returns true if the user CAN send a message (under quota or unlimited). + */ +export async function checkMonthlyTokenQuota( + userId: string, + monthlyLimit: number +): Promise { + if (monthlyLimit <= 0) return true; // 0 = unlimited + const tokens = await getCurrentMonthTokens(userId); + return tokens < monthlyLimit; +} From d10b95288759307f3414d5b5b0ab7bf08969165c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 15:01:56 +0000 Subject: [PATCH 13/40] fix: upgrade next.js to 15.5.9 to resolve critical security vulnerabilities Fixes CVE-2025-55183, CVE-2025-55184, CVE-2025-66478, CVE-2025-67779 https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c7b0a30..27ffd43 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lucide-react": "^0.544.0", "mammoth": "^1.11.0", "nanoid": "^5.1.6", - "next": "15.5.4", + "next": "15.5.9", "node-ensure": "^0.0.0", "pdfjs-dist": "^2.16.105", "radix-ui": "^1.4.3", @@ -51,7 +51,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "15.5.4", + "eslint-config-next": "15.5.9", "shadcn": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", From 22af25f490cb1b3269096d9cae0fb7f77236bb28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 15:14:09 +0000 Subject: [PATCH 14/40] fix: update package-lock.json for next@15.5.9 https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- package-lock.json | 157 +++++++++++++++++++++++++++++++--------------- package.json | 4 +- 2 files changed, 110 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index d61cdc3..d0d7cd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "lucide-react": "^0.544.0", "mammoth": "^1.11.0", "nanoid": "^5.1.6", - "next": "15.5.4", + "next": "^15.5.9", "node-ensure": "^0.0.0", "pdfjs-dist": "^2.16.105", "radix-ui": "^1.4.3", @@ -47,7 +47,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "15.5.4", + "eslint-config-next": "^15.5.9", "shadcn": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -1851,15 +1851,15 @@ } }, "node_modules/@next/env": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz", - "integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.4.tgz", - "integrity": "sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.9.tgz", + "integrity": "sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1867,9 +1867,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz", - "integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1883,9 +1883,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz", - "integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1899,9 +1899,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz", - "integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1915,9 +1915,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz", - "integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1931,9 +1931,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz", - "integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1947,9 +1947,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz", - "integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1963,9 +1963,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz", - "integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1979,9 +1979,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz", - "integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -5469,6 +5469,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -7969,13 +8029,13 @@ } }, "node_modules/eslint-config-next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.4.tgz", - "integrity": "sha512-BzgVVuT3kfJes8i2GHenC1SRJ+W3BTML11lAOYFOOPzrk2xp66jBOAGEFRw+3LkYCln5UzvFsLhojrshb5Zfaw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.9.tgz", + "integrity": "sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.5.4", + "@next/eslint-plugin-next": "15.5.9", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -11830,13 +11890,12 @@ } }, "node_modules/next": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz", - "integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.4", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -11849,14 +11908,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.4", - "@next/swc-darwin-x64": "15.5.4", - "@next/swc-linux-arm64-gnu": "15.5.4", - "@next/swc-linux-arm64-musl": "15.5.4", - "@next/swc-linux-x64-gnu": "15.5.4", - "@next/swc-linux-x64-musl": "15.5.4", - "@next/swc-win32-arm64-msvc": "15.5.4", - "@next/swc-win32-x64-msvc": "15.5.4", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/package.json b/package.json index 27ffd43..874e032 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lucide-react": "^0.544.0", "mammoth": "^1.11.0", "nanoid": "^5.1.6", - "next": "15.5.9", + "next": "^15.5.9", "node-ensure": "^0.0.0", "pdfjs-dist": "^2.16.105", "radix-ui": "^1.4.3", @@ -51,7 +51,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "15.5.9", + "eslint-config-next": "^15.5.9", "shadcn": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", From ba4ed84b8f1b4c780748aaa765e23a7aaeda709a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 18:52:45 +0000 Subject: [PATCH 15/40] feat: route all models through OpenRouter with single API key - Change default provider from OpenAI to OpenRouter for all models - Add multimediaModel slot (Gemini) alongside chatModel and utilityModel - Add UtilityModelWizard and MultimediaModelWizard UI components - Refactor model-wizards.tsx to use generic GenericChatModelWizard - Update API settings route to mask/restore keys for all model types - Update AgentConfig type to include multimediaModel - Update .env.example to prioritize OPENROUTER_API_KEY Default models: - Chat: anthropic/claude-opus-4-6 - Utility: anthropic/claude-sonnet-4-6 - Multimedia: google/gemini-2.5-pro-preview-05-06 - Embeddings: openai/text-embedding-3-small https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- .env.example | 16 ++- scripts/test-memory-ingestion.ts | 1 + src/app/api/settings/route.ts | 20 ++++ src/app/dashboard/projects/page.tsx | 10 +- src/app/dashboard/settings/page.tsx | 4 +- src/components/settings/model-wizards.tsx | 118 ++++++++++++++++++---- src/lib/storage/settings-store.ts | 18 ++-- src/lib/types.ts | 2 + 8 files changed, 150 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 94ecea8..53c3c33 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,13 @@ # === Eggent Configuration === -# OpenAI (required for default model) -OPENAI_API_KEY=sk-... - -# Anthropic (optional) -ANTHROPIC_API_KEY=sk-ant-... - -# Google (optional) -GOOGLE_API_KEY=... - -# OpenRouter (optional) +# OpenRouter (required — all models route through OpenRouter by default) OPENROUTER_API_KEY=sk-or-... +# Direct provider keys (optional — only needed if you bypass OpenRouter) +# OPENAI_API_KEY=sk-... +# ANTHROPIC_API_KEY=sk-ant-... +# GOOGLE_API_KEY=... + # Tavily Search (optional - for web search) TAVILY_API_KEY=tvly-... diff --git a/scripts/test-memory-ingestion.ts b/scripts/test-memory-ingestion.ts index a8d0af4..9538667 100644 --- a/scripts/test-memory-ingestion.ts +++ b/scripts/test-memory-ingestion.ts @@ -8,6 +8,7 @@ import { AppSettings } from "../src/lib/types"; const mockSettings: AppSettings = { chatModel: { provider: "openai", model: "gpt-4o" }, utilityModel: { provider: "openai", model: "gpt-4o-mini" }, + multimediaModel: { provider: "openai", model: "gpt-4o" }, embeddingsModel: { provider: "mock", model: "text-embedding-3-small", diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index e795b2b..3dcb9eb 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -33,6 +33,12 @@ function maskSettingsKeys(settings: AppSettings): AppSettings { if (masked.chatModel.apiKey) { masked.chatModel.apiKey = maskKey(masked.chatModel.apiKey); } + if (masked.utilityModel.apiKey) { + masked.utilityModel.apiKey = maskKey(masked.utilityModel.apiKey); + } + if (masked.multimediaModel.apiKey) { + masked.multimediaModel.apiKey = maskKey(masked.multimediaModel.apiKey); + } if (masked.embeddingsModel.apiKey) { masked.embeddingsModel.apiKey = maskKey(masked.embeddingsModel.apiKey); } @@ -59,6 +65,20 @@ function restoreMaskedKeys( }; } + if (isMaskedKey(next.utilityModel?.apiKey)) { + next.utilityModel = { + ...(next.utilityModel || {}), + apiKey: current.utilityModel.apiKey, + }; + } + + if (isMaskedKey(next.multimediaModel?.apiKey)) { + next.multimediaModel = { + ...(next.multimediaModel || {}), + apiKey: current.multimediaModel.apiKey, + }; + } + if (isMaskedKey(next.embeddingsModel?.apiKey)) { next.embeddingsModel = { ...(next.embeddingsModel || {}), diff --git a/src/app/dashboard/projects/page.tsx b/src/app/dashboard/projects/page.tsx index fb2a80a..8d2c0ea 100644 --- a/src/app/dashboard/projects/page.tsx +++ b/src/app/dashboard/projects/page.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TelegramIntegrationManager } from "@/components/telegram-integration-manager"; -import { ChatModelWizard, EmbeddingsModelWizard } from "@/components/settings/model-wizards"; +import { ChatModelWizard, UtilityModelWizard, MultimediaModelWizard, EmbeddingsModelWizard } from "@/components/settings/model-wizards"; import type { AppSettings } from "@/lib/types"; import { updateSettingsByPath } from "@/lib/settings/update-settings-path"; import { @@ -656,6 +656,14 @@ function ProjectsPageClient() { settings={settingsDraft} updateSettings={updateOnboardingSettings} /> + + + +
diff --git a/src/components/settings/model-wizards.tsx b/src/components/settings/model-wizards.tsx index 04ce6e5..9b23716 100644 --- a/src/components/settings/model-wizards.tsx +++ b/src/components/settings/model-wizards.tsx @@ -5,7 +5,7 @@ import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { MODEL_PROVIDERS } from "@/lib/providers/model-config"; -import type { AppSettings } from "@/lib/types"; +import type { AppSettings, ModelConfig } from "@/lib/types"; export type UpdateSettingsFn = (path: string, value: unknown) => void; @@ -193,16 +193,26 @@ function useModels( return { models, loading, error }; } -export function ChatModelWizard({ - settings, +// --------------------------------------------------------------------------- +// Generic chat-type model wizard (reused for chat, utility, multimedia) +// --------------------------------------------------------------------------- + +function GenericChatModelWizard({ + title, + description, + settingsKey, + modelConfig, updateSettings, }: { - settings: AppSettings; + title: string; + description?: string; + settingsKey: "chatModel" | "utilityModel" | "multimediaModel"; + modelConfig: ModelConfig; updateSettings: UpdateSettingsFn; }) { - const provider = settings.chatModel.provider; - const apiKey = settings.chatModel.apiKey || ""; - const model = settings.chatModel.model; + const provider = modelConfig.provider; + const apiKey = modelConfig.apiKey || ""; + const model = modelConfig.model; const providerConfig = MODEL_PROVIDERS[provider]; const requiresApiKey = providerConfig?.requiresApiKey ?? true; @@ -226,13 +236,18 @@ export function ChatModelWizard({ apiKey, requiresApiKey, "chat", - settings.chatModel.baseUrl + modelConfig.baseUrl ); return (
-

Chat Model

+
+

{title}

+ {description && ( +

{description}

+ )} +
{requiresApiKey && ( @@ -254,14 +269,14 @@ export function ChatModelWizard({ value={provider} onChange={(event) => { const nextProvider = event.target.value; - updateSettings("chatModel.provider", nextProvider); - updateSettings("chatModel.model", ""); + updateSettings(`${settingsKey}.provider`, nextProvider); + updateSettings(`${settingsKey}.model`, ""); if (nextProvider === "ollama") { - updateSettings("chatModel.baseUrl", "http://localhost:11434/v1"); - updateSettings("chatModel.apiKey", ""); + updateSettings(`${settingsKey}.baseUrl`, "http://localhost:11434/v1"); + updateSettings(`${settingsKey}.apiKey`, ""); } else { - updateSettings("chatModel.baseUrl", ""); + updateSettings(`${settingsKey}.baseUrl`, ""); } }} className="w-full rounded-md border bg-background px-3 py-2 text-sm" @@ -287,7 +302,7 @@ export function ChatModelWizard({ updateSettings("chatModel.apiKey", event.target.value)} + onChange={(event) => updateSettings(`${settingsKey}.apiKey`, event.target.value)} placeholder={ providerConfig?.envKey ? `Enter key or set ${providerConfig.envKey} in .env` @@ -323,8 +338,8 @@ export function ChatModelWizard({ Base URL updateSettings("chatModel.baseUrl", event.target.value)} + value={modelConfig.baseUrl || ""} + onChange={(event) => updateSettings(`${settingsKey}.baseUrl`, event.target.value)} placeholder={ provider === "ollama" ? "http://localhost:11434/v1" @@ -349,7 +364,7 @@ export function ChatModelWizard({ loading={loading} error={error} disabled={!hasApiKey} - onChange={(value) => updateSettings("chatModel.model", value)} + onChange={(value) => updateSettings(`${settingsKey}.model`, value)} placeholder="Select model..." />
@@ -367,9 +382,9 @@ export function ChatModelWizard({ step="0.1" min="0" max="2" - value={settings.chatModel.temperature || 0.7} + value={modelConfig.temperature || 0.7} onChange={(event) => - updateSettings("chatModel.temperature", parseFloat(event.target.value)) + updateSettings(`${settingsKey}.temperature`, parseFloat(event.target.value)) } disabled={!model} className="max-w-[120px]" @@ -379,6 +394,64 @@ export function ChatModelWizard({ ); } +// --------------------------------------------------------------------------- +// Exported wizards +// --------------------------------------------------------------------------- + +export function ChatModelWizard({ + settings, + updateSettings, +}: { + settings: AppSettings; + updateSettings: UpdateSettingsFn; +}) { + return ( + + ); +} + +export function UtilityModelWizard({ + settings, + updateSettings, +}: { + settings: AppSettings; + updateSettings: UpdateSettingsFn; +}) { + return ( + + ); +} + +export function MultimediaModelWizard({ + settings, + updateSettings, +}: { + settings: AppSettings; + updateSettings: UpdateSettingsFn; +}) { + return ( + + ); +} + export function EmbeddingsModelWizard({ settings, updateSettings, @@ -458,7 +531,10 @@ export function EmbeddingsModelWizard({ return (
-

Embeddings Model

+
+

Embeddings Model

+

Model for vector embeddings (memory & RAG)

+
{requiresApiKey && ( diff --git a/src/lib/storage/settings-store.ts b/src/lib/storage/settings-store.ts index 5866489..a07bf60 100644 --- a/src/lib/storage/settings-store.ts +++ b/src/lib/storage/settings-store.ts @@ -16,20 +16,26 @@ async function ensureDir(dir: string) { export const DEFAULT_SETTINGS: AppSettings = { chatModel: { - provider: "openai", - model: "gpt-4o", + provider: "openrouter", + model: "anthropic/claude-opus-4-6", temperature: 0.7, maxTokens: 4096, }, utilityModel: { - provider: "openai", - model: "gpt-4o-mini", + provider: "openrouter", + model: "anthropic/claude-sonnet-4-6", temperature: 0.3, maxTokens: 2048, }, + multimediaModel: { + provider: "openrouter", + model: "google/gemini-2.5-pro-preview-05-06", + temperature: 0.5, + maxTokens: 4096, + }, embeddingsModel: { - provider: "openai", - model: "text-embedding-3-small", + provider: "openrouter", + model: "openai/text-embedding-3-small", dimensions: 1536, }, codeExecution: { diff --git a/src/lib/types.ts b/src/lib/types.ts index efbced2..69c8d67 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -16,6 +16,7 @@ export interface ModelConfig { export interface AppSettings { chatModel: ModelConfig; utilityModel: ModelConfig; + multimediaModel: ModelConfig; embeddingsModel: { provider: "openai" | "openrouter" | "google" | "ollama" | "custom" | "mock"; model: string; @@ -192,6 +193,7 @@ export interface KnowledgeFile { export interface AgentConfig { chatModel: ModelConfig; utilityModel: ModelConfig; + multimediaModel: ModelConfig; embeddingsModel: AppSettings["embeddingsModel"]; memorySubdir: string; knowledgeSubdirs: string[]; From 764997b461cd3885b132b0fa70654f5b805a04cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 19:13:21 +0000 Subject: [PATCH 16/40] feat: switch default embedding model to qwen3-embedding-8b 13x cheaper than OpenAI, #1 on MTEB multilingual, 32K context. Using 1536 dims via MRL for storage/quality balance. Also add Qwen3 and Gemini embedding models to known dimensions map. https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/components/settings/model-wizards.tsx | 4 ++++ src/lib/storage/settings-store.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/settings/model-wizards.tsx b/src/components/settings/model-wizards.tsx index 9b23716..be57a7a 100644 --- a/src/components/settings/model-wizards.tsx +++ b/src/components/settings/model-wizards.tsx @@ -526,6 +526,10 @@ export function EmbeddingsModelWizard({ "gte-large": 1024, "gte-base": 768, "mpnet-base": 768, + "qwen3-embedding-8b": 4096, + "qwen3-embedding-4b": 2048, + "qwen3-embedding-0.6b": 1024, + "gemini-embedding": 3072, }; return ( diff --git a/src/lib/storage/settings-store.ts b/src/lib/storage/settings-store.ts index a07bf60..f4f168d 100644 --- a/src/lib/storage/settings-store.ts +++ b/src/lib/storage/settings-store.ts @@ -35,7 +35,7 @@ export const DEFAULT_SETTINGS: AppSettings = { }, embeddingsModel: { provider: "openrouter", - model: "openai/text-embedding-3-small", + model: "qwen/qwen3-embedding-8b", dimensions: 1536, }, codeExecution: { From 44f3edad718971149a62f3eadcaf0f28a50f3426 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 19:21:31 +0000 Subject: [PATCH 17/40] feat: update multimedia model to gemini-3.1-pro-preview Google's latest SOTA reasoning model with multimodal capabilities. 1M context, audio/image/video input support. https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/lib/storage/settings-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/storage/settings-store.ts b/src/lib/storage/settings-store.ts index f4f168d..24b3645 100644 --- a/src/lib/storage/settings-store.ts +++ b/src/lib/storage/settings-store.ts @@ -29,7 +29,7 @@ export const DEFAULT_SETTINGS: AppSettings = { }, multimediaModel: { provider: "openrouter", - model: "google/gemini-2.5-pro-preview-05-06", + model: "google/gemini-3.1-pro-preview", temperature: 0.5, maxTokens: 4096, }, From a3eca282b240e012a593685072eaa7ef88480102 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 19:28:58 +0000 Subject: [PATCH 18/40] =?UTF-8?q?feat:=20swap=20chat/utility=20models=20?= =?UTF-8?q?=E2=80=94=20Sonnet=20as=20main,=20Opus=20for=20heavy=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chatModel: claude-sonnet-4-6 (everyday conversations, ~5x cheaper) - utilityModel: claude-opus-4-6 (complex reasoning, coding, 8K output) https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/lib/storage/settings-store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/storage/settings-store.ts b/src/lib/storage/settings-store.ts index 24b3645..59c93f9 100644 --- a/src/lib/storage/settings-store.ts +++ b/src/lib/storage/settings-store.ts @@ -17,15 +17,15 @@ async function ensureDir(dir: string) { export const DEFAULT_SETTINGS: AppSettings = { chatModel: { provider: "openrouter", - model: "anthropic/claude-opus-4-6", + model: "anthropic/claude-sonnet-4-6", temperature: 0.7, maxTokens: 4096, }, utilityModel: { provider: "openrouter", - model: "anthropic/claude-sonnet-4-6", + model: "anthropic/claude-opus-4-6", temperature: 0.3, - maxTokens: 2048, + maxTokens: 8192, }, multimediaModel: { provider: "openrouter", From ae00e6a1d5f73ca0525dba739beea759fce38c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 20:06:14 +0000 Subject: [PATCH 19/40] =?UTF-8?q?feat:=20wire=20multi-model=20routing=20?= =?UTF-8?q?=E2=80=94=20Opus=20for=20subordinate=20agents,=20Gemini=20for?= =?UTF-8?q?=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Subordinate agents now use utilityModel (Opus) instead of chatModel - Image attachments auto-route to multimediaModel (Gemini) with vision - Added multimodal message building (base64 images via AI SDK ImagePart) - Chat API, external handler, and Telegram all forward attachments - Regular text messages continue using chatModel (Sonnet) as before https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/app/api/chat/route.ts | 3 +- src/app/api/integrations/telegram/route.ts | 19 ++++- src/lib/agent/agent.ts | 91 ++++++++++++++++----- src/lib/external/handle-external-message.ts | 4 +- 4 files changed, 92 insertions(+), 25 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 839a55a..7bfd5a2 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -15,7 +15,7 @@ export async function POST(req: NextRequest) { try { await ensureCronSchedulerStarted(); const body = await req.json(); - const { chatId, projectId, currentPath } = body; + const { chatId, projectId, currentPath, attachments } = body; let message: string | undefined = body.message; // Support AI SDK's DefaultChatTransport format which sends a `messages` array @@ -82,6 +82,7 @@ export async function POST(req: NextRequest) { userMessage: message, projectId, currentPath: typeof currentPath === "string" ? currentPath : undefined, + attachments: Array.isArray(attachments) ? attachments : undefined, }); // Record usage stats (fire-and-forget, don't block the response) diff --git a/src/app/api/integrations/telegram/route.ts b/src/app/api/integrations/telegram/route.ts index 3de1276..275b965 100644 --- a/src/app/api/integrations/telegram/route.ts +++ b/src/app/api/integrations/telegram/route.ts @@ -663,6 +663,7 @@ export async function POST(req: NextRequest) { name: string; path: string; size: number; + type: string; } | null = null; @@ -684,6 +685,7 @@ export async function POST(req: NextRequest) { name: saved.name, path: saved.path, size: saved.size, + type: saved.type, }; } @@ -736,10 +738,24 @@ export async function POST(req: NextRequest) { } try { + // Build image attachments for vision model routing + const imageAttachments = + incomingSavedFile && incomingSavedFile.type.startsWith("image/") + ? [ + { + name: incomingSavedFile.name, + type: incomingSavedFile.type, + path: incomingSavedFile.path, + }, + ] + : undefined; + const result = await handleExternalMessage({ sessionId, message: incomingSavedFile - ? `${incomingText}\n\nAttached file: ${incomingSavedFile.name}` + ? imageAttachments + ? incomingText || "Describe this image." + : `${incomingText}\n\nAttached file: ${incomingSavedFile.name}` : incomingText, projectId: externalContext?.projectId ?? defaultProjectId, chatId: externalContext?.chatId, @@ -751,6 +767,7 @@ export async function POST(req: NextRequest) { replyToMessageId: messageId ?? null, }, }, + attachments: imageAttachments, }); // Record usage stats for linked app user diff --git a/src/lib/agent/agent.ts b/src/lib/agent/agent.ts index 41af8fd..a8b0fbc 100644 --- a/src/lib/agent/agent.ts +++ b/src/lib/agent/agent.ts @@ -3,6 +3,7 @@ import { generateText, stepCountIs, type ModelMessage, + type UserContent, type ToolExecutionOptions, type ToolSet, } from "ai"; @@ -13,8 +14,9 @@ import { getChat, saveChat } from "@/lib/storage/chat-store"; import { createAgentTools } from "@/lib/tools/tool"; import { getProjectMcpTools } from "@/lib/mcp/client"; import type { AgentContext } from "@/lib/agent/types"; -import type { ChatMessage } from "@/lib/types"; +import type { Attachment, ChatMessage } from "@/lib/types"; import { publishUiSyncEvent } from "@/lib/realtime/event-bus"; +import fs from "fs/promises"; const LLM_LOG_BORDER = "═".repeat(60); @@ -283,6 +285,41 @@ function convertModelMessageToChatMessages(msg: ModelMessage, now: string): Chat }]; } +/** + * Check whether the given attachments include any images. + */ +function hasImages(attachments?: Attachment[]): boolean { + return !!attachments?.some((a) => a.type.startsWith("image/")); +} + +/** + * Build a multimodal user message content array from text + image attachments. + * Falls back to a plain string when there are no image attachments. + */ +async function buildUserContent( + text: string, + attachments?: Attachment[] +): Promise { + if (!hasImages(attachments)) { + return text; + } + + const parts: UserContent = [{ type: "text", text }]; + + for (const att of attachments!) { + if (att.type.startsWith("image/") && att.path) { + const imageData = await fs.readFile(att.path); + parts.push({ + type: "image", + image: imageData, + mediaType: att.type, + }); + } + } + + return parts; +} + function logLLMRequest(options: { model: string; system: string; @@ -325,9 +362,13 @@ export async function runAgent(options: { projectId?: string; currentPath?: string; agentNumber?: number; + attachments?: Attachment[]; }) { const settings = await getSettings(); - const model = createModel(settings.chatModel); + const modelConfig = hasImages(options.attachments) + ? settings.multimediaModel + : settings.chatModel; + const model = createModel(modelConfig); // Build context const context: AgentContext = { @@ -376,19 +417,20 @@ export async function runAgent(options: { tools: toolNames, }); - // Append user message to history + // Append user message to history (multimodal if image attachments present) + const userContent = await buildUserContent(options.userMessage, options.attachments); const messages: ModelMessage[] = [ ...context.history, - { role: "user", content: options.userMessage }, + { role: "user", content: userContent }, ]; logLLMRequest({ - model: `${settings.chatModel.provider}/${settings.chatModel.model}`, + model: `${modelConfig.provider}/${modelConfig.model}`, system: systemPrompt, messages, toolNames, - temperature: settings.chatModel.temperature, - maxTokens: settings.chatModel.maxTokens, + temperature: modelConfig.temperature, + maxTokens: modelConfig.maxTokens, label: "LLM Request (stream)", }); @@ -399,8 +441,8 @@ export async function runAgent(options: { messages, tools, stopWhen: stepCountIs(15), // Allow up to 15 tool call rounds - temperature: settings.chatModel.temperature ?? 0.7, - maxOutputTokens: settings.chatModel.maxTokens ?? 4096, + temperature: modelConfig.temperature ?? 0.7, + maxOutputTokens: modelConfig.maxTokens ?? 4096, onFinish: async (event) => { if (mcpCleanup) { try { @@ -464,9 +506,13 @@ export async function runAgentText(options: { currentPath?: string; agentNumber?: number; runtimeData?: Record; + attachments?: Attachment[]; }): Promise { const settings = await getSettings(); - const model = createModel(settings.chatModel); + const modelConfig = hasImages(options.attachments) + ? settings.multimediaModel + : settings.chatModel; + const model = createModel(modelConfig); const context: AgentContext = { chatId: options.chatId, @@ -507,18 +553,19 @@ export async function runAgentText(options: { tools: toolNames, }); + const userContent = await buildUserContent(options.userMessage, options.attachments); const messages: ModelMessage[] = [ ...context.history, - { role: "user", content: options.userMessage }, + { role: "user", content: userContent }, ]; logLLMRequest({ - model: `${settings.chatModel.provider}/${settings.chatModel.model}`, + model: `${modelConfig.provider}/${modelConfig.model}`, system: systemPrompt, messages, toolNames, - temperature: settings.chatModel.temperature, - maxTokens: settings.chatModel.maxTokens, + temperature: modelConfig.temperature, + maxTokens: modelConfig.maxTokens, label: "LLM Request (non-stream)", }); @@ -529,8 +576,8 @@ export async function runAgentText(options: { messages, tools, stopWhen: stepCountIs(15), - temperature: settings.chatModel.temperature ?? 0.7, - maxOutputTokens: settings.chatModel.maxTokens ?? 4096, + temperature: modelConfig.temperature ?? 0.7, + maxOutputTokens: modelConfig.maxTokens ?? 4096, }); const text = generated.text ?? ""; @@ -598,7 +645,7 @@ export async function runSubordinateAgent(options: { parentHistory: ModelMessage[]; }): Promise { const settings = await getSettings(); - const model = createModel(settings.chatModel); + const model = createModel(settings.utilityModel); const context: AgentContext = { chatId: `subordinate-${Date.now()}`, @@ -644,12 +691,12 @@ export async function runSubordinateAgent(options: { ]; logLLMRequest({ - model: `${settings.chatModel.provider}/${settings.chatModel.model}`, + model: `${settings.utilityModel.provider}/${settings.utilityModel.model}`, system: systemPrompt, messages, toolNames, - temperature: settings.chatModel.temperature, - maxTokens: settings.chatModel.maxTokens, + temperature: settings.utilityModel.temperature, + maxTokens: settings.utilityModel.maxTokens, label: "LLM Request (subordinate)", }); @@ -660,8 +707,8 @@ export async function runSubordinateAgent(options: { messages, tools, stopWhen: stepCountIs(10), - temperature: settings.chatModel.temperature ?? 0.7, - maxOutputTokens: settings.chatModel.maxTokens ?? 4096, + temperature: settings.utilityModel.temperature ?? 0.7, + maxOutputTokens: settings.utilityModel.maxTokens ?? 4096, }); return text; } finally { diff --git a/src/lib/external/handle-external-message.ts b/src/lib/external/handle-external-message.ts index 158c89a..a098d34 100644 --- a/src/lib/external/handle-external-message.ts +++ b/src/lib/external/handle-external-message.ts @@ -7,7 +7,7 @@ import { saveExternalSession, type ExternalSession, } from "@/lib/storage/external-session-store"; -import type { ChatMessage } from "@/lib/types"; +import type { Attachment, ChatMessage } from "@/lib/types"; export interface HandleExternalMessageInput { sessionId: string; @@ -16,6 +16,7 @@ export interface HandleExternalMessageInput { chatId?: string; currentPath?: string; runtimeData?: Record; + attachments?: Attachment[]; } interface SwitchProjectSignal { @@ -258,6 +259,7 @@ export async function handleExternalMessage( projectId: resolvedProjectId, currentPath: currentPath || undefined, runtimeData: input.runtimeData, + attachments: input.attachments, }); const afterChat = await getChat(resolvedChatId); From 7ba250731f0926694d398f3f623c14b6649b45f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 06:17:00 +0000 Subject: [PATCH 20/40] fix: send error messages to Telegram user for all failure types Previously, only ExternalMessageError exceptions were caught and reported back to the Telegram user. All other errors (LLM API failures, missing API keys, timeouts) were silently swallowed, causing the bot to not respond at all. Now the catch-all block sends the error description to the user in Telegram so they can diagnose the issue. https://claude.ai/code/session_01XjFNsYgKjJrENstcSbcoVT --- src/app/api/integrations/telegram/route.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/api/integrations/telegram/route.ts b/src/app/api/integrations/telegram/route.ts index 275b965..9be93c5 100644 --- a/src/app/api/integrations/telegram/route.ts +++ b/src/app/api/integrations/telegram/route.ts @@ -790,7 +790,23 @@ export async function POST(req: NextRequest) { await sendTelegramMessage(botToken, chatId, `Ошибка: ${errorMessage}`, messageId); return Response.json({ ok: true, handledError: true, status: error.status }); } - throw error; + + // Catch-all: send a user-visible error message for unexpected failures + // (e.g. LLM API errors, missing API keys, timeouts, etc.) + console.error("Telegram agent processing error:", error); + const fallbackMessage = + error instanceof Error ? error.message : "Внутренняя ошибка сервера."; + try { + await sendTelegramMessage( + botToken, + chatId, + `Ошибка обработки: ${fallbackMessage}`, + messageId + ); + } catch (sendError) { + console.error("Failed to send error message to Telegram:", sendError); + } + return Response.json({ ok: true, handledError: true, internalError: true }); } } catch (error) { if ( From fa03ecd5c5ac517bf73adca74c109c30fc3e1fdb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 06:33:37 +0000 Subject: [PATCH 21/40] fix: auto-register Telegram webhook on startup and auto-allow first user The Telegram bot was not responding because: 1. The webhook was never registered with Telegram API (setWebhook not called) 2. Empty allowedUserIds list blocked all users without any way to self-onboard Changes: - Add instrumentation.ts that auto-registers the Telegram webhook on app startup when TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, and a base URL are available. Also auto-detects base URL from deployment platforms (Vercel, Railway, Render, Fly.io) when APP_BASE_URL is not set. - Auto-allow the first user who messages the bot in a private chat when the allowedUserIds list is completely empty (no security boundary exists when nobody is configured). https://claude.ai/code/session_01JuWqDF92mQPfXk7fVH4q3K --- instrumentation.ts | 91 ++++++++++++++++++++++ src/app/api/integrations/telegram/route.ts | 64 ++++++++------- 2 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 instrumentation.ts diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..c2a6f66 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,91 @@ +export async function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + + const botToken = (process.env.TELEGRAM_BOT_TOKEN ?? "").trim(); + const webhookSecret = (process.env.TELEGRAM_WEBHOOK_SECRET ?? "").trim(); + + if (!botToken || !webhookSecret) return; + + const baseUrl = inferBaseUrl(); + if (!baseUrl) { + console.warn( + "[Telegram] Skipping auto-webhook: no APP_BASE_URL or deployment URL detected" + ); + return; + } + + const webhookUrl = `${baseUrl}/api/integrations/telegram`; + + try { + const infoRes = await fetch( + `https://api.telegram.org/bot${botToken}/getWebhookInfo` + ); + const info = (await infoRes.json()) as { + ok?: boolean; + result?: { url?: string }; + }; + + if (info.ok && info.result?.url === webhookUrl) { + console.log("[Telegram] Webhook already registered:", webhookUrl); + return; + } + + const res = await fetch( + `https://api.telegram.org/bot${botToken}/setWebhook`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: webhookUrl, + secret_token: webhookSecret, + drop_pending_updates: false, + }), + } + ); + + const data = (await res.json()) as { + ok?: boolean; + description?: string; + }; + + if (data.ok) { + console.log("[Telegram] Webhook auto-registered:", webhookUrl); + } else { + console.warn( + "[Telegram] Webhook auto-registration failed:", + data.description + ); + } + } catch (error) { + console.warn("[Telegram] Webhook auto-registration error:", error); + } +} + +function inferBaseUrl(): string { + const explicit = (process.env.APP_BASE_URL ?? "").trim().replace(/\/+$/, ""); + if (explicit) return explicit; + + // Vercel + const vercelUrl = (process.env.VERCEL_URL ?? "").trim(); + if (vercelUrl) return `https://${vercelUrl}`; + + // Railway + const railwayUrl = ( + process.env.RAILWAY_PUBLIC_DOMAIN ?? + process.env.RAILWAY_STATIC_URL ?? + "" + ).trim(); + if (railwayUrl) { + return railwayUrl.startsWith("http") ? railwayUrl : `https://${railwayUrl}`; + } + + // Render + const renderUrl = (process.env.RENDER_EXTERNAL_URL ?? "").trim(); + if (renderUrl) return renderUrl; + + // Fly.io + const flyApp = (process.env.FLY_APP_NAME ?? "").trim(); + if (flyApp) return `https://${flyApp}.fly.dev`; + + return ""; +} diff --git a/src/app/api/integrations/telegram/route.ts b/src/app/api/integrations/telegram/route.ts index 275b965..260972f 100644 --- a/src/app/api/integrations/telegram/route.ts +++ b/src/app/api/integrations/telegram/route.ts @@ -18,6 +18,7 @@ import { consumeTelegramAccessCode, getTelegramIntegrationRuntimeConfig, normalizeTelegramUserId, + saveTelegramIntegrationStoredSettings, } from "@/lib/storage/telegram-integration-store"; import { saveChatFile } from "@/lib/storage/chat-files-store"; import { createChat, getChat } from "@/lib/storage/chat-store"; @@ -563,44 +564,53 @@ export async function POST(req: NextRequest) { } if (!allowedUserIds.has(fromUserId)) { - const accessCode = extractAccessCodeCandidate(text); - const granted = - accessCode && - (await consumeTelegramAccessCode({ - code: accessCode, - userId: fromUserId, - })); + // Auto-allow the first user in private chat when no users are configured + if (allowedUserIds.size === 0 && chatType === "private") { + await saveTelegramIntegrationStoredSettings({ + allowedUserIds: [fromUserId], + }); + allowedUserIds.add(fromUserId); + console.log(`[Telegram] Auto-allowed first user: ${fromUserId}`); + } else { + const accessCode = extractAccessCodeCandidate(text); + const granted = + accessCode && + (await consumeTelegramAccessCode({ + code: accessCode, + userId: fromUserId, + })); + + if (granted) { + await sendTelegramMessage( + botToken, + chatId, + "Доступ выдан. Теперь можно отправлять сообщения агенту.", + messageId + ); + return Response.json({ + ok: true, + accessGranted: true, + userId: fromUserId, + }); + } - if (granted) { await sendTelegramMessage( botToken, chatId, - "Доступ выдан. Теперь можно отправлять сообщения агенту.", + [ + "Доступ запрещён: ваш user_id не в списке разрешённых.", + "Отправьте код активации командой /code <код> или /start <код>.", + `Ваш user_id: ${fromUserId}`, + ].join("\n"), messageId ); return Response.json({ ok: true, - accessGranted: true, + ignored: true, + reason: "user_not_allowed", userId: fromUserId, }); } - - await sendTelegramMessage( - botToken, - chatId, - [ - "Доступ запрещён: ваш user_id не в списке разрешённых.", - "Отправьте код активации командой /code <код> или /start <код>.", - `Ваш user_id: ${fromUserId}`, - ].join("\n"), - messageId - ); - return Response.json({ - ok: true, - ignored: true, - reason: "user_not_allowed", - userId: fromUserId, - }); } // Resolve app user linked to this Telegram account (if any) From 43930ae40bb2cfbf208b680eb1f1a4f200b5f5a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 06:40:06 +0000 Subject: [PATCH 22/40] fix: eliminate duplicate chat responses and unnecessary code execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues fixed: 1. Simple questions (weather, jokes, etc.) triggered code_execution unnecessarily because the system prompt pushed the model to always use tools. Updated prompts to instruct the model to respond directly with text for simple questions. 2. The model produced duplicate response bubbles because the `response` tool caused an extra agent round-trip — the model would call response tool, get the result back, then generate another text message. Fixed by: - Removing the response tool entirely (model now responds with text directly) - Merging consecutive assistant messages in both storage (onFinish) and rendering (chatMessagesToUIMessages) to prevent duplicate bubbles https://claude.ai/code/session_016D6aN4HLJeTQkNSYRrprsv --- src/components/chat/chat-panel.tsx | 16 +++++++++--- src/components/chat/tool-output.tsx | 4 --- src/lib/agent/agent.ts | 39 ++++++++++++++++++++++++++--- src/lib/agent/prompts.ts | 15 +++++------ src/lib/tools/tool.ts | 14 ----------- src/prompts/system.md | 14 ++++++++--- src/prompts/tool-response.md | 11 -------- 7 files changed, 67 insertions(+), 46 deletions(-) delete mode 100644 src/prompts/tool-response.md diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index 895ffe4..261e9e4 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -11,7 +11,10 @@ import type { ChatMessage } from "@/lib/types"; import { useBackgroundSync } from "@/hooks/use-background-sync"; import { generateClientId } from "@/lib/utils"; -/** Convert stored ChatMessage to UIMessage (parts format for useChat) */ +/** Convert stored ChatMessage to UIMessage (parts format for useChat). + * Consecutive assistant messages are merged into a single UIMessage so the + * multi-step agent loop does not produce duplicate bubbles in the UI. + */ function chatMessagesToUIMessages(chatMessages: ChatMessage[]): UIMessage[] { const result: UIMessage[] = []; @@ -53,8 +56,15 @@ function chatMessagesToUIMessages(chatMessages: ChatMessage[]): UIMessage[] { parts.push({ type: "text" as const, text: m.content }); } - // Only add message if it has content - if (parts.length > 0) { + if (parts.length === 0) continue; + + // Merge into the previous assistant message when possible so that + // multi-step agent turns (tool call → result → next text) appear as a + // single message bubble instead of duplicated responses. + const prev = result.length > 0 ? result[result.length - 1] : null; + if (prev && prev.role === "assistant") { + prev.parts = [...prev.parts, ...parts]; + } else { result.push({ id: m.id, role: "assistant", diff --git a/src/components/chat/tool-output.tsx b/src/components/chat/tool-output.tsx index 5ce8930..b6df061 100644 --- a/src/components/chat/tool-output.tsx +++ b/src/components/chat/tool-output.tsx @@ -65,7 +65,6 @@ const TOOL_LABELS: Record = { get_current_project: "Current Project", switch_project: "Switch Project", create_project: "Create Project", - response: "Response", }; export function ToolOutput({ toolName, args, result }: ToolOutputProps) { @@ -73,9 +72,6 @@ export function ToolOutput({ toolName, args, result }: ToolOutputProps) { const Icon = TOOL_ICONS[toolName] || Terminal; const label = TOOL_LABELS[toolName] || toolName; - // Don't render the response tool visually - if (toolName === "response") return null; - return (