commit d0de81d0a7fa636e1c53b89dd08df0eb698afecb Author: InWorkUser Date: Fri Aug 14 11:06:58 2026 +0700 Initial Helpomatica source. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..252d871 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.deps +.venv +venv +data +.env +.git +.gitignore +__pycache__/ +*.py[cod] +*.log +*.out +*.err +.isolated/ +.idea/ +.vscode/ +.DS_Store +Thumbs.db +agent-transcripts/ +test_respond.py +test_preanalysis.py +ok_case.err +ok_case2.err diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae25bd6 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +CURSOR_API_KEY=crsr_your_key_here +CURSOR_MODEL=auto + +# Bootstrap admin (created on first start if users list is empty) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin + +# Optional: unused placeholder for future signed cookies (not read by main.py) +SESSION_SECRET=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ba80f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Local Python installs / venvs +.deps/ +.venv/ +venv/ +__pycache__/ +*.pyc +*.py[cod] +*.pyo + +# Secrets — never push +.env + +# Runtime data (chats, uploads, db.json). Keep the empty dir in git. +data/* +!data/.gitkeep + +# Logs and test leftovers +*.log +*.log.* +*.out +*.err + +# OS junk +.DS_Store +Thumbs.db + +# Editor / local scratch +.isolated/ +.idea/ +.vscode/ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1ff9bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.12-slim + +WORKDIR /app + +# OS libs for the cursor-sdk Linux wheel: it vendors Node + cursor-sdk-bridge.js +# (see cursor_sdk/_vendor/bridge/). ca-certificates for Cursor API HTTPS. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libstdc++6 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir --upgrade pip \ + && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt + +ENV PATH="/opt/venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +COPY main.py log_preanalysis.py ./ +COPY static ./static + +EXPOSE 8010 + +# GET / serves the login page without auth (unlike /api/auth/me, which is 401). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/', timeout=4)" + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8010"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a0d4c6 --- /dev/null +++ b/README.md @@ -0,0 +1,365 @@ +# Helpomatica + +Локальная веб-панель в духе Claude Projects: проекты, инструкции, файлы, +общие чаты, пользователи с ролями и ответы **Cursor Agent**. Интерфейс +на русском. Это не SPA-фреймворк и не облачный продукт — один процесс +FastAPI раздаёт статику и JSON API, данные лежат на диске сервера. + +Ответы агента **не полностью офлайн**: нужен `CURSOR_API_KEY` с +[Cursor Dashboard → Integrations](https://cursor.com/dashboard/integrations). +Веб-панель, логин, файлы и предразбор логов работают и без ключа. + +--- + +## Как запустить локально + +Скопируйте `.env.example` в `.env` и вставьте ключ: + +```env +CURSOR_API_KEY=crsr_your_key_here +CURSOR_MODEL=auto +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin +``` + +Затем: + +```powershell +.\run.ps1 +``` + +Скрипт ставит зависимости в `.deps` (`pip install --target .deps`), +выставляет `PYTHONPATH` и поднимает uvicorn с `--reload` на +**`0.0.0.0:8010`**. + +- На этой машине: http://127.0.0.1:8010 +- С других ПК в LAN: `http://:8010` +- Вход по умолчанию: **`admin` / `admin`** + +Если PowerShell блокирует локальные сценарии: + +```powershell +powershell -ExecutionPolicy Bypass -File .\run.ps1 +``` + +Если страница не открывается с другого ПК, разрешите входящий TCP 8010 +в брандмауэре Windows: + +```powershell +netsh advfirewall firewall add rule name="Helpomatica 8010" dir=in action=allow protocol=TCP localport=8010 +``` + +Загрузки и агент выполняются **на машине сервера**, не в браузере клиента. +Файлы копируются во временный workspace из `data/uploads/`. + +--- + +## Docker + +Нужен файл `.env` (из `.env.example`). Данные не кладутся в образ: +том `./data:/app/data` хранит `db.json` и загрузки. + +```bash +docker compose up -d --build +``` + +Откройте http://localhost:8010 и войдите. После пересборки контейнера +проекты, чаты и файлы остаются в `./data`. + +Healthcheck бьёт в `GET /` (страница логина, без авторизации). +`GET /api/auth/me` для проверки не подходит — без cookie он отдаёт 401. + +Подробности про Cursor Agent в Linux-контейнере — в разделе +[Cursor SDK и Docker](#cursor-sdk-и-docker). + +--- + +## Что это такое + +Панель для команды на одной машине / в LAN: + +- **Проекты** — название, описание, инструкции, модель, иконка. +- **Инструкции** — общий контекст для всех чатов проекта. +- **Файлы** — материалы проекта (видны во всех чатах) или только этого чата. +- **Чаты** — общие на проект: любой вошедший пользователь читает и пишет + в тех же разговорах. +- **Пользователи** — роли `admin` и `user`. Админ видит имена авторов + сообщений; обычный пользователь видит «Вы» / «Коллега». +- **Ответы** — локальный Cursor Agent (`AsyncAgent`) со стримингом, + размышлениями, историей инструментов и кнопкой «Стоп». + +Дополнительно в UI: поиск по чатам, экспорт чата в Markdown, чипы +контекстных файлов, копирование кода, цитаты строк лога, диаграммы +Mermaid, формулы KaTeX, светлая/тёмная тема, индикатор статуса агента. + +--- + +## Архитектура + +``` +Браузер (static/index.html + app.js + style.css) + │ cookie helpomatica_session + ▼ +FastAPI (main.py) ──► data/db.json + │ data/uploads/ + │ %TEMP%/helpomatica-agent/hlp-* (в Docker: /tmp/...) + ▼ +cursor-sdk: AsyncClient.launch_bridge → AsyncAgent.send → SSE + ▼ +Cursor API (нужен CURSOR_API_KEY) +``` + +- **Бэкенд:** FastAPI, статика смонтирована на `/static`, корень `GET /` + отдаёт `static/index.html`. Фронтенд — ванильный JS, без React/Vue. +- **Хранение:** JSON-файл `data/db.json` (атомарная запись через `.tmp`) + и файловая система `data/uploads/`. Папка `data/` в Git не входит. +- **Сессии:** список в `db.json`, cookie HttpOnly `helpomatica_session` + (`SameSite=Lax`, без `Secure` — HTTP по IP в LAN), срок 30 дней, + не больше 200 сессий. Пароль: PBKDF2-HMAC-SHA256, 200 000 итераций. +- **Доступ:** все авторизованные видят все проекты и общие чаты. + Отдельных ACL на проект нет. Файлы с `scope=chat` попадают в агент + только этого чата; `scope=project` — во все чаты проекта. +- **Параллельность:** глобального single-flight нет. У каждого `chat_id` + своя очередь: один активный запрос и максимум один в ожидании + (второй лишний → HTTP 409). Разные чаты запускают отдельные local bridge. +- **Слушает** `0.0.0.0:8010`. + +`SESSION_SECRET` в `.env` — заготовка, **main.py его не читает**. + +--- + +## Стек + +Версии из текущего `pip install` в `.deps` (в `requirements.txt` +пакеты **без пинов**): + +| Слой | Что | +|------|-----| +| Язык | Python **3.12** (локально 3.12.10). `cursor-sdk` требует **≥ 3.10** | +| API | FastAPI **0.141.1**, Starlette, Pydantic v2 | +| Сервер | uvicorn[standard] **0.52.1** | +| Агент | cursor-sdk **1.0.27** (`AsyncAgent`, `AsyncClient.launch_bridge`, `LocalAgentOptions`) + httpx | +| Конфиг | python-dotenv **1.2.2** | +| Загрузки | python-multipart **0.0.32** | +| Предразбор логов | `log_preanalysis.py` (stdlib) | +| Фронтенд | `static/app.js`, `static/style.css` — без сборки | +| CDN | Mermaid 11, KaTeX 0.16.22, шрифты Google (Manrope, Playfair Display) | +| Данные | JSON + файлы на диске | + +Локальный запуск (`run.ps1`) кладёт пакеты в `.deps` и добавляет каталог +в `sys.path`. Docker ставит те же пакеты в venv через `pip install -r requirements.txt`. + +--- + +## Форма `data/db.json` + +Корень — объект со списками. При пустом файле / первом старте создаётся +админ из `ADMIN_USERNAME` / `ADMIN_PASSWORD`. + +```json +{ + "users": [ + { + "id": "hex", + "username": "admin", + "display_name": "Admin", + "password_hash": "salt$pbkdf2hex", + "role": "admin", + "created_at": "ISO-8601" + } + ], + "sessions": [{ "id": "token", "user_id": "…", "created_at": "…" }], + "projects": [{ + "id": "…", "name": "…", "description": "…", "instructions": "…", + "icon": "✦", "model": "auto", "created_by": "…", + "created_at": "…", "updated_at": "…" + }], + "chats": [{ + "id": "…", "project_id": "…", "title": "…", "sort_order": 0, + "created_by": "…", "updated_by": "…", + "created_at": "…", "updated_at": "…" + }], + "messages": [{ + "id": "…", "chat_id": "…", "role": "user|assistant", "content": "…", + "attachments": [{ "id": "…", "name": "…" }], + "user_id": "… или null", "author_name": "Admin|Помощник|…", + "thinking": "…", "thinking_duration_ms": 17519, + "activity": [{ "id": "a1", "type": "thinking|shell|…", "title": "…", + "detail": "…", "status": "running|completed" }], + "created_at": "…" + }], + "files": [{ + "id": "…", "project_id": "…", "chat_id": "null или id чата", + "scope": "project|chat", "name": "line_u_codes.log.1", + "label": "", "stored_name": "_оригинал", + "content_type": "…", "size": 123, "created_at": "…" + }], + "analysis_cache": [{ + "key": "sha256", "file_sig": "sha256", "project_id": "…", + "job_id": "", "question": "нормализованный текст", + "answer": "…", "preanalysis": "markdown", "summary": "…", + "created_at": "…" + }] +} +``` + +Байты файлов лежат в `data/uploads/`, в UI показывается +оригинальное `name`. + +--- + +## Как проходит запрос к агенту + +1. Пользователь пишет сообщение. Фронт сохраняет его + `POST /api/chats/{id}/messages` (роль `user`, вложения, автор). +2. Сразу же `POST /api/chats/{id}/respond` — SSE-поток + (`text/event-stream`). +3. Сервер занимает слот чата (или ставит в очередь на один). + Событие `status: queued` / `starting`. +4. Собирается workspace: `prepare_agent_workspace` создаёт + `%TEMP%/helpomatica-agent/hlp--*` (в Linux/Docker — + `/tmp/helpomatica-agent/hlp-…`). Туда hardlink/копия загрузок + (и в `./`, и в `./files/`), плюс подсказки `README.md`, + `PROJECT_FILES.md`, `AGENTS.md`, `.cursorignore`. +5. Если вопрос похож на разбор логов (`log_preanalysis.looks_like_log_job`) + и в скоупе есть `*.log*`: + - ищется `analysis_cache`; + - иначе поток-скан логов с SSE `progress` («Читаю лог… N%»); + - результат пишется в `./PREANALYSIS.md`. +6. Промпт: инструкции проекта + выдержки файлов + последние 30 сообщений + + правило «cwd уже workspace, читай PREANALYSIS.md первым». +7. В **отдельном потоке** (на Windows — `WindowsProactorEventLoopPolicy`) + вызывается `AsyncClient.launch_bridge(workspace=cwd)` и + `AsyncAgent.create` / `agent.send`. События моста читаются в этом + потоке; основной event loop забирает их через `asyncio.to_thread(queue.get)`. +8. В браузер уходят SSE: + - `thinking` — дельты `thinking-delta`; + - `activity` — инструменты, shell, статус, шаги «Логи»; + - `delta` — токены ответа (`text-delta`); + - `thinking_done`, `done` / `cancelled` / `error`. +9. Готовый текст сохраняется как сообщение ассистента (`thinking`, + `activity`, `thinking_duration_ms`). При успехе пишется кэш разбора + (до 80 записей). + +Кнопка «Стоп» — `POST /api/chats/{id}/cancel`. + +--- + +## Workspace агента и файлы + +- Каталог: `tempfile.gettempdir()/helpomatica-agent/hlp-…`. +- Копируются файлы **проекта** + файлы **этого чата** + вложения сообщений. +- Вложения, chat-scope и логи копируются всегда; остальные файлы проекта + режутся лимитами 40 МБ / файл и 80 МБ суммарно. +- Если hardlink недоступен — `shutil.copy2`. +- Агент работает только с этим cwd; исходники панели и диск клиента + ему не отдаются. + +Цитаты в ответе вида `:15231:line_u_codes.log.1` или +`::log:line_u_codes.log.1:15231::` фронт превращает в ссылку на +`GET /api/files/{id}/excerpt?start=&end=` (фрагмент ±25 строк). + +--- + +## Авторизация и API (кратко) + +Публично: `GET /`, `/static/*`, `POST /api/auth/login`. +Остальные `/api/*` требуют cookie. + +| Метод | Путь | Назначение | +|-------|------|------------| +| POST | `/api/auth/login` | вход, Set-Cookie | +| POST | `/api/auth/logout` | выход | +| GET | `/api/auth/me` | текущий пользователь | +| GET/POST | `/api/users` | список / создание (только admin) | +| GET | `/api/agent/status` | ключ, SDK, busy, очереди чатов | +| CRUD | `/api/projects`, `/api/chats` | проекты, чаты, порядок | +| GET | `/api/chats/{id}/export` | Markdown | +| POST | `/api/chats/{id}/messages` | сохранить сообщение | +| POST | `/api/chats/{id}/respond` | SSE-ответ агента | +| POST | `/api/chats/{id}/cancel` | остановить генерацию | +| GET | `/api/search?q=` | поиск по названиям и тексту (от 2 символов, до 30) | +| POST | `/api/projects/{id}/files` | загрузка (`scope` + `chat_id`) | +| POST | `/api/chats/{id}/files/bind` | привязать файлы к чату | +| GET | `/api/files/{id}` | скачать | +| GET | `/api/files/{id}/excerpt` | фрагмент лога по строкам | + +Модель проекта выбирается в UI (`auto`, `composer-2.5`, `composer-2`, +`gpt-5.2`, `claude-4.6-sonnet`) и уходит в `AgentOptions.model`. +По умолчанию — `CURSOR_MODEL` из `.env`. + +--- + +## Cursor SDK и Docker + +На Windows wheel `cursor-sdk` кладёт в +`cursor_sdk/_vendor/bridge/bin/`: + +- `cursor-sdk-bridge.cmd` → запускает **`node.exe`** (вендорный, ~89 МБ) +- `../dist/bin/cursor-sdk-bridge.js` — сам мост (Node, не отдельный .exe агента) + +`resolve_bridge_path()` ищет: `CURSOR_SDK_BRIDGE_BIN`, затем bundled +`bin/cursor-sdk-bridge` (POSIX) / `cursor-sdk-bridge.cmd` (Windows), +затем PATH. + +**Не копируйте `.deps` с Windows-хоста в Linux-образ** — там `node.exe`. +Dockerfile ставит пакет заново: Linux-wheel cursor-sdk (x64/arm64) +должен принести свой `node` + launcher `cursor-sdk-bridge`. + +Если в контейнере мост не стартует (нет bundled node, ошибка glibc, +нет `cursor-sdk-bridge`): + +- веб-UI, логин, файлы и **предразбор логов** всё равно работают; +- ответы Cursor Agent нужно снимать на Windows-хосте через `.\run.ps1`. + +Образ не содержит `.env`. Ключ передаётся `env_file: .env`. + +--- + +## Безопасность + +- Сразу смените пароль админа (`admin`/`admin` только для первого старта). +- Не коммитьте `.env` (уже в `.gitignore`). Ключ Cursor — секрет. +- Панель слушает весь LAN: ограничьте доступ брандмауэром. +- Cookie без `Secure`: нормально для HTTP в LAN, не для публичного HTTPS + без доработки. +- Это не multi-tenant isolation: любой `user` видит все проекты и чаты. +- Генерации картинок в панели нет; агенту это явно запрещено в промпте. + +--- + +## Файлы репозитория + +| Путь | Роль | +|------|------| +| `main.py` | FastAPI-приложение | +| `log_preanalysis.py` | потоковый скан логов → `PREANALYSIS.md` | +| `static/` | HTML/CSS/JS | +| `run.ps1` | локальный запуск на Windows | +| `requirements.txt` | зависимости | +| `.env.example` | шаблон переменных (без секретов) | +| `Dockerfile`, `docker-compose.yml`, `.dockerignore` | контейнер | +| `test_preanalysis.py` | короткий тест предразбора логов | +| `data/` | БД и загрузки на диске сервера — **не в Git** (только `data/.gitkeep`) | + +--- + +## Выгрузка в Gitea + +В репозиторий кладите только исходники продукта: `main.py`, `log_preanalysis.py`, `static/`, `requirements.txt`, `run.ps1`, Docker-файлы, `.env.example`, `.gitignore`, `README.md`. + +**Не коммитьте и не копируйте в Gitea:** + +- `.env` — там `CURSOR_API_KEY` и пароль админа +- `data/` — чаты, загрузки, `db.json` (том Docker `./data` тоже только локальный) +- `.deps/` — локальный `pip install --target` (ставится заново из `requirements.txt`) + +После клона: + +```powershell +copy .env.example .env +# вставьте CURSOR_API_KEY и смените ADMIN_PASSWORD +docker compose up -d --build +# или локально на Windows: +.\run.ps1 +``` diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1661b60 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + helpomatica: + build: . + image: helpomatica:local + ports: + - "8010:8010" + env_file: + - .env + environment: + CURSOR_MODEL: ${CURSOR_MODEL:-auto} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + volumes: + - ./data:/app/data + - agent-tmp:/tmp/helpomatica-agent + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/', timeout=4)", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + agent-tmp: diff --git a/log_preanalysis.py b/log_preanalysis.py new file mode 100644 index 0000000..ab52573 --- /dev/null +++ b/log_preanalysis.py @@ -0,0 +1,625 @@ +"""Stream-scan InMark-style logs without loading the whole file into RAM.""" + +from __future__ import annotations + +import gzip +import re +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Iterable + +ProgressCb = Callable[[dict[str, Any]], None] + +LOG_JOB_HINTS = re.compile( + r"job[_\s]?id|заказ|расхожден|error\s*6[45]|промежутк|частот|" + r"plc_errors|line_u|задани[еюя]|маркиров|отбраков|лог[аиов]?\b|" + r"histogram|ошибк|gap|код[аыов]?\b|смен[аыеу]", + re.IGNORECASE, +) +JOB_ID_RE = re.compile( + r"(?:job(?:[_\s-]?id)?|заказ(?:а|у|ом)?|задани[еюя]|jobid)\s*[:=#№]?\s*(\d{3,8})", + re.IGNORECASE, +) +STANDALONE_JOB_RE = re.compile(r"\b(\d{4,6})\b") +DATE_ISO_RE = re.compile(r"\b(20\d{2}-\d{2}-\d{2})\b") +DATE_RU_RE = re.compile(r"\b(\d{1,2})[./](\d{1,2})[./](20\d{2}|\d{2})\b") +TS_ISO_RE = re.compile(r"(20\d{2}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})") +TS_RU_RE = re.compile(r"(\d{2}\.\d{2}\.20\d{2})[ T](\d{2}:\d{2}:\d{2})") +SOURCE_BRACKET_RE = re.compile(r"\[([A-Za-zА-Яа-яЁё0-9_./-]{2,40})\]") +SOURCE_FIELD_RE = re.compile( + r"\b(?:source|src|sender|from|channel|канал)\s*[=:]\s*([^\s,;\]=]+)", + re.IGNORECASE, +) +ERROR_RE = re.compile(r"\b(?:Error|error_?)\s*(\d{1,3})\b") +ERROR64_RE = re.compile(r"\b(?:Error|error_?)\s*64\b", re.IGNORECASE) +ERROR65_RE = re.compile(r"\b(?:Error|error_?)\s*65\b", re.IGNORECASE) +JOB_IN_LINE_RE = re.compile( + r"(?:job(?:[_\s-]?id)?|заказ|JobId)\s*[=:#]?\s*(\d{3,8})", + re.IGNORECASE, +) +CODE_RE = re.compile( + r"\b(?:01\d{14}21[A-Za-z0-9]{1,24}|[A-F0-9]{24,44}|\d{20,32})\b" +) +LOG_NAME_RE = re.compile(r"\.log(\.\d+)?(\.gz)?$", re.IGNORECASE) + +PREFERRED_LOG_NAMES = ( + "line_u_codes.log", + "plc_errors.log", + "line_u.log", + "line.log", +) + +GAP_SECONDS = 30 +MAX_UNIQUE_CODES = 20_000 +MAX_ERROR64_HITS = 80 +MAX_GAPS = 25 +MAX_SAMPLE_LINES = 12 +MAX_PREANALYSIS_CHARS = 70_000 +READ_CHUNK = 256 * 1024 + + +def looks_like_log_job(query: str, log_file_names: Iterable[str] | None = None) -> bool: + text = str(query or "") + if LOG_JOB_HINTS.search(text): + return True + names = [str(name or "") for name in (log_file_names or [])] + if names and (JOB_ID_RE.search(text) or DATE_ISO_RE.search(text) or DATE_RU_RE.search(text)): + return True + return False + + +def is_log_filename(name: str) -> bool: + lower = str(name or "").lower() + if LOG_NAME_RE.search(lower): + return True + return ".log." in lower or lower.endswith(".log") + + +def detect_job_id(query: str) -> str | None: + text = str(query or "") + match = JOB_ID_RE.search(text) + if match: + return match.group(1) + hinted = LOG_JOB_HINTS.search(text) + if hinted: + standalone = STANDALONE_JOB_RE.search(text) + if standalone: + return standalone.group(1) + return None + + +def detect_shift_date(query: str) -> str | None: + text = str(query or "") + iso = DATE_ISO_RE.search(text) + if iso: + return iso.group(1) + ru = DATE_RU_RE.search(text) + if not ru: + return None + day, month, year = ru.group(1), ru.group(2), ru.group(3) + if len(year) == 2: + year = f"20{year}" + try: + return datetime(int(year), int(month), int(day)).strftime("%Y-%m-%d") + except ValueError: + return None + + +def detect_log_encoding(path: Path) -> str: + try: + raw = path.open("rb").read(65536) + except OSError: + return "utf-8" + if raw.startswith(b"\xef\xbb\xbf"): + return "utf-8-sig" + for encoding in ("utf-8", "cp1251"): + try: + raw.decode(encoding) + return encoding + except UnicodeDecodeError: + continue + return "cp1251" + + +def _parse_timestamp(line: str) -> datetime | None: + match = TS_ISO_RE.search(line) + if match: + try: + return datetime.strptime( + f"{match.group(1)} {match.group(2)}", "%Y-%m-%d %H:%M:%S" + ) + except ValueError: + return None + match = TS_RU_RE.search(line) + if not match: + return None + try: + return datetime.strptime( + f"{match.group(1)} {match.group(2)}", "%d.%m.%Y %H:%M:%S" + ) + except ValueError: + return None + + +def _source_of(line: str, fallback: str) -> str: + field = SOURCE_FIELD_RE.search(line) + if field: + return field.group(1)[:40] + bracket = SOURCE_BRACKET_RE.search(line) + if bracket: + token = bracket.group(1) + if not re.fullmatch(r"\d{1,2}:\d{2}:\d{2}.*", token): + return token[:40] + return fallback + + +def _open_log_binary(path: Path): + name = path.name.lower() + handle = path.open("rb") + if name.endswith(".gz"): + return gzip.GzipFile(fileobj=handle, mode="rb"), handle + return handle, handle + + +def iter_log_lines(path: Path, encoding: str): + """Yield (line_no, text, bytes_read, total_bytes) without loading the file.""" + size = 0 + try: + size = path.stat().st_size + except OSError: + size = 0 + stream, raw = _open_log_binary(path) + try: + leftover = b"" + line_no = 0 + bytes_read = 0 + while True: + chunk = stream.read(READ_CHUNK) + if not chunk: + break + bytes_read += len(chunk) + data = leftover + chunk + parts = data.split(b"\n") + leftover = parts.pop() + for part in parts: + line_no += 1 + text = part.rstrip(b"\r").decode(encoding, errors="replace") + yield line_no, text, min(bytes_read, size) if size else bytes_read, size + if leftover: + line_no += 1 + text = leftover.rstrip(b"\r").decode(encoding, errors="replace") + yield line_no, text, size or bytes_read, size + finally: + try: + stream.close() + except Exception: # noqa: BLE001 + pass + if raw is not stream: + try: + raw.close() + except Exception: # noqa: BLE001 + pass + + +def rank_log_files(entries: list[dict]) -> list[dict]: + def score(entry: dict) -> tuple: + name = str(entry.get("name") or "").lower() + preferred = 0 + for index, prefix in enumerate(PREFERRED_LOG_NAMES): + if name.startswith(prefix) or prefix in name: + preferred = 10 - index + break + size = int(entry.get("size") or 0) + return (preferred, 1 if is_log_filename(name) else 0, min(size, 10**12)) + + logs = [entry for entry in entries if is_log_filename(str(entry.get("name") or ""))] + logs.sort(key=score, reverse=True) + return logs[:6] + + +def _emit_progress( + on_progress: ProgressCb | None, + *, + phase: str, + message: str, + percent: int | None, + file: str = "", +) -> None: + if not on_progress: + return + payload: dict[str, Any] = { + "phase": phase, + "message": message, + "percent": percent, + } + if file: + payload["file"] = file + on_progress(payload) + + +def _scan_one_file( + path: Path, + display_name: str, + job_id: str | None, + shift_date: str | None, + *, + on_progress: ProgressCb | None = None, + overall_offset: int = 0, + overall_total: int = 1, + cancel: Callable[[], bool] | None = None, +) -> dict[str, Any]: + encoding = detect_log_encoding(path) + sources: Counter[str] = Counter() + errors: Counter[str] = Counter() + hours: Counter[str] = Counter() + unique_codes: set[str] = set() + unique_capped = False + code_events = 0 + error64: list[dict[str, Any]] = [] + error65: list[dict[str, Any]] = [] + gaps: list[dict[str, Any]] = [] + samples: list[dict[str, Any]] = [] + first_ts: datetime | None = None + last_ts: datetime | None = None + prev_ts: datetime | None = None + prev_line = 0 + line_count = 0 + job_lines = 0 + shift_lines = 0 + last_pct = -1 + + def maybe_sample(line_no: int, line: str, reason: str) -> None: + if len(samples) >= MAX_SAMPLE_LINES: + return + samples.append( + { + "file": display_name, + "line": line_no, + "text": line[:240], + "reason": reason, + } + ) + + for line_no, line, bytes_read, total in iter_log_lines(path, encoding): + if cancel and cancel(): + break + line_count = line_no + if overall_total: + local = (bytes_read / total) if total else 1.0 + overall = overall_offset + local * (total or 0) + pct = min(99, int(100 * overall / overall_total)) + if pct >= last_pct + 2: + last_pct = pct + _emit_progress( + on_progress, + phase="scan", + message=f"Читаю лог… {pct}%", + percent=pct, + file=display_name, + ) + + ts = _parse_timestamp(line) + if ts: + if first_ts is None: + first_ts = ts + maybe_sample(line_no, line, "first") + last_ts = ts + hours[ts.strftime("%Y-%m-%d %H:00")] += 1 + if prev_ts is not None: + delta = (ts - prev_ts).total_seconds() + if delta >= GAP_SECONDS: + gaps.append( + { + "file": display_name, + "from_line": prev_line, + "to_line": line_no, + "from_ts": prev_ts.isoformat(sep=" ", timespec="seconds"), + "to_ts": ts.isoformat(sep=" ", timespec="seconds"), + "seconds": int(delta), + } + ) + prev_ts = ts + prev_line = line_no + + in_shift = True + if shift_date and ts: + in_shift = ts.strftime("%Y-%m-%d") == shift_date + elif shift_date and shift_date in line: + in_shift = True + elif shift_date: + in_shift = False + if in_shift: + shift_lines += 1 + + job_hit = JOB_IN_LINE_RE.search(line) + if job_id and (job_id in line or (job_hit and job_hit.group(1) == job_id)): + job_lines += 1 + elif job_hit: + job_lines += 0 + + sources[_source_of(line, display_name)] += 1 + + for err in ERROR_RE.findall(line): + if in_shift or not shift_date: + errors[err] += 1 + + if ERROR64_RE.search(line) and len(error64) < MAX_ERROR64_HITS: + error64.append( + { + "file": display_name, + "line": line_no, + "ts": ts.isoformat(sep=" ", timespec="seconds") if ts else "", + "text": line[:220], + } + ) + maybe_sample(line_no, line, "Error64") + if ERROR65_RE.search(line) and len(error65) < MAX_ERROR64_HITS: + error65.append( + { + "file": display_name, + "line": line_no, + "ts": ts.isoformat(sep=" ", timespec="seconds") if ts else "", + "text": line[:220], + } + ) + maybe_sample(line_no, line, "Error65") + + for code in CODE_RE.findall(line): + code_events += 1 + if not unique_capped: + unique_codes.add(code) + if len(unique_codes) >= MAX_UNIQUE_CODES: + unique_capped = True + + if last_ts and line_count: + maybe_sample(line_count, "", "last") + if samples and samples[-1]["reason"] == "last" and not samples[-1]["text"]: + samples.pop() + + gaps.sort(key=lambda item: int(item.get("seconds") or 0), reverse=True) + gaps = gaps[:MAX_GAPS] + + return { + "file": display_name, + "path": str(path), + "encoding": encoding, + "lines": line_count, + "job_lines": job_lines, + "shift_lines": shift_lines, + "first_ts": first_ts.isoformat(sep=" ", timespec="seconds") if first_ts else "", + "last_ts": last_ts.isoformat(sep=" ", timespec="seconds") if last_ts else "", + "sources": sources.most_common(30), + "errors": errors.most_common(25), + "hours": hours.most_common(24), + "unique_codes": len(unique_codes), + "unique_capped": unique_capped, + "code_events": code_events, + "error64": error64, + "error65": error65, + "gaps": gaps, + "samples": samples, + } + + +def _format_duration(seconds: int) -> str: + value = max(0, int(seconds)) + if value < 60: + return f"{value} с" + minutes, rest = divmod(value, 60) + if minutes < 60: + return f"{minutes} мин {rest} с" if rest else f"{minutes} мин" + hours, minutes = divmod(minutes, 60) + return f"{hours} ч {minutes} мин" + + +def render_preanalysis_markdown(report: dict[str, Any]) -> str: + job_id = report.get("job_id") or "не найден в вопросе" + shift = report.get("shift_date") or "не указана" + lines: list[str] = [ + "# PREANALYSIS", + "", + "Сервер уже просканировал логи **одним проходом**. " + "Используй этот файл первым. Не читай логи целиком заново, " + "если цифры выглядят правдоподобно. Для проверки бери фрагмент " + "по номеру строки (`:15231:line_u_codes.log.1` или " + "`::log:line_u_codes.log.1:15231::`).", + "", + f"- Job id: `{job_id}`", + f"- Дата смены (из вопроса): `{shift}`", + f"- Файлов просканировано: {len(report.get('files') or [])}", + "", + ] + + for item in report.get("files") or []: + name = item.get("file") or "log" + lines.extend( + [ + f"## {name}", + "", + f"- Кодировка: `{item.get('encoding')}`", + f"- Строк: {item.get('lines')}", + f"- Строк с job id: {item.get('job_lines')}", + f"- Строк за смену: {item.get('shift_lines')}", + f"- Интервал: {item.get('first_ts') or '—'} → {item.get('last_ts') or '—'}", + f"- Событий с кодами: {item.get('code_events')}", + f"- Уникальных кодов: {item.get('unique_codes')}" + + (" (учёт ограничен) " if item.get("unique_capped") else ""), + "", + "### Источники", + "", + ] + ) + sources = item.get("sources") or [] + if sources: + for src, count in sources: + lines.append(f"- `{src}`: {count}") + else: + lines.append("- (нет)") + lines.extend(["", "### Ошибки за смену (гистограмма)", ""]) + errors = item.get("errors") or [] + if errors: + for code, count in errors: + lines.append(f"- Error {code}: {count}") + else: + lines.append("- (нет совпадений Error N)") + lines.extend(["", "### Частота по часам (топ)", ""]) + hours = item.get("hours") or [] + if hours: + for hour, count in hours[:16]: + lines.append(f"- {hour}: {count}") + else: + lines.append("- (нет меток времени)") + lines.extend(["", "### Длинные промежутки (≥ 30 с)", ""]) + gaps = item.get("gaps") or [] + if gaps: + for gap in gaps: + lines.append( + f"- {_format_duration(int(gap.get('seconds') or 0))}: " + f"{gap.get('from_ts')} → {gap.get('to_ts')} " + f"(`:{gap.get('from_line')}:{name}` … `:{gap.get('to_line')}:{name}`)" + ) + else: + lines.append("- нет промежутков ≥ 30 с") + lines.extend(["", "### Error64", ""]) + hits64 = item.get("error64") or [] + if hits64: + for hit in hits64[:40]: + ts = hit.get("ts") or "—" + lines.append( + f"- {ts} `:{hit.get('line')}:{name}` — {hit.get('text')}" + ) + else: + lines.append("- нет") + lines.extend(["", "### Error65", ""]) + hits65 = item.get("error65") or [] + if hits65: + for hit in hits65[:40]: + ts = hit.get("ts") or "—" + lines.append( + f"- {ts} `:{hit.get('line')}:{name}` — {hit.get('text')}" + ) + else: + lines.append("- нет") + lines.extend(["", "### Примеры строк", ""]) + for sample in item.get("samples") or []: + text = str(sample.get("text") or "").strip() + if not text: + continue + lines.append( + f"- `:{sample.get('line')}:{name}` ({sample.get('reason')}): `{text}`" + ) + lines.append("") + + text = "\n".join(lines).strip() + "\n" + if len(text) > MAX_PREANALYSIS_CHARS: + text = text[:MAX_PREANALYSIS_CHARS].rstrip() + "\n\n…\n" + return text + + +def preanalyze_logs( + files: list[tuple[Path, str]], + query: str, + *, + on_progress: ProgressCb | None = None, + cancel: Callable[[], bool] | None = None, +) -> dict[str, Any]: + job_id = detect_job_id(query) + shift_date = detect_shift_date(query) + existing = [(path, name) for path, name in files if path.exists() and path.is_file()] + total_bytes = 0 + for path, _name in existing: + try: + total_bytes += path.stat().st_size + except OSError: + pass + total_bytes = max(total_bytes, 1) + offset = 0 + scanned: list[dict[str, Any]] = [] + _emit_progress( + on_progress, + phase="scan", + message="Читаю лог… 0%", + percent=0, + ) + for path, name in existing: + size = 0 + try: + size = path.stat().st_size + except OSError: + size = 0 + item = _scan_one_file( + path, + name, + job_id, + shift_date, + on_progress=on_progress, + overall_offset=offset, + overall_total=total_bytes, + cancel=cancel, + ) + scanned.append(item) + offset += size + if cancel and cancel(): + break + _emit_progress( + on_progress, + phase="scan", + message="Читаю лог… 100%", + percent=100, + ) + report = { + "job_id": job_id or "", + "shift_date": shift_date or "", + "files": scanned, + } + report["markdown"] = render_preanalysis_markdown(report) + report["summary"] = _short_summary(report) + return report + + +def _short_summary(report: dict[str, Any]) -> str: + bits = [] + if report.get("job_id"): + bits.append(f"job {report['job_id']}") + if report.get("shift_date"): + bits.append(report["shift_date"]) + for item in report.get("files") or []: + bits.append( + f"{item.get('file')}: {item.get('lines')} строк, " + f"Error64×{len(item.get('error64') or [])}, " + f"промежутков {len(item.get('gaps') or [])}" + ) + return "; ".join(bits) if bits else "логи просканированы" + + +def excerpt_lines( + path: Path, + start: int, + end: int, + encoding: str | None = None, +) -> tuple[str, str]: + """Return (text, encoding) for 1-indexed inclusive line range.""" + start = max(1, int(start)) + end = max(start, int(end)) + encoding = encoding or detect_log_encoding(path) + collected: list[str] = [] + for line_no, text, _read, _total in iter_log_lines(path, encoding): + if line_no < start: + continue + if line_no > end: + break + collected.append(f"{line_no}\t{text}") + return "\n".join(collected) + ("\n" if collected else ""), encoding + + +def write_preanalysis_md(workspace: Path, markdown: str) -> Path: + target = Path(workspace) / "PREANALYSIS.md" + target.write_text(markdown, encoding="utf-8") + listing = Path(workspace) / "PROJECT_FILES.md" + if listing.exists(): + extra = "\n- ./PREANALYSIS.md — server-side log scan; read this first\n" + text = listing.read_text(encoding="utf-8") + if "PREANALYSIS.md" not in text: + listing.write_text(text.rstrip() + extra, encoding="utf-8") + return target diff --git a/main.py b/main.py new file mode 100644 index 0000000..623fe99 --- /dev/null +++ b/main.py @@ -0,0 +1,3535 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +DEPS_DIR = ROOT / ".deps" +if DEPS_DIR.exists(): + sys.path.insert(0, str(DEPS_DIR)) + +import asyncio +import hashlib +import json +import logging +import os +import queue +import re +import secrets +import shutil +import tempfile +import threading +import uuid +from datetime import datetime, timezone +from threading import Lock +from typing import Any, AsyncIterator, Mapping +from urllib.parse import quote + +from dotenv import load_dotenv +from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, Response, UploadFile +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +import log_preanalysis + + +load_dotenv(ROOT / ".env") +DATA_DIR = ROOT / "data" +UPLOAD_DIR = DATA_DIR / "uploads" +AGENT_DIR = DATA_DIR / "agent_workspaces" +DB_FILE = DATA_DIR / "db.json" +STATIC_DIR = ROOT / "static" +db_lock = Lock() +logger = logging.getLogger("helpomatica") +AGENT_TIMEOUT_SECONDS = 180 +SESSION_COOKIE = "helpomatica_session" +SESSION_DAYS = 30 +PBKDF2_ITERATIONS = 200_000 + +# Parallel local agents: different chat_ids may run bridges concurrently. +# Per-chat still single-flight — one active run + at most one waiting request. +# (Optional future: CURSOR_USE_CLOUD=1 → Cloud Agents API for remote parallel runs.) +agent_status_lock = Lock() +active_runs: dict[str, dict[str, Any]] = {} +chat_queue_lock = Lock() +# chat_id -> {"running": bool, "waiting": int, "turn": threading.Event} +chat_queues: dict[str, dict[str, Any]] = {} + + +class CursorStartupError(RuntimeError): + pass + + +def format_agent_error(error: Exception) -> str: + message = str(error).strip() + if message: + return message + return f"{error.__class__.__name__}: проверьте CURSOR_API_KEY и запуск через .\\run.ps1" + + +def now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def empty_db() -> dict: + return { + "users": [], + "sessions": [], + "projects": [], + "chats": [], + "messages": [], + "files": [], + "analysis_cache": [], + } + + +def hash_password(password: str, salt: str | None = None) -> str: + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + PBKDF2_ITERATIONS, + ) + return f"{salt}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + salt, digest = stored.split("$", 1) + except ValueError: + return False + candidate = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + PBKDF2_ITERATIONS, + ).hex() + return secrets.compare_digest(candidate, digest) + + +def public_user(user: dict) -> dict: + return { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "role": user["role"], + "created_at": user["created_at"], + } + + +def default_model() -> str: + return os.getenv("CURSOR_MODEL", "auto").strip() or "auto" + + +def migrate_db(data: dict) -> dict: + changed = False + for key in ("users", "sessions", "projects", "chats", "messages", "files", "analysis_cache"): + if key not in data or not isinstance(data[key], list): + data[key] = [] + changed = True + + if not data["users"]: + username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin" + password = os.getenv("ADMIN_PASSWORD", "admin") + admin = { + "id": uuid.uuid4().hex, + "username": username, + "display_name": "Admin", + "password_hash": hash_password(password), + "role": "admin", + "created_at": now(), + } + data["users"].append(admin) + changed = True + logger.info("Bootstrap admin user created: %s", username) + else: + admin = next((u for u in data["users"] if u.get("role") == "admin"), data["users"][0]) + + admin_id = admin["id"] + admin_name = admin.get("display_name") or admin.get("username") or "Admin" + + for project in data["projects"]: + if "model" not in project: + project["model"] = default_model() + changed = True + if "created_by" not in project: + project["created_by"] = admin_id + changed = True + + for chat in data["chats"]: + if "created_by" not in chat: + chat["created_by"] = admin_id + changed = True + if "updated_by" not in chat: + chat["updated_by"] = admin_id + changed = True + + chats_missing_order = [chat for chat in data["chats"] if "sort_order" not in chat] + if chats_missing_order: + by_project: dict[str, list[dict]] = {} + for chat in data["chats"]: + by_project.setdefault(chat["project_id"], []).append(chat) + for project_chats in by_project.values(): + ordered = sorted( + project_chats, + key=lambda item: item.get("updated_at") or item.get("created_at") or "", + reverse=True, + ) + for index, chat in enumerate(ordered): + if chat.get("sort_order") != index: + chat["sort_order"] = index + changed = True + + for message in data["messages"]: + if "user_id" not in message: + if message.get("role") == "user": + message["user_id"] = admin_id + message["author_name"] = admin_name + else: + message["user_id"] = None + message["author_name"] = "Помощник" + changed = True + elif "author_name" not in message: + if message.get("role") == "assistant": + message["author_name"] = "Помощник" + else: + message["author_name"] = admin_name + changed = True + + for file_entry in data["files"]: + chat_id = file_entry.get("chat_id") or None + if chat_id == "": + chat_id = None + scope = str(file_entry.get("scope") or "").strip().lower() + if scope not in ("project", "chat"): + # Legacy uploads had no chat_id: keep them global within the project. + scope = "chat" if chat_id else "project" + if scope == "chat" and not chat_id: + scope = "project" + if scope == "project": + chat_id = None + if file_entry.get("scope") != scope: + file_entry["scope"] = scope + changed = True + if "chat_id" not in file_entry or file_entry.get("chat_id") != chat_id: + file_entry["chat_id"] = chat_id + changed = True + + if changed: + save_db(data) + return data + + +def load_db() -> dict: + DATA_DIR.mkdir(exist_ok=True) + UPLOAD_DIR.mkdir(exist_ok=True) + if not DB_FILE.exists(): + save_db(empty_db()) + with db_lock: + data = json.loads(DB_FILE.read_text(encoding="utf-8")) + return migrate_db(data) + + +def save_db(data: dict) -> None: + DATA_DIR.mkdir(exist_ok=True) + with db_lock: + temp_file = DB_FILE.with_suffix(".tmp") + temp_file.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + temp_file.replace(DB_FILE) + + +def find(items: list[dict], item_id: str, label: str) -> dict: + item = next((entry for entry in items if entry["id"] == item_id), None) + if not item: + raise HTTPException(status_code=404, detail=f"{label} не найден") + return item + + +def resolve_session(session_id: str | None) -> dict | None: + if not session_id: + return None + data = load_db() + session = next((s for s in data["sessions"] if s["id"] == session_id), None) + if not session: + return None + user = next((u for u in data["users"] if u["id"] == session["user_id"]), None) + return user + + +def get_current_user(request: Request) -> dict: + user = getattr(request.state, "user", None) + if not user: + raise HTTPException(status_code=401, detail="Требуется вход") + return user + + +def require_admin(user: dict = Depends(get_current_user)) -> dict: + if user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Только для администратора") + return user + + +# Cap workspace copies so a huge upload library cannot fill the temp disk. +MAX_WORKSPACE_FILE_BYTES = 40 * 1024 * 1024 +MAX_WORKSPACE_TOTAL_BYTES = 80 * 1024 * 1024 +_INVALID_WIN_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_RESERVED_WIN_NAMES = { + "con", "prn", "aux", "nul", + *(f"com{i}" for i in range(1, 10)), + *(f"lpt{i}" for i in range(1, 10)), +} + + +def sanitize_workspace_filename(name: str) -> str: + """Keep the original basename, including rotated logs like ``*.log.1``.""" + base = Path(name or "file").name.strip().replace("\x00", "") + base = _INVALID_WIN_NAME.sub("_", base).strip(" .") + if not base: + base = "file" + first, dot, rest = base.partition(".") + if first.lower() in _RESERVED_WIN_NAMES: + base = f"_{first}{dot}{rest}" + if len(base) > 180: + base = base[:180] + return base + + +def unique_workspace_name(desired: str, used: set[str]) -> str: + if desired.lower() not in used: + used.add(desired.lower()) + return desired + stem = Path(desired).stem + suffix = Path(desired).suffix + index = 2 + while True: + candidate = f"{stem}_{index}{suffix}" + if candidate.lower() not in used: + used.add(candidate.lower()) + return candidate + index += 1 + + +def rank_files_for_workspace( + files: list[dict], + query: str, + attachment_ids: list[str] | None = None, +) -> list[dict]: + """Prefer message attachments, then files named in the query, then recent.""" + lowered = query.lower() + keywords = {word for word in re.findall(r"[\w\u0400-\u04FF.+-]+", lowered) if len(word) > 2} + attach_set = {fid for fid in (attachment_ids or []) if fid} + + def sort_key(entry: dict) -> tuple: + attached = 1 if entry.get("id") in attach_set else 0 + name = entry.get("name", "").lower() + mentioned = 1 if any(token in name for token in keywords) else 0 + created = entry.get("created_at") or "" + return (attached, mentioned, created) + + return sorted(files, key=sort_key, reverse=True) + + +def _windows_long_path(path_text: str) -> str: + """Expand Win32 8.3 short names (INWORK~1) to the real long path.""" + try: + import ctypes + from ctypes import wintypes + + get_long = ctypes.windll.kernel32.GetLongPathNameW + get_long.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] + get_long.restype = wintypes.DWORD + buf = ctypes.create_unicode_buffer(32768) + n = get_long(path_text, buf, 32768) + if n and n < 32768 and buf.value: + return buf.value + except Exception: # noqa: BLE001 + pass + return path_text + + +def agent_workspace_cwd(workspace: Path | str) -> str: + """Absolute long-path cwd for Cursor bridge/agent (avoid Win32 8.3 short paths).""" + try: + resolved = Path(workspace).resolve() + except OSError: + resolved = Path(os.path.abspath(str(workspace))) + text = str(resolved) + if sys.platform == "win32": + text = _windows_long_path(text) + return text + + +def file_scope_of(entry: dict) -> str: + scope = str(entry.get("scope") or "").strip().lower() + if scope in ("project", "chat"): + return scope + return "chat" if entry.get("chat_id") else "project" + + +def iter_scoped_files( + data: dict, + project_id: str, + chat_id: str | None = None, +) -> list[dict]: + """Project-scoped files plus this chat's files. Unique by file id.""" + visible: list[dict] = [] + seen_ids: set[str] = set() + for entry in data.get("files", []): + if entry.get("project_id") != project_id: + continue + scope = file_scope_of(entry) + if scope == "project": + include = True + elif scope == "chat" and chat_id and entry.get("chat_id") == chat_id: + include = True + else: + include = False + if not include: + continue + fid = entry.get("id") or "" + if fid: + if fid in seen_ids: + continue + seen_ids.add(fid) + visible.append(entry) + return visible + + +_ATTACHED_NAMES_RE = re.compile( + r"\[Прикреплённые файлы проекта:\s*([^\]]+)\]", + re.IGNORECASE, +) + + +def message_attachment_ids(messages: list[dict]) -> list[str]: + ids: list[str] = [] + seen: set[str] = set() + for message in reversed(messages): + if message.get("role") != "user": + continue + for ref in message.get("attachments") or []: + fid = str((ref or {}).get("id") or "").strip() + if fid and fid not in seen: + seen.add(fid) + ids.append(fid) + return ids + + +def message_attachment_names(messages: list[dict]) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + for message in reversed(messages or []): + if message.get("role") != "user": + continue + for ref in message.get("attachments") or []: + name = str((ref or {}).get("name") or "").strip() + key = name.lower() + if name and key not in seen: + seen.add(key) + names.append(name) + content = str(message.get("content") or "") + match = _ATTACHED_NAMES_RE.search(content) + if not match: + continue + for raw in match.group(1).split(","): + name = raw.strip() + key = name.lower() + if name and key not in seen: + seen.add(key) + names.append(name) + return names + + +def resolve_message_attachments( + data: dict, + project_id: str, + chat_id: str | None, + messages: list[dict] | None = None, + attachment_ids: list[str] | None = None, +) -> list[dict]: + """Resolve attached files by id, then by original basename (any scope).""" + files = [ + entry + for entry in data.get("files", []) + if entry.get("project_id") == project_id + ] + by_id = {str(entry.get("id") or ""): entry for entry in files if entry.get("id")} + found: list[dict] = [] + seen: set[str] = set() + + def add(entry: dict | None) -> None: + if not entry: + return + fid = str(entry.get("id") or "") + key = fid or str(entry.get("name") or "").lower() + if not key or key in seen: + return + seen.add(key) + found.append(entry) + + for fid in attachment_ids or []: + add(by_id.get(str(fid))) + for fid in message_attachment_ids(messages or []): + add(by_id.get(str(fid))) + for name in message_attachment_names(messages or []): + key = name.lower() + match = next( + ( + entry + for entry in files + if str(entry.get("name") or "").lower() == key + and ( + file_scope_of(entry) == "project" + or not chat_id + or entry.get("chat_id") == chat_id + ) + ), + None, + ) + add(match) + return found + + +def dedupe_workspace_files(files: list[dict]) -> list[dict]: + """Keep first occurrence per file id and per original basename.""" + seen_ids: set[str] = set() + seen_names: set[str] = set() + unique: list[dict] = [] + for entry in files: + fid = entry.get("id") or "" + if fid and fid in seen_ids: + continue + aliases = _workspace_name_aliases(entry) + key = (aliases[0] if aliases else "file").lower() + if key in seen_names: + continue + if fid: + seen_ids.add(fid) + seen_names.add(key) + unique.append(entry) + return unique + + +_UUID_PREFIX = re.compile(r"^[0-9a-f]{32}_", re.IGNORECASE) + + +def _strip_upload_uuid_prefixes(name: str) -> str: + """Peel ``{32hex}_`` prefixes from upload storage names.""" + current = Path(name or "file").name + while True: + stripped = _UUID_PREFIX.sub("", current, count=1) + if stripped == current: + return current + current = stripped + + +def _workspace_name_aliases(entry: dict) -> list[str]: + """Prefer original/UI basename; if metadata is hashed-only, also expose bare name.""" + raw_name = str(entry.get("name") or entry.get("stored_name") or "file") + preferred = sanitize_workspace_filename(_strip_upload_uuid_prefixes(raw_name)) + display = sanitize_workspace_filename(raw_name) + ordered = [preferred] + # When UI/db name is still ``{uuid}_file.gz``, keep that name as a secondary alias. + if display.lower() != preferred.lower(): + ordered.append(display) + return ordered or ["file"] + + +def _link_or_copy(source: Path, dest: Path) -> None: + if dest.exists(): + return + try: + os.link(source, dest) + except OSError: + shutil.copy2(source, dest) + + +def prepare_agent_workspace( + project: dict, + data: dict | None = None, + query: str = "", + chat_id: str | None = None, + attachment_ids: list[str] | None = None, + messages: list[dict] | None = None, +) -> Path: + """Create an isolated cwd and copy scoped uploads into it. + + The Cursor agent runs on the Helpomatica server machine. Client-PC paths + are never available; only files from data/uploads/ for this project that + are in scope (project-wide, or this chat) are placed under ./ and ./files/ + so tools can open them by relative name. + + Message attachments and chat-scoped files are always copied (no 40/80MB + cap, no extension filter). Project-wide extras still use the size cap. + Always return a resolved absolute long path so bridge/shell cwd is not an + 8.3 short path (e.g. ``C:\\Users\\INWORK~1\\...``), which confuses agents + into leaving the temp workspace. + """ + isolated_root = Path(agent_workspace_cwd(Path(tempfile.gettempdir()) / "helpomatica-agent")) + isolated_root.mkdir(parents=True, exist_ok=True) + # mkdtemp may return a short 8.3 path on Windows — resolve to long path. + workspace = Path( + agent_workspace_cwd( + tempfile.mkdtemp(prefix=f"hlp-{project['id'][:8]}-", dir=isolated_root) + ) + ) + files_dir = workspace / "files" + files_dir.mkdir(exist_ok=True) + + if data is None: + _write_workspace_hints(workspace, [], []) + return workspace + + attachments = resolve_message_attachments( + data, project["id"], chat_id, messages=messages, attachment_ids=attachment_ids + ) + attach_ids = {str(entry.get("id") or "") for entry in attachments if entry.get("id")} + attach_ids.update(str(fid) for fid in (attachment_ids or []) if fid) + scoped = iter_scoped_files(data, project["id"], chat_id) + others = [ + entry + for entry in rank_files_for_workspace(scoped, query, list(attach_ids)) + if str(entry.get("id") or "") not in attach_ids + ] + # Attachments first (must land), then remaining chat-scoped, then project. + chat_rest = [entry for entry in others if file_scope_of(entry) == "chat"] + project_rest = [entry for entry in others if file_scope_of(entry) != "chat"] + ordered = dedupe_workspace_files(attachments + chat_rest + project_rest) + + used_names: set[str] = { + "readme.md", + "project_files.md", + "agents.md", + "workspace_root.txt", + ".cursorignore", + "preanalysis.md", + } + copied_ids: set[str] = set() + listing: list[str] = [] + notes: list[str] = [] + total_bytes = 0 + + def place_copy(source: Path | None, primary: str, aliases: list[str]) -> None: + dest_nested = files_dir / primary + dest_flat = workspace / primary + if source is None: + stub = ( + f"# MISSING UPLOAD\n" + f"Recorded as `{primary}` but the bytes are not on disk. " + "Ask the user to re-upload this file.\n" + ) + dest_nested.write_text(stub, encoding="utf-8") + else: + _link_or_copy(source, dest_nested) + _link_or_copy(dest_nested, dest_flat) + for alias in aliases[1:]: + alias_name = unique_workspace_name(alias, used_names) + _link_or_copy(dest_nested, files_dir / alias_name) + _link_or_copy(dest_nested, workspace / alias_name) + listing.append(f"- ./files/{alias_name} (also ./{alias_name})") + + for entry in ordered: + fid = str(entry.get("id") or "") + if fid and fid in copied_ids: + continue + is_required = ( + fid in attach_ids + or file_scope_of(entry) == "chat" + or log_preanalysis.is_log_filename(str(entry.get("name") or "")) + ) + name_aliases = _workspace_name_aliases(entry) + primary = unique_workspace_name(name_aliases[0], used_names) + source = UPLOAD_DIR / str(entry.get("stored_name") or "") + if not source.exists() or not source.is_file(): + logger.warning( + "workspace file missing on disk: name=%s stored=%s chat=%s", + entry.get("name"), + entry.get("stored_name"), + chat_id, + ) + notes.append( + f"- ./files/{primary} (also ./{primary}) — MISSING on disk " + f"(stored_name={entry.get('stored_name')}). Re-upload this file." + ) + try: + place_copy(None, primary, name_aliases) + except OSError as error: + logger.warning("workspace stub failed for %s: %s", primary, error) + used_names.discard(primary.lower()) + continue + listing.append( + f"- ./files/{primary} (also ./{primary}) — MISSING on disk, stub only" + ) + if fid: + copied_ids.add(fid) + continue + try: + size = source.stat().st_size + except OSError as error: + logger.warning("workspace stat failed for %s: %s", entry.get("name"), error) + notes.append( + f"- ./files/{primary} — could not read size ({error})" + ) + continue + if not is_required: + if size > MAX_WORKSPACE_FILE_BYTES: + logger.warning( + "skip workspace copy (too large): %s (%s bytes)", + entry.get("name"), + size, + ) + notes.append( + f"- ./files/{primary} — SKIPPED, too large ({size} bytes)" + ) + continue + if total_bytes + size > MAX_WORKSPACE_TOTAL_BYTES: + logger.warning( + "skip workspace copy (total cap): %s", + entry.get("name"), + ) + notes.append( + f"- ./files/{primary} — SKIPPED, workspace total size cap" + ) + continue + try: + place_copy(source, primary, name_aliases) + except OSError as error: + logger.warning("workspace copy failed for %s: %s", entry.get("name"), error) + used_names.discard(primary.lower()) + notes.append(f"- ./files/{primary} — COPY FAILED ({error})") + continue + if fid: + copied_ids.add(fid) + total_bytes += size + listing.append(f"- ./files/{primary} (also ./{primary})") + logger.info( + "workspace copy %s (%s bytes) required=%s chat=%s", + primary, + size, + is_required, + chat_id, + ) + + _write_workspace_hints(workspace, listing, notes) + return workspace + + +def _write_workspace_hints( + workspace: Path, + listing: list[str], + notes: list[str] | None = None, +) -> None: + note_block = "" + if notes: + note_block = ( + "\n## Warnings\n\n" + + "\n".join(notes) + + "\n" + ) + (workspace / "PROJECT_FILES.md").write_text( + ( + "# Files in this workspace\n\n" + "These are the only uploaded materials for this run. Read them from " + "this folder only. Do not search parent directories, Helpomatica, " + "Downloads, or invent client-PC paths. Names match the UI chips " + "(for example `line_u_codes.log.1`), not hashed storage names.\n\n" + f"{chr(10).join(listing) or '(no files copied)'}\n" + f"{note_block}" + ), + encoding="utf-8", + ) + (workspace / "README.md").write_text( + ( + "# Helpomatica agent workspace\n\n" + "This directory **is** your working directory (`cwd`). Files are " + "already here under `./` and `./files/`.\n\n" + "- FORBIDDEN: saying you left the workspace, that Shell is not in " + "the workspace, or `cd` to Helpomatica / «папка проекта» / home / " + "Downloads / parent folders.\n" + "- Work only here. If a file is missing from `./` or `./files/`, " + "say so — do not search elsewhere.\n" + "- For `*.gz` logs: decompress with gzip/Python **only** on files " + "inside this workspace (e.g. `./line_u.log.3.gz`).\n" + "- If `./PREANALYSIS.md` exists, read it first. Do not re-scan whole logs " + "unless those numbers look wrong. Cite log lines as `:15231:line_u_codes.log.1`.\n" + ), + encoding="utf-8", + ) + (workspace / "WORKSPACE_ROOT.txt").write_text( + f"{workspace}\n", + encoding="utf-8", + ) + (workspace / ".cursorignore").write_text( + ( + "# Isolated agent workspace. Do not treat the Helpomatica git repo\n" + "# or parent folders as part of this project.\n" + ), + encoding="utf-8", + ) + cursor_dir = workspace / ".cursor" + cursor_dir.mkdir(exist_ok=True) + (cursor_dir / "workspace.md").write_text( + ( + "# cwd constraint\n\n" + "Working directory is this temp workspace. Never leave it. Never " + "say you left it. All in-scope uploads are in ./ and ./files/.\n" + ), + encoding="utf-8", + ) + (workspace / "AGENTS.md").write_text( + ( + "# Agent instructions\n\n" + "Your shell cwd is this workspace. FORBIDDEN to `cd` to Helpomatica, " + "home, or «папка проекта». Use only ./ and ./files/. If a file is " + "missing, report that — do not search parent dirs. " + "If PREANALYSIS.md is present, use it first.\n" + ), + encoding="utf-8", + ) + + +READABLE_EXTENSIONS = { + ".csv", ".json", ".log", ".md", ".py", ".sql", ".txt", ".xml", ".yaml", ".yml" +} +MAX_PROMPT_FILE_BYTES = 2 * 1024 * 1024 +DEFAULT_QUERY_KEYWORDS = ( + "code", "codes", "insert", "create", "table", "barcode", "gtin", "баз", "код" +) +SQL_FOCUS_PATTERNS = ( + r"CREATE\s+FUNCTION[^\n]*codes_input_insert", + r"CREATE\s+FUNCTION[^\n]*codes_input", + r"CREATE\s+TABLE[^\n]*\bcodes\b", + r"INSERT\s+INTO\s+codes\b", + r"codes_input_meta", + r"codes_output_insert", +) + + +def is_gzip_name(name: str) -> bool: + return str(name or "").lower().endswith(".gz") + + +def is_prompt_readable_name(name: str) -> bool: + """Rotated logs like ``.log.1`` are text; ``.gz`` is not inlined.""" + lower = str(name or "").lower() + if is_gzip_name(lower): + return False + suffix = Path(lower).suffix + if suffix in READABLE_EXTENSIONS: + return True + return bool(re.search(r"\.log(\.\d+)?$", lower)) + + +def decode_text(raw: bytes) -> str: + for encoding in ("utf-8-sig", "utf-16", "cp1251"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return "" + + +def query_keywords(query: str) -> set[str]: + words = {word.lower() for word in re.findall(r"[\w\u0400-\u04FF]+", query.lower()) if len(word) > 2} + words.update(DEFAULT_QUERY_KEYWORDS) + return words + + +def extract_relevant_excerpt(text: str, query: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + + lines = text.splitlines() + keywords = query_keywords(query) + ranges: list[tuple[int, int, int]] = [] + + for pattern in SQL_FOCUS_PATTERNS: + for match in re.finditer(pattern, text, flags=re.IGNORECASE): + start_line = text[: match.start()].count("\n") + ranges.append((10, max(0, start_line - 5), min(len(lines), start_line + 80))) + + for index, line in enumerate(lines): + lower = line.lower() + score = sum(1 for keyword in keywords if keyword in lower) + if score: + ranges.append((score, max(0, index - 10), min(len(lines), index + 25))) + + selected: list[str] = [] + used = 0 + header = "\n".join(lines[:30]) + if header: + selected.append(header) + used += len(header) + + for _, start, end in sorted(ranges, key=lambda item: item[0], reverse=True): + chunk = "\n".join(lines[start:end]) + if not chunk or chunk in selected: + continue + if used + len(chunk) > max_chars: + remaining = max_chars - used + if remaining < 400: + break + chunk = chunk[:remaining] + selected.append(chunk) + used += len(chunk) + if used >= max_chars: + break + + if not selected: + return text[:max_chars] + "\n[Файл сокращён]" + + excerpt = "\n\n".join(selected) + if len(excerpt) < len(text): + excerpt += "\n[Показаны только релевантные фрагменты файла]" + return excerpt[:max_chars] + + +def select_project_files( + data: dict, + project_id: str, + query: str, + chat_id: str | None = None, + attachment_ids: list[str] | None = None, +) -> list[dict]: + files = iter_scoped_files(data, project_id, chat_id) + lowered = query.lower() + if any(token in lowered for token in ("базов", "base")): + filtered = [entry for entry in files if "base" in entry["name"].lower()] + if filtered: + files = filtered + elif "2.10" in lowered: + filtered = [entry for entry in files if "2.10" in entry["name"]] + if filtered: + files = filtered + ranked = rank_files_for_workspace(files, query, attachment_ids) + return dedupe_workspace_files(ranked) + + +def build_sql_summary(text: str, query: str, max_chars: int) -> str: + lines = text.splitlines() + keywords = query_keywords(query) + tables = [ + line.strip() + for line in lines + if "CREATE TABLE" in line.upper() and any(keyword in line.lower() for keyword in keywords) + ][:8] + if not tables: + tables = [ + line.strip() + for line in lines + if "CREATE TABLE" in line.upper() and "codes" in line.lower() + ][:8] + functions = [ + line.strip() + for line in lines + if "CREATE FUNCTION" in line.upper() and any(keyword in line.lower() for keyword in keywords) + ][:6] + triggers = [ + line.strip() + for line in lines + if "TRIGGER" in line.upper() and any(keyword in line.lower() for keyword in keywords) + ][:6] + snippets: list[str] = [] + for pattern in SQL_FOCUS_PATTERNS: + for match in re.finditer(pattern, text, flags=re.IGNORECASE): + start_line = text[: match.start()].count("\n") + snippet = "\n".join(lines[start_line : start_line + 35]).strip() + if snippet and snippet not in snippets: + snippets.append(snippet[:700]) + if len(snippets) >= 2: + break + if len(snippets) >= 2: + break + summary_parts = [] + if tables: + summary_parts.append("Таблицы:\n" + "\n".join(tables)) + if functions: + summary_parts.append("Функции:\n" + "\n".join(functions)) + if triggers: + summary_parts.append("Триггеры:\n" + "\n".join(triggers)) + if snippets: + summary_parts.append("Фрагменты SQL:\n" + "\n\n---\n\n".join(snippets)) + summary = "\n\n".join(summary_parts) or text[:max_chars] + return summary[:max_chars] + + +def build_file_context( + data: dict, + project_id: str, + query: str = "", + chat_id: str | None = None, + attachment_ids: list[str] | None = None, +) -> tuple[str, list[str]]: + parts: list[str] = [] + selected = select_project_files( + data, project_id, query, chat_id=chat_id, attachment_ids=attachment_ids + ) + # Unique names for UI context chips (no eight copies of the same gz). + used_names = [entry["name"] for entry in selected] + remaining = 2_500 + per_file = 1_200 + for entry in selected: + source = UPLOAD_DIR / entry["stored_name"] + safe = sanitize_workspace_filename(entry["name"]) + workspace_hint = f"Рабочая копия: ./files/{safe} и ./{safe}" + exists = source.exists() and source.is_file() + size = 0 + if exists: + try: + size = source.stat().st_size + except OSError: + exists = False + readable = is_prompt_readable_name(entry["name"]) + if not exists or not readable: + gz_hint = ( + " Это .gz — распакуйте gzip/Python gzip только внутри workspace." + if is_gzip_name(entry["name"]) + else "" + ) + missing = "" if exists else " Файл не найден на диске сервера." + parts.append( + f"--- {entry['name']} ---\n" + f"[Бинарный/нетекстовый файл: текст не встроен. {workspace_hint} — " + f"откройте этот путь в workspace, не ищите файл на ПК пользователя." + f"{gz_hint}{missing}]" + ) + continue + if size > MAX_PROMPT_FILE_BYTES: + parts.append( + f"--- {entry['name']} ---\n" + f"[Файл большой ({size} байт), текст не встроен в промпт. " + f"{workspace_hint} — читайте его из workspace.]" + ) + continue + if remaining <= 0: + parts.append( + f"--- {entry['name']} ---\n" + f"[Файл есть в проекте, текст не включён из‑за лимита контекста. " + f"{workspace_hint}]" + ) + continue + text = decode_text(source.read_bytes()) + if not text: + parts.append( + f"--- {entry['name']} ---\n" + f"[Не удалось определить кодировку. {workspace_hint}]" + ) + continue + budget = min(per_file, remaining) + if str(entry["name"]).lower().endswith(".sql"): + excerpt = build_sql_summary(text, query, budget) + else: + excerpt = extract_relevant_excerpt(text, query, budget) + remaining -= len(excerpt) + parts.append(f"--- {entry['name']} ---\n{workspace_hint}\n{excerpt}") + return ("\n\n".join(parts) or "[Файлы не добавлены]", used_names) + + +def _file_chip(entry: dict) -> dict: + return {"id": entry.get("id") or "", "name": entry.get("name") or "файл"} + + +def dedupe_file_chips(items: list[dict]) -> list[dict]: + seen_ids: set[str] = set() + seen_names: set[str] = set() + unique: list[dict] = [] + for item in items: + fid = str((item or {}).get("id") or "") + name = str((item or {}).get("name") or "").strip() + key = name.lower() + if fid and fid in seen_ids: + continue + if key and key in seen_names: + continue + if fid: + seen_ids.add(fid) + if key: + seen_names.add(key) + unique.append({"id": fid, "name": name or "файл"}) + return unique + + +def build_context_chip_groups( + data: dict, + project_id: str, + chat_id: str | None, + messages: list[dict], +) -> dict: + last_user = next( + (message for message in reversed(messages) if message.get("role") == "user"), + {}, + ) + attachments = dedupe_file_chips( + [ + { + "id": str((ref or {}).get("id") or ""), + "name": str((ref or {}).get("name") or "файл"), + } + for ref in last_user.get("attachments") or [] + ] + ) + attach_ids = {item["id"] for item in attachments if item["id"]} + attach_names = {item["name"].lower() for item in attachments if item["name"]} + chat_files: list[dict] = [] + project_files: list[dict] = [] + for entry in iter_scoped_files(data, project_id, chat_id): + chip = _file_chip(entry) + if chip["id"] in attach_ids or chip["name"].lower() in attach_names: + continue + if file_scope_of(entry) == "chat": + chat_files.append(chip) + else: + project_files.append(chip) + return { + "attachments": attachments, + "chat_files": dedupe_file_chips(chat_files), + "project_files": dedupe_file_chips(project_files), + } + + +def _sdk_attr(obj: Any, *names: str, default: Any = "") -> Any: + if obj is None: + return default + if isinstance(obj, Mapping): + for name in names: + if name in obj and obj[name] is not None: + return obj[name] + return default + for name in names: + if hasattr(obj, name): + value = getattr(obj, name) + if value is not None: + return value + return default + + +def _assistant_text_from_sdk(message: Any) -> str: + payload = _sdk_attr(message, "message", default=None) + content = _sdk_attr(payload, "content", default=()) or () + parts: list[str] = [] + for block in content: + text = _sdk_attr(block, "text", default="") + if text: + parts.append(str(text)) + return "".join(parts) + + +def _emit_text_chunk( + parts: list[str], + text: str, + *, + snapshot: bool = False, +) -> tuple[str, bool]: + incoming = str(text or "") + if not incoming: + return "", False + current = "".join(parts) + if snapshot: + if incoming == current: + return "", False + if incoming.startswith(current): + chunk = incoming[len(current) :] + if chunk: + parts.append(chunk) + return chunk, False + parts.clear() + parts.append(incoming) + return incoming, True + parts.append(incoming) + return incoming, False + + +_JUNK_STATUS_TOKENS = { + "finished", + "running", + "done", + "completed", + "complete", + "success", + "ok", + "ready", + "idle", + "завершено", + "готово", +} + +_PROMPT_LEFTOVER_RE = re.compile( + r"^\s*(на русском языке\.?\s*)+", + re.IGNORECASE, +) +_FINISHED_TOKEN_RE = re.compile(r"(?i)\b(FINISHED|RUNNING)\b") + +_TOOL_TITLES = { + "read": "Читаю файл", + "readfile": "Читаю файл", + "shell": "Команда", + "bash": "Команда", + "command": "Команда", + "grep": "Поиск", + "rg": "Поиск", + "glob": "Поиск файлов", + "write": "Запись файла", + "edit": "Правка", + "delete": "Удаление", + "ls": "Список файлов", + "readlints": "Проверка", + "mcp": "Инструмент", + "semsearch": "Семантический поиск", + "createplan": "План", + "updatetodos": "Задачи", + "task": "Задача", + "generateimage": "Изображение", + "recordscreen": "Запись экрана", +} + + +def _is_junk_status(text: str) -> bool: + normalized = re.sub(r"[^a-zа-яё]+", "", (text or "").strip().lower()) + return normalized in _JUNK_STATUS_TOKENS + + +def _clean_thinking_chunk(text: str) -> str: + value = str(text or "") + kept: list[str] = [] + for line in value.splitlines(keepends=True): + stripped = line.strip() + if _is_junk_status(stripped): + continue + kept.append(_FINISHED_TOKEN_RE.sub("", line)) + value = "".join(kept) + value = _PROMPT_LEFTOVER_RE.sub("", value) + if _is_junk_status(value): + return "" + return value + + +def _suffix_prefix_overlap(current: str, incoming: str) -> int: + max_n = min(len(current), len(incoming)) + for length in range(max_n, 0, -1): + if current.endswith(incoming[:length]): + return length + return 0 + + +def _append_thinking_chunk(parts: list[str], text: str, *, snapshot: bool = False) -> str: + """Append thinking text. Never replace previously accumulated history.""" + incoming = _clean_thinking_chunk(text) + if not incoming: + return "" + current = "".join(parts) + if snapshot: + if not current: + chunk = incoming + elif incoming == current or current.endswith(incoming): + return "" + elif incoming.startswith(current): + chunk = incoming[len(current) :] + elif incoming in current: + return "" + else: + overlap = _suffix_prefix_overlap(current, incoming) + if overlap == len(incoming): + return "" + if overlap > 0: + chunk = incoming[overlap:] + else: + sep = "" if current.endswith("\n") else "\n" + chunk = f"{sep}{incoming}" + else: + chunk = incoming + if not chunk: + return "" + parts.append(chunk) + return chunk + + +def _as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, Mapping): + return dict(value) + return {} + + +def _short_path(value: str) -> str: + text = str(value or "").strip().replace("\\", "/") + if not text: + return "" + return text.rstrip("/").rsplit("/", 1)[-1] or text + + +def _normalize_tool_type(value: str) -> str: + key = re.sub(r"[^a-zA-Z]", "", str(value or "")).lower() + return key or "tool" + + +def _tool_title(tool_type: str) -> str: + return _TOOL_TITLES.get(_normalize_tool_type(tool_type), "Инструмент") + + +def _truncate_detail(text: str, limit: int = 4000) -> str: + value = str(text or "").strip() + if len(value) <= limit: + return value + return value[:limit].rstrip() + "\n…" + + +def _tool_detail(tool_type: str, args: Any) -> str: + payload = _as_dict(args) + kind = _normalize_tool_type(tool_type) + if kind in {"read", "readfile", "write", "edit", "delete"}: + return _short_path( + str( + payload.get("path") + or payload.get("file") + or payload.get("target_file") + or "" + ) + ) + if kind in {"shell", "bash", "command"}: + return str(payload.get("command") or payload.get("cmd") or "").strip() + if kind in {"grep", "rg"}: + pattern = str(payload.get("pattern") or payload.get("query") or "").strip() + path = _short_path(str(payload.get("path") or payload.get("glob") or "")) + return f"{pattern} {path}".strip() if path else pattern + if kind == "glob": + return str( + payload.get("globPattern") + or payload.get("glob_pattern") + or payload.get("pattern") + or "" + ).strip() + if kind == "ls": + return str(payload.get("path") or payload.get("targetDirectory") or "").strip() + if kind == "semsearch": + return str(payload.get("query") or payload.get("pattern") or "").strip() + if kind == "mcp": + name = str(payload.get("toolName") or payload.get("name") or "").strip() + server = str(payload.get("server") or payload.get("provider") or "").strip() + return f"{server}/{name}".strip("/") if server or name else "" + if kind == "task": + return str(payload.get("description") or payload.get("prompt") or "").strip() + for key in ("path", "command", "pattern", "query", "name", "message"): + item = payload.get(key) + if item: + return str(item).strip() + return "" + + +def _describe_tool(tool_call: Any, name_fallback: str = "") -> tuple[str, str, str]: + payload = _as_dict(tool_call) + tool_type = str( + payload.get("type") + or payload.get("name") + or name_fallback + or "tool" + ) + args = payload.get("args") + if args is None: + args = payload + title = _tool_title(tool_type) + if title == "Инструмент" and name_fallback: + title = _tool_title(name_fallback) + if title == "Инструмент": + title = str(name_fallback).strip() or "Инструмент" + detail = _truncate_detail(_tool_detail(tool_type, args), 1200) + return _normalize_tool_type(tool_type) or "tool", title, detail + + +def _activity_transcript(activity: list[dict[str, Any]]) -> str: + lines: list[str] = [] + for step in activity: + kind = str(step.get("type") or "") + title = str(step.get("title") or "").strip() + detail = str(step.get("detail") or "").strip() + if kind == "thinking": + if detail: + lines.append(detail) + continue + if title and detail: + lines.append(f"{title}: {detail}") + elif title: + lines.append(title) + elif detail: + lines.append(detail) + return "\n".join(lines).strip() + + +def _coalesce_thinking_steps(activity: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged: list[dict[str, Any]] = [] + for step in activity: + kind = str(step.get("type") or "") + detail = str(step.get("detail") or "") + if kind in {"thinking", "status"} and _is_junk_status(detail): + continue + last = merged[-1] if merged else None + if kind == "thinking" and last is not None and str(last.get("type") or "") == "thinking": + prev = str(last.get("detail") or "") + if detail: + if detail == prev or prev.endswith(detail) or detail in prev: + pass + elif detail.startswith(prev): + last["detail"] = detail + else: + last["detail"] = prev + detail + last["status"] = step.get("status") or last.get("status") + if step.get("title"): + last["title"] = step.get("title") + continue + merged.append(dict(step)) + return merged + + +def _trim_activity(activity: list[dict[str, Any]]) -> list[dict[str, Any]]: + coalesced = _coalesce_thinking_steps(activity) + trimmed: list[dict[str, Any]] = [] + for step in coalesced[-240:]: + entry = { + "id": step.get("id"), + "type": step.get("type"), + "title": step.get("title"), + "detail": _truncate_detail(str(step.get("detail") or ""), 8000), + "status": step.get("status"), + } + trimmed.append(entry) + return trimmed + + +def build_chat_prompt( + project: dict, + file_context: str, + history: str, + extra: str = "", +) -> str: + extra_block = f"\n{extra.strip()}\n\n" if (extra or "").strip() else "" + return ( + f"Проект: {project['name']}\n" + f"Описание: {project['description']}\n" + f"Инструкции: {project['instructions'] or 'Нет'}\n\n" + f"Материалы:\n{file_context}\n\n" + f"История:\n{history}\n\n" + "Правила платформы Helpomatica:\n" + "- Текущая рабочая директория (cwd) УЖЕ является изолированным " + "workspace агента. Файлы УЖЕ лежат в cwd: ./имя_файла и ./files/имя_файла " + "(см. PROJECT_FILES.md). PREANALYSIS.md и логи тоже в `./`. " + "Работай только там.\n" + "- ЗАПРЕЩЕНО: писать, что Shell/cwd «не в workspace», что ты «уходишь " + "из workspace», «переходишь к рабочей папке проекта», делаешь `cd` в " + "Helpomatica, домашнюю папку, Downloads или родительские каталоги. " + "Не выполняй `cd` вообще. cwd уже правильный. Никогда не пиши, что " + "shell покинул workspace.\n" + "- Если файла нет в ./ или ./files/ — скажи, что его нет среди " + "загруженных материалов. НЕ ищи в родительских папках, в git-репозитории " + "Helpomatica, на диске сервера и на ПК пользователя " + "(Downloads, C:\\Users\\..., /home/...). Не выдумывай абсолютные пути.\n" + "- Имена файлов совпадают с чипами в UI (например line_u.log.3.gz), " + "а не с hashed stored_name. Пользователь мог открыть Helpomatica с " + "другого ПК по LAN — путей с его компьютера не существует.\n" + "- Для логов *.gz / *.log.*.gz: распаковывай и читай только файлы " + "внутри workspace (gzip, Python gzip на ./файл.gz).\n" + "- Если есть ./PREANALYSIS.md — прочитай его ПЕРВЫМ. Не сканируй " + "логи целиком, если цифры выглядят правдоподобно. Цитируй строки как " + "`:15231:line_u_codes.log.1` или `::log:line_u_codes.log.1:15231::`.\n" + "- Генерация изображений недоступна: в Helpomatica нет image API и нет " + "инструмента создания картинок. Не утверждай, что сгенерировал, нарисовал " + "или сохранил изображение. Не вставляй markdown-изображения без реального " + "доступного URL. Если пользователь просит картинку — честно скажи, что " + "генерация изображений недоступна, и предложи текстовое описание или " + "mermaid-диаграмму.\n" + "- Отвечай только полезным текстом/markdown/mermaid по существу вопроса.\n\n" + f"{extra_block}" + "Ответь кратко и по делу на последнее сообщение пользователя." + ) + + +def normalize_analysis_question(text: str) -> str: + value = _ATTACHED_NAMES_RE.sub("", str(text or "")) + value = re.sub(r"\s+", " ", value).strip().lower() + return value + + +def analysis_file_signature(files: list[dict]) -> str: + parts = [ + f"{entry.get('id')}:{entry.get('size', 0)}" + for entry in sorted(files, key=lambda item: str(item.get("id") or "")) + if entry.get("id") + ] + return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() + + +def analysis_cache_key(files: list[dict], question: str) -> str: + raw = f"{analysis_file_signature(files)}|{normalize_analysis_question(question)}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def lookup_analysis_cache( + data: dict, + project_id: str, + files: list[dict], + question: str, +) -> dict | None: + cache = data.get("analysis_cache") or [] + key = analysis_cache_key(files, question) + for entry in reversed(cache): + if entry.get("project_id") == project_id and entry.get("key") == key: + return entry + job_id = log_preanalysis.detect_job_id(question) + file_sig = analysis_file_signature(files) + if not job_id or not file_sig: + return None + for entry in reversed(cache): + if ( + entry.get("project_id") == project_id + and entry.get("file_sig") == file_sig + and entry.get("job_id") == job_id + ): + near = dict(entry) + near["near_match"] = True + return near + return None + + +def store_analysis_cache( + project_id: str, + files: list[dict], + question: str, + answer: str, + preanalysis: str = "", + summary: str = "", +) -> None: + answer = (answer or "").strip() + if not answer: + return + data = load_db() + entry = { + "key": analysis_cache_key(files, question), + "file_sig": analysis_file_signature(files), + "project_id": project_id, + "job_id": log_preanalysis.detect_job_id(question) or "", + "question": normalize_analysis_question(question)[:500], + "answer": answer[:8000], + "preanalysis": (preanalysis or "")[:80_000], + "summary": (summary or "")[:2000], + "created_at": now(), + } + cache = [ + item + for item in (data.get("analysis_cache") or []) + if not ( + item.get("project_id") == project_id and item.get("key") == entry["key"] + ) + ] + cache.append(entry) + data["analysis_cache"] = cache[-80:] + save_db(data) + + +def _prompt_extra_from_cache_and_scan( + cache_hit: dict | None, + preanalysis_md: str, +) -> str: + parts: list[str] = [] + if preanalysis_md: + parts.append( + "В cwd уже лежит ./PREANALYSIS.md — используй его первым, " + "не перечитывай логи целиком." + ) + if cache_hit and (cache_hit.get("answer") or cache_hit.get("summary")): + if cache_hit.get("near_match"): + parts.append( + "Похожий предыдущий разбор по тем же файлам (не пересчитывай " + "с нуля, уточни если вопрос уже):\n" + f"{(cache_hit.get('summary') or '')}\n\n" + f"{(cache_hit.get('answer') or '')[:6000]}" + ) + else: + parts.append( + "Предыдущий разбор (не пересчитывай с нуля, уточни если вопрос уже):\n" + f"{(cache_hit.get('summary') or '')}\n\n" + f"{(cache_hit.get('answer') or '')[:6000]}\n\n" + "Вопрос совпадает — ответь короче, без полного пересчёта." + ) + return "\n\n".join(parts) + + +def resolve_upload_path(entry: dict) -> Path: + return UPLOAD_DIR / str(entry.get("stored_name") or "") + + +def log_display_name(entry: dict) -> str: + return sanitize_workspace_filename( + _strip_upload_uuid_prefixes(str(entry.get("name") or "log")) + ) + + +def file_in_user_scope(data: dict, stored_file: dict) -> bool: + """Any authenticated user can see project files; chat-scoped files stay in-project.""" + if not stored_file: + return False + project_id = stored_file.get("project_id") + if not project_id: + return False + return any(item.get("id") == project_id for item in data.get("projects") or []) + + +def snippet_around(text: str, needle: str, radius: int = 70) -> str: + raw = str(text or "").replace("\n", " ") + lowered = raw.lower() + index = lowered.find(needle.lower()) + if index < 0: + return raw[: radius * 2] + start = max(0, index - radius) + end = min(len(raw), index + len(needle) + radius) + snippet = raw[start:end].strip() + if start > 0: + snippet = "…" + snippet + if end < len(raw): + snippet = snippet + "…" + return snippet + + +def attachment_filename(filename: str, fallback: str = "download") -> str: + name = (filename or fallback).replace("\r", " ").replace("\n", " ").strip() or fallback + ascii_name = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._") or fallback + return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}" + + +def export_chat_markdown(chat: dict, project: dict, messages: list[dict]) -> str: + title = chat.get("title") or "Чат" + lines = [ + f"# {title}", + "", + f"- Проект: {project.get('name') or ''}", + f"- Обновлён: {chat.get('updated_at') or ''}", + "", + ] + for message in messages: + role = "Помощник" if message.get("role") == "assistant" else ( + message.get("author_name") or "Пользователь" + ) + created = message.get("created_at") or "" + lines.append(f"## {role}") + if created: + lines.append(f"*{created}*") + lines.append("") + attachments = message.get("attachments") or [] + if attachments: + names = ", ".join( + str(item.get("name") or item.get("id") or "файл") for item in attachments + ) + lines.append(f"Вложения: {names}") + lines.append("") + lines.append(str(message.get("content") or "").strip() or "_(пусто)_") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def sse_format(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +def _put_event(out: queue.Queue, event_type: str, **payload: Any) -> None: + out.put({"type": event_type, **payload}) + + +def _stream_cursor_agent_threaded( + prompt: str, + workspace: Path, + model: str, + chat_id: str, + out: queue.Queue, + cancel_event: threading.Event, +) -> None: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + async def _run() -> None: + from cursor_sdk import ( + AgentOptions, + AsyncAgent, + AsyncClient, + CursorAgentError, + LocalAgentOptions, + ) + + api_key = os.getenv("CURSOR_API_KEY", "").strip() + if not api_key: + raise RuntimeError( + "CURSOR_API_KEY не настроен. Добавьте ключ в файл .env и перезапустите сервер." + ) + + loop = asyncio.get_running_loop() + with agent_status_lock: + entry = active_runs.get(chat_id) + if entry is not None: + entry["loop"] = loop + + answer_parts: list[str] = [] + thinking_parts: list[str] = [] + activity_log: list[dict[str, Any]] = [] + thinking_ms: int | None = None + cancelled = False + activity_seq = 0 + current_thinking_id: str | None = None + tool_activity_ids: dict[str, str] = {} + seen_tool_keys: set[tuple[str, str]] = set() + + def emit_thinking(text: str) -> None: + if not text: + return + _put_event(out, "thinking", text=text, replace=False) + + def append_thinking_transcript(text: str) -> None: + chunk = _append_thinking_chunk(thinking_parts, text, snapshot=False) + if chunk: + emit_thinking(chunk) + + def emit_answer(text: str, *, replace: bool = False) -> None: + if not text and not replace: + return + _put_event(out, "delta", text=text, replace=replace) + + def emit_activity(step: dict[str, Any]) -> dict[str, Any]: + nonlocal activity_seq + payload = dict(step) + if not payload.get("id"): + activity_seq += 1 + payload["id"] = f"a{activity_seq}" + existing = next( + (item for item in activity_log if item.get("id") == payload["id"]), + None, + ) + if existing is not None: + if payload.get("append") and payload.get("detail"): + existing["detail"] = str(existing.get("detail") or "") + str( + payload.get("detail") or "" + ) + payload["detail"] = existing["detail"] + payload.pop("append", None) + else: + for key, value in payload.items(): + if value is not None: + existing[key] = value + payload = dict(existing) + else: + payload.pop("append", None) + activity_log.append(payload) + _put_event(out, "activity", step=payload) + return payload + + def handle_thinking_text(text: str, *, snapshot: bool = False) -> None: + nonlocal current_thinking_id + chunk = _append_thinking_chunk( + thinking_parts, text, snapshot=snapshot + ) + if not chunk: + return + emit_thinking(chunk) + if not current_thinking_id: + last = activity_log[-1] if activity_log else None + if last is not None and str(last.get("type") or "") == "thinking": + current_thinking_id = str(last.get("id") or "") or None + if current_thinking_id: + emit_activity( + { + "id": current_thinking_id, + "type": "thinking", + "title": "Размышления", + "detail": chunk, + "status": "running", + "append": True, + } + ) + else: + step = emit_activity( + { + "type": "thinking", + "title": "Размышления", + "detail": chunk, + "status": "running", + } + ) + current_thinking_id = str(step["id"]) + + def close_thinking_step(*, end_phase: bool = True) -> None: + nonlocal current_thinking_id + if not current_thinking_id: + return + emit_activity( + { + "id": current_thinking_id, + "type": "thinking", + "title": "Размышления", + "status": "completed", + } + ) + if end_phase: + current_thinking_id = None + + def handle_answer_text(text: str, *, snapshot: bool | None = False) -> None: + incoming = str(text or "") + if not incoming: + return + current = "".join(answer_parts) + use_snapshot = snapshot + if snapshot is None: + if not current: + use_snapshot = False + elif incoming == current: + return + elif incoming.startswith(current): + use_snapshot = True + elif current.startswith(incoming): + return + else: + use_snapshot = False + chunk, replace = _emit_text_chunk( + answer_parts, incoming, snapshot=bool(use_snapshot) + ) + if chunk or replace: + emit_answer(chunk, replace=replace) + + def flush_answer_to_thinking() -> None: + text = "".join(answer_parts).strip() + if not text: + return + close_thinking_step() + emit_activity( + { + "type": "narration", + "title": "Заметка", + "detail": _truncate_detail(text, 4000), + "status": "completed", + } + ) + append_thinking_transcript(f"{text}\n") + answer_parts.clear() + emit_answer("", replace=True) + + def handle_tool_event( + tool_call: Any, + *, + name: str = "", + status: str = "", + call_id: str = "", + ) -> None: + state = (status or "running").strip().lower() + if state in ("complete", "success"): + state = "completed" + ident = str(call_id or _sdk_attr(tool_call, "call_id", "callId", default="") or "") + already = bool(ident) and (ident, state) in seen_tool_keys + if ident: + seen_tool_keys.add((ident, state)) + kind, title, detail = _describe_tool(tool_call, name) + if not ident: + for item in reversed(activity_log[-12:]): + if ( + item.get("title") == title + and (item.get("detail") or "") == (detail or "") + and (item.get("status") or "") == (state or "running") + ): + return + step_id = tool_activity_ids.get(ident) if ident else None + if already and state == "running": + if step_id and detail: + emit_activity( + { + "id": step_id, + "type": kind, + "title": title, + "detail": detail, + "status": "running", + } + ) + return + + flush_answer_to_thinking() + close_thinking_step() + if state == "running" or not step_id: + step = emit_activity( + { + "id": step_id, + "type": kind, + "title": title, + "detail": detail, + "status": state or "running", + } + ) + if ident: + tool_activity_ids[ident] = str(step["id"]) + if not already: + line = f"{title}" + (f": {detail}" if detail else "") + append_thinking_transcript(f"{line}\n") + return + emit_activity( + { + "id": step_id, + "type": kind, + "title": title, + "detail": detail or None, + "status": state, + } + ) + + def handle_status_event(status: str, message: str = "") -> None: + label = str(message or status or "").strip() + if not label or _is_junk_status(label) or _is_junk_status(status): + return + close_thinking_step() + emit_activity( + { + "type": "status", + "title": "Статус", + "detail": label, + "status": "completed", + } + ) + append_thinking_transcript(f"{label}\n") + + def handle_shell_output(payload: Any) -> None: + data = _as_dict(payload) + chunk = str( + data.get("stdout") + or data.get("text") + or data.get("data") + or data.get("output") + or "" + ) + if not chunk: + return + last_shell = next( + ( + item + for item in reversed(activity_log) + if item.get("type") in {"shell", "bash", "command"} + ), + None, + ) + if last_shell is None: + return + emit_activity( + { + "id": last_shell.get("id"), + "type": last_shell.get("type"), + "title": last_shell.get("title") or "Команда", + "detail": chunk, + "status": last_shell.get("status") or "running", + "append": True, + } + ) + + # Same resolved absolute long path for bridge --workspace AND + # LocalAgentOptions.cwd (and Agent.create local=). Short 8.3 paths + # make the agent think the shell left the workspace. + # The SDK bridge subprocess inherits the server cwd (Helpomatica repo) + # unless we set it explicitly — patch create_subprocess_exec for launch. + workspace_cwd = agent_workspace_cwd(workspace) + local_opts = LocalAgentOptions(cwd=workspace_cwd) + logger.info("agent launch workspace=%s", workspace_cwd) + import cursor_sdk._async_bridge as _async_bridge + + orig_exec = _async_bridge.asyncio.create_subprocess_exec + + async def _exec_in_workspace(*args: Any, **kwargs: Any): + kwargs.setdefault("cwd", workspace_cwd) + return await orig_exec(*args, **kwargs) + + _async_bridge.asyncio.create_subprocess_exec = _exec_in_workspace + try: + bridge_client = await AsyncClient.launch_bridge( + workspace=workspace_cwd, + local=local_opts, + ) + finally: + _async_bridge.asyncio.create_subprocess_exec = orig_exec + try: + async with bridge_client as client: + await asyncio.sleep(0.75) + agent = await AsyncAgent.create( + AgentOptions( + api_key=api_key, + model=model or default_model(), + local=local_opts, + ), + client=client, + ) + try: + run = await agent.send(prompt) + with agent_status_lock: + entry = active_runs.get(chat_id) + if entry is not None: + entry["run"] = run + + async for event in run.events(): + if cancel_event.is_set(): + cancelled = True + try: + if run.supports("cancel"): + await run.cancel() + except Exception as cancel_error: # noqa: BLE001 + logger.warning("cancel failed: %s", cancel_error) + break + + update = getattr(event, "interaction_update", None) + if update is not None: + utype = str(_sdk_attr(update, "type", default="")) + nested = _sdk_attr(update, "update", default=None) + if utype == "thinking-delta": + handle_thinking_text( + str(_sdk_attr(update, "text", default="") or ""), + snapshot=False, + ) + elif utype == "thinking-completed": + close_thinking_step(end_phase=False) + duration = _sdk_attr( + update, + "thinking_duration_ms", + "thinkingDurationMs", + default=None, + ) + if duration is not None: + try: + thinking_ms = int(duration) + except (TypeError, ValueError): + pass + elif utype == "text-delta": + handle_answer_text( + str(_sdk_attr(update, "text", default="") or ""), + snapshot=False, + ) + elif utype in ( + "tool-call-started", + "tool-call-completed", + "partial-tool-call", + ): + tool_call = _sdk_attr(update, "tool_call", default={}) + name = str( + _sdk_attr(tool_call, "type", "name", default="") + or _sdk_attr(update, "name", default="") + ) + status = ( + "running" + if utype in ("tool-call-started", "partial-tool-call") + else "completed" + ) + handle_tool_event( + tool_call, + name=name, + status=status, + call_id=str( + _sdk_attr( + update, "call_id", "callId", default="" + ) + or "" + ), + ) + elif utype == "shell-output-delta": + handle_shell_output( + _sdk_attr(update, "event", default={}) + ) + elif utype == "summary": + summary = str( + _sdk_attr(update, "summary", default="") or "" + ).strip() + if summary: + close_thinking_step() + emit_activity( + { + "type": "status", + "title": "Сводка", + "detail": _truncate_detail(summary, 2000), + "status": "completed", + } + ) + append_thinking_transcript(f"{summary}\n") + elif utype in ( + "token-delta", + "step-started", + "step-completed", + "turn-ended", + "user-message-appended", + "summary-started", + "summary-completed", + ): + pass + elif isinstance(nested, Mapping): + nested_type = str(nested.get("type") or utype) + nested_text = str(nested.get("text") or "") + if nested_type == "thinking-delta" and nested_text: + handle_thinking_text(nested_text, snapshot=False) + elif nested_type == "text-delta" and nested_text: + handle_answer_text(nested_text, snapshot=False) + elif nested_type in ( + "tool-call-started", + "tool-call-completed", + "partial-tool-call", + ): + handle_tool_event( + nested.get("toolCall") + or nested.get("tool_call") + or nested, + name=str(nested.get("name") or ""), + status=( + "completed" + if nested_type == "tool-call-completed" + else "running" + ), + call_id=str( + nested.get("callId") + or nested.get("call_id") + or "" + ), + ) + elif utype: + detail = str( + _sdk_attr(update, "text", "message", default="") + or "" + ).strip() + if detail and not _is_junk_status(detail): + close_thinking_step() + emit_activity( + { + "type": "status", + "title": utype, + "detail": _truncate_detail(detail, 800), + "status": "running", + } + ) + + message = getattr(event, "sdk_message", None) + if message is not None: + mtype = str(_sdk_attr(message, "type", default="")) + if mtype == "thinking": + handle_thinking_text( + str(_sdk_attr(message, "text", default="") or ""), + snapshot=True, + ) + duration = _sdk_attr( + message, + "thinking_duration_ms", + "thinkingDurationMs", + default=None, + ) + if duration is not None: + try: + thinking_ms = int(duration) + except (TypeError, ValueError): + pass + close_thinking_step(end_phase=False) + elif mtype == "assistant": + handle_answer_text( + _assistant_text_from_sdk(message), + snapshot=None, + ) + elif mtype == "tool_call": + handle_tool_event( + { + "type": _sdk_attr(message, "name", default=""), + "args": _sdk_attr(message, "args", default={}), + }, + name=str(_sdk_attr(message, "name", default="")), + status=str( + _sdk_attr(message, "status", default="") + ), + call_id=str( + _sdk_attr( + message, "call_id", "callId", default="" + ) + or "" + ), + ) + elif mtype in ("status", "task"): + handle_status_event( + str(_sdk_attr(message, "status", default="") or ""), + str( + _sdk_attr( + message, "message", "text", default="" + ) + or "" + ), + ) + + step = getattr(event, "step", None) + if step is not None: + stype = str(_sdk_attr(step, "type", default="")) + step_message = _sdk_attr(step, "message", default=None) + if stype == "thinkingMessage": + handle_thinking_text( + str( + _sdk_attr(step_message, "text", default="") + or "" + ), + snapshot=True, + ) + duration = _sdk_attr( + step_message, + "thinking_duration_ms", + "thinkingDurationMs", + default=None, + ) + if duration is not None: + try: + thinking_ms = int(duration) + except (TypeError, ValueError): + pass + close_thinking_step(end_phase=False) + elif stype == "assistantMessage": + handle_answer_text( + str( + _sdk_attr(step_message, "text", default="") + or "" + ), + snapshot=None, + ) + elif stype == "toolCall": + handle_tool_event( + step_message, + name=str( + _sdk_attr(step_message, "name", "type", default="") + ), + status=str( + _sdk_attr(step_message, "status", default="") + ), + call_id=str( + _sdk_attr( + step_message, + "call_id", + "callId", + default="", + ) + or "" + ), + ) + + if not cancelled: + result = await run.wait() + if str(result.status).lower().endswith("error"): + raise RuntimeError( + f"Cursor Agent завершил работу с ошибкой (run: {result.id})." + ) + if not answer_parts and (result.result or "").strip(): + handle_answer_text( + (result.result or "").strip(), snapshot=True + ) + finally: + try: + await agent.close() + except Exception: # noqa: BLE001 + pass + except (CursorAgentError, OSError) as error: + raise CursorStartupError(format_agent_error(error)) from error + + close_thinking_step() + answer = "".join(answer_parts).strip() + activity = _trim_activity(activity_log) + thinking = "".join(thinking_parts).strip() or _activity_transcript(activity) + if thinking_ms is not None: + _put_event(out, "thinking_done", duration_ms=thinking_ms) + if cancelled: + _put_event( + out, + "cancelled", + text=answer, + thinking=thinking, + thinking_duration_ms=thinking_ms, + activity=activity, + ) + return + if not answer: + raise RuntimeError("Cursor Agent не вернул текстовый ответ.") + _put_event( + out, + "done", + text=answer, + thinking=thinking, + thinking_duration_ms=thinking_ms, + activity=activity, + ) + + try: + from cursor_sdk import AsyncClient # noqa: F401 + except ImportError as error: + _put_event( + out, + "error", + detail="Пакет cursor-sdk не найден. Запустите сервер через .\\run.ps1", + ) + return + + try: + asyncio.run(_run()) + except CursorStartupError as error: + _put_event(out, "error", detail=f"Cursor Agent не запустился: {error}", status=502) + except Exception as error: # noqa: BLE001 + _put_event(out, "error", detail=format_agent_error(error), status=503) + finally: + out.put(None) + + +def claim_chat_slot(chat_id: str) -> tuple[str, threading.Event]: + """Return ('run'|'wait', turn_event). Raise 409 if queue full.""" + with chat_queue_lock: + state = chat_queues.setdefault( + chat_id, + {"running": False, "waiting": 0, "turn": threading.Event()}, + ) + if "turn" not in state: + state["turn"] = threading.Event() + if not state["running"]: + state["running"] = True + state["turn"].clear() + return "run", state["turn"] + if int(state["waiting"]) >= 1: + raise HTTPException( + status_code=409, + detail="В этом чате уже есть запрос в очереди. Дождитесь ответа.", + ) + state["waiting"] = 1 + return "wait", state["turn"] + + +def abandon_waiting_slot(chat_id: str) -> None: + with chat_queue_lock: + state = chat_queues.get(chat_id) + if not state: + return + state["waiting"] = max(0, int(state["waiting"]) - 1) + + +def release_running_slot(chat_id: str) -> None: + with chat_queue_lock: + state = chat_queues.get(chat_id) + if not state: + return + if int(state["waiting"]) > 0: + state["waiting"] = 0 + # Promote waiter: keep running=True and signal turn. + state["turn"].set() + return + state["running"] = False + state["turn"].set() + + +async def wait_chat_turn( + turn_event: threading.Event, + cancel_event: threading.Event, + request: Request, +) -> bool: + while not turn_event.is_set(): + if cancel_event.is_set() or await request.is_disconnected(): + return False + await asyncio.sleep(0.15) + return not cancel_event.is_set() + + +def register_active_run(chat_id: str, cancel_event: threading.Event, user_id: str) -> None: + with agent_status_lock: + active_runs[chat_id] = { + "cancel_event": cancel_event, + "run": None, + "loop": None, + "thread": None, + "user_id": user_id, + } + + +def unregister_active_run(chat_id: str, cancel_event: threading.Event) -> None: + with agent_status_lock: + entry = active_runs.get(chat_id) + if entry is not None and entry.get("cancel_event") is cancel_event: + active_runs.pop(chat_id, None) + + +def sdk_available() -> bool: + try: + import cursor_sdk # noqa: F401 + + return True + except ImportError: + return False + + +class LoginRequest(BaseModel): + username: str + password: str + + +class UserCreate(BaseModel): + username: str + password: str + display_name: str = "" + role: str = "user" + + +class ProjectCreate(BaseModel): + name: str + description: str = "" + instructions: str = "" + icon: str = "✦" + model: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + instructions: str | None = None + icon: str | None = None + model: str | None = None + + +class ChatCreate(BaseModel): + project_id: str + title: str = "Новый чат" + + +class ChatUpdate(BaseModel): + title: str | None = None + + +class ChatOrderUpdate(BaseModel): + chat_ids: list[str] + + +class AttachmentRef(BaseModel): + id: str + name: str = "" + + +class MessageCreate(BaseModel): + role: str = "user" + content: str + attachments: list[AttachmentRef] = [] + + +class BindFilesPayload(BaseModel): + file_ids: list[str] = [] + + +app = FastAPI(title="Helpomatica", version="1.1.0") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + if path.startswith("/api/") and path != "/api/auth/login": + user = resolve_session(request.cookies.get(SESSION_COOKIE)) + if not user: + return JSONResponse(status_code=401, content={"detail": "Требуется вход"}) + request.state.user = user + return await call_next(request) + + +@app.on_event("startup") +def configure_logging() -> None: + logging.basicConfig(level=logging.INFO) + load_db() + api_key = os.getenv("CURSOR_API_KEY", "").strip() + if not api_key: + logger.warning("CURSOR_API_KEY не задан в .env") + else: + logger.info("CURSOR_API_KEY загружен (%s..., len=%s)", api_key[:8], len(api_key)) + try: + import cursor_sdk + + logger.info("cursor_sdk: %s", cursor_sdk.__file__) + except ImportError as error: + logger.error("cursor_sdk недоступен: %s", error) + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.post("/api/auth/login") +def login(payload: LoginRequest, response: Response) -> dict: + username = payload.username.strip() + password = payload.password + if not username or not password: + raise HTTPException(status_code=422, detail="Введите логин и пароль") + data = load_db() + user = next( + (entry for entry in data["users"] if entry["username"].lower() == username.lower()), + None, + ) + if not user or not verify_password(password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Неверный логин или пароль") + session_id = secrets.token_urlsafe(32) + data["sessions"].append( + { + "id": session_id, + "user_id": user["id"], + "created_at": now(), + } + ) + # Keep sessions list bounded. + if len(data["sessions"]) > 200: + data["sessions"] = data["sessions"][-200:] + save_db(data) + response.set_cookie( + key=SESSION_COOKIE, + value=session_id, + httponly=True, + samesite="lax", + max_age=SESSION_DAYS * 24 * 60 * 60, + path="/", + ) + return public_user(user) + + +@app.post("/api/auth/logout") +def logout(request: Request, response: Response) -> dict: + session_id = request.cookies.get(SESSION_COOKIE) + if session_id: + data = load_db() + data["sessions"] = [s for s in data["sessions"] if s["id"] != session_id] + save_db(data) + response.delete_cookie(SESSION_COOKIE, path="/") + return {"ok": True} + + +@app.get("/api/auth/me") +def me(user: dict = Depends(get_current_user)) -> dict: + return public_user(user) + + +@app.get("/api/users") +def list_users(_: dict = Depends(require_admin)) -> list[dict]: + data = load_db() + return [public_user(user) for user in sorted(data["users"], key=lambda u: u["created_at"])] + + +@app.post("/api/users", status_code=201) +def create_user(payload: UserCreate, _: dict = Depends(require_admin)) -> dict: + username = payload.username.strip() + password = payload.password + display_name = (payload.display_name or username).strip() + role = payload.role if payload.role in ("admin", "user") else "user" + if not username or not password: + raise HTTPException(status_code=422, detail="Логин и пароль обязательны") + if len(password) < 4: + raise HTTPException(status_code=422, detail="Пароль слишком короткий") + data = load_db() + if any(u["username"].lower() == username.lower() for u in data["users"]): + raise HTTPException(status_code=409, detail="Пользователь уже существует") + user = { + "id": uuid.uuid4().hex, + "username": username, + "display_name": display_name or username, + "password_hash": hash_password(password), + "role": role, + "created_at": now(), + } + data["users"].append(user) + save_db(data) + return public_user(user) + + +@app.get("/api/agent/status") +def agent_status(user: dict = Depends(get_current_user)) -> dict: + api_key = bool(os.getenv("CURSOR_API_KEY", "").strip()) + with agent_status_lock: + # Exclude wait:* placeholders — only real running agents. + active_chat_ids = [ + key for key in active_runs.keys() if not str(key).startswith("wait:") + ] + busy = len(active_chat_ids) > 0 + with chat_queue_lock: + queued = { + chat_id: {"running": bool(state["running"]), "waiting": int(state["waiting"])} + for chat_id, state in chat_queues.items() + if state["running"] or int(state["waiting"]) > 0 + } + return { + "key_ok": api_key, + "sdk_ok": sdk_available(), + "busy": busy, + "state": "busy" if busy else "idle", + "active_chats": active_chat_ids, + "active_count": len(active_chat_ids), + "queues": queued, + "model_default": default_model(), + "user_id": user["id"], + } + + +@app.get("/api/projects") +def list_projects(_: dict = Depends(get_current_user)) -> list[dict]: + data = load_db() + projects = [] + for project in data["projects"]: + enriched = project.copy() + enriched["chat_count"] = sum( + chat["project_id"] == project["id"] for chat in data["chats"] + ) + enriched["file_count"] = sum( + file["project_id"] == project["id"] for file in data["files"] + ) + projects.append(enriched) + return sorted(projects, key=lambda item: item["updated_at"], reverse=True) + + +@app.post("/api/projects", status_code=201) +def create_project(payload: ProjectCreate, user: dict = Depends(get_current_user)) -> dict: + name = payload.name.strip() + if not name: + raise HTTPException(status_code=422, detail="Введите название проекта") + data = load_db() + timestamp = now() + project = { + "id": uuid.uuid4().hex, + "name": name, + "description": payload.description.strip(), + "instructions": payload.instructions.strip(), + "icon": payload.icon or "✦", + "model": (payload.model or default_model()).strip() or default_model(), + "created_by": user["id"], + "created_at": timestamp, + "updated_at": timestamp, + } + data["projects"].append(project) + save_db(data) + return project + + +@app.patch("/api/projects/{project_id}") +def update_project( + project_id: str, + payload: ProjectUpdate, + _: dict = Depends(get_current_user), +) -> dict: + data = load_db() + project = find(data["projects"], project_id, "Проект") + for key, value in payload.model_dump(exclude_none=True).items(): + project[key] = value.strip() if isinstance(value, str) else value + if not project["name"]: + raise HTTPException(status_code=422, detail="Название не может быть пустым") + if not project.get("model"): + project["model"] = default_model() + project["updated_at"] = now() + save_db(data) + return project + + +@app.delete("/api/projects/{project_id}", status_code=204) +def delete_project(project_id: str, _: dict = Depends(get_current_user)) -> None: + data = load_db() + find(data["projects"], project_id, "Проект") + chat_ids = { + chat["id"] for chat in data["chats"] if chat["project_id"] == project_id + } + for stored_file in [ + file for file in data["files"] if file["project_id"] == project_id + ]: + (UPLOAD_DIR / stored_file["stored_name"]).unlink(missing_ok=True) + data["projects"] = [ + project for project in data["projects"] if project["id"] != project_id + ] + data["chats"] = [ + chat for chat in data["chats"] if chat["project_id"] != project_id + ] + data["messages"] = [ + message for message in data["messages"] if message["chat_id"] not in chat_ids + ] + data["files"] = [ + file for file in data["files"] if file["project_id"] != project_id + ] + save_db(data) + shutil.rmtree( + Path(tempfile.gettempdir()) / "helpomatica-agent" / project_id, + ignore_errors=True, + ) + shutil.rmtree(AGENT_DIR / project_id, ignore_errors=True) + + +@app.get("/api/projects/{project_id}/chats") +def list_chats(project_id: str, _: dict = Depends(get_current_user)) -> list[dict]: + data = load_db() + find(data["projects"], project_id, "Проект") + chats = [chat for chat in data["chats"] if chat["project_id"] == project_id] + chats.sort(key=lambda item: item.get("updated_at") or "", reverse=True) + chats.sort(key=lambda item: int(item.get("sort_order", 10**9))) + return chats + + +@app.put("/api/projects/{project_id}/chats/order") +def reorder_chats( + project_id: str, + payload: ChatOrderUpdate, + user: dict = Depends(get_current_user), +) -> list[dict]: + data = load_db() + find(data["projects"], project_id, "Проект") + project_chats = { + chat["id"]: chat + for chat in data["chats"] + if chat["project_id"] == project_id + } + if not payload.chat_ids: + raise HTTPException(status_code=422, detail="Передайте упорядоченный список чатов") + if len(payload.chat_ids) != len(set(payload.chat_ids)): + raise HTTPException(status_code=422, detail="Список чатов содержит дубликаты") + if set(payload.chat_ids) != set(project_chats): + raise HTTPException(status_code=422, detail="Список чатов не совпадает с проектом") + timestamp = now() + for index, chat_id in enumerate(payload.chat_ids): + chat = project_chats[chat_id] + chat["sort_order"] = index + chat["updated_by"] = user["id"] + project = find(data["projects"], project_id, "Проект") + project["updated_at"] = timestamp + save_db(data) + return sorted( + [project_chats[chat_id] for chat_id in payload.chat_ids], + key=lambda item: item.get("sort_order", 0), + ) + + +@app.post("/api/chats", status_code=201) +def create_chat(payload: ChatCreate, user: dict = Depends(get_current_user)) -> dict: + data = load_db() + project = find(data["projects"], payload.project_id, "Проект") + timestamp = now() + project_orders = [ + int(chat.get("sort_order", 0)) + for chat in data["chats"] + if chat["project_id"] == payload.project_id + ] + next_order = (min(project_orders) - 1) if project_orders else 0 + chat = { + "id": uuid.uuid4().hex, + "project_id": payload.project_id, + "title": payload.title.strip() or "Новый чат", + "sort_order": next_order, + "created_by": user["id"], + "updated_by": user["id"], + "created_at": timestamp, + "updated_at": timestamp, + } + data["chats"].append(chat) + project["updated_at"] = timestamp + save_db(data) + return chat + + +@app.patch("/api/chats/{chat_id}") +def update_chat( + chat_id: str, + payload: ChatUpdate, + user: dict = Depends(get_current_user), +) -> dict: + data = load_db() + chat = find(data["chats"], chat_id, "Чат") + if payload.title is not None: + title = payload.title.strip() + if not title: + raise HTTPException(status_code=422, detail="Название не может быть пустым") + chat["title"] = title + chat["updated_by"] = user["id"] + chat["updated_at"] = now() + save_db(data) + return chat + + +@app.delete("/api/chats/{chat_id}", status_code=204) +def delete_chat(chat_id: str, _: dict = Depends(get_current_user)) -> None: + data = load_db() + find(data["chats"], chat_id, "Чат") + data["chats"] = [chat for chat in data["chats"] if chat["id"] != chat_id] + data["messages"] = [ + message for message in data["messages"] if message["chat_id"] != chat_id + ] + leftover: list[dict] = [] + for stored_file in data["files"]: + if file_scope_of(stored_file) == "chat" and stored_file.get("chat_id") == chat_id: + (UPLOAD_DIR / stored_file["stored_name"]).unlink(missing_ok=True) + continue + leftover.append(stored_file) + data["files"] = leftover + save_db(data) + + +@app.get("/api/chats/{chat_id}/messages") +def list_messages(chat_id: str, _: dict = Depends(get_current_user)) -> list[dict]: + data = load_db() + find(data["chats"], chat_id, "Чат") + return [ + message for message in data["messages"] if message["chat_id"] == chat_id + ] + + +@app.get("/api/chats/{chat_id}/export") +def export_chat(chat_id: str, _: dict = Depends(get_current_user)) -> Response: + data = load_db() + chat = find(data["chats"], chat_id, "Чат") + project = find(data["projects"], chat["project_id"], "Проект") + messages = [message for message in data["messages"] if message["chat_id"] == chat_id] + markdown = export_chat_markdown(chat, project, messages) + title = re.sub(r'[<>:"/\\|?*]+', "_", str(chat.get("title") or "chat"))[:80] or "chat" + return Response( + content=markdown.encode("utf-8"), + media_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": attachment_filename(f"{title}.md", "chat.md")}, + ) + + +@app.post("/api/chats/{chat_id}/messages", status_code=201) +def create_message( + chat_id: str, + payload: MessageCreate, + user: dict = Depends(get_current_user), +) -> dict: + data = load_db() + chat = find(data["chats"], chat_id, "Чат") + content = payload.content.strip() + if not content: + raise HTTPException(status_code=422, detail="Сообщение не может быть пустым") + timestamp = now() + role = payload.role if payload.role in ("user", "assistant") else "user" + attachments: list[dict] = [] + if role == "user" and payload.attachments: + project_files = { + entry["id"]: entry + for entry in data["files"] + if entry.get("project_id") == chat["project_id"] + } + seen: set[str] = set() + for ref in payload.attachments: + file_id = (ref.id or "").strip() + if not file_id or file_id in seen: + continue + entry = project_files.get(file_id) + if not entry: + continue + seen.add(file_id) + other_chat = entry.get("chat_id") + if ( + file_scope_of(entry) == "chat" + and other_chat + and other_chat != chat_id + ): + continue + if file_scope_of(entry) != "chat" or entry.get("chat_id") != chat_id: + entry["scope"] = "chat" + entry["chat_id"] = chat_id + attachments.append({"id": entry["id"], "name": entry["name"]}) + message = { + "id": uuid.uuid4().hex, + "chat_id": chat_id, + "role": role, + "content": content, + "attachments": attachments, + "user_id": user["id"] if role == "user" else None, + "author_name": ( + user.get("display_name") or user.get("username") + if role == "user" + else "Помощник" + ), + "created_at": timestamp, + } + data["messages"].append(message) + if chat["title"] == "Новый чат" and role == "user": + title_source = re.sub( + r"\n\n\[Прикреплённые файлы проекта:\s*[^\]]+\]\s*$", + "", + content, + ).strip() or content + chat["title"] = title_source[:55] + ("…" if len(title_source) > 55 else "") + chat["updated_at"] = timestamp + chat["updated_by"] = user["id"] + project = find(data["projects"], chat["project_id"], "Проект") + project["updated_at"] = timestamp + save_db(data) + return message + + +def save_assistant_message( + chat_id: str, + content: str, + thinking: str = "", + thinking_duration_ms: int | None = None, + activity: list[dict] | None = None, +) -> dict | None: + content = (content or "").strip() + if not content: + return None + data = load_db() + chat = find(data["chats"], chat_id, "Чат") + timestamp = now() + thinking_text = (thinking or "").strip() + if len(thinking_text) > 80_000: + thinking_text = thinking_text[:80_000] + "\n…" + message = { + "id": uuid.uuid4().hex, + "chat_id": chat_id, + "role": "assistant", + "content": content, + "thinking": thinking_text, + "thinking_duration_ms": thinking_duration_ms, + "activity": _trim_activity(activity or []), + "user_id": None, + "author_name": "Помощник", + "created_at": timestamp, + } + data["messages"].append(message) + chat["updated_at"] = timestamp + project = find(data["projects"], chat["project_id"], "Проект") + project["updated_at"] = timestamp + save_db(data) + return message + + +@app.post("/api/chats/{chat_id}/respond") +async def generate_response( + chat_id: str, + request: Request, + user: dict = Depends(get_current_user), +) -> StreamingResponse: + data = load_db() + chat = find(data["chats"], chat_id, "Чат") + project = find(data["projects"], chat["project_id"], "Проект") + messages = [ + message for message in data["messages"] if message["chat_id"] == chat_id + ] + if not messages or messages[-1]["role"] != "user": + raise HTTPException(status_code=409, detail="Сначала отправьте сообщение") + + slot, turn_event = claim_chat_slot(chat_id) + cancel_event = threading.Event() + out: queue.Queue = queue.Queue() + if slot == "run": + register_active_run(chat_id, cancel_event, user["id"]) + else: + # Queued request is cancellable via its own cancel_event stored lightly. + with agent_status_lock: + active_runs.setdefault( + f"wait:{chat_id}", + { + "cancel_event": cancel_event, + "run": None, + "loop": None, + "thread": None, + "user_id": user["id"], + }, + ) + + async def event_stream() -> AsyncIterator[str]: + became_runner = slot == "run" + try: + if slot == "wait": + yield sse_format("status", {"state": "queued", "detail": "В очереди…"}) + got_turn = await wait_chat_turn(turn_event, cancel_event, request) + with agent_status_lock: + active_runs.pop(f"wait:{chat_id}", None) + if not got_turn: + yield sse_format("cancelled", {"text": ""}) + return + became_runner = True + register_active_run(chat_id, cancel_event, user["id"]) + + # No global agent lock: each chat launches its own local bridge. + yield sse_format("status", {"state": "starting", "detail": "Запуск…"}) + + fresh = load_db() + fresh_chat = find(fresh["chats"], chat_id, "Чат") + fresh_project = find(fresh["projects"], fresh_chat["project_id"], "Проект") + fresh_messages = [ + message for message in fresh["messages"] if message["chat_id"] == chat_id + ] + if not fresh_messages or fresh_messages[-1]["role"] != "user": + yield sse_format("error", {"detail": "Сначала отправьте сообщение"}) + return + + deduplicated: list[dict] = [] + for message in fresh_messages: + if ( + deduplicated + and deduplicated[-1]["role"] == message["role"] + and deduplicated[-1]["content"] == message["content"] + ): + continue + deduplicated.append(message) + history = "\n\n".join( + f"{'Пользователь' if message['role'] == 'user' else 'Ассистент'}: " + f"{message['content']}" + for message in deduplicated[-30:] + ) + query = deduplicated[-1]["content"] + attachment_ids = message_attachment_ids(fresh_messages) + scoped_files = iter_scoped_files(fresh, fresh_project["id"], chat_id) + file_context, context_files = build_file_context( + fresh, + fresh_project["id"], + query, + chat_id=chat_id, + attachment_ids=attachment_ids, + ) + model = (fresh_project.get("model") or default_model()).strip() or default_model() + workspace = prepare_agent_workspace( + fresh_project, + fresh, + query, + chat_id=chat_id, + attachment_ids=attachment_ids, + messages=fresh_messages, + ) + + chip_groups = build_context_chip_groups( + fresh, fresh_project["id"], chat_id, fresh_messages + ) + yield sse_format( + "context", + {**chip_groups, "files": context_files, "model": model}, + ) + + log_entries = log_preanalysis.rank_log_files(scoped_files) + log_names = [str(entry.get("name") or "") for entry in log_entries] + run_preanalysis = bool( + log_entries and log_preanalysis.looks_like_log_job(query, log_names) + ) + cache_hit = lookup_analysis_cache( + fresh, fresh_project["id"], scoped_files, query + ) + preanalysis_md = "" + preanalysis_summary = "" + pre_activity: list[dict] = [] + if run_preanalysis: + if cache_hit and cache_hit.get("preanalysis"): + preanalysis_md = str(cache_hit.get("preanalysis") or "") + preanalysis_summary = str(cache_hit.get("summary") or "") + yield sse_format( + "progress", + { + "phase": "cache", + "message": "Есть предыдущий разбор", + "percent": 100, + }, + ) + pre_step = { + "id": "pre-scan", + "type": "status", + "title": "Логи", + "detail": preanalysis_summary or "Кэш предыдущего разбора", + "status": "completed", + } + pre_activity.append(pre_step) + yield sse_format("activity", pre_step) + else: + yield sse_format( + "status", + {"state": "scanning", "detail": "Читаю лог… 0%"}, + ) + prog_q: queue.Queue = queue.Queue() + + def on_progress(info: dict) -> None: + prog_q.put(("p", info)) + + scan_files = [ + (resolve_upload_path(entry), log_display_name(entry)) + for entry in log_entries + ] + + def run_scan() -> None: + try: + result = log_preanalysis.preanalyze_logs( + scan_files, + query, + on_progress=on_progress, + cancel=cancel_event.is_set, + ) + prog_q.put(("done", result)) + except Exception as error: # noqa: BLE001 + logger.exception("preanalysis failed") + prog_q.put(("err", format_agent_error(error))) + + threading.Thread(target=run_scan, daemon=True).start() + scan_result: dict | None = None + while True: + kind, payload = await asyncio.to_thread(prog_q.get) + if kind == "p": + yield sse_format("progress", payload) + detail = str(payload.get("message") or "Читаю лог…") + yield sse_format( + "status", + {"state": "scanning", "detail": detail}, + ) + step = { + "id": "pre-scan", + "type": "status", + "title": "Логи", + "detail": detail, + "status": "running", + } + if not pre_activity: + pre_activity.append(step) + else: + pre_activity[0].update(step) + yield sse_format("activity", step) + elif kind == "done": + scan_result = payload if isinstance(payload, dict) else None + break + else: + logger.warning("preanalysis error: %s", payload) + break + if scan_result: + preanalysis_md = str(scan_result.get("markdown") or "") + preanalysis_summary = str(scan_result.get("summary") or "") + if pre_activity: + pre_activity[0]["status"] = "completed" + pre_activity[0]["detail"] = ( + preanalysis_summary or pre_activity[0].get("detail") or "Готово" + ) + yield sse_format("activity", pre_activity[0]) + if preanalysis_md: + try: + log_preanalysis.write_preanalysis_md(workspace, preanalysis_md) + except OSError as error: + logger.warning("PREANALYSIS.md write failed: %s", error) + + extra = _prompt_extra_from_cache_and_scan(cache_hit, preanalysis_md) + prompt = build_chat_prompt(fresh_project, file_context, history, extra=extra) + yield sse_format( + "progress", + { + "phase": "agent", + "message": "Думаю…", + "percent": None, + }, + ) + yield sse_format("status", {"state": "streaming", "detail": "Думаю…"}) + + thread = threading.Thread( + target=_stream_cursor_agent_threaded, + args=(prompt, workspace, model, chat_id, out, cancel_event), + daemon=True, + ) + with agent_status_lock: + entry = active_runs.get(chat_id) + if entry is not None: + entry["thread"] = thread + thread.start() + + final_text = "" + thinking_text = "" + thinking_ms = None + activity_items: list[dict] = list(pre_activity) + terminal = None + while True: + item = await asyncio.to_thread(out.get) + if item is None: + break + event_type = item.get("type", "status") + if event_type == "thinking": + chunk = item.get("text") or "" + thinking_text += chunk + yield sse_format("thinking", {"text": chunk, "replace": False}) + elif event_type == "activity": + payload = item.get("step") or { + k: v for k, v in item.items() if k != "type" + } + existing = next( + ( + step + for step in activity_items + if payload.get("id") and step.get("id") == payload.get("id") + ), + None, + ) + if existing is not None: + existing.update({k: v for k, v in payload.items() if v is not None}) + else: + activity_items.append(payload) + yield sse_format("activity", payload) + elif event_type == "thinking_done": + duration = item.get("duration_ms") + if duration is not None: + try: + thinking_ms = int(duration) + except (TypeError, ValueError): + pass + yield sse_format("thinking_done", {"duration_ms": thinking_ms}) + elif event_type == "delta": + chunk = item.get("text") or "" + if item.get("replace"): + final_text = chunk + else: + final_text += chunk + yield sse_format( + "delta", + {"text": chunk, "replace": bool(item.get("replace"))}, + ) + elif event_type == "done": + final_text = item.get("text") or final_text + thinking_text = item.get("thinking") or thinking_text + if item.get("thinking_duration_ms") is not None: + thinking_ms = item.get("thinking_duration_ms") + if item.get("activity"): + activity_items = _trim_activity( + list(pre_activity) + (item.get("activity") or []) + ) + terminal = "done" + message = save_assistant_message( + chat_id, + final_text, + thinking=thinking_text, + thinking_duration_ms=thinking_ms, + activity=activity_items, + ) + try: + store_analysis_cache( + fresh_project["id"], + scoped_files, + query, + final_text, + preanalysis=preanalysis_md, + summary=preanalysis_summary, + ) + except Exception as cache_error: # noqa: BLE001 + logger.warning("analysis cache store failed: %s", cache_error) + yield sse_format("done", {"text": final_text, "message": message}) + elif event_type == "cancelled": + final_text = item.get("text") or final_text + thinking_text = item.get("thinking") or thinking_text + if item.get("thinking_duration_ms") is not None: + thinking_ms = item.get("thinking_duration_ms") + if item.get("activity"): + activity_items = _trim_activity(item.get("activity") or []) + terminal = "cancelled" + message = save_assistant_message( + chat_id, + final_text, + thinking=thinking_text, + thinking_duration_ms=thinking_ms, + activity=activity_items, + ) + yield sse_format( + "cancelled", + {"text": final_text, "message": message}, + ) + elif event_type == "error": + terminal = "error" + yield sse_format( + "error", + {"detail": item.get("detail", "Ошибка агента")}, + ) + else: + yield sse_format(event_type, {k: v for k, v in item.items() if k != "type"}) + + if terminal is None and final_text: + message = save_assistant_message( + chat_id, + final_text, + thinking=thinking_text, + thinking_duration_ms=thinking_ms, + activity=activity_items, + ) + try: + store_analysis_cache( + fresh_project["id"], + scoped_files, + query, + final_text, + preanalysis=preanalysis_md, + summary=preanalysis_summary, + ) + except Exception as cache_error: # noqa: BLE001 + logger.warning("analysis cache store failed: %s", cache_error) + yield sse_format("done", {"text": final_text, "message": message}) + elif terminal is None and cancel_event.is_set(): + yield sse_format("cancelled", {"text": ""}) + except HTTPException as error: + yield sse_format("error", {"detail": error.detail}) + except Exception as error: # noqa: BLE001 + logger.exception("respond failed chat=%s", chat_id) + yield sse_format("error", {"detail": format_agent_error(error)}) + finally: + unregister_active_run(chat_id, cancel_event) + with agent_status_lock: + wait_entry = active_runs.get(f"wait:{chat_id}") + if wait_entry and wait_entry.get("cancel_event") is cancel_event: + active_runs.pop(f"wait:{chat_id}", None) + if became_runner: + release_running_slot(chat_id) + elif slot == "wait": + abandon_waiting_slot(chat_id) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/api/chats/{chat_id}/cancel") +async def cancel_response( + chat_id: str, + _: dict = Depends(get_current_user), +) -> dict: + with agent_status_lock: + entry = active_runs.get(chat_id) or active_runs.get(f"wait:{chat_id}") + if not entry: + raise HTTPException(status_code=404, detail="Нет активной генерации") + entry["cancel_event"].set() + run = entry.get("run") + loop = entry.get("loop") + if run is not None and loop is not None: + try: + fut = asyncio.run_coroutine_threadsafe(run.cancel(), loop) + fut.result(timeout=5) + except Exception as error: # noqa: BLE001 + logger.warning("cancel_run chat=%s: %s", chat_id, error) + return {"ok": True, "chat_id": chat_id} + + +@app.get("/api/search") +def search_chats(q: str = "", _: dict = Depends(get_current_user)) -> list[dict]: + needle = (q or "").strip() + if len(needle) < 2: + return [] + needle_l = needle.lower() + data = load_db() + projects_by_id = {item["id"]: item for item in data.get("projects") or []} + chats_by_id = {item["id"]: item for item in data.get("chats") or []} + results: list[dict] = [] + + def add_result( + chat: dict, + *, + match_in: str, + snippet: str, + message_id: str = "", + ) -> None: + if len(results) >= 30: + return + project = projects_by_id.get(chat.get("project_id") or "") + results.append( + { + "chat_id": chat["id"], + "project_id": chat.get("project_id") or "", + "project_name": (project or {}).get("name") or "", + "chat_title": chat.get("title") or "Чат", + "message_id": message_id, + "match_in": match_in, + "snippet": snippet[:240], + } + ) + + for chat in sorted( + data.get("chats") or [], + key=lambda item: item.get("updated_at") or "", + reverse=True, + ): + title = str(chat.get("title") or "") + if needle_l in title.lower(): + add_result(chat, match_in="title", snippet=title) + if len(results) >= 30: + return results + + for message in reversed(data.get("messages") or []): + if len(results) >= 30: + break + content = str(message.get("content") or "") + if needle_l not in content.lower(): + continue + chat = chats_by_id.get(message.get("chat_id") or "") + if not chat: + continue + add_result( + chat, + match_in="message", + snippet=snippet_around(content, needle), + message_id=str(message.get("id") or ""), + ) + return results[:30] + + +@app.get("/api/projects/{project_id}/files") +def list_files( + project_id: str, + chat_id: str | None = None, + _: dict = Depends(get_current_user), +) -> list[dict]: + data = load_db() + find(data["projects"], project_id, "Проект") + bound_chat_id = (chat_id or "").strip() or None + if bound_chat_id: + chat = find(data["chats"], bound_chat_id, "Чат") + if chat["project_id"] != project_id: + raise HTTPException(status_code=400, detail="Чат не принадлежит проекту") + return iter_scoped_files(data, project_id, bound_chat_id) + return [ + file + for file in data["files"] + if file["project_id"] == project_id and file_scope_of(file) == "project" + ] + + +@app.post("/api/projects/{project_id}/files", status_code=201) +def upload_file( + project_id: str, + request: Request, + file: UploadFile = File(...), + label: str = Form(default=""), + chat_id: str | None = Form(default=None), + file_scope: str | None = Form(default=None, alias="scope"), + _: dict = Depends(get_current_user), +) -> dict: + data = load_db() + project = find(data["projects"], project_id, "Проект") + query_chat = (request.query_params.get("chat_id") or "").strip() or None + query_scope = (request.query_params.get("scope") or "").strip().lower() + bound_chat_id = (chat_id or "").strip() or query_chat + requested_scope = ((file_scope or "").strip().lower() or query_scope) + if requested_scope == "chat" and not bound_chat_id: + raise HTTPException( + status_code=400, + detail="Для файлов чата нужен chat_id", + ) + stored_scope = "project" + if bound_chat_id: + chat = find(data["chats"], bound_chat_id, "Чат") + if chat["project_id"] != project_id: + raise HTTPException(status_code=400, detail="Чат не принадлежит проекту") + stored_scope = "chat" + original_name = Path(file.filename or "file").name + stored_name = f"{uuid.uuid4().hex}_{original_name}" + destination = UPLOAD_DIR / stored_name + with destination.open("wb") as output: + shutil.copyfileobj(file.file, output) + entry = { + "id": uuid.uuid4().hex, + "project_id": project_id, + "chat_id": bound_chat_id, + "scope": stored_scope, + "name": original_name, + "label": label.strip(), + "stored_name": stored_name, + "content_type": file.content_type or "application/octet-stream", + "size": destination.stat().st_size, + "created_at": now(), + } + data["files"].append(entry) + project["updated_at"] = now() + save_db(data) + logger.info( + "upload file project=%s scope=%s chat_id=%s name=%s", + project_id, + stored_scope, + bound_chat_id, + original_name, + ) + return entry + + +@app.post("/api/chats/{chat_id}/files/bind") +def bind_files_to_chat( + chat_id: str, + payload: BindFilesPayload, + _: dict = Depends(get_current_user), +) -> list[dict]: + data = load_db() + chat = find(data["chats"], chat_id, "Чат") + bound: list[dict] = [] + seen: set[str] = set() + for file_id in payload.file_ids: + fid = (file_id or "").strip() + if not fid or fid in seen: + continue + seen.add(fid) + entry = next( + ( + item + for item in data["files"] + if item.get("id") == fid and item.get("project_id") == chat["project_id"] + ), + None, + ) + if not entry: + continue + other_chat = entry.get("chat_id") + if file_scope_of(entry) == "chat" and other_chat and other_chat != chat_id: + continue + entry["scope"] = "chat" + entry["chat_id"] = chat_id + bound.append(entry) + chat["updated_at"] = now() + save_db(data) + return bound + + +@app.get("/api/files/{file_id}") +def download_file(file_id: str, _: dict = Depends(get_current_user)) -> FileResponse: + data = load_db() + stored_file = find(data["files"], file_id, "Файл") + if not file_in_user_scope(data, stored_file): + raise HTTPException(status_code=404, detail="Файл не найден") + path = UPLOAD_DIR / stored_file["stored_name"] + if not path.exists(): + raise HTTPException(status_code=404, detail="Файл не найден на диске") + original_name = stored_file.get("name") or path.name + return FileResponse( + path, + filename=original_name, + media_type=stored_file.get("content_type") or "application/octet-stream", + content_disposition_type="attachment", + ) + + +@app.get("/api/files/{file_id}/excerpt") +def download_file_excerpt( + file_id: str, + start: int | None = Query(default=None, ge=1), + end: int | None = Query(default=None, ge=1), + line: int | None = Query(default=None, ge=1), + window: int = Query(default=25, ge=1, le=200), + from_line: int | None = Query(default=None, alias="from", ge=1), + to_line: int | None = Query(default=None, alias="to", ge=1), + _: dict = Depends(get_current_user), +) -> Response: + data = load_db() + stored_file = find(data["files"], file_id, "Файл") + if not file_in_user_scope(data, stored_file): + raise HTTPException(status_code=404, detail="Файл не найден") + path = UPLOAD_DIR / stored_file["stored_name"] + if not path.exists(): + raise HTTPException(status_code=404, detail="Файл не найден на диске") + start_line = start or from_line + end_line = end or to_line + if line is not None: + start_line = max(1, line - window) + end_line = line + window + if start_line is None: + start_line = 1 + if end_line is None: + end_line = start_line + window + if end_line < start_line: + start_line, end_line = end_line, start_line + if end_line - start_line > 500: + end_line = start_line + 500 + try: + text, _encoding = log_preanalysis.excerpt_lines(path, start_line, end_line) + except OSError as error: + raise HTTPException(status_code=500, detail=f"Не удалось прочитать лог: {error}") from error + original = stored_file.get("name") or path.name + filename = f"{Path(original).name}.{start_line}-{end_line}.txt" + return Response( + content=text.encode("utf-8"), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": attachment_filename(filename, "excerpt.txt")}, + ) + + +@app.delete("/api/files/{file_id}", status_code=204) +def delete_file(file_id: str, _: dict = Depends(get_current_user)) -> None: + data = load_db() + stored_file = find(data["files"], file_id, "Файл") + (UPLOAD_DIR / stored_file["stored_name"]).unlink(missing_ok=True) + data["files"] = [file for file in data["files"] if file["id"] != file_id] + save_db(data) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..577da20 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +cursor-sdk +python-dotenv +python-multipart +uvicorn[standard] diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..f39a96e --- /dev/null +++ b/run.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = "Stop" + +if (-not (Test-Path ".deps\fastapi")) { + python -m pip install --target .deps -r requirements.txt +} + +$env:PYTHONPATH = Join-Path $PWD ".deps" +$env:HELPMATICA_ROOT = $PWD + +if (-not (Test-Path ".env")) { + Write-Host "" + Write-Host "Создайте .env из .env.example и добавьте CURSOR_API_KEY для ответов AI." -ForegroundColor Yellow + Write-Host "Ключ: https://cursor.com/dashboard/integrations" -ForegroundColor Yellow + Write-Host "" +} + +# Bind 0.0.0.0 so other machines on the LAN can reach this host. +# Local: http://127.0.0.1:8010 | LAN: http://:8010 +# Windows Firewall may need an inbound allow for TCP 8010. +python -m uvicorn main:app --reload --reload-dir . --host 0.0.0.0 --port 8010 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7bf07d0 --- /dev/null +++ b/static/app.js @@ -0,0 +1,2466 @@ +const state = { + user: null, + projects: [], + activeProject: null, + activeChat: null, + chats: [], + files: [], + chatFiles: [], + composerUploads: [], + responding: false, + streamController: null, + agentStatusTimer: null, +}; + +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => [...document.querySelectorAll(selector)]; + +async function api(path, options = {}) { + const response = await fetch(`/api${path}`, { + credentials: "same-origin", + ...options, + }); + if (response.status === 401 && path !== "/auth/login" && path !== "/auth/me") { + showLogin(); + throw new Error("Требуется вход"); + } + if (!response.ok) { + let detail = "Произошла ошибка"; + try { + const payload = await response.json(); + if (typeof payload.detail === "string" && payload.detail.trim()) { + detail = payload.detail; + } else if (Array.isArray(payload.detail)) { + detail = payload.detail.map((item) => item.msg || item).join("; "); + } else if (payload.detail) { + detail = JSON.stringify(payload.detail); + } + } catch {} + throw new Error(detail); + } + if (response.status === 204) return null; + return response.json(); +} + +function jsonOptions(method, body) { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +function escapeHtml(value = "") { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function unescapeBasicHtml(value = "") { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} + +function isSafeLinkUrl(url = "") { + const value = String(url).trim(); + return /^(https?:\/\/|mailto:|\/(?!\/))/i.test(value); +} + +function isSafeImageUrl(url = "") { + const value = String(url).trim(); + return /^(https?:\/\/|\/api\/|data:image\/)/i.test(value); +} + +function logExcerptHref(fileName, line) { + const files = visibleFiles(); + const base = String(fileName || "").replace(/\\/g, "/").split("/").pop().toLowerCase(); + if (!base) return ""; + const file = files.find((item) => String(item?.name || "").toLowerCase() === base) + || files.find((item) => String(item?.name || "").toLowerCase().endsWith(base)); + if (!file?.id) return ""; + const n = Number(line) || 1; + const start = Math.max(1, n - 25); + const end = n + 25; + return `/api/files/${encodeURIComponent(file.id)}/excerpt?start=${start}&end=${end}`; +} + +function logCiteChip(fileName, line) { + const href = logExcerptHref(fileName, line); + const inner = `:${escapeHtml(String(line))}${escapeHtml(fileName)}`; + if (href) { + return `${inner}`; + } + return `${inner}`; +} + +function formatInlineMarkdown(html = "") { + const protectedParts = []; + const protect = (markup) => { + const index = protectedParts.length; + protectedParts.push(markup); + return `\u0000INLINE${index}\u0000`; + }; + + let text = String(html) + .replace(/::log:([^:\s]+):(\d+)::/g, (_match, path, line) => protect(logCiteChip(path, line))) + .replace(/`([A-Za-z0-9_./\\-]+\.(?:log(?:\.\d+)?|[A-Za-z0-9]+)):(\d+)`/g, (_match, path, line) => + protect(logCiteChip(path, line)), + ) + .replace(/`(\d+):([A-Za-z0-9_./\\-]+\.(?:log(?:\.\d+)?|[A-Za-z0-9]+))`/g, (_match, line, path) => + protect(logCiteChip(path, line)), + ) + .replace(/`([^`\n]+)`/g, (_match, code) => protect(`${code}`)) + .replace(/\\\((.+?)\\\)/g, (match) => protect(`${match}`)) + .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_match, alt, url) => { + const href = unescapeBasicHtml(url).trim(); + if (isSafeImageUrl(href)) { + return protect( + `${alt}`, + ); + } + const label = alt || "изображение недоступно"; + return protect( + `[Нет изображения: ${label}]`, + ); + }) + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => { + const href = unescapeBasicHtml(url).trim(); + if (!isSafeLinkUrl(href)) { + return label; + } + return protect( + `${label}`, + ); + }) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*\n]+)\*/g, "$1") + .replace(/(^|[\s([{])_([^_\n]+)_(?=[\s)\]}.,!?:;]|$)/g, "$1$2") + .replace( + /(^|[\s(])(:(\d+):([A-Za-z0-9_./\\-]+))/g, + (_match, prefix, _full, line, path) => `${prefix}${protect(logCiteChip(path, line))}`, + ); + + return text.replace(/\u0000INLINE(\d+)\u0000/g, (_match, index) => protectedParts[Number(index)] || ""); +} + +function splitTableCells(line = "") { + let trimmed = line.trim(); + if (trimmed.startsWith("|")) trimmed = trimmed.slice(1); + if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1); + return trimmed.split("|").map((cell) => cell.trim()); +} + +function isTableSeparator(line = "") { + const cells = splitTableCells(line); + if (!cells.length) return false; + return cells.every((cell) => { + const token = cell.replace(/\s/g, ""); + return /^:?-{3,}:?$/.test(token); + }); +} + +function renderMarkdownTable(lines, start) { + if (start + 1 >= lines.length) return null; + const headerLine = lines[start]; + const separatorLine = lines[start + 1]; + if (!headerLine.trim().includes("|")) return null; + if (!isTableSeparator(separatorLine)) return null; + + const headers = splitTableCells(headerLine); + if (!headers.length) return null; + + const rows = []; + let index = start + 2; + while (index < lines.length) { + const rowLine = lines[index]; + if (!rowLine.trim() || !rowLine.includes("|") || isTableSeparator(rowLine)) break; + rows.push(splitTableCells(rowLine)); + index += 1; + } + + const head = headers + .map((cell) => `${formatInlineMarkdown(cell)}`) + .join(""); + const body = rows + .map((row) => { + const cells = headers + .map((_, cellIndex) => `${formatInlineMarkdown(row[cellIndex] || "")}`) + .join(""); + return `${cells}`; + }) + .join(""); + + return { + html: `
${head}${body}
`, + end: index, + }; +} + +function isDarkTheme() { + return document.documentElement.dataset.theme === "dark"; +} + +function getMermaidTheme() { + return isDarkTheme() ? "dark" : "neutral"; +} + +function initMermaid() { + if (!window.mermaid) return; + const dark = isDarkTheme(); + window.mermaid.initialize({ + startOnLoad: false, + theme: getMermaidTheme(), + securityLevel: "antiscript", + fontFamily: "Manrope, system-ui, sans-serif", + themeVariables: dark + ? { + // Match chat dark surface; avoid cream/white voids from light pads. + background: "#262620", + primaryColor: "#2c2c25", + primaryTextColor: "#efede4", + primaryBorderColor: "#9d9a8e", + lineColor: "#c8c4b8", + secondaryColor: "#32322a", + tertiaryColor: "#262620", + mainBkg: "#2c2c25", + nodeBkg: "#2c2c25", + nodeBorder: "#9d9a8e", + clusterBkg: "#21211c", + clusterBorder: "#3b3a32", + titleColor: "#efede4", + edgeLabelBackground: "#262620", + fontFamily: "Manrope, system-ui, sans-serif", + } + : { + background: "#f5f5f0", + primaryColor: "#efece3", + primaryTextColor: "#1a1a16", + primaryBorderColor: "#8a8678", + lineColor: "#3a3a32", + secondaryColor: "#e4e1d6", + tertiaryColor: "#f5f5f0", + fontFamily: "Manrope, system-ui, sans-serif", + }, + }); +} + +function showMermaidFailure(node, error) { + const wrap = node.closest(".msg-mermaid-wrap"); + const sourceEl = wrap?.querySelector(".msg-mermaid-source"); + const container = wrap?.querySelector(".msg-mermaid") || node.parentElement; + if (!container) return; + const detail = error?.message || String(error || "unknown error"); + const source = sourceEl?.textContent || node.textContent || ""; + container.innerHTML = ` +
Не удалось отрисовать диаграмму: ${escapeHtml(detail)}
+
${escapeHtml(source)}
+ `; + if (sourceEl) sourceEl.hidden = false; +} + +async function renderMermaidDiagrams(root = $("#messages")) { + if (!window.mermaid || !root) return; + const nodes = [...root.querySelectorAll("pre.mermaid")]; + if (!nodes.length) return; + for (const node of nodes) { + try { + await window.mermaid.run({ nodes: [node] }); + } catch (error) { + console.warn("Mermaid render failed", error); + showMermaidFailure(node, error); + } + } +} + +function closeDiagramLightbox() { + const lightbox = $("#diagramLightbox"); + if (!lightbox) return; + lightbox.classList.add("hidden"); + const inner = $("#diagramLightboxInner"); + if (inner) inner.innerHTML = ""; +} + +function openDiagramLightbox(fromEl) { + const lightbox = $("#diagramLightbox"); + const inner = $("#diagramLightboxInner"); + if (!lightbox || !inner || !fromEl) return; + + inner.innerHTML = ""; + if (fromEl.tagName === "IMG") { + const clone = document.createElement("img"); + clone.src = fromEl.currentSrc || fromEl.src; + clone.alt = fromEl.alt || "Диаграмма"; + inner.appendChild(clone); + } else { + const svg = fromEl.closest?.("svg") || fromEl.querySelector?.("svg") || fromEl; + if (!svg || String(svg.tagName).toLowerCase() !== "svg") return; + + // Clone the rendered SVG. Mermaid often sets width="100%", which collapses + // inside the lightbox (no definite containing width) — size from viewBox/bbox. + const clone = svg.cloneNode(true); + const vb = svg.viewBox?.baseVal; + const rect = svg.getBoundingClientRect(); + const w = (vb && vb.width > 0 ? vb.width : rect.width) || svg.clientWidth || 800; + const h = (vb && vb.height > 0 ? vb.height : rect.height) || svg.clientHeight || 600; + if (!clone.getAttribute("viewBox") && w && h) { + const minX = vb ? vb.x : 0; + const minY = vb ? vb.y : 0; + clone.setAttribute("viewBox", `${minX} ${minY} ${w} ${h}`); + } + clone.removeAttribute("width"); + clone.removeAttribute("height"); + clone.style.cssText = "width:auto;height:auto;max-width:95vw;max-height:90vh;"; + clone.setAttribute("preserveAspectRatio", "xMidYMid meet"); + // Keep intrinsic ratio for CSS max-* sizing. + clone.setAttribute("width", String(Math.round(w))); + clone.setAttribute("height", String(Math.round(h))); + inner.appendChild(clone); + } + lightbox.classList.remove("hidden"); +} + +function renderKatexMath(root = $("#messages")) { + if (!window.renderMathInElement || !root) return; + try { + window.renderMathInElement(root, { + delimiters: [ + { left: "$$", right: "$$", display: true }, + { left: "\\(", right: "\\)", display: false }, + ], + throwOnError: false, + strict: "ignore", + }); + } catch (error) { + console.warn("KaTeX render failed", error); + } +} + +function formatMessageHtml(content = "", role = "assistant", { renderMermaid = true } = {}) { + const text = String(content).trimEnd(); + if (role === "user") { + return escapeHtml(text); + } + + const blocks = []; + let withFences = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => { + const index = blocks.length; + const safeCode = escapeHtml(code.replace(/\n$/, "")); + const langNorm = String(lang || "").toLowerCase(); + if (langNorm === "mermaid" && renderMermaid) { + blocks.push( + `
+ + +
${safeCode}
+
` + ); + } else { + blocks.push( + `
+ +
${safeCode}
+
` + ); + } + return `\n\n\u0000BLOCK${index}\u0000\n\n`; + }); + + withFences = withFences.replace(/\$\$([\s\S]+?)\$\$/g, (_match, expr) => { + const index = blocks.length; + const safeExpr = escapeHtml(String(expr).trim()); + blocks.push(`
$$${safeExpr}$$
`); + return `\n\n\u0000BLOCK${index}\u0000\n\n`; + }); + + const lines = escapeHtml(withFences).split("\n"); + const output = []; + let paragraph = []; + let index = 0; + + const flushParagraph = () => { + if (!paragraph.length) return; + const body = formatInlineMarkdown(paragraph.join("\n")); + output.push(`

${body.replace(/\n/g, "
")}

`); + paragraph = []; + }; + + const consumeQuote = (start) => { + const quoteLines = []; + let cursor = start; + while (cursor < lines.length) { + const trimmed = lines[cursor].trim(); + const match = trimmed.match(/^>\s?(.*)$/); + if (!match) break; + quoteLines.push(match[1]); + cursor += 1; + } + if (!quoteLines.length) return null; + const body = formatInlineMarkdown(quoteLines.join("\n")).replace(/\n/g, "
"); + return { + html: `
${body}
`, + end: cursor, + }; + }; + + const consumeList = (start) => { + const first = lines[start].trim(); + const taskRe = /^([*-+])\s+\[([ xX])\]\s+(.+)$/; + const ulRe = /^([*-+])\s+(?!\[[ xX]\])(.+)$/; + const olRe = /^(\d+)\.\s+(.+)$/; + let kind = null; + if (taskRe.test(first)) kind = "task"; + else if (olRe.test(first)) kind = "ol"; + else if (ulRe.test(first)) kind = "ul"; + else return null; + + const items = []; + let cursor = start; + while (cursor < lines.length) { + const trimmed = lines[cursor].trim(); + if (kind === "task") { + const match = trimmed.match(taskRe); + if (!match) break; + const checked = match[2].toLowerCase() === "x"; + items.push( + `
  • ${formatInlineMarkdown(match[3])}
  • `, + ); + } else if (kind === "ol") { + const match = trimmed.match(olRe); + if (!match) break; + items.push(`
  • ${formatInlineMarkdown(match[2])}
  • `); + } else { + const match = trimmed.match(ulRe); + if (!match) break; + items.push(`
  • ${formatInlineMarkdown(match[2])}
  • `); + } + cursor += 1; + } + + if (!items.length) return null; + if (kind === "task") { + return { html: `
      ${items.join("")}
    `, end: cursor }; + } + if (kind === "ol") { + return { html: `
      ${items.join("")}
    `, end: cursor }; + } + return { html: `
      ${items.join("")}
    `, end: cursor }; + }; + + while (index < lines.length) { + const line = lines[index]; + const trimmed = line.trim(); + + if (!trimmed) { + flushParagraph(); + index += 1; + continue; + } + + const blockMatch = trimmed.match(/^\u0000BLOCK(\d+)\u0000$/); + if (blockMatch) { + flushParagraph(); + output.push(blocks[Number(blockMatch[1])]); + index += 1; + continue; + } + + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + flushParagraph(); + output.push('
    '); + index += 1; + continue; + } + + const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headerMatch) { + flushParagraph(); + const level = Math.min(headerMatch[1].length, 6); + output.push( + `${formatInlineMarkdown(headerMatch[2])}`, + ); + index += 1; + continue; + } + + const quote = consumeQuote(index); + if (quote) { + flushParagraph(); + output.push(quote.html); + index = quote.end; + continue; + } + + const list = consumeList(index); + if (list) { + flushParagraph(); + output.push(list.html); + index = list.end; + continue; + } + + const table = renderMarkdownTable(lines, index); + if (table) { + flushParagraph(); + output.push(table.html); + index = table.end; + continue; + } + + paragraph.push(line); + index += 1; + } + + flushParagraph(); + return output.join(""); +} + +function formatDate(value) { + const date = new Date(value); + const today = new Date(); + const sameDay = date.toDateString() === today.toDateString(); + return new Intl.DateTimeFormat("ru", sameDay + ? { hour: "2-digit", minute: "2-digit" } + : { day: "numeric", month: "short" } + ).format(date); +} + +function formatSize(bytes) { + const size = Number(bytes) || 0; + if (size < 1024) return `${size} Б`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} КБ`; + return `${(size / 1024 / 1024).toFixed(1)} МБ`; +} + +function showToast(message) { + const toast = $("#toast"); + toast.textContent = message; + toast.classList.add("visible"); + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => toast.classList.remove("visible"), 2600); +} + +function showLogin() { + $("#loginScreen").classList.remove("hidden"); + $("#appShell").classList.add("hidden"); + if (state.agentStatusTimer) { + clearInterval(state.agentStatusTimer); + state.agentStatusTimer = null; + } +} + +function showApp() { + $("#loginScreen").classList.add("hidden"); + $("#appShell").classList.remove("hidden"); + renderUserChrome(); +} + +function renderUserChrome() { + if (!state.user) return; + const name = state.user.display_name || state.user.username; + $("#userDisplayName").textContent = name; + $("#userRoleLabel").textContent = state.user.role; + $("#userAvatar").textContent = (name[0] || "?").toUpperCase(); + $("#usersNav").classList.toggle("hidden", state.user.role !== "admin"); +} + +function showView(viewName) { + $$(".view").forEach((view) => view.classList.remove("active")); + $(`#${viewName}View`).classList.add("active"); + $$(".nav-item").forEach((item) => { + if (viewName === "users") { + item.classList.toggle("active", item.dataset.view === "users"); + } else { + item.classList.toggle( + "active", + item.dataset.view === "projects" || item.dataset.view === "home", + ); + } + }); + $(".sidebar").classList.remove("open"); +} + +function openModal(id) { + $(id).classList.remove("hidden"); + setTimeout(() => $(id).querySelector("input, textarea")?.focus(), 50); +} + +function closeModal(id) { + $(id).classList.add("hidden"); +} + +function closeMenus() { + $$(".menu-dropdown").forEach((menu) => menu.classList.add("hidden")); +} + +function messageRoleLabel(message) { + if (message.role === "assistant") return "Помощник"; + if (!state.user) return message.author_name || "Вы"; + if (state.user.role === "admin") { + return message.author_name || message.user_id || "Пользователь"; + } + if (message.user_id && message.user_id === state.user.id) return "Вы"; + return message.author_name || "Коллега"; +} + +function setResponding(active, queued = false) { + state.responding = active; + $("#messageSend").classList.toggle("hidden", active); + $("#messageStop").classList.toggle("hidden", !active); + $("#messageSend").disabled = active; + $("#messageInput").placeholder = queued + ? "В очереди…" + : active + ? "Генерация ответа…" + : "Напишите сообщение…"; +} + +async function refreshAgentStatus() { + try { + const status = await api("/agent/status"); + const dot = $("#agentStatusDot"); + const text = $("#agentStatusText"); + let label = "idle"; + let cls = "idle"; + if (!status.key_ok) { + label = "no key"; + cls = "error"; + } else if (!status.sdk_ok) { + label = "no sdk"; + cls = "error"; + } else if (status.busy) { + const n = Number(status.active_count) || (status.active_chats || []).length || 1; + label = n > 1 ? `busy×${n}` : "busy"; + cls = "busy"; + } + text.textContent = label; + dot.className = `status-dot ${cls}`; + } catch { + $("#agentStatusText").textContent = "—"; + $("#agentStatusDot").className = "status-dot error"; + } +} + +async function loadProjects() { + state.projects = await api("/projects"); + renderProjects(); + renderProjectNavigation(); + await renderRecents(); +} + +function renderProjects() { + const grid = $("#projectGrid"); + const empty = $("#projectsEmpty"); + grid.innerHTML = state.projects.map((project) => ` +
    +
    ${escapeHtml(project.icon)}
    +

    ${escapeHtml(project.name)}

    +

    ${escapeHtml(project.description || "Без описания")}

    +
    + ${project.chat_count} ${plural(project.chat_count, "чат", "чата", "чатов")} + ${project.file_count} ${plural(project.file_count, "файл", "файла", "файлов")} +
    +
    + `).join(""); + empty.classList.toggle("hidden", state.projects.length > 0); + grid.classList.toggle("hidden", state.projects.length === 0); + $$(".project-card").forEach((card) => { + card.addEventListener("click", () => openProject(card.dataset.projectId)); + }); +} + +function plural(number, one, few, many) { + const mod10 = number % 10; + const mod100 = number % 100; + if (mod10 === 1 && mod100 !== 11) return one; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; + return many; +} + +function renderProjectNavigation() { + const list = $("#projectNavList"); + if (!list) return; + // Replace contents (never append) so load/open cannot double-render rows. + list.replaceChildren(); + const fragment = document.createDocumentFragment(); + state.projects.forEach((project) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `side-row ${state.activeProject?.id === project.id ? "active" : ""}`; + button.dataset.sideProject = project.id; + button.innerHTML = ` + ${escapeHtml(project.icon)} + ${escapeHtml(project.name)} + `; + button.addEventListener("click", () => openProject(project.id)); + fragment.appendChild(button); + }); + list.appendChild(fragment); +} + +async function renderRecents() { + const allChats = (await Promise.all( + state.projects.map(async (project) => { + const chats = await api(`/projects/${project.id}/chats`); + return chats.map((chat) => ({ ...chat, projectName: project.name })); + }) + )).flat().sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)).slice(0, 12); + + $("#recentList").innerHTML = allChats.length + ? allChats.map((chat) => ` + + `).join("") + : ''; + + $$("[data-recent-chat]").forEach((item) => { + item.addEventListener("click", async () => { + if (state.activeProject?.id !== item.dataset.recentProject) { + await openProject(item.dataset.recentProject, false); + } + await openChat(item.dataset.recentChat); + }); + }); +} + +async function openProject(projectId, navigate = true) { + const previousProjectId = state.activeProject?.id; + state.activeProject = state.projects.find((project) => project.id === projectId); + if (!state.activeProject) return; + if (previousProjectId && previousProjectId !== projectId) { + state.composerUploads = []; + } + state.chatFiles = []; + [state.chats, state.files] = await Promise.all([ + api(`/projects/${projectId}/chats`), + api(`/projects/${projectId}/files`), + ]); + $("#projectIcon").textContent = state.activeProject.icon; + $("#projectName").textContent = state.activeProject.name; + $("#projectDescription").textContent = state.activeProject.description || "Без описания"; + $("#instructionsPreview").textContent = state.activeProject.instructions || + "Добавьте правила и контекст для всех разговоров проекта."; + const model = state.activeProject.model || "auto"; + const select = $("#modelSelect"); + if (![...select.options].some((opt) => opt.value === model)) { + select.insertAdjacentHTML("beforeend", ``); + } + select.value = model; + $("#breadcrumbs").textContent = `Проекты / ${state.activeProject.name}`; + renderChats(); + renderFiles(); + renderComposerAttachments(); + renderProjectNavigation(); + if (navigate) showView("project"); +} + +function renderChats() { + const list = $("#chatList"); + list.innerHTML = state.chats.map((chat) => ` +
    + + + ${escapeHtml(chat.title)} + ${formatDate(chat.updated_at)} +
    + `).join(""); + $("#chatsEmpty").classList.toggle("hidden", state.chats.length > 0); + $$("#chatList [data-chat-id]").forEach((row) => { + row.addEventListener("click", () => { + if (row.dataset.didDrag === "1") { + row.dataset.didDrag = "0"; + return; + } + openChat(row.dataset.chatId); + }); + }); + bindChatListDrag(); +} + +let chatDragId = null; + +function bindChatListDrag() { + const list = $("#chatList"); + if (!list || list.dataset.dndBound === "1") return; + list.dataset.dndBound = "1"; + + list.addEventListener("dragstart", (event) => { + const row = event.target.closest("[data-chat-id]"); + if (!row || !list.contains(row)) return; + chatDragId = row.dataset.chatId; + row.dataset.didDrag = "1"; + row.classList.add("dragging"); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", chatDragId); + }); + + list.addEventListener("dragend", () => { + $$("#chatList .chat-row").forEach((row) => row.classList.remove("dragging", "drag-over")); + chatDragId = null; + }); + + list.addEventListener("dragover", (event) => { + const row = event.target.closest("[data-chat-id]"); + if (!row || !chatDragId || row.dataset.chatId === chatDragId) return; + event.preventDefault(); + $$("#chatList .chat-row").forEach((item) => item.classList.toggle("drag-over", item === row)); + }); + + list.addEventListener("dragleave", (event) => { + const row = event.target.closest("[data-chat-id]"); + if (row) row.classList.remove("drag-over"); + }); + + list.addEventListener("drop", async (event) => { + event.preventDefault(); + const target = event.target.closest("[data-chat-id]"); + $$("#chatList .chat-row").forEach((row) => row.classList.remove("drag-over", "dragging")); + const sourceId = chatDragId || event.dataTransfer.getData("text/plain"); + chatDragId = null; + if (!target || !sourceId || sourceId === target.dataset.chatId || !state.activeProject) return; + + const ids = state.chats.map((chat) => chat.id); + const from = ids.indexOf(sourceId); + const to = ids.indexOf(target.dataset.chatId); + if (from < 0 || to < 0) return; + ids.splice(from, 1); + ids.splice(to, 0, sourceId); + const previous = state.chats.slice(); + state.chats = ids.map((id) => previous.find((chat) => chat.id === id)).filter(Boolean); + renderChats(); + try { + state.chats = await api(`/projects/${state.activeProject.id}/chats/order`, jsonOptions("PUT", { + chat_ids: ids, + })); + renderChats(); + } catch (error) { + state.chats = previous; + renderChats(); + showToast(error.message); + } + }); +} + +const ATTACH_FOOTER_RE = /\n\n\[Прикреплённые файлы проекта:\s*([^\]]+)\]\s*$/; + +function fileExtensionBadge(name) { + const raw = String(name || ""); + const dot = raw.lastIndexOf("."); + if (dot < 0 || dot === raw.length - 1) return "FILE"; + const ext = raw.slice(dot + 1).replace(/[^a-zA-Z0-9]/g, ""); + if (!ext) return "FILE"; + return ext.slice(0, 5).toUpperCase(); +} + +function iconDownloadSvg() { + return ``; +} + +function iconTrashSvg() { + return ``; +} + +function iconCloseSvg() { + return ``; +} + +function fileDownloadHref(fileId) { + if (!fileId || String(fileId).startsWith("local-")) return ""; + return `/api/files/${encodeURIComponent(fileId)}`; +} + +function visibleFiles() { + const byId = new Map(); + for (const file of [...(state.files || []), ...(state.chatFiles || [])]) { + if (file?.id) byId.set(file.id, file); + } + return [...byId.values()]; +} + +function fileIsChatScoped(file, chatId) { + return (file?.scope === "chat" || file?.chat_id) && file?.chat_id === chatId; +} + +function localComposerId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `local-${crypto.randomUUID()}`; + } + return `local-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function isLocalComposerFile(file) { + return Boolean(file?.localFile) || String(file?.id || "").startsWith("local-"); +} + +function formatThinkingDuration(ms) { + const value = Number(ms); + if (!Number.isFinite(value) || value < 0) return ""; + const sec = Math.max(1, Math.round(value / 1000)); + if (sec < 60) return `${sec} с`; + const minutes = Math.floor(sec / 60); + const rest = sec % 60; + return rest ? `${minutes} мин ${rest} с` : `${minutes} мин`; +} + +function thinkingStepTitle(step) { + const type = String(step?.type || ""); + if (step?.title) return String(step.title); + if (type === "thinking" || type === "narration") return "Размышления"; + if (type === "status") return "Статус"; + return "Инструмент"; +} + +const JUNK_STATUS_TOKENS = new Set([ + "running", "finished", "done", "completed", "complete", "success", + "ok", "ready", "idle", "завершено", "готово", +]); + +function compactStatusToken(text) { + return String(text || "").trim().toLowerCase().replace(/[^a-zа-яё]+/g, ""); +} + +function isJunkActivityStep(step) { + const type = String(step?.type || ""); + const detail = compactStatusToken(step?.detail); + const title = compactStatusToken(step?.title); + if (type === "status" && (JUNK_STATUS_TOKENS.has(detail) || JUNK_STATUS_TOKENS.has(title))) { + return true; + } + if (type === "thinking" && JUNK_STATUS_TOKENS.has(detail)) { + return true; + } + return false; +} + +function isThinkingStepType(type) { + return type === "thinking"; +} + +function mergeThinkingDetail(prev, incoming) { + const before = String(prev || ""); + const next = String(incoming || ""); + if (!next) return before; + if (!before) return next; + if (next === before || before.endsWith(next)) return before; + if (next.startsWith(before)) return next; + if (before.includes(next) && next.length > 12) return before; + return `${before}${next}`; +} + +function thinkingStepHtml(step) { + const type = String(step?.type || "thinking"); + const status = String(step?.status || ""); + const title = escapeHtml(thinkingStepTitle(step)); + const detail = String(step?.detail || ""); + const isThink = type === "thinking"; + const expandable = !isThink && detail.length > 220; + let detailHtml = ""; + if (detail) { + if (expandable) { + detailHtml = ` +
    ${escapeHtml(detail.slice(0, 180))}…
    +
    + подробнее +
    ${escapeHtml(detail)}
    +
    `; + } else { + detailHtml = `
    ${escapeHtml(detail)}
    `; + } + } + const titleHtml = isThink + ? "" + : `
    ${title}
    `; + return `
  • ${titleHtml}${detailHtml}
  • `; +} + +function renderThinkingStepsHtml(steps) { + const items = Array.isArray(steps) + ? coalesceThinkingSteps(steps.filter((step) => step && (step.title || step.detail) && !isJunkActivityStep(step))) + : []; + if (!items.length) return ""; + return `
      ${items.map(thinkingStepHtml).join("")}
    `; +} + +function coalesceThinkingSteps(steps) { + const merged = []; + for (const step of steps || []) { + const type = String(step?.type || "thinking"); + const last = merged[merged.length - 1]; + if (isThinkingStepType(type) && last && isThinkingStepType(last.type)) { + last.detail = mergeThinkingDetail(last.detail, step.detail); + last.status = step.status || last.status; + last.title = last.title || step.title; + continue; + } + merged.push({ ...step, type }); + } + return merged; +} + +function normalizeMessageActivity(message) { + if (Array.isArray(message?.activity) && message.activity.length) { + return coalesceThinkingSteps( + message.activity.filter((step) => step && (step.title || step.detail) && !isJunkActivityStep(step)), + ); + } + const thinking = String(message?.thinking || "").trim(); + if (!thinking) return []; + return [{ type: "thinking", title: "Размышления", detail: thinking, status: "completed" }]; +} + +function thinkingToggleHtml({ + label = "Размышления", + durationMs = null, + collapsed = true, + content = "", + steps = null, + streaming = false, +} = {}) { + const duration = formatThinkingDuration(durationMs); + const openClass = collapsed ? "collapsed" : "open"; + const body = (steps && steps.length) + ? renderThinkingStepsHtml(steps) + : escapeHtml(content); + return ` +
    + +
    ${body}
    +
    + `; +} + +function bindThinkingToggles(root) { + root.querySelectorAll(".thinking-toggle").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + const block = button.closest(".thinking-block"); + if (!block) return; + block.classList.toggle("collapsed"); + block.classList.toggle("open", !block.classList.contains("collapsed")); + }); + }); +} + +function normalizeAttachmentList(attachments) { + if (!Array.isArray(attachments)) return []; + return attachments + .map((item) => ({ + id: item?.id || "", + name: item?.name || "файл", + })) + .filter((item) => item.id || item.name); +} + +function parseMessageAttachments(message) { + const stored = normalizeAttachmentList(message?.attachments); + if (stored.length) return stored; + const match = String(message?.content || "").match(ATTACH_FOOTER_RE); + if (!match) return []; + return match[1] + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + .map((name) => { + const file = visibleFiles().find((entry) => entry.name === name); + return { id: file?.id || "", name }; + }); +} + +function messageBodyContent(message) { + return String(message?.content || "").replace(ATTACH_FOOTER_RE, ""); +} + +function renderAttachmentChips(attachments, { removable = false } = {}) { + return attachments.map((file) => { + const name = file?.name || "файл"; + const id = file?.id || ""; + const badge = fileExtensionBadge(name); + const href = fileDownloadHref(id); + const nameHtml = href + ? `${escapeHtml(name)}` + : `${escapeHtml(name)}`; + const removeHtml = removable + ? `` + : (href + ? `${iconDownloadSvg()}` + : ""); + return ` + + ${escapeHtml(badge)} + ${nameHtml} + ${removeHtml} + + `; + }).join(""); +} + +function renderFiles() { + const list = $("#fileList"); + const empty = $("#filesEmpty"); + if (!list) return; + list.innerHTML = state.files.map((file) => { + const extension = fileExtensionBadge(file.name); + const href = fileDownloadHref(file.id); + return ` +
    + ${escapeHtml(extension)} + + ${escapeHtml(file.name)} + ${formatSize(file.size)} + + ${iconDownloadSvg()} + +
    + `; + }).join(""); + empty?.classList.toggle("hidden", state.files.length > 0); + $$("[data-file-delete]").forEach((button) => { + button.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + try { + await api(`/files/${button.dataset.fileDelete}`, { method: "DELETE" }); + state.composerUploads = state.composerUploads.filter( + (file) => file.id !== button.dataset.fileDelete, + ); + renderComposerAttachments(); + await openProject(state.activeProject.id, false); + showToast("Файл удалён"); + } catch (error) { + showToast(error.message); + } + }); + }); +} + +async function loadUsers() { + const users = await api("/users"); + $("#usersTable").innerHTML = ` +
    + ИмяЛогинРоль +
    + ${users.map((user) => ` +
    + ${escapeHtml(user.display_name)} + ${escapeHtml(user.username)} + ${escapeHtml(user.role)} +
    + `).join("")} + `; +} + +function composerMessageFromUploads(typed, uploads) { + const snapshot = (uploads || []).map((file) => ({ ...file })); + const named = snapshot.filter((file) => file?.name); + const text = String(typed || "").trim(); + let content = text; + if (named.length) { + const names = named.map((file) => file.name).join(", "); + content = text + ? `${text}\n\n[Прикреплённые файлы проекта: ${names}]` + : `[Прикреплённые файлы проекта: ${names}]`; + } + return { + content, + attachments: named + .filter((file) => file.id && !isLocalComposerFile(file)) + .map((file) => ({ id: file.id, name: file.name })), + snapshot, + }; +} + +async function createChat(initialMessage = "", { attachUploads = false } = {}) { + if (!state.activeProject) { + if (!state.projects.length) return openModal("#projectModal"); + await openProject(state.projects[0].id, false); + } + const typed = String(initialMessage || "").trim(); + const pendingUploads = attachUploads ? state.composerUploads.slice() : []; + const sendNow = Boolean(typed) || pendingUploads.length > 0; + const chat = await api("/chats", jsonOptions("POST", { + project_id: state.activeProject.id, + title: "Новый чат", + })); + state.chats.unshift(chat); + if (sendNow) { + try { + if (pendingUploads.length) { + await flushComposerUploads(chat.id); + } + const payload = composerMessageFromUploads(typed, state.composerUploads); + state.composerUploads = []; + renderComposerAttachments(); + await api(`/chats/${chat.id}/messages`, jsonOptions("POST", { + role: "user", + content: payload.content, + attachments: payload.attachments, + })); + } catch (error) { + state.composerUploads = pendingUploads; + renderComposerAttachments(); + await openProject(state.activeProject.id, false); + await openChat(chat.id); + throw error; + } + } + await openProject(state.activeProject.id, false); + await openChat(chat.id); + if (sendNow) await requestAssistant(); + await loadProjects(); +} + +async function openChat(chatId, { messageId = "" } = {}) { + const previousChatId = state.activeChat?.id; + state.activeChat = state.chats.find((chat) => chat.id === chatId); + if (!state.activeChat) { + state.chats = await api(`/projects/${state.activeProject.id}/chats`); + state.activeChat = state.chats.find((chat) => chat.id === chatId); + } + if (!state.activeChat) return; + hideGenProgress(); + // Pending uploads stay when opening a chat from the project screen. + // Clear only when switching between two different chats already in chat view. + const leavingChatView = $("#chatView")?.classList.contains("active"); + if (leavingChatView && previousChatId && previousChatId !== chatId) { + state.composerUploads = []; + } + renderComposerAttachments(); + $("#chatTitle").textContent = state.activeChat.title; + $("#chatProjectName").textContent = state.activeProject.name; + $("#breadcrumbs").textContent = `${state.activeProject.name} / ${state.activeChat.title}`; + const exportHref = `/api/chats/${encodeURIComponent(chatId)}/export`; + const exportBtn = $("#exportChat"); + const exportMenu = $("#exportChatMenu"); + if (exportBtn) exportBtn.href = exportHref; + if (exportMenu) exportMenu.href = exportHref; + try { + const listed = await api( + `/projects/${state.activeProject.id}/files?chat_id=${encodeURIComponent(chatId)}`, + ); + state.chatFiles = listed.filter((file) => fileIsChatScoped(file, chatId)); + } catch { + state.chatFiles = []; + } + const messages = await api(`/chats/${chatId}/messages`); + renderMessages(messages, { highlightId: messageId }); + showView("chat"); +} + +function bindMessageActions(root) { + root.querySelectorAll("[data-copy-code]").forEach((button) => { + button.addEventListener("click", async () => { + const code = root.querySelector(`[data-code-block="${button.dataset.copyCode}"]`); + try { + await navigator.clipboard.writeText(code?.textContent || ""); + showToast("Код скопирован"); + } catch { + showToast("Не удалось скопировать"); + } + }); + }); + root.querySelectorAll("[data-quote]").forEach((button) => { + button.addEventListener("click", () => { + let raw = ""; + try { + raw = decodeURIComponent(button.getAttribute("data-quote") || ""); + } catch { + raw = button.getAttribute("data-quote") || ""; + } + const quoted = raw + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + const input = $("#messageInput"); + const prefix = input.value.trim() ? `${input.value.trim()}\n\n` : ""; + input.value = `${prefix}${quoted}\n\n`; + input.focus(); + }); + }); +} + +function renderMessages(messages, { highlightId = "" } = {}) { + $("#messages").innerHTML = messages.length + ? messages.map((message) => { + const isUser = message.role === "user"; + const bodyContent = messageBodyContent(message); + const attachments = parseMessageAttachments(message); + const quotePayload = encodeURIComponent(bodyContent || ""); + const chipsHtml = attachments.length + ? `
    ${renderAttachmentChips(attachments)}
    ` + : ""; + const activity = (!isUser) ? normalizeMessageActivity(message) : []; + const thinkingHtml = activity.length + ? thinkingToggleHtml({ + label: "Размышления", + durationMs: message.thinking_duration_ms, + collapsed: true, + steps: activity, + }) + : ""; + const highlightClass = highlightId && message.id === highlightId ? " highlight" : ""; + return ` +
    +
    ${escapeHtml(messageRoleLabel(message))}
    +
    + ${chipsHtml} + ${thinkingHtml} +
    ${formatMessageHtml(bodyContent, message.role)}
    +
    + +
    +
    +
    + `; + }).join("") + : '
    Начните разговор.
    Чаты общие для всех пользователей проекта.
    '; + bindMessageActions($("#messages")); + bindThinkingToggles($("#messages")); + const highlightRow = highlightId + ? $("#messages").querySelector(`[data-message-id="${CSS.escape(highlightId)}"]`) + : null; + if (highlightRow) { + highlightRow.scrollIntoView({ block: "center" }); + } else { + $("#messages").scrollTop = $("#messages").scrollHeight; + } + void renderMermaidDiagrams($("#messages")).then(() => { + renderKatexMath($("#messages")); + }); +} + +let streamActivity = { steps: [], usedActivity: false, thinkingText: "" }; + +function resetStreamActivity() { + streamActivity = { steps: [], usedActivity: false, thinkingText: "" }; +} + +function ensureStreamingBubble() { + let row = $("#assistantStreaming"); + if (row) return row; + resetStreamActivity(); + $("#messages").insertAdjacentHTML("beforeend", ` +
    +
    Помощник
    + +
    + ${thinkingToggleHtml({ + label: "Думаю", + collapsed: false, + content: "", + steps: [], + streaming: true, + })} +
    +
    +
    + `); + const created = $("#assistantStreaming"); + bindThinkingToggles(created); + created.dataset.thinkingStartedAt = String(Date.now()); + return created; +} + +function streamingThinkingEls() { + const block = $("#assistantStreaming .thinking-block"); + return { + block, + label: block?.querySelector(".thinking-label"), + duration: block?.querySelector(".thinking-duration"), + content: block?.querySelector(".thinking-content"), + }; +} + +function renderStreamingThinking() { + const { block, content } = streamingThinkingEls(); + if (!block || !content) return; + const steps = streamActivity.steps; + content.innerHTML = steps.length + ? renderThinkingStepsHtml(steps) + : escapeHtml(streamActivity.thinkingText); + block.classList.remove("collapsed", "hidden"); + block.classList.add("open", "streaming"); + content.scrollTop = content.scrollHeight; +} + +function appendStreamingThinking(text, { replace = false } = {}) { + if (replace) { + // History must accumulate; ignore replace from older servers. + } + const chunk = String(text || ""); + if (!chunk.trim() || JUNK_STATUS_TOKENS.has(compactStatusToken(chunk))) { + renderStreamingThinking(); + return; + } + streamActivity.thinkingText += chunk; + const last = streamActivity.steps[streamActivity.steps.length - 1]; + if (last && isThinkingStepType(last.type)) { + last.detail = mergeThinkingDetail(last.detail, chunk); + } else { + streamActivity.steps.push({ + id: last?.id === "think-fallback" ? "think-fallback" : `think-${streamActivity.steps.length + 1}`, + type: "thinking", + title: "Размышления", + detail: chunk, + status: "running", + }); + } + renderStreamingThinking(); +} + +function upsertStreamingActivity(step) { + if (!step || typeof step !== "object") return; + const incoming = { ...step }; + incoming.type = String(incoming.type || (incoming.title === "Размышления" || incoming.title === "Думаю" ? "thinking" : incoming.type || "")); + if (isJunkActivityStep(incoming)) return; + if (!streamActivity.usedActivity) { + streamActivity.steps = streamActivity.steps.filter((item) => item.id !== "think-fallback"); + streamActivity.usedActivity = true; + } + const last = streamActivity.steps[streamActivity.steps.length - 1]; + if (isThinkingStepType(incoming.type) && last && isThinkingStepType(last.type)) { + last.detail = mergeThinkingDetail(last.detail, incoming.detail); + last.status = incoming.status || last.status; + last.title = incoming.title || last.title; + if (incoming.id && !last.id) last.id = incoming.id; + renderStreamingThinking(); + return; + } + const idx = streamActivity.steps.findIndex((item) => item.id && incoming.id && item.id === incoming.id); + if (idx >= 0) { + const prev = streamActivity.steps[idx]; + const sameThinking = isThinkingStepType(prev.type) && isThinkingStepType(incoming.type); + streamActivity.steps[idx] = { + ...prev, + ...incoming, + detail: sameThinking + ? mergeThinkingDetail(prev.detail, incoming.detail) + : (incoming.detail != null ? incoming.detail : prev.detail), + }; + } else { + streamActivity.steps.push({ ...incoming }); + } + renderStreamingThinking(); +} + +function finishStreamingThinking(durationMs) { + const row = $("#assistantStreaming"); + const { block, label, duration, content } = streamingThinkingEls(); + if (!block) return; + const started = Number(row?.dataset.thinkingStartedAt || 0); + const elapsed = started ? Date.now() - started : 0; + const ms = durationMs != null ? durationMs : elapsed; + const hasText = Boolean( + streamActivity.steps.length + || (streamActivity.thinkingText || "").trim() + || (content?.textContent || "").trim() + ); + if (!hasText) { + block.classList.add("hidden"); + return; + } + renderStreamingThinking(); + if (label) label.textContent = "Размышления"; + if (duration) duration.textContent = formatThinkingDuration(ms); + block.classList.add("collapsed"); + block.classList.remove("open", "streaming"); +} + +function renderContextFileChip(entry) { + const name = typeof entry === "string" ? entry : (entry?.name || "файл"); + const id = typeof entry === "string" ? "" : (entry?.id || ""); + const file = id + ? visibleFiles().find((item) => item.id === id) + : visibleFiles().find((item) => item.name === name); + const href = fileDownloadHref(file?.id || id); + const badge = fileExtensionBadge(name); + const body = `${escapeHtml(badge)}${escapeHtml(name)}`; + return href + ? `${body}` + : `${body}`; +} + +function dedupeContextEntries(items) { + const seenIds = new Set(); + const seenNames = new Set(); + const unique = []; + for (const item of items || []) { + const name = typeof item === "string" ? item : (item?.name || ""); + const id = typeof item === "string" ? "" : (item?.id || ""); + const key = name.toLowerCase(); + if (id && seenIds.has(id)) continue; + if (key && seenNames.has(key)) continue; + if (id) seenIds.add(id); + if (key) seenNames.add(key); + unique.push(typeof item === "string" ? { id: "", name } : { id, name: name || "файл" }); + } + return unique; +} + +function renderContextMore(label, files) { + if (!files.length) return ""; + return `
    ${escapeHtml(label)}${ + files.map(renderContextFileChip).join("") + }
    `; +} + +function renderContextChips(data) { + const chips = $("#contextChips"); + if (!chips) return; + const attachments = dedupeContextEntries(data?.attachments || []); + const chatFiles = dedupeContextEntries(data?.chat_files || []); + const projectFiles = dedupeContextEntries(data?.project_files || []); + let primary = dedupeContextEntries([...attachments, ...chatFiles]); + const project = projectFiles.filter((item) => { + const idMatch = item.id && primary.some((entry) => entry.id === item.id); + const nameMatch = primary.some((entry) => entry.name.toLowerCase() === item.name.toLowerCase()); + return !idMatch && !nameMatch; + }); + if (!primary.length && !project.length) { + const legacy = dedupeContextEntries( + Array.isArray(data?.files) + ? data.files.map((item) => (typeof item === "string" ? item : item?.name)).filter(Boolean) + : [], + ); + if (!legacy.length) { + chips.classList.add("hidden"); + chips.innerHTML = ""; + return; + } + primary = legacy; + } + const shown = primary.slice(0, 5); + const extra = primary.slice(5); + let html = `Контекст${shown.map(renderContextFileChip).join("")}`; + if (extra.length) html += renderContextMore(`ещё ${extra.length}`, extra); + if (project.length) { + if (attachments.length || project.length > 3) { + html += renderContextMore(`Файлы проекта (${project.length})`, project); + } else { + const projectShown = project.slice(0, 5); + const projectExtra = project.slice(5); + html += projectShown.map(renderContextFileChip).join(""); + if (projectExtra.length) html += renderContextMore(`ещё ${projectExtra.length}`, projectExtra); + } + } + chips.classList.remove("hidden"); + chips.innerHTML = html; +} + +function parseSseChunk(buffer) { + const normalized = String(buffer).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const events = []; + const parts = normalized.split("\n\n"); + const rest = parts.pop() || ""; + for (const part of parts) { + if (!part.trim()) continue; + let event = "message"; + const dataLines = []; + for (const line of part.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); + } + let data = {}; + try { + data = JSON.parse(dataLines.join("\n") || "{}"); + } catch { + data = { detail: dataLines.join("\n") }; + } + events.push({ event, data }); + } + return { events, rest }; +} + +function renderStreamingText(text) { + const body = $("#streamingBody"); + if (!body) return; + try { + body.innerHTML = formatMessageHtml(text, "assistant", { renderMermaid: false }); + } catch (error) { + console.warn("stream render failed", error); + body.textContent = text; + } +} + +function renderComposerAttachments() { + const boxes = $$(".composer-attachments"); + const uploads = normalizeAttachmentList(state.composerUploads); + for (const box of boxes) { + if (!uploads.length) { + box.classList.add("hidden"); + box.innerHTML = ""; + continue; + } + box.classList.remove("hidden"); + box.innerHTML = renderAttachmentChips(uploads, { removable: true }); + box.querySelectorAll("[data-composer-remove]").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const removeId = button.dataset.composerRemove; + state.composerUploads = state.composerUploads.filter((file) => file.id !== removeId); + renderComposerAttachments(); + }); + }); + } +} + +function rememberComposerUploads(uploaded) { + const byId = new Map(state.composerUploads.map((file) => [file.id, file])); + for (const entry of uploaded || []) { + if (entry?.id) byId.set(entry.id, { id: entry.id, name: entry.name || "файл" }); + } + state.composerUploads = [...byId.values()]; + renderComposerAttachments(); +} + +async function uploadProjectFiles(fileList, { chatId = null, scope = null } = {}) { + const files = [...fileList]; + if (!files.length) return []; + if (!state.activeProject) { + throw new Error("Сначала откройте проект"); + } + const resolvedScope = scope || (chatId ? "chat" : "project"); + if (resolvedScope === "chat" && !chatId) { + throw new Error("Сначала откройте чат"); + } + const uploaded = []; + for (const file of files) { + const form = new FormData(); + form.append("file", file); + form.append("scope", resolvedScope); + if (chatId) form.append("chat_id", chatId); + const query = new URLSearchParams(); + if (chatId) query.set("chat_id", chatId); + query.set("scope", resolvedScope); + const entry = await api(`/projects/${state.activeProject.id}/files?${query}`, { + method: "POST", + body: form, + }); + uploaded.push(entry); + if (entry?.id) { + if (resolvedScope === "chat") { + if (!state.chatFiles.some((item) => item.id === entry.id)) { + state.chatFiles = [entry, ...state.chatFiles]; + } + } else if (!state.files.some((item) => item.id === entry.id)) { + state.files = [entry, ...state.files]; + } + } + if (resolvedScope !== "chat") renderFiles(); + } + try { + if (resolvedScope === "chat" && chatId) { + const listed = await api( + `/projects/${state.activeProject.id}/files?chat_id=${encodeURIComponent(chatId)}`, + ); + state.chatFiles = listed.filter((file) => fileIsChatScoped(file, chatId)); + } else { + state.files = await api(`/projects/${state.activeProject.id}/files`); + } + } catch {} + if (resolvedScope !== "chat") renderFiles(); + return uploaded; +} + +async function flushComposerUploads(chatId) { + if (!chatId) throw new Error("Сначала откройте чат"); + const pending = []; + const ready = []; + for (const item of state.composerUploads) { + if (item?.localFile) pending.push(item); + else if (item?.id && !isLocalComposerFile(item)) ready.push(item); + } + const uploaded = pending.length + ? await uploadProjectFiles( + pending.map((item) => item.localFile), + { chatId, scope: "chat" }, + ) + : []; + if (ready.length) { + try { + await api(`/chats/${chatId}/files/bind`, jsonOptions("POST", { + file_ids: ready.map((item) => item.id), + })); + } catch {} + } + const merged = new Map(); + for (const entry of [...ready, ...uploaded]) { + if (entry?.id) merged.set(entry.id, { id: entry.id, name: entry.name || "файл" }); + } + state.composerUploads = [...merged.values()]; + renderComposerAttachments(); + return state.composerUploads; +} + +async function attachComposerFiles(files, chatId = null) { + const boundId = chatId || state.activeChat?.id || null; + if (boundId) { + const uploaded = await uploadProjectFiles(files, { chatId: boundId, scope: "chat" }); + rememberComposerUploads(uploaded); + return uploaded; + } + for (const file of files) { + state.composerUploads.push({ + id: localComposerId(), + name: file.name, + localFile: file, + }); + } + renderComposerAttachments(); + return state.composerUploads; +} + +function hasFilePayload(event) { + const types = event.dataTransfer?.types; + if (!types) return false; + if (typeof types.includes === "function") return types.includes("Files"); + if (typeof types.contains === "function") return types.contains("Files"); + return Array.from(types).includes("Files"); +} + +const COMPOSER_MAX_HEIGHT = 180; + +function autoGrowTextarea(textarea, { maxHeight = COMPOSER_MAX_HEIGHT } = {}) { + if (!textarea) return; + textarea.style.height = "auto"; + const next = Math.min(textarea.scrollHeight, maxHeight); + textarea.style.height = `${Math.max(next, 24)}px`; + textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden"; +} + +function resetTextareaHeight(textarea, { maxHeight = COMPOSER_MAX_HEIGHT } = {}) { + if (!textarea) return; + textarea.style.height = "auto"; + autoGrowTextarea(textarea, { maxHeight }); +} + +function bindAutoGrow(textarea, { maxHeight = COMPOSER_MAX_HEIGHT } = {}) { + if (!textarea || textarea.dataset.autoGrowBound === "1") return; + textarea.dataset.autoGrowBound = "1"; + const resize = () => autoGrowTextarea(textarea, { maxHeight }); + textarea.addEventListener("input", resize); + textarea.addEventListener("focus", resize); + resize(); +} + +function setupFileDropZone(element, { onFiles, activeClass = "drop-target" } = {}) { + if (!element || element.dataset.dropBound === "1") return; + element.dataset.dropBound = "1"; + + const clearActive = () => element.classList.remove(activeClass); + + element.addEventListener("dragenter", (event) => { + if (!hasFilePayload(event)) return; + event.preventDefault(); + event.stopPropagation(); + element.classList.add(activeClass); + }); + + element.addEventListener("dragover", (event) => { + if (!hasFilePayload(event)) return; + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + element.classList.add(activeClass); + }); + + element.addEventListener("dragleave", (event) => { + if (!hasFilePayload(event)) return; + // Stay active while the pointer moves across child nodes. + if (event.relatedTarget && element.contains(event.relatedTarget)) return; + clearActive(); + }); + + element.addEventListener("drop", async (event) => { + if (!hasFilePayload(event)) return; + event.preventDefault(); + event.stopPropagation(); + clearActive(); + // Clear ancestors that may still show the drag highlight. + let parent = element.parentElement; + while (parent) { + parent.classList.remove(activeClass); + parent = parent.parentElement; + } + const files = [...(event.dataTransfer?.files || [])]; + if (!files.length) return; + try { + await onFiles(files); + } catch (error) { + showToast(error.message); + } + }); +} + +async function handleDroppedFiles(files, { chatId = null, composer = false } = {}) { + if (!files?.length) return; + if (composer) { + await attachComposerFiles(files, chatId); + } else { + await uploadProjectFiles(files, { chatId: null, scope: "project" }); + } + showToast(`${files.length} ${plural(files.length, "файл добавлен", "файла добавлены", "файлов добавлено")}`); +} + +function showGenProgress({ message = "", percent = null, phase = "" } = {}) { + const box = $("#genProgress"); + const label = $("#genProgressLabel"); + const bar = $("#genProgressBar"); + if (!box || !label || !bar) return; + box.classList.remove("hidden"); + label.textContent = message || (phase === "agent" ? "Думаю…" : "Читаю лог…"); + if (percent == null || Number.isNaN(Number(percent))) { + bar.style.width = "32%"; + bar.classList.add("indeterminate"); + } else { + bar.classList.remove("indeterminate"); + bar.style.width = `${Math.max(0, Math.min(100, Number(percent)))}%`; + } +} + +function hideGenProgress() { + const box = $("#genProgress"); + const bar = $("#genProgressBar"); + if (box) box.classList.add("hidden"); + if (bar) { + bar.classList.remove("indeterminate"); + bar.style.width = "0%"; + } +} + +async function requestAssistant() { + if (state.responding || !state.activeChat) return; + setResponding(true); + const controller = new AbortController(); + state.streamController = controller; + ensureStreamingBubble(); + showGenProgress({ phase: "agent", message: "Запуск…", percent: null }); + let streamed = ""; + let queued = false; + + try { + const response = await fetch(`/api/chats/${state.activeChat.id}/respond`, { + method: "POST", + credentials: "same-origin", + signal: controller.signal, + }); + if (response.status === 401) { + showLogin(); + throw new Error("Требуется вход"); + } + if (!response.ok) { + let detail = "Ошибка генерации"; + try { + const payload = await response.json(); + detail = payload.detail || detail; + } catch {} + throw new Error(detail); + } + if (!response.body || typeof response.body.getReader !== "function") { + throw new Error("Поток ответа недоступен в этом браузере"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parsed = parseSseChunk(buffer); + buffer = parsed.rest; + for (const { event, data } of parsed.events) { + if (event === "status") { + queued = data.state === "queued"; + setResponding(true, queued); + const { label } = streamingThinkingEls(); + if (label && data.detail) label.textContent = data.detail; + if (data.state === "scanning" && data.detail) { + const pct = String(data.detail).match(/(\d+)\s*%/); + showGenProgress({ + phase: "scan", + message: data.detail, + percent: pct ? Number(pct[1]) : 0, + }); + } + } else if (event === "progress") { + showGenProgress(data); + const { label } = streamingThinkingEls(); + if (label && data.message) label.textContent = data.message; + } else if (event === "context") { + renderContextChips(data); + } else if (event === "activity") { + upsertStreamingActivity(data); + } else if (event === "thinking") { + appendStreamingThinking(data.text || ""); + } else if (event === "thinking_done") { + finishStreamingThinking(data.duration_ms); + } else if (event === "delta") { + if (data.replace) streamed = data.text || ""; + else streamed += data.text || ""; + renderStreamingText(streamed); + const messages = $("#messages"); + if (messages) messages.scrollTop = messages.scrollHeight; + } else if (event === "error") { + throw new Error(data.detail || "Ошибка агента"); + } else if (event === "done" || event === "cancelled") { + if (event === "cancelled") showToast("Генерация остановлена"); + if (!streamed && (data.text || "")) { + streamed = data.text; + renderStreamingText(streamed); + } + if (Array.isArray(data.message?.activity) && data.message.activity.length) { + streamActivity.usedActivity = true; + streamActivity.steps = coalesceThinkingSteps( + data.message.activity.filter((step) => step && !isJunkActivityStep(step)), + ); + renderStreamingThinking(); + } + finishStreamingThinking(data.message?.thinking_duration_ms || data.duration_ms); + } + } + } + if (buffer.trim()) { + const parsed = parseSseChunk(`${buffer}\n\n`); + for (const { event, data } of parsed.events) { + if (event === "activity") { + upsertStreamingActivity(data); + } else if (event === "thinking") { + appendStreamingThinking(data.text || ""); + } else if (event === "thinking_done") { + finishStreamingThinking(data.duration_ms); + } else if (event === "delta") { + if (data.replace) streamed = data.text || ""; + else streamed += data.text || ""; + renderStreamingText(streamed); + } else if (event === "error") { + throw new Error(data.detail || "Ошибка агента"); + } else if ((event === "done" || event === "cancelled") && !streamed && data.text) { + streamed = data.text; + renderStreamingText(streamed); + if (Array.isArray(data.message?.activity) && data.message.activity.length) { + streamActivity.usedActivity = true; + streamActivity.steps = coalesceThinkingSteps( + data.message.activity.filter((step) => step && !isJunkActivityStep(step)), + ); + renderStreamingThinking(); + } + finishStreamingThinking(data.message?.thinking_duration_ms); + } + } + } + renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); + await loadProjects(); + } catch (error) { + if (error.name === "AbortError") { + showToast("Остановлено"); + try { + renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); + } catch {} + } else { + $("#assistantStreaming")?.remove(); + showToast(error.message); + } + } finally { + state.streamController = null; + setResponding(false); + hideGenProgress(); + $("#messageInput").focus(); + refreshAgentStatus(); + } +} + +async function cancelAssistant() { + if (!state.activeChat) return; + try { + await api(`/chats/${state.activeChat.id}/cancel`, { method: "POST" }); + } catch (error) { + // Still abort local stream reader. + showToast(error.message); + } + state.streamController?.abort(); +} + +async function sendMessage() { + const input = $("#messageInput"); + const typed = input.value.trim(); + if (!state.activeChat || state.responding) return; + const pendingSnapshot = state.composerUploads.slice(); + if (!typed && !pendingSnapshot.length) return; + input.value = ""; + resetTextareaHeight(input); + try { + await flushComposerUploads(state.activeChat.id); + const payload = composerMessageFromUploads(typed, state.composerUploads); + if (!payload.content) return; + state.composerUploads = []; + renderComposerAttachments(); + await api(`/chats/${state.activeChat.id}/messages`, jsonOptions("POST", { + role: "user", + content: payload.content, + attachments: payload.attachments, + })); + state.chats = await api(`/projects/${state.activeProject.id}/chats`); + state.activeChat = state.chats.find((chat) => chat.id === state.activeChat.id); + $("#chatTitle").textContent = state.activeChat.title; + renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); + await requestAssistant(); + await loadProjects(); + } catch (error) { + input.value = typed; + resetTextareaHeight(input); + state.composerUploads = pendingSnapshot; + renderComposerAttachments(); + showToast(error.message); + } +} + +function hideSearchResults() { + $("#searchResults")?.classList.add("hidden"); +} + +function renderSearchResults(items) { + const box = $("#searchResults"); + if (!box) return; + if (!items?.length) { + box.innerHTML = '
    Ничего не найдено
    '; + box.classList.remove("hidden"); + return; + } + box.innerHTML = items.map((item) => ` + + `).join(""); + box.classList.remove("hidden"); + box.querySelectorAll("[data-search-chat]").forEach((button) => { + button.addEventListener("click", async () => { + hideSearchResults(); + const input = $("#sidebarSearch"); + if (input) input.value = ""; + try { + if (state.activeProject?.id !== button.dataset.searchProject) { + await openProject(button.dataset.searchProject, false); + } + await openChat(button.dataset.searchChat, { + messageId: button.dataset.searchMessage || "", + }); + } catch (error) { + showToast(error.message); + } + }); + }); +} + +async function runSidebarSearch(query) { + const q = String(query || "").trim(); + if (q.length < 2) { + hideSearchResults(); + return; + } + try { + const items = await api(`/search?q=${encodeURIComponent(q)}`); + renderSearchResults(items); + } catch (error) { + showToast(error.message); + } +} + +function setupEvents() { + $("#loginForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + $("#loginError").classList.add("hidden"); + try { + state.user = await api("/auth/login", jsonOptions("POST", { + username: form.get("username"), + password: form.get("password"), + })); + showApp(); + await bootApp(); + } catch (error) { + $("#loginError").textContent = error.message; + $("#loginError").classList.remove("hidden"); + } + }); + + $("#logoutButton").addEventListener("click", async () => { + try { + await api("/auth/logout", { method: "POST" }); + } catch {} + state.user = null; + showLogin(); + }); + + ["#headerAddProject", "#mainAddProject", "#sidebarAddProject", "#emptyAddProject"].forEach((id) => { + $(id).addEventListener("click", () => openModal("#projectModal")); + }); + $$(".modal-close").forEach((button) => { + button.addEventListener("click", () => closeModal("#projectModal")); + }); + $$(".instructions-close").forEach((button) => { + button.addEventListener("click", () => closeModal("#instructionsModal")); + }); + $$(".rename-close").forEach((button) => { + button.addEventListener("click", () => closeModal("#renameModal")); + }); + + $("#projectForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const formEl = event.currentTarget; + const form = new FormData(formEl); + try { + const project = await api("/projects", jsonOptions("POST", Object.fromEntries(form))); + formEl?.reset(); + closeModal("#projectModal"); + await loadProjects(); + await openProject(project.id); + showToast("Проект создан"); + } catch (error) { + showToast(error.message); + } + }); + + $("#editInstructions").addEventListener("click", () => { + $("#instructionsForm").elements.instructions.value = state.activeProject.instructions || ""; + openModal("#instructionsModal"); + }); + + $("#instructionsForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const instructions = event.currentTarget.elements.instructions.value; + try { + const updated = await api(`/projects/${state.activeProject.id}`, jsonOptions("PATCH", { instructions })); + Object.assign(state.activeProject, updated); + $("#instructionsPreview").textContent = instructions || + "Добавьте правила и контекст для всех разговоров проекта."; + closeModal("#instructionsModal"); + showToast("Инструкции сохранены"); + } catch (error) { + showToast(error.message); + } + }); + + $("#modelSelect").addEventListener("change", async (event) => { + if (!state.activeProject) return; + try { + const updated = await api( + `/projects/${state.activeProject.id}`, + jsonOptions("PATCH", { model: event.target.value }), + ); + Object.assign(state.activeProject, updated); + const idx = state.projects.findIndex((p) => p.id === updated.id); + if (idx >= 0) state.projects[idx] = { ...state.projects[idx], ...updated }; + showToast(`Модель: ${updated.model}`); + } catch (error) { + showToast(error.message); + } + }); + + $("#renameForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const name = form.elements.name.value.trim(); + const kind = form.elements.kind.value; + try { + if (kind === "project") { + const updated = await api( + `/projects/${state.activeProject.id}`, + jsonOptions("PATCH", { name }), + ); + Object.assign(state.activeProject, updated); + await loadProjects(); + await openProject(updated.id, false); + showToast("Проект переименован"); + } else { + const updated = await api( + `/chats/${state.activeChat.id}`, + jsonOptions("PATCH", { title: name }), + ); + Object.assign(state.activeChat, updated); + $("#chatTitle").textContent = updated.title; + await openProject(state.activeProject.id, false); + showToast("Чат переименован"); + } + closeModal("#renameModal"); + } catch (error) { + showToast(error.message); + } + }); + + $("#projectMenuBtn").addEventListener("click", (event) => { + event.stopPropagation(); + $("#chatMenu").classList.add("hidden"); + $("#projectMenu").classList.toggle("hidden"); + }); + $("#chatMenuBtn").addEventListener("click", (event) => { + event.stopPropagation(); + $("#projectMenu").classList.add("hidden"); + $("#chatMenu").classList.toggle("hidden"); + }); + document.addEventListener("click", closeMenus); + + $("#renameProject").addEventListener("click", () => { + closeMenus(); + $("#renameEyebrow").textContent = "Проект"; + $("#renameTitle").textContent = "Переименовать проект"; + $("#renameForm").elements.kind.value = "project"; + $("#renameForm").elements.name.value = state.activeProject.name; + openModal("#renameModal"); + }); + $("#renameChat").addEventListener("click", () => { + closeMenus(); + $("#renameEyebrow").textContent = "Чат"; + $("#renameTitle").textContent = "Переименовать чат"; + $("#renameForm").elements.kind.value = "chat"; + $("#renameForm").elements.name.value = state.activeChat.title; + openModal("#renameModal"); + }); + + const handleFileUpload = async (event) => { + const files = [...event.target.files]; + if (!files.length) return; + const fromChat = event.target.id === "chatFileInput"; + const fromProjectComposer = event.target.id === "projectFileInput"; + try { + await handleDroppedFiles(files, { + chatId: fromChat ? state.activeChat?.id : null, + composer: fromChat || fromProjectComposer, + }); + event.target.value = ""; + } catch (error) { + showToast(error.message); + } + }; + + $("#fileInput")?.addEventListener("change", (event) => handleFileUpload(event)); + $("#projectFileInput")?.addEventListener("change", (event) => handleFileUpload(event)); + $("#chatFileInput")?.addEventListener("change", (event) => handleFileUpload(event)); + + setupFileDropZone($("#projectDropZone"), { + onFiles: (files) => handleDroppedFiles(files, { composer: false }), + }); + setupFileDropZone($("#filesPanel"), { + onFiles: (files) => handleDroppedFiles(files, { composer: false }), + }); + setupFileDropZone($("#projectComposer"), { + onFiles: (files) => handleDroppedFiles(files, { composer: true }), + }); + setupFileDropZone($("#chatDropZone"), { + onFiles: (files) => handleDroppedFiles(files, { + chatId: state.activeChat?.id, + composer: true, + }), + }); + setupFileDropZone($("#chatComposer"), { + onFiles: (files) => handleDroppedFiles(files, { + chatId: state.activeChat?.id, + composer: true, + }), + }); + + bindAutoGrow($("#messageInput")); + bindAutoGrow($("#quickPrompt"), { maxHeight: COMPOSER_MAX_HEIGHT }); + + $("#quickSend").addEventListener("click", async () => { + const input = $("#quickPrompt"); + const value = input.value; + input.value = ""; + resetTextareaHeight(input); + try { + await createChat(value, { attachUploads: true }); + } catch (error) { + input.value = value; + resetTextareaHeight(input); + showToast(error.message); + } + }); + $("#quickPrompt").addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + $("#quickSend").click(); + } + }); + $("#addChat").addEventListener("click", () => createChat()); + $("#newChatButton").addEventListener("click", () => createChat()); + $("#messageSend").addEventListener("click", sendMessage); + $("#messageStop").addEventListener("click", cancelAssistant); + $("#messageInput").addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } + }); + + $("#backToProject").addEventListener("click", () => openProject(state.activeProject.id)); + $("#deleteChat").addEventListener("click", async () => { + closeMenus(); + if (!confirm(`Удалить чат «${state.activeChat.title}»?`)) return; + await api(`/chats/${state.activeChat.id}`, { method: "DELETE" }); + state.activeChat = null; + await openProject(state.activeProject.id); + await loadProjects(); + showToast("Чат удалён"); + }); + $("#deleteProject").addEventListener("click", async () => { + closeMenus(); + if (!confirm(`Удалить проект «${state.activeProject.name}» со всеми файлами и чатами?`)) return; + await api(`/projects/${state.activeProject.id}`, { method: "DELETE" }); + state.activeProject = null; + $("#breadcrumbs").textContent = "Все проекты"; + await loadProjects(); + showView("projects"); + showToast("Проект удалён"); + }); + + $$(".nav-item").forEach((item) => { + item.addEventListener("click", async () => { + if (item.dataset.view === "users") { + try { + await loadUsers(); + $("#breadcrumbs").textContent = "Пользователи"; + showView("users"); + } catch (error) { + showToast(error.message); + } + return; + } + $("#breadcrumbs").textContent = "Все проекты"; + showView("projects"); + }); + }); + + $("#userCreateForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const formEl = event.currentTarget; + const form = new FormData(formEl); + try { + await api("/users", jsonOptions("POST", { + username: form.get("username"), + display_name: form.get("display_name") || "", + password: form.get("password"), + role: form.get("role") || "user", + })); + formEl?.reset(); + await loadUsers(); + showToast("Пользователь создан"); + } catch (error) { + showToast(error.message); + } + }); + + $("#themeToggle").addEventListener("click", async () => { + const html = document.documentElement; + const next = html.dataset.theme === "dark" ? "light" : "dark"; + html.dataset.theme = next; + localStorage.setItem("theme", next); + initMermaid(); + if (state.activeChat && $("#chatView")?.classList.contains("active")) { + try { + renderMessages(await api(`/chats/${state.activeChat.id}/messages`)); + } catch {} + } + }); + $("#openSidebar").addEventListener("click", () => $(".sidebar").classList.add("open")); + $("#closeSidebar").addEventListener("click", () => $(".sidebar").classList.remove("open")); + + const searchInput = $("#sidebarSearch"); + if (searchInput) { + searchInput.addEventListener("input", () => { + clearTimeout(runSidebarSearch.timer); + runSidebarSearch.timer = setTimeout(() => runSidebarSearch(searchInput.value), 200); + }); + searchInput.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + searchInput.value = ""; + hideSearchResults(); + } + }); + } + document.addEventListener("click", (event) => { + const wrap = $("#sidebarSearchWrap"); + if (wrap && !wrap.contains(event.target)) hideSearchResults(); + }); + + $("#diagramLightboxClose")?.addEventListener("click", (event) => { + event.stopPropagation(); + closeDiagramLightbox(); + }); + $("#diagramLightbox")?.addEventListener("click", (event) => { + if (event.target === $("#diagramLightbox")) closeDiagramLightbox(); + }); + $("#diagramLightboxInner")?.addEventListener("click", (event) => { + event.stopPropagation(); + }); + $("#messages")?.addEventListener("click", (event) => { + const img = event.target.closest?.("img.msg-image"); + if (img) { + event.preventDefault(); + openDiagramLightbox(img); + return; + } + const mermaidHost = event.target.closest?.(".msg-mermaid"); + if (mermaidHost) { + const svg = mermaidHost.querySelector("svg"); + if (svg) { + event.preventDefault(); + openDiagramLightbox(svg); + } + } + }); + + $$(".modal-backdrop").forEach((backdrop) => { + backdrop.addEventListener("click", (event) => { + if (event.target === backdrop) backdrop.classList.add("hidden"); + }); + }); + + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeDiagramLightbox(); + $$(".modal-backdrop").forEach((modal) => modal.classList.add("hidden")); + closeMenus(); + hideSearchResults(); + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + createChat(); + } + }); +} + +async function bootApp() { + await loadProjects(); + await refreshAgentStatus(); + if (state.agentStatusTimer) clearInterval(state.agentStatusTimer); + state.agentStatusTimer = setInterval(refreshAgentStatus, 5000); +} + +async function init() { + document.documentElement.dataset.theme = localStorage.getItem("theme") || "dark"; + initMermaid(); + setupEvents(); + try { + state.user = await api("/auth/me"); + showApp(); + await bootApp(); + } catch { + showLogin(); + } +} + +init(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7a56302 --- /dev/null +++ b/static/index.html @@ -0,0 +1,395 @@ + + + + + + + Helpomatica — рабочее пространство + + + + + + + + + + + + + + + + + +
    + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ca32cec --- /dev/null +++ b/static/style.css @@ -0,0 +1,2479 @@ +:root { + --bg: #f6f5f0; + --sidebar: #edece6; + --surface: #fffefa; + --surface-2: #f1f0ea; + --text: #272621; + --muted: #77746b; + --line: #dedcd3; + --accent: #b75b3c; + --accent-hover: #9f4b30; + --accent-soft: #f1ded5; + --shadow: 0 18px 60px rgba(44, 40, 30, 0.09); +} + +html[data-theme="dark"] { + --bg: #181814; + --sidebar: #21211c; + --surface: #262620; + --surface-2: #2c2c25; + --text: #efede4; + --muted: #9d9a8e; + --line: #3b3a32; + --accent: #da7856; + --accent-hover: #e48a69; + --accent-soft: #482e25; + --shadow: 0 18px 60px rgba(0, 0, 0, 0.22); +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Manrope", system-ui, sans-serif; + font-size: 14px; +} + +button, +input, +textarea, +select { + color: inherit; + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + display: grid; + grid-template-columns: 270px 1fr; + width: 100%; + height: 100%; + max-height: 100dvh; + overflow: hidden; +} + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 20; + display: flex; + width: 270px; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--sidebar); + padding: 18px 12px 12px; + min-height: 0; +} + +.brand { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 10px; + padding: 0 8px 18px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-mark { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 9px; + background: var(--text); + color: var(--bg); + font-family: "Playfair Display", serif; + font-size: 17px; +} + +.mobile-close { + display: none; + margin-left: auto; +} + +.new-chat-button { + display: flex; + flex-shrink: 0; + align-items: center; + width: 100%; + gap: 9px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + padding: 10px 11px; + text-align: left; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.new-chat-button:hover { + border-color: var(--muted); +} + +.plus { + font-size: 18px; +} + +.shortcut { + margin-left: auto; + color: var(--muted); + font-size: 11px; +} + +.sidebar-search { + position: relative; + flex-shrink: 0; + margin: 10px 0 2px; +} + +.sidebar-search input { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + padding: 8px 11px; + outline: none; +} + +.sidebar-search input:focus { + border-color: var(--accent); +} + +.search-results { + position: absolute; + top: calc(100% + 4px); + right: 0; + left: 0; + z-index: 40; + display: grid; + max-height: 42vh; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + padding: 6px; + box-shadow: var(--shadow); +} + +.search-empty { + padding: 10px 8px; + color: var(--muted); + font-size: 12px; +} + +.search-hit { + display: grid; + gap: 2px; + border: 0; + border-radius: 8px; + background: transparent; + padding: 8px 9px; + text-align: left; +} + +.search-hit:hover { + background: var(--surface-2); +} + +.search-hit strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-hit small { + color: var(--muted); + font-size: 11px; +} + +.search-hit span { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.main-nav { + flex-shrink: 0; + padding: 16px 0 10px; +} + +.nav-item { + display: flex; + width: 100%; + align-items: center; + gap: 10px; + border: 0; + border-radius: 8px; + background: transparent; + padding: 9px 11px; + text-align: left; +} + +.nav-item:hover, +.nav-item.active { + background: var(--surface-2); +} + +.sidebar-section { + display: flex; + flex-direction: column; + min-height: 0; + margin-top: 12px; +} + +.projects-section { + flex: 0 1 auto; + max-height: 38vh; + overflow: hidden; +} + +.recents-section { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.section-heading { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + padding: 5px 8px 7px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.icon-button { + display: grid; + min-width: 30px; + height: 30px; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; +} + +.icon-button:hover { + background: var(--surface-2); +} + +.project-nav-list, +.recent-list { + display: grid; + gap: 4px; + min-height: 0; + overflow-y: auto; + align-content: start; +} + +.project-nav-list { + flex: 1 1 auto; +} + +.recent-list { + flex: 1 1 auto; + gap: 5px; + padding-bottom: 4px; +} + +.side-row { + display: flex; + width: 100%; + align-items: center; + gap: 9px; + overflow: hidden; + border: 0; + border-radius: 8px; + background: transparent; + padding: 8px 9px; + text-align: left; +} + +.side-row:hover, +.side-row.active { + background: var(--surface-2); +} + +.side-row .row-icon { + color: var(--accent); +} + +.side-row .row-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recent-list .side-row { + color: var(--muted); + font-size: 12px; + padding: 9px 10px; +} + +.sidebar-empty { + padding: 8px; + color: var(--muted); + font-size: 12px; +} + +.sidebar-footer { + flex-shrink: 0; + margin-top: auto; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.profile-button { + display: flex; + width: 100%; + align-items: center; + gap: 9px; + border: 0; + border-radius: 9px; + background: transparent; + padding: 7px; + text-align: left; +} + +.profile-button:hover { + background: var(--surface-2); +} + +.profile-button > span:nth-child(2) { + display: grid; + line-height: 1.3; +} + +.profile-button small { + color: var(--muted); +} + +.avatar { + display: grid; + width: 31px; + height: 31px; + place-items: center; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-weight: 700; +} + +.dots { + margin-left: auto; + color: var(--muted); +} + +.main { + grid-column: 2; + display: flex; + min-width: 0; + min-height: 0; + height: 100%; + max-height: 100%; + flex-direction: column; + overflow: hidden; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-shrink: 0; + height: 64px; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 90%, transparent); + padding: 0 28px; + backdrop-filter: blur(16px); +} + +.breadcrumbs { + color: var(--muted); + font-size: 13px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.menu-button { + display: none; +} + +.outline-button, +.primary-button { + border: 1px solid var(--line); + border-radius: 9px; + padding: 9px 14px; + background: var(--surface); + font-weight: 600; +} + +.outline-button:hover { + border-color: var(--muted); +} + +.primary-button { + border-color: var(--accent); + background: var(--accent); + color: white; +} + +.primary-button:hover { + background: var(--accent-hover); +} + +.view { + display: none; +} + +.view.active { + display: block; + flex: 1; + min-height: 0; + overflow: auto; +} + +#chatView.active { + display: flex; + flex: 1 1 0; + min-height: 0; + max-height: 100%; + flex-direction: column; + overflow: hidden; +} + +.content-wide { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 70px 24px; +} + +.page-title-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 40px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 10px; + font-family: "Playfair Display", serif; + font-size: clamp(34px, 5vw, 52px); + letter-spacing: -0.035em; + line-height: 1.05; +} + +.subtitle { + margin-bottom: 0; + color: var(--muted); + font-size: 15px; +} + +.project-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.project-card { + display: flex; + min-height: 210px; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 15px; + background: var(--surface); + padding: 22px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); + cursor: pointer; + transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; +} + +.project-card:hover { + transform: translateY(-3px); + border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); + box-shadow: var(--shadow); +} + +.card-icon { + display: grid; + width: 42px; + height: 42px; + margin-bottom: 27px; + place-items: center; + border-radius: 12px; + background: var(--accent-soft); + color: var(--accent); + font-size: 18px; +} + +.project-card h3 { + margin-bottom: 6px; + font-size: 17px; +} + +.project-card p { + display: -webkit-box; + overflow: hidden; + margin-bottom: 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.6; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.card-meta { + display: flex; + gap: 14px; + margin-top: auto; + color: var(--muted); + font-size: 11px; +} + +.empty-state { + display: grid; + min-height: 390px; + place-items: center; + align-content: center; + border: 1px dashed var(--line); + border-radius: 16px; + text-align: center; +} + +.empty-state > * { + max-width: 380px; +} + +.empty-state .empty-icon { + margin-bottom: 16px; + color: var(--accent); + font-size: 38px; +} + +.empty-state h2 { + margin-bottom: 8px; +} + +.empty-state p { + margin-bottom: 22px; + color: var(--muted); +} + +.hidden { + display: none !important; +} + +.project-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + min-height: calc(100vh - 64px); +} + +.project-main { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 66px 24px; +} + +.project-heading { + position: relative; + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 36px; +} + +.project-heading h1 { + margin-bottom: 6px; + font-size: 40px; +} + +.project-icon-large { + display: grid; + width: 58px; + height: 58px; + flex: 0 0 auto; + place-items: center; + border-radius: 15px; + background: var(--accent-soft); + color: var(--accent); + font-size: 24px; +} + +.project-menu { + margin-left: auto; +} + +.composer-card { + position: relative; + margin-bottom: 44px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + padding: 16px; + box-shadow: var(--shadow); + transition: background 140ms ease, box-shadow 140ms ease, border-color 140ms ease; +} + +.composer-card textarea, +.chat-composer textarea { + display: block; + width: 100%; + min-height: 24px; + max-height: 180px; + resize: none; + overflow-x: hidden; + overflow-y: hidden; + border: 0; + outline: none; + background: transparent; + line-height: 1.55; +} + +.composer-card textarea { + min-height: 48px; + font-size: 16px; +} + +.composer-card .composer-attachments { + margin-bottom: 10px; +} + +.composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + margin-top: 10px; + padding-top: 10px; +} + +.composer-footer .attach-button { + flex: 0 0 auto; +} + +.composer-hint { + flex: 1 1 auto; + min-width: 0; + color: var(--muted); + font-size: 11px; +} + +.send-button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 0; + border-radius: 9px; + background: var(--text); + color: var(--bg); + font-size: 18px; +} + +.send-button:hover { + background: var(--accent); + color: white; +} + +.list-header, +.panel-title { + display: flex; + align-items: center; + justify-content: space-between; +} + +.list-header { + margin-bottom: 12px; +} + +.list-header h2 { + margin: 0; + font-size: 14px; +} + +.text-button { + border: 0; + background: transparent; + color: var(--accent); + font-size: 12px; + font-weight: 600; +} + +.text-button:hover { + text-decoration: underline; +} + +.chat-list { + display: grid; +} + +.chat-row { + display: grid; + grid-template-columns: 18px 32px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border-top: 1px solid var(--line); + padding: 15px 6px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.chat-row:hover .chat-row-title { + color: var(--accent); +} + +.chat-row.dragging { + opacity: 0.45; +} + +.chat-row.drag-over { + background: color-mix(in srgb, var(--accent) 12%, transparent); + box-shadow: inset 0 2px 0 var(--accent); +} + +.chat-drag-handle { + color: var(--muted); + font-size: 11px; + letter-spacing: -2px; + cursor: grab; + user-select: none; +} + +.chat-row.dragging .chat-drag-handle { + cursor: grabbing; +} + +.chat-bubble-icon { + color: var(--muted); + font-size: 16px; +} + +.chat-row-title { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-row-time { + color: var(--muted); + font-size: 11px; +} + +.empty-inline { + border-top: 1px solid var(--line); + padding: 28px 6px; + color: var(--muted); + font-size: 13px; +} + +.details-panel { + border-left: 1px solid var(--line); + background: var(--surface); + padding: 42px 26px; +} + +.panel-section { + border-bottom: 1px solid var(--line); + padding: 20px 0 28px; +} + +.panel-section:first-child { + padding-top: 0; +} + +.panel-title { + margin-bottom: 14px; +} + +.panel-title h3 { + margin: 0; + font-size: 13px; +} + +.panel-section > p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.65; + white-space: pre-wrap; +} + +.upload-label input { + display: none; +} + +.files-panel { + position: relative; + border-radius: 12px; + transition: background 140ms ease, box-shadow 140ms ease, border-color 140ms ease; +} + +.files-panel.drop-target, +.project-layout.drop-target, +.composer-card.drop-target, +.chat-page.drop-target, +.chat-composer.drop-target { + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 55%, var(--line)); +} + +html[data-theme="dark"] .files-panel.drop-target, +html[data-theme="dark"] .project-layout.drop-target, +html[data-theme="dark"] .composer-card.drop-target, +html[data-theme="dark"] .chat-page.drop-target, +html[data-theme="dark"] .chat-composer.drop-target { + background: color-mix(in srgb, var(--accent) 20%, var(--surface)); + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 70%, transparent); +} + +.project-layout.drop-target .details-panel, +.project-layout.drop-target .composer-card { + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); +} + +.chat-page.drop-target .chat-composer, +.chat-composer.drop-target { + border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + background: color-mix(in srgb, var(--accent) 14%, var(--surface)); +} + +.drop-hint { + display: none; + margin: 10px 0 0; + color: var(--accent); + font-size: 12px; + font-weight: 600; + text-align: center; +} + +.composer-drop-hint { + margin-top: 12px; +} + +.files-panel.drop-target .drop-hint, +.composer-card.drop-target .drop-hint, +.project-layout.drop-target .files-panel .drop-hint, +.project-layout.drop-target .composer-card .drop-hint { + display: block; +} + +.file-list { + display: grid; + gap: 8px; +} + +#fileList:not(:empty) ~ #filesEmpty { + display: none !important; +} + +.file-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 26px 26px; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 9px; + padding: 8px; +} + +.file-type { + display: grid; + height: 34px; + place-items: center; + border-radius: 7px; + background: var(--surface-2); + color: var(--accent); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.file-info { + min-width: 0; + color: inherit; + text-decoration: none; +} + +.file-info:hover strong { + color: var(--accent); +} + +.file-info strong, +.file-info small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-info strong { + font-size: 11px; +} + +.file-info small { + color: var(--muted); + font-size: 9px; +} + +.file-action { + display: grid; + width: 26px; + height: 26px; + place-items: center; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted); + text-decoration: none; + cursor: pointer; +} + +.file-action:hover { + background: var(--surface-2); + color: var(--text); +} + +.file-delete:hover { + background: color-mix(in srgb, #c44 18%, transparent); + color: var(--text); +} + +.chat-page { + display: flex; + width: 100%; + max-width: min(1400px, 100% - 32px); + height: 100%; + max-height: 100%; + margin: 0 auto; + padding: 0 24px; + box-sizing: border-box; + flex: 1 1 0; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.chat-header { + display: grid; + flex-shrink: 0; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--line); + padding: 20px 0; +} + +.chat-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.export-chat-link { + text-decoration: none; +} + +.chat-header h2 { + overflow: hidden; + margin: 0; + font-size: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-header .eyebrow { + margin-bottom: 3px; +} + +.back-button { + width: 36px; + height: 36px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.messages { + display: flex; + flex: 1 1 0; + min-height: 0; + flex-direction: column; + gap: 22px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + padding: 28px 4px 28px 0; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent; +} + +.messages::-webkit-scrollbar { + width: 8px; +} + +.messages::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--muted) 40%, transparent); +} + +.message-row { + display: flex; + max-width: min(82%, 720px); + width: fit-content; + flex-direction: column; + gap: 6px; +} + +.message-row.highlight .message { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 3px; +} + +.message-row.assistant { + align-self: flex-start; + align-items: flex-start; + max-width: 100%; + width: 100%; +} + +.message-row.user { + align-self: flex-end; + align-items: flex-end; + max-width: min(78%, 640px); +} + +.message-role { + padding: 0 4px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.message { + width: fit-content; + max-width: 100%; + min-height: 0; + height: auto; + border: 1px solid var(--line); + border-radius: 16px 16px 16px 5px; + background: var(--surface); + padding: 13px 16px 10px; + line-height: 1.6; +} + +.message-row.assistant .message { + width: 100%; + max-width: 100%; +} + +.message.assistant { + width: 100%; +} + +.message.user { + border-color: transparent; + border-radius: 16px 16px 5px 16px; + background: var(--accent-soft); + color: var(--text); +} + +.message-body { + min-height: 0; + height: auto; + overflow-wrap: anywhere; + word-break: break-word; +} + +.message-body > :first-child { + margin-top: 0; +} + +.message-body > :last-child { + margin-bottom: 0; +} + +.message-body p { + margin: 0 0 0.75em; +} + +.message-body p:last-child { + margin-bottom: 0; +} + +.message-body .msg-h { + margin: 0.95em 0 0.4em; + color: var(--text); + font-family: "Manrope", system-ui, sans-serif; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.3; +} + +.message-body .msg-h1 { + font-size: 1.28em; +} + +.message-body .msg-h2 { + font-size: 1.14em; +} + +.message-body .msg-h3, +.message-body .msg-h4, +.message-body .msg-h5, +.message-body .msg-h6 { + font-size: 1.05em; +} + +.message-body .msg-h + p, +.message-body .msg-h + .msg-list, +.message-body .msg-h + .msg-table-wrap, +.message-body .msg-h + .msg-quote, +.message-body .msg-h + .msg-math-block { + margin-top: 0.35em; +} + +.message-body strong { + font-weight: 700; +} + +.message-body .msg-list { + margin: 0 0 0.75em; + padding: 0; + list-style: none; +} + +.message-body .msg-ul > .msg-li, +.message-body .msg-ol > .msg-li { + position: relative; + margin: 0.28em 0; + padding-left: 1.15em; +} + +.message-body .msg-ul > .msg-li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--accent); +} + +.message-body .msg-ol { + counter-reset: msg-ol; +} + +.message-body .msg-ol > .msg-li { + counter-increment: msg-ol; + padding-left: 1.45em; +} + +.message-body .msg-ol > .msg-li::before { + content: counter(msg-ol) "."; + position: absolute; + left: 0; + color: var(--accent); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.message-body .msg-task-list { + display: grid; + gap: 0.35em; +} + +.message-body .msg-task-item { + display: flex; + align-items: flex-start; + gap: 0.55em; + margin: 0; + padding: 0; + list-style: none; +} + +.message-body .msg-task-check { + margin: 0.35em 0 0; + accent-color: var(--accent); + pointer-events: none; +} + +.message-body .msg-quote { + margin: 0.65em 0 0.85em; + border-left: 3px solid color-mix(in srgb, var(--accent) 70%, var(--line)); + border-radius: 0 8px 8px 0; + background: color-mix(in srgb, var(--surface-2) 70%, transparent); + padding: 0.55em 0.9em; + color: color-mix(in srgb, var(--text) 88%, var(--muted)); +} + +.message-body .msg-hr { + margin: 1em 0; + border: 0; + border-top: 1px solid var(--line); +} + +.message-body em { + font-style: italic; +} + +.message-body .msg-link { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.message-body .msg-link:hover { + color: var(--accent-hover); +} + +.message-body .msg-image { + display: block; + width: 100%; + max-width: 100%; + max-height: min(70vh, 720px); + height: auto; + margin: 0.65em 0; + border: 1px solid var(--line); + border-radius: 10px; + background: #f5f5f0; + padding: 10px; + object-fit: contain; + cursor: zoom-in; +} + +.message-body .msg-image-missing { + display: inline-block; + margin: 0.35em 0; + border: 1px dashed var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-2) 80%, transparent); + padding: 0.45em 0.7em; + color: var(--muted); + font-size: 12px; +} + +.message-body .msg-cite { + display: inline-flex; + align-items: baseline; + gap: 0.35em; + max-width: 100%; + border: 1px solid var(--line); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-2) 85%, var(--line)); + padding: 0.1em 0.55em 0.15em; + color: var(--muted); + font-family: "Cascadia Code", "Consolas", "Courier New", monospace; + font-size: 0.84em; + text-decoration: none; + vertical-align: baseline; +} + +a.msg-cite:hover { + border-color: var(--accent); + color: var(--text); +} + +.message-body .msg-cite-line { + color: var(--accent); + font-weight: 700; +} + +.message-body .msg-cite-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-body .msg-math-block { + margin: 0.75em 0; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in srgb, var(--bg) 70%, var(--surface-2)); + padding: 0.85em 1em; + text-align: center; +} + +.message-body .msg-math-inline { + font-style: normal; +} + +.message-body .katex-display { + margin: 0.4em 0; +} + +.message-body .msg-inline { + border-radius: 5px; + background: color-mix(in srgb, var(--surface-2) 80%, var(--line)); + padding: 0.1em 0.4em; + font-family: "Cascadia Code", "Consolas", "Courier New", monospace; + font-size: 0.9em; +} + +.message-body .msg-table-wrap { + max-width: 100%; + margin: 0.65em 0 0.85em; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in srgb, var(--bg) 55%, var(--surface)); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent; +} + +.message-body .msg-table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + font-size: 12.5px; + line-height: 1.45; +} + +.message-body .msg-table th, +.message-body .msg-table td { + border-bottom: 1px solid var(--line); + padding: 8px 12px; + text-align: left; + vertical-align: top; + white-space: nowrap; +} + +.message-body .msg-table th { + background: color-mix(in srgb, var(--surface-2) 85%, var(--line)); + color: var(--text); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.message-body .msg-table tbody tr:last-child td { + border-bottom: 0; +} + +.message-body .msg-table tbody tr:nth-child(even) td { + background: color-mix(in srgb, var(--surface-2) 45%, transparent); +} + +.message-body .msg-code { + margin: 0.75em 0; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in srgb, var(--bg) 70%, var(--surface-2)); + padding: 12px 14px; + font-family: "Cascadia Code", "Consolas", "Courier New", monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre; +} + +.message-body .msg-code:first-child { + margin-top: 0; +} + +.message-body .msg-code:last-child { + margin-bottom: 0; +} + +.message-body .msg-code code { + font: inherit; +} + +.message.user .message-body { + white-space: pre-wrap; +} + +.assistant-loading { + color: var(--muted); +} + +.loading-dots { + display: inline-block; + animation: pulse 1s ease-in-out infinite; +} + +.thinking-block { + margin: 0 0 12px; + padding: 0 0 0 10px; + border-left: 2px solid color-mix(in srgb, var(--muted) 40%, transparent); +} + +.thinking-block.hidden { + display: none; +} + +.thinking-toggle { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + margin: 0; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + line-height: 1.4; + text-align: left; + cursor: pointer; +} + +.thinking-toggle:hover { + color: var(--text); +} + +.thinking-chevron { + display: inline-block; + flex: 0 0 auto; + font-size: 9px; + transform: rotate(0deg); + transition: transform 0.15s ease; +} + +.thinking-block.open .thinking-chevron, +.thinking-block:not(.collapsed) .thinking-chevron { + transform: rotate(90deg); +} + +.thinking-label { + font-weight: 600; +} + +.thinking-duration { + color: color-mix(in srgb, var(--muted) 80%, transparent); + font-weight: 400; +} + +.thinking-duration:not(:empty)::before { + content: "· "; +} + +.thinking-content { + margin-top: 6px; + max-height: 360px; + overflow: auto; + color: var(--muted); + font-size: 12.5px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.thinking-steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + white-space: normal; +} + +.thinking-step { + padding: 0; +} + +.thinking-step-title { + font-weight: 600; + font-size: 12px; + color: color-mix(in srgb, var(--muted) 88%, var(--text)); +} + +.thinking-step[data-type="thinking"] .thinking-step-title::before, +.thinking-step[data-type="narration"] .thinking-step-title::before { + content: "✦ "; + opacity: 0.45; + font-weight: 400; +} + +.thinking-step[data-type="thinking"] .thinking-step-detail { + margin-top: 0; +} + +.thinking-step:not([data-type="thinking"]):not([data-type="narration"]) .thinking-step-title::before { + content: "› "; + opacity: 0.5; + font-weight: 400; +} + +.thinking-step[data-status="running"] .thinking-step-title { + animation: pulse 1s ease-in-out infinite; +} + +.thinking-step-detail { + margin-top: 2px; + white-space: pre-wrap; + overflow-wrap: anywhere; + opacity: 0.88; + font-size: 12px; + line-height: 1.5; +} + +.thinking-step-more { + margin-top: 2px; +} + +.thinking-step-more summary { + cursor: pointer; + font-size: 11px; + color: color-mix(in srgb, var(--muted) 75%, transparent); +} + +.thinking-block.collapsed .thinking-content { + display: none; +} + +.thinking-block.streaming .thinking-label { + animation: pulse 1s ease-in-out infinite; +} + +.context-more { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.context-more summary { + cursor: pointer; + list-style: none; +} + +.context-more summary::-webkit-details-marker { + display: none; +} + +.context-more[open] summary { + color: var(--text); +} + +@keyframes pulse { + 50% { + opacity: 0.3; + } +} + +.messages-empty { + display: grid; + flex: 1; + place-items: center; + color: var(--muted); + text-align: center; +} + +.chat-composer { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 10px; + margin-top: 0; + margin-bottom: 16px; + border: 1px solid var(--line); + border-radius: 15px; + background: var(--surface); + padding: 12px 14px; + box-shadow: var(--shadow); + transition: background 140ms ease, box-shadow 140ms ease, border-color 140ms ease; +} + +.gen-progress { + display: grid; + flex-shrink: 0; + gap: 6px; + margin: 0 0 8px; +} + +.gen-progress.hidden { + display: none; +} + +.gen-progress-label { + color: var(--muted); + font-size: 12px; +} + +.gen-progress-track { + overflow: hidden; + height: 3px; + border-radius: 999px; + background: var(--line); +} + +.gen-progress-bar { + height: 100%; + width: 0; + border-radius: inherit; + background: var(--accent); + transition: width 160ms ease; +} + +.gen-progress-bar.indeterminate { + width: 32% !important; + animation: gen-progress-slide 1.15s ease-in-out infinite; +} + +@keyframes gen-progress-slide { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(380%); } +} + +.composer-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + width: 100%; + min-width: 0; +} + +.composer-attachments.hidden { + display: none !important; +} + +.composer-file-chip { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + padding: 3px 8px 3px 4px; + color: var(--muted); + font-size: 11px; +} + +.composer-file-badge { + display: grid; + min-width: 34px; + height: 20px; + flex: 0 0 auto; + place-items: center; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.composer-file-chip .file-chip-name, +.composer-file-chip strong.file-chip-name { + overflow: hidden; + max-width: 220px; + color: inherit; + font-weight: 600; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-file-chip a.file-chip-name:hover { + color: var(--accent); +} + +.file-chip-download { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 auto; + place-items: center; + border-radius: 999px; + color: var(--muted); + text-decoration: none; +} + +.file-chip-download:hover { + background: var(--surface); + color: var(--text); +} + +.composer-file-remove { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.composer-file-remove:hover { + background: color-mix(in srgb, #c44 18%, transparent); + color: var(--text); +} + +.message-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.composer-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 36px; + align-items: end; + gap: 10px; + width: 100%; + min-width: 0; +} + +.composer-actions { + display: grid; + width: 34px; + height: 34px; + place-items: center; +} + +.composer-actions > .send-button, +.composer-actions > .stop-button { + grid-area: 1 / 1; +} + +.attach-button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface-2); + color: var(--muted); + font-size: 15px; + line-height: 1; +} + +.attach-button:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + background: var(--accent-soft); + color: var(--accent); +} + +.attach-button input { + display: none; +} + +.chat-composer textarea { + min-width: 0; + max-height: 180px; + padding: 6px 2px; +} + +.send-button:disabled { + cursor: wait; + opacity: 0.45; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + background: rgba(20, 19, 15, 0.48); + padding: 20px; + backdrop-filter: blur(4px); +} + +.modal { + width: min(560px, 100%); + border: 1px solid var(--line); + border-radius: 17px; + background: var(--surface); + padding: 26px; + box-shadow: var(--shadow); +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 22px; +} + +.modal-header h2 { + margin: 0; + font-family: "Playfair Display", serif; + font-size: 28px; +} + +.modal label { + display: grid; + gap: 7px; + margin-top: 15px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.modal input, +.modal textarea { + width: 100%; + resize: vertical; + border: 1px solid var(--line); + border-radius: 9px; + outline: none; + background: var(--bg); + padding: 11px 12px; + line-height: 1.5; +} + +.modal input:focus, +.modal textarea:focus { + border-color: var(--accent); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 23px; +} + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 80; + max-width: 360px; + transform: translateY(20px); + border-radius: 10px; + background: var(--text); + color: var(--bg); + padding: 11px 15px; + opacity: 0; + pointer-events: none; + transition: 180ms ease; +} + +.toast.visible { + transform: translateY(0); + opacity: 1; +} + +.login-screen { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + min-height: 100vh; + place-items: center; + padding: 24px; + background: + radial-gradient(ellipse at 20% 10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 45%), + radial-gradient(ellipse at 80% 90%, color-mix(in srgb, var(--accent-soft) 70%, transparent), transparent 40%), + var(--bg); +} + +.login-card { + width: min(400px, 100%); + border: 1px solid var(--line); + border-radius: 18px; + background: var(--surface); + padding: 28px; + box-shadow: var(--shadow); +} + +.login-brand { + margin-bottom: 22px; + text-align: center; +} + +.login-brand .brand-mark { + margin: 0 auto 14px; +} + +.login-brand h1 { + margin-bottom: 8px; + font-size: 34px; +} + +.login-brand p { + margin: 0; + color: var(--muted); +} + +.login-card label { + display: grid; + gap: 7px; + margin-top: 14px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.login-card input { + width: 100%; + border: 1px solid var(--line); + border-radius: 9px; + outline: none; + background: var(--bg); + padding: 11px 12px; +} + +.login-card input:focus { + border-color: var(--accent); +} + +.login-submit { + width: 100%; + margin-top: 20px; +} + +.login-error { + margin: 12px 0 0; + color: #c24b3b; + font-size: 12px; + text-align: center; +} + +.logout-button { + display: block; + width: 100%; + margin-top: 4px; + padding: 6px 10px; + text-align: left; +} + +.agent-status { + display: flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface); + padding: 5px 10px; + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-transform: lowercase; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} + +.status-dot.idle { + background: #5f9f6b; +} + +.status-dot.busy { + background: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); +} + +.status-dot.error { + background: #c24b3b; +} + +.menu-wrap { + position: relative; + margin-left: auto; +} + +.menu-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 30; + display: grid; + min-width: 160px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + padding: 6px; + box-shadow: var(--shadow); +} + +.menu-dropdown button, +.menu-dropdown a.menu-link { + border: 0; + border-radius: 7px; + background: transparent; + padding: 9px 10px; + color: inherit; + text-align: left; + text-decoration: none; +} + +.menu-dropdown button:hover, +.menu-dropdown a.menu-link:hover { + background: var(--surface-2); +} + +.menu-dropdown button.danger { + color: #c24b3b; +} + +.model-select { + width: 100%; + color: var(--text); + border: 1px solid var(--line); + border-radius: 9px; + background-color: var(--surface); + padding: 10px 11px; +} + +.model-select option, +select option { + color: var(--text); + background-color: var(--surface); +} + +.panel-hint { + margin: 10px 0 0; + color: var(--muted); + font-size: 11px; +} + +.stop-button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 0; + border-radius: 9px; + background: #c24b3b; + color: white; + font-size: 12px; +} + +.stop-button:hover { + filter: brightness(1.05); +} + +.context-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin-bottom: 6px; +} + +.chip-label { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.chip { + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + padding: 3px 8px; + color: var(--muted); + font-size: 11px; +} + +.chip.chip-file { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 6px; + padding: 3px 8px 3px 4px; + text-decoration: none; +} + +.chip.chip-file span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +a.chip.chip-file:hover { + color: var(--text); + border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); +} + +.message-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--line) 80%, transparent); +} + +.message-actions button, +.code-copy { + border: 1px solid var(--line); + border-radius: 7px; + background: color-mix(in srgb, var(--surface-2) 70%, var(--surface)); + color: color-mix(in srgb, var(--text) 78%, var(--muted)); + padding: 4px 9px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; + transition: color 140ms ease, border-color 140ms ease, background 140ms ease; +} + +.message-actions button:hover, +.code-copy:hover { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + background: var(--accent-soft); + color: var(--accent); +} + +.code-wrap { + position: relative; + margin: 0.75em 0; +} + +.code-copy { + position: absolute; + top: 8px; + right: 10px; + z-index: 1; +} + +.code-wrap .msg-code { + margin: 0; + padding-top: 34px; +} + +.message-body .msg-mermaid-wrap { + margin: 0.75em 0; +} + +.message-body .msg-mermaid { + width: 100%; + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); + padding: 34px 14px 14px; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent; +} + +.message-body .msg-mermaid .mermaid, +.message-body .msg-mermaid svg { + display: block; + width: 100%; + max-width: 100%; + height: auto; + margin: 0 auto; + background: transparent; + color: var(--text); + cursor: zoom-in; +} + +.message-body .msg-mermaid .mermaid { + text-align: center; +} + +.message-body .msg-mermaid-source { + display: none; +} + +.message-body .msg-mermaid-error { + margin: 0 0 10px; + border: 1px solid color-mix(in srgb, #b75b3c 45%, var(--line)); + border-radius: 8px; + background: color-mix(in srgb, #b75b3c 12%, var(--surface-2)); + padding: 10px 12px; + color: var(--accent); + font-size: 13px; +} + +.message-body .msg-mermaid-fallback { + margin: 0; + overflow-x: auto; + border-radius: 8px; + background: var(--surface); + padding: 12px; + color: var(--text); + font-family: "Cascadia Code", "Consolas", "Courier New", monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.diagram-lightbox { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + background: rgba(12, 12, 10, 0.82); + padding: 24px; + cursor: zoom-out; +} + +.diagram-lightbox.hidden { + display: none !important; +} + +.diagram-lightbox-inner { + display: grid; + max-width: min(96vw, 1400px); + max-height: 92vh; + place-items: center; + overflow: auto; + cursor: default; +} + +.diagram-lightbox-inner img, +.diagram-lightbox-inner svg { + display: block; + max-width: 95vw; + max-height: 90vh; + width: auto; + height: auto; + margin: 0 auto; + border-radius: 8px; + background: var(--surface-2); + padding: 16px; + object-fit: contain; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); +} + +.diagram-lightbox-close { + position: absolute; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 10px; + background: rgba(30, 30, 26, 0.85); + color: #f5f5f0; + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.users-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + gap: 12px; + align-items: end; + margin-bottom: 28px; +} + +.users-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +.users-form input, +.users-form select { + width: 100%; + color: var(--text); + border: 1px solid var(--line); + border-radius: 9px; + background-color: var(--surface); + padding: 10px 11px; +} + +.users-table { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); + overflow: hidden; +} + +.users-head, +.users-row { + display: grid; + grid-template-columns: 1.2fr 1fr 0.6fr; + gap: 12px; + padding: 12px 16px; +} + +.users-head { + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.users-row + .users-row { + border-top: 1px solid var(--line); +} + +.role-pill { + width: fit-content; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + padding: 3px 8px; + font-size: 11px; + font-weight: 700; +} + +@media (max-width: 1000px) { + .project-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .project-layout { + grid-template-columns: 1fr; + } + + .details-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + border-top: 1px solid var(--line); + border-left: 0; + padding: 30px; + } +} + +@media (max-width: 720px) { + .app-shell { + display: block; + } + + .sidebar { + transform: translateX(-100%); + box-shadow: var(--shadow); + transition: transform 180ms ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .mobile-close, + .menu-button { + display: grid; + } + + .main { + min-width: 0; + } + + .topbar { + padding: 0 15px; + } + + .breadcrumbs { + display: none; + } + + .content-wide { + width: 100%; + max-width: none; + padding: 42px 16px; + } + + .page-title-row { + align-items: flex-start; + flex-direction: column; + gap: 22px; + } + + .project-grid { + grid-template-columns: 1fr; + } + + .project-main { + width: 100%; + max-width: none; + padding: 38px 16px; + } + + .project-heading { + align-items: flex-start; + } + + .project-heading h1 { + font-size: 31px; + } + + .details-panel { + grid-template-columns: 1fr; + padding: 28px 18px; + } + + .chat-page { + height: 100%; + max-height: 100%; + max-width: none; + padding: 0 12px; + } + + .app-shell { + height: 100%; + max-height: 100dvh; + } + + .main { + height: 100%; + max-height: 100%; + min-height: 0; + } + + .users-form { + grid-template-columns: 1fr; + } + + .agent-status span:last-child { + display: none; + } +} diff --git a/test_preanalysis.py b/test_preanalysis.py new file mode 100644 index 0000000..6cad65b --- /dev/null +++ b/test_preanalysis.py @@ -0,0 +1,57 @@ +from pathlib import Path +import tempfile + +import log_preanalysis as lp + + +def test_detect_job_and_date() -> None: + query = "Разбери заказ 17876 за 2026-08-06: расхождение, Error64, промежутки, частота" + assert lp.looks_like_log_job(query, ["line_u_codes.log.1"]) + assert lp.detect_job_id(query) == "17876" + assert lp.detect_shift_date(query) == "2026-08-06" + assert lp.detect_shift_date("смена 06.08.2026") == "2026-08-06" + + +def test_stream_scan(tmp_path: Path) -> None: + log = tmp_path / "line_u_codes.log.1" + lines = [ + "2026-08-06 08:00:00 [Scanner] job=17876 code=010460406000000021ABC1", + "2026-08-06 08:00:01 [PLC] job=17876 Error 12", + "2026-08-06 08:05:00 [Scanner] job=17876 Error64 timeout", + "2026-08-06 08:05:01 [PLC] Error65", + "2026-08-06 10:00:00 [Scanner] last line", + ] + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + percents: list[int] = [] + + def on_progress(info: dict) -> None: + if info.get("percent") is not None: + percents.append(int(info["percent"])) + + report = lp.preanalyze_logs( + [(log, "line_u_codes.log.1")], + "заказ 17876 2026-08-06 Error64 промежутки частота", + on_progress=on_progress, + ) + assert report["job_id"] == "17876" + item = report["files"][0] + assert item["lines"] == 5 + assert item["error64"] + assert item["error65"] + assert item["gaps"] + assert item["gaps"][0]["seconds"] >= 30 + md = report["markdown"] + assert "PREANALYSIS" in md + assert "Error64" in md + text, encoding = lp.excerpt_lines(log, 2, 3) + assert encoding == "utf-8" + assert "2\t" in text and "Error 12" in text + assert percents + assert percents[-1] == 100 + + +if __name__ == "__main__": + test_detect_job_and_date() + with tempfile.TemporaryDirectory() as folder: + test_stream_scan(Path(folder)) + print("ok")