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