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/Dockerfile b/Dockerfile
index 63b21ad..2fcdf49 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,6 +15,7 @@ FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+ENV HOSTNAME=0.0.0.0
ENV PYTHON_VENV=/opt/eggent-python
ENV PATH="${PYTHON_VENV}/bin:${PATH}"
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
@@ -24,38 +25,45 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/app/data/ms-playwright
ENV npm_config_cache=/app/data/npm-cache
ENV XDG_CACHE_HOME=/app/data/.cache
-RUN mkdir -p "${TMPDIR}" "${PLAYWRIGHT_BROWSERS_PATH}" "${npm_config_cache}" "${XDG_CACHE_HOME}"
-
-RUN apt-get update \
+RUN mkdir -p /app/data/tmp \
+ && apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
+ gosu \
jq \
python3 \
- python3-requests \
+ python3-pip \
python3-venv \
sudo \
ripgrep \
- && python3 -m venv --system-site-packages "${PYTHON_VENV}" \
- && "${PYTHON_VENV}/bin/python3" -m pip --version \
+ && python3 -m venv "${PYTHON_VENV}" \
+ && "${PYTHON_VENV}/bin/pip" install requests httpx pillow \
+ && curl -LsSf https://astral.sh/uv/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh \
+ && mv /root/.local/bin/uv /usr/local/bin/uv \
+ && mv /root/.local/bin/uvx /usr/local/bin/uvx \
&& 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
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/bundled-skills ./bundled-skills
+COPY --from=builder /app/src/prompts ./src/prompts
+
+# Only chown /app/data — the sole writable directory at runtime.
+# Everything else (node_modules, .next, etc.) is read-only and stays root-owned.
+RUN mkdir -p /app/data/tmp /app/data/settings /app/data/projects /app/data/chats \
+ && chown -R node:node /app/data
-RUN mkdir -p /app/data/tmp /app/data/ms-playwright /app/data/npm-cache /app/data/.cache \
- && chown -R node:node /app "${PYTHON_VENV}"
+COPY entrypoint.sh /app/entrypoint.sh
+RUN chmod +x /app/entrypoint.sh
-USER node
+# Start as root so the entrypoint can fix volume permissions, then drop to node
EXPOSE 3000
+ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["npm", "run", "start"]
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 пользователей.
diff --git a/_config.yml b/_config.yml
new file mode 100644
index 0000000..efc043b
--- /dev/null
+++ b/_config.yml
@@ -0,0 +1,11 @@
+# Jekyll configuration
+# 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: ""
+ values:
+ render_with_liquid: false
diff --git a/bundled-skills/nano-banana-pro/SKILL.md b/bundled-skills/nano-banana-pro/SKILL.md
index 7c54cc9..2b7a929 100644
--- a/bundled-skills/nano-banana-pro/SKILL.md
+++ b/bundled-skills/nano-banana-pro/SKILL.md
@@ -1,14 +1,14 @@
---
name: nano-banana-pro
-description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro).
-homepage: https://ai.google.dev/
+description: Generate or edit images via Gemini 3.1 Flash Image through OpenRouter (Nano Banana Pro).
+homepage: https://openrouter.ai/
metadata:
{
"eggent":
{
"emoji": "🍌",
- "requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] },
- "primaryEnv": "GEMINI_API_KEY",
+ "requires": { "bins": ["uv"], "env": ["OPENROUTER_API_KEY"] },
+ "primaryEnv": "OPENROUTER_API_KEY",
"install":
[
{
@@ -23,9 +23,9 @@ metadata:
}
---
-# Nano Banana Pro (Gemini 3 Pro Image)
+# Nano Banana Pro (Gemini 3.1 Flash Image via OpenRouter)
-Use the bundled script to generate or edit images.
+Use the bundled script to generate or edit images. Requests are routed through the shared OpenRouter API key instead of a direct Google Gemini key.
Generate
@@ -45,14 +45,21 @@ Multi-image composition (up to 14 images)
uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png
```
+Override model (optional)
+
+```bash
+uv run {baseDir}/scripts/generate_image.py --prompt "description" --filename "output.png" --model "google/gemini-3.1-flash-image-preview"
+```
+
API key
-- `GEMINI_API_KEY` env var
-- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.eggent/eggent.json`
+- `OPENROUTER_API_KEY` env var (shared key for all OpenRouter models)
+- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.OPENROUTER_API_KEY` in `~/.eggent/eggent.json`
Notes
-- Resolutions: `1K` (default), `2K`, `4K`.
+- Resolutions: `0.5K`, `1K` (default), `2K`, `4K`. (`0.5K` is exclusive to this model.)
- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`.
- The script prints a `MEDIA:` line for eggent to auto-attach on supported chat providers.
- Do not read the image back; report the saved path only.
+- Default model: `google/gemini-3.1-flash-image-preview` (can be overridden with `--model`).
diff --git a/bundled-skills/nano-banana-pro/scripts/generate_image.py b/bundled-skills/nano-banana-pro/scripts/generate_image.py
index 16b56ef..f458817 100755
--- a/bundled-skills/nano-banana-pro/scripts/generate_image.py
+++ b/bundled-skills/nano-banana-pro/scripts/generate_image.py
@@ -2,12 +2,12 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
-# "google-genai>=1.0.0",
+# "httpx>=0.27.0",
# "pillow>=10.0.0",
# ]
# ///
"""
-Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
+Generate images using Gemini 3.1 Flash Image via OpenRouter API (Nano Banana Pro).
Usage:
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
@@ -17,21 +17,54 @@
"""
import argparse
+import base64
+import json
import os
import sys
+from io import BytesIO
from pathlib import Path
+OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
+DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"
+
def get_api_key(provided_key: str | None) -> str | None:
"""Get API key from argument first, then environment."""
if provided_key:
return provided_key
- return os.environ.get("GEMINI_API_KEY")
+ return os.environ.get("OPENROUTER_API_KEY")
+
+
+def image_to_base64_url(img_path: str) -> tuple[str, tuple[int, int]]:
+ """Convert image file to base64 data URL, return (data_url, (width, height))."""
+ from PIL import Image as PILImage
+
+ img = PILImage.open(img_path)
+ size = img.size
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ b64 = base64.b64encode(buf.getvalue()).decode()
+ return f"data:image/png;base64,{b64}", size
+
+
+def save_image(image_bytes: bytes, output_path: Path):
+ """Decode image bytes and save as PNG."""
+ from PIL import Image as PILImage
+
+ image = PILImage.open(BytesIO(image_bytes))
+ if image.mode == 'RGBA':
+ rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
+ rgb_image.paste(image, mask=image.split()[3])
+ rgb_image.save(str(output_path), 'PNG')
+ elif image.mode == 'RGB':
+ image.save(str(output_path), 'PNG')
+ else:
+ image.convert('RGB').save(str(output_path), 'PNG')
def main():
parser = argparse.ArgumentParser(
- description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
+ description="Generate images using Nano Banana Pro (Gemini 3.1 Flash Image via OpenRouter)"
)
parser.add_argument(
"--prompt", "-p",
@@ -52,13 +85,18 @@ def main():
)
parser.add_argument(
"--resolution", "-r",
- choices=["1K", "2K", "4K"],
+ choices=["0.5K", "1K", "2K", "4K"],
default="1K",
- help="Output resolution: 1K (default), 2K, or 4K"
+ help="Output resolution: 0.5K, 1K (default), 2K, or 4K"
)
parser.add_argument(
"--api-key", "-k",
- help="Gemini API key (overrides GEMINI_API_KEY env var)"
+ help="OpenRouter API key (overrides OPENROUTER_API_KEY env var)"
+ )
+ parser.add_argument(
+ "--model", "-m",
+ default=DEFAULT_MODEL,
+ help=f"OpenRouter model ID (default: {DEFAULT_MODEL})"
)
args = parser.parse_args()
@@ -69,24 +107,21 @@ def main():
print("Error: No API key provided.", file=sys.stderr)
print("Please either:", file=sys.stderr)
print(" 1. Provide --api-key argument", file=sys.stderr)
- print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr)
+ print(" 2. Set OPENROUTER_API_KEY environment variable", file=sys.stderr)
sys.exit(1)
# Import here after checking API key to avoid slow import on error
- from google import genai
- from google.genai import types
- from PIL import Image as PILImage
-
- # Initialise client
- client = genai.Client(api_key=api_key)
+ import httpx
# Set up output path
output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
- # Load input images if provided (up to 14 supported by Nano Banana Pro)
- input_images = []
+ # Build message content
output_resolution = args.resolution
+ content_parts = []
+
+ # Load input images if provided (up to 14 supported)
if args.input_images:
if len(args.input_images) > 14:
print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
@@ -95,19 +130,19 @@ def main():
max_input_dim = 0
for img_path in args.input_images:
try:
- img = PILImage.open(img_path)
- input_images.append(img)
+ data_url, (width, height) = image_to_base64_url(img_path)
+ content_parts.append({
+ "type": "image_url",
+ "image_url": {"url": data_url}
+ })
print(f"Loaded input image: {img_path}")
-
- # Track largest dimension for auto-resolution
- width, height = img.size
max_input_dim = max(max_input_dim, width, height)
except Exception as e:
print(f"Error loading input image '{img_path}': {e}", file=sys.stderr)
sys.exit(1)
# Auto-detect resolution from largest input if not explicitly set
- if args.resolution == "1K" and max_input_dim > 0: # Default value
+ if args.resolution == "1K" and max_input_dim > 0:
if max_input_dim >= 3000:
output_resolution = "4K"
elif max_input_dim >= 1500:
@@ -116,67 +151,109 @@ def main():
output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
- # Build contents (images first if editing, prompt only if generating)
- if input_images:
- contents = [*input_images, args.prompt]
- img_count = len(input_images)
- print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
+ # Add text prompt
+ content_parts.append({"type": "text", "text": args.prompt})
+
+ # Build request payload per OpenRouter image generation API:
+ # - modalities: ["image", "text"] (image first)
+ # - image_config.image_size for resolution control
+ payload = {
+ "model": args.model,
+ "messages": [
+ {"role": "user", "content": content_parts}
+ ],
+ "modalities": ["image", "text"],
+ "image_config": {
+ "image_size": output_resolution,
+ },
+ "stream": False,
+ }
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://eggent.app",
+ "X-Title": "Eggent Nano Banana Pro",
+ }
+
+ img_count = len(args.input_images) if args.input_images else 0
+ if args.input_images:
+ print(f"Processing {img_count} image{'s' if img_count > 1 else ''} at {output_resolution} via OpenRouter ({args.model})...")
else:
- contents = args.prompt
- print(f"Generating image with resolution {output_resolution}...")
+ print(f"Generating image at {output_resolution} via OpenRouter ({args.model})...")
try:
- response = client.models.generate_content(
- model="gemini-3-pro-image-preview",
- contents=contents,
- config=types.GenerateContentConfig(
- response_modalities=["TEXT", "IMAGE"],
- image_config=types.ImageConfig(
- image_size=output_resolution
- )
- )
+ resp = httpx.post(
+ OPENROUTER_URL,
+ headers=headers,
+ json=payload,
+ timeout=120.0,
)
+ resp.raise_for_status()
+ data = resp.json()
+ except httpx.HTTPStatusError as e:
+ print(f"API error {e.response.status_code}: {e.response.text}", file=sys.stderr)
+ sys.exit(1)
+ except Exception as e:
+ print(f"Request error: {e}", file=sys.stderr)
+ sys.exit(1)
- # Process response and convert to PNG
- image_saved = False
- for part in response.parts:
- if part.text is not None:
- print(f"Model response: {part.text}")
- elif part.inline_data is not None:
- # Convert inline data to PIL Image and save as PNG
- from io import BytesIO
-
- # inline_data.data is already bytes, not base64
- image_data = part.inline_data.data
- if isinstance(image_data, str):
- # If it's a string, it might be base64
- import base64
- image_data = base64.b64decode(image_data)
-
- image = PILImage.open(BytesIO(image_data))
-
- # Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed)
- if image.mode == 'RGBA':
- rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
- rgb_image.paste(image, mask=image.split()[3])
- rgb_image.save(str(output_path), 'PNG')
- elif image.mode == 'RGB':
- image.save(str(output_path), 'PNG')
- else:
- image.convert('RGB').save(str(output_path), 'PNG')
- image_saved = True
-
- if image_saved:
- full_path = output_path.resolve()
- print(f"\nImage saved: {full_path}")
- # eggent parses MEDIA tokens and will attach the file on supported providers.
- print(f"MEDIA: {full_path}")
- else:
- print("Error: No image was generated in the response.", file=sys.stderr)
- sys.exit(1)
+ # Parse response
+ image_saved = False
+ choices = data.get("choices", [])
+ if not choices:
+ print("Error: Empty response from API.", file=sys.stderr)
+ print(f"Response: {json.dumps(data, indent=2)}", file=sys.stderr)
+ sys.exit(1)
- except Exception as e:
- print(f"Error generating image: {e}", file=sys.stderr)
+ message = choices[0].get("message", {})
+
+ # Print text content if present
+ content = message.get("content", "")
+ if isinstance(content, str) and content:
+ print(f"Model response: {content}")
+ elif isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "text":
+ text = part.get("text", "")
+ if text:
+ print(f"Model response: {text}")
+
+ # Images are returned in message.images[] as base64 data URLs
+ images = message.get("images", [])
+ for img_part in images:
+ if not isinstance(img_part, dict):
+ continue
+ image_url = img_part.get("image_url", {}).get("url", "")
+ if image_url.startswith("data:"):
+ _header, b64_data = image_url.split(",", 1)
+ image_bytes = base64.b64decode(b64_data)
+ save_image(image_bytes, output_path)
+ image_saved = True
+ break
+
+ # Fallback: some responses may put images inside content array
+ if not image_saved and isinstance(content, list):
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+ if part.get("type") == "image_url":
+ image_url = part.get("image_url", {}).get("url", "")
+ if image_url.startswith("data:"):
+ _header, b64_data = image_url.split(",", 1)
+ image_bytes = base64.b64decode(b64_data)
+ save_image(image_bytes, output_path)
+ image_saved = True
+ break
+
+ if image_saved:
+ full_path = output_path.resolve()
+ print(f"\nImage saved: {full_path}")
+ # eggent parses MEDIA tokens and will attach the file on supported providers.
+ print(f"MEDIA: {full_path}")
+ else:
+ print("Error: No image was generated in the response.", file=sys.stderr)
+ print(f"Full response: {json.dumps(data, indent=2)}", file=sys.stderr)
sys.exit(1)
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 %}
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 %}
diff --git a/bundled-skills/remotion/rules/charts.md b/bundled-skills/remotion/rules/charts.md
index f036485..b81b8e5 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 %}
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100755
index 0000000..cf5a46d
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+set -e
+
+DATA_DIRS="/app/data/tmp /app/data/settings /app/data/projects /app/data/chats"
+
+if [ "$(id -u)" = "0" ]; then
+ # Running as root — create data subdirectories, fix permissions, then drop to "node"
+ mkdir -p $DATA_DIRS 2>/dev/null || true
+ chown -R node:node /app/data 2>/dev/null || true
+ exec gosu node "$@"
+else
+ # Already running as non-root — just make sure subdirectories exist
+ mkdir -p $DATA_DIRS 2>/dev/null || true
+ exec "$@"
+fi
diff --git a/instrumentation.ts b/instrumentation.ts
new file mode 100644
index 0000000..46d504b
--- /dev/null
+++ b/instrumentation.ts
@@ -0,0 +1,95 @@
+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 ensureProtocol(url: string): string {
+ return url.startsWith("http") ? url : `https://${url}`;
+}
+
+function inferBaseUrl(): string {
+ const explicit = (process.env.APP_BASE_URL ?? "").trim().replace(/\/+$/, "");
+ if (explicit) return ensureProtocol(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/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 c7b0a30..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.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",
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/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..7bfd5a2 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
@@ -9,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
@@ -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);
}
}
@@ -54,8 +82,18 @@ 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)
+ 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/files/download/route.ts b/src/app/api/files/download/route.ts
index ed836fd..a7a30aa 100644
--- a/src/app/api/files/download/route.ts
+++ b/src/app/api/files/download/route.ts
@@ -3,9 +3,24 @@ import fs from "fs/promises";
import path from "path";
import { getWorkDir } from "@/lib/storage/project-store";
+const INLINE_MIME_TYPES: Record = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".txt": "text/plain",
+ ".md": "text/markdown",
+ ".json": "application/json",
+ ".csv": "text/csv",
+ ".pdf": "application/pdf",
+};
+
export async function GET(req: NextRequest) {
const projectId = req.nextUrl.searchParams.get("project");
const filePath = req.nextUrl.searchParams.get("path");
+ const inline = req.nextUrl.searchParams.get("inline") === "1";
if (!projectId || !filePath) {
return Response.json(
@@ -30,11 +45,18 @@ export async function GET(req: NextRequest) {
try {
const content = await fs.readFile(fullPath);
const fileName = path.basename(filePath);
+ const ext = path.extname(fileName).toLowerCase();
+ const mimeType = INLINE_MIME_TYPES[ext] || "application/octet-stream";
+
+ const disposition = inline && INLINE_MIME_TYPES[ext]
+ ? `inline; filename="${fileName}"`
+ : `attachment; filename="${fileName}"`;
return new Response(content, {
headers: {
- "Content-Disposition": `attachment; filename="${fileName}"`,
- "Content-Type": "application/octet-stream",
+ "Content-Disposition": disposition,
+ "Content-Type": inline && INLINE_MIME_TYPES[ext] ? mimeType : "application/octet-stream",
+ "Cache-Control": "private, max-age=3600",
},
});
} catch {
diff --git a/src/app/api/integrations/telegram/route.ts b/src/app/api/integrations/telegram/route.ts
index 542835c..d90bc65 100644
--- a/src/app/api/integrations/telegram/route.ts
+++ b/src/app/api/integrations/telegram/route.ts
@@ -18,9 +18,16 @@ 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";
+import { getUserByTelegramId } from "@/lib/storage/users-store";
+import {
+ checkDailyQuota,
+ checkMonthlyTokenQuota,
+ recordMessageUsage,
+} from "@/lib/storage/usage-stats-store";
import {
contextKey,
type ExternalSession,
@@ -160,6 +167,7 @@ function chatBelongsToProject(
async function ensureTelegramExternalChatContext(params: {
sessionId: string;
defaultProjectId?: string;
+ userId?: string;
}): Promise {
const { session, resolvedProjectId } = await resolveTelegramProjectContext({
sessionId: params.sessionId,
@@ -179,7 +187,8 @@ async function ensureTelegramExternalChatContext(params: {
await createChat(
resolvedChatId,
`External session ${session.id}`,
- resolvedProjectId
+ resolvedProjectId,
+ params.userId
);
}
@@ -207,10 +216,7 @@ async function resolveTelegramProjectContext(params: {
let resolvedProjectId: string | undefined;
const explicitProjectId = params.defaultProjectId?.trim() || "";
- if (explicitProjectId) {
- if (!projectById.has(explicitProjectId)) {
- throw new Error(`Project "${explicitProjectId}" not found`);
- }
+ if (explicitProjectId && projectById.has(explicitProjectId)) {
resolvedProjectId = explicitProjectId;
session.activeProjectId = explicitProjectId;
} else if (session.activeProjectId && projectById.has(session.activeProjectId)) {
@@ -555,44 +561,68 @@ 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,
});
}
+ }
+ // 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,
- [
- "Доступ запрещён: ваш user_id не в списке разрешённых.",
- "Отправьте код активации командой /code <код> или /start <код>.",
- `Ваш user_id: ${fromUserId}`,
- ].join("\n"),
+ "Доступ к Telegram-боту отключён для вашего аккаунта. Обратитесь к администратору.",
messageId
);
- return Response.json({
- ok: true,
- ignored: true,
- reason: "user_not_allowed",
- userId: fromUserId,
- });
+ return Response.json({ ok: true, ignored: true, reason: "telegram_permission_denied" });
}
let sessionId = await getTelegramChatSessionId(botId, chatId);
@@ -640,6 +670,7 @@ export async function POST(req: NextRequest) {
name: string;
path: string;
size: number;
+ type: string;
}
| null = null;
@@ -649,6 +680,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(
@@ -660,6 +692,7 @@ export async function POST(req: NextRequest) {
name: saved.name,
path: saved.path,
size: saved.size,
+ type: saved.type,
};
}
@@ -687,11 +720,49 @@ 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 {
+ // 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,
@@ -703,8 +774,18 @@ export async function POST(req: NextRequest) {
replyToMessageId: messageId ?? null,
},
},
+ attachments: imageAttachments,
});
+ // 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) {
@@ -716,7 +797,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 (
diff --git a/src/app/api/media/serve/route.ts b/src/app/api/media/serve/route.ts
new file mode 100644
index 0000000..a5bda81
--- /dev/null
+++ b/src/app/api/media/serve/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest } from "next/server";
+import fs from "fs/promises";
+import path from "path";
+
+const DATA_DIR = path.resolve(process.cwd(), "data");
+
+const MIME_TYPES: Record = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+};
+
+const ALLOWED_EXTENSIONS = new Set(Object.keys(MIME_TYPES));
+
+export async function GET(req: NextRequest) {
+ const filePath = req.nextUrl.searchParams.get("path");
+
+ if (!filePath) {
+ return Response.json({ error: "path parameter required" }, { status: 400 });
+ }
+
+ // Security: only allow image extensions
+ const ext = path.extname(filePath).toLowerCase();
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
+ return Response.json({ error: "Only image files are allowed" }, { status: 403 });
+ }
+
+ // Security: resolve and ensure the file is within the data directory
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(DATA_DIR + path.sep) && !resolved.startsWith(DATA_DIR)) {
+ return Response.json({ error: "Access denied" }, { status: 403 });
+ }
+
+ try {
+ const stat = await fs.stat(resolved);
+ if (!stat.isFile()) {
+ return Response.json({ error: "Not a file" }, { status: 404 });
+ }
+
+ const content = await fs.readFile(resolved);
+ const mimeType = MIME_TYPES[ext] || "application/octet-stream";
+
+ return new Response(content, {
+ headers: {
+ "Content-Type": mimeType,
+ "Content-Length": String(stat.size),
+ "Cache-Control": "private, max-age=3600",
+ },
+ });
+ } catch {
+ return Response.json({ error: "File not found" }, { status: 404 });
+ }
+}
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/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/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/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/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..a9ce660 100644
--- a/src/components/chat/tool-output.tsx
+++ b/src/components/chat/tool-output.tsx
@@ -12,6 +12,8 @@ import {
Puzzle,
CalendarClock,
FolderOpen,
+ ImageIcon,
+ Download,
} from "lucide-react";
import { CodeBlock } from "./code-block";
@@ -65,16 +67,74 @@ const TOOL_LABELS: Record = {
get_current_project: "Current Project",
switch_project: "Switch Project",
create_project: "Create Project",
- response: "Response",
};
+const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
+
+function extractMediaPaths(text: string): { mediaPaths: string[]; cleanedText: string } {
+ const lines = text.split("\n");
+ const mediaPaths: string[] = [];
+ const cleanedLines: string[] = [];
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed.startsWith("MEDIA:")) {
+ const filePath = trimmed.slice(6).trim();
+ if (filePath) {
+ const dotIdx = filePath.lastIndexOf(".");
+ if (dotIdx >= 0) {
+ const ext = filePath.slice(dotIdx).toLowerCase();
+ if (IMAGE_EXTENSIONS.has(ext)) {
+ mediaPaths.push(filePath);
+ continue;
+ }
+ }
+ }
+ }
+ cleanedLines.push(line);
+ }
+
+ return { mediaPaths, cleanedText: cleanedLines.join("\n") };
+}
+
+function MediaImage({ filePath }: { filePath: string }) {
+ const src = `/api/media/serve?path=${encodeURIComponent(filePath)}`;
+ const fileName = filePath.split("/").pop() || "image";
+
+ return (
+
+ );
+}
+
export function ToolOutput({ toolName, args, result }: ToolOutputProps) {
const [expanded, setExpanded] = useState(false);
const Icon = TOOL_ICONS[toolName] || Terminal;
const label = TOOL_LABELS[toolName] || toolName;
- // Don't render the response tool visually
- if (toolName === "response") return null;
+ const { mediaPaths, cleanedText } = extractMediaPaths(result);
+ const hasImages = mediaPaths.length > 0;
return (
@@ -101,6 +161,15 @@ export function ToolOutput({ toolName, args, result }: ToolOutputProps) {
) : null}
+ {/* Always show generated images, even when collapsed */}
+ {hasImages && (
+
+ {mediaPaths.map((filePath, idx) => (
+
+ ))}
+
+ )}
+
{expanded && (
{/* Tool arguments */}
@@ -118,13 +187,13 @@ export function ToolOutput({ toolName, args, result }: ToolOutputProps) {
) : null}
{/* Tool result */}
- {result ? (
+ {cleanedText.trim() ? (
Output:
- {result}
+ {cleanedText.trim()}
) : null}
diff --git a/src/components/file-tree.tsx b/src/components/file-tree.tsx
index 30b7f0c..52c3bda 100644
--- a/src/components/file-tree.tsx
+++ b/src/components/file-tree.tsx
@@ -9,6 +9,7 @@ import {
FileText,
FileCode,
File,
+ FileImage,
Download,
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
@@ -21,6 +22,13 @@ interface FileEntry {
size: number;
}
+const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
+
+function isImageFile(name: string): boolean {
+ const ext = name.split(".").pop()?.toLowerCase() || "";
+ return IMAGE_EXTENSIONS.has(ext);
+}
+
function getFileIcon(name: string) {
const ext = name.split(".").pop()?.toLowerCase();
switch (ext) {
@@ -38,6 +46,13 @@ function getFileIcon(name: string) {
case "txt":
case "csv":
return FileText;
+ case "png":
+ case "jpg":
+ case "jpeg":
+ case "gif":
+ case "webp":
+ case "svg":
+ return FileImage;
default:
return File;
}
@@ -127,6 +142,14 @@ function TreeNode({
void loadChildren(true, true);
}
setCurrentPath(relativePath);
+ } else if (type === "file") {
+ // Open file: images and viewable files inline, others download
+ const params = new URLSearchParams({
+ project: projectId,
+ path: relativePath,
+ inline: "1",
+ });
+ window.open(`/api/files/download?${params.toString()}`, "_blank");
}
};
@@ -139,7 +162,7 @@ function TreeNode({