diff --git a/.env.example b/.env.example index fbd32428..1cfcb43f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,38 @@ APP_URL=http://localhost:3000 PORT=3000 +# --- Security / reverse proxy --- +# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via +# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only +# from proxies on the configured trusted networks. Deploy this app behind a +# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For` +# with the real client IP. If XFF is trusted from an untrusted source, any +# per-IP throttling — including the /mcp Basic brute-force limiter — can be +# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs. +# (The /mcp limiter keeps a global per-email key as an IP-independent backstop, +# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.) +# +# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For. +# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted +# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip. +# This is the safe default for the common case where the reverse proxy runs on +# loopback or a private network; req.ip still resolves to the real client. +# WARNING: this changed the previous default of trust-all. If your reverse proxy +# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the +# proxy's IP — set TRUST_PROXY accordingly. Accepted values: +# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites +# X-Forwarded-For; otherwise clients can spoof their IP) +# - false never trust X-Forwarded-For (req.ip is the socket peer) +# - number of trusted proxy hops in front of the app +# - comma-separated CIDR/IP list of trusted proxies, e.g. +# `127.0.0.1, 10.0.0.0/8` +# TRUST_PROXY= + +# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that +# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you +# change APP_SECRET after setup, every stored AI API key becomes undecryptable — +# you must re-enter them in AI settings — and all existing sessions/JWTs are +# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB. # minimum of 32 characters. Generate one with: openssl rand -hex 32 APP_SECRET=REPLACE_WITH_LONG_SECRET @@ -69,15 +101,55 @@ DEBUG_DB=false # Log http requests LOG_HTTP=false -# MCP server (community): service account the embedded MCP uses to talk to this Docmost instance +# MCP server (community): the embedded /mcp endpoint authenticates PER USER. +# An MCP client authenticates with one of: +# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own +# Docmost login/password. The server validates the credentials and the MCP +# session then acts under that user's permissions (edits attributed to them). +# - Bearer access JWT: `Authorization: Bearer ` (the user's +# `authToken` cookie value). Validated as an ACCESS token. +# +# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR +# Bearer credentials and these are set, the MCP session falls back to this +# shared service account (back-compat; useful for CI/scripts). Leave BLANK to +# require per-user credentials. MCP_DOCMOST_EMAIL= MCP_DOCMOST_PASSWORD= # MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api -# Optional bearer token to protect the /mcp endpoint. If unset, /mcp relies on -# the workspace MCP toggle and network isolation (do not expose the port publicly). +# Optional shared guard for the /mcp endpoint. When set, every /mcp request must +# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now +# carries the per-user credentials). When unset, /mcp relies on the per-user +# credentials above plus the workspace MCP toggle and network isolation (do not +# expose the port publicly). # MCP_TOKEN= # MCP_SESSION_IDLE_MS=1800000 # Per-embedding-call timeout in milliseconds for the RAG indexer. # A slow/hung embeddings endpoint fails after this and the batch continues. # AI_EMBEDDING_TIMEOUT_MS=120000 + +# --- Anonymous public-share AI assistant --- +# Opt-in per workspace (AI settings -> "public share assistant"; off by default). +# When enabled, anonymous visitors of a published share can ask an AI about that +# share at POST /api/shares/ai/stream. The assistant is read-only and hard-scoped +# to the single share tree, but every call spends real tokens on the workspace +# owner's configured AI provider. +# +# DEPLOYMENT REQUIREMENT: the per-IP rate limit on this endpoint is only +# effective behind a trusted reverse proxy that OVERWRITES (not appends) +# X-Forwarded-For with the real client IP. The app runs with trustProxy, so +# without such a proxy an attacker can rotate X-Forwarded-For to evade the +# per-IP limit. Put this endpoint (and the app) behind a proxy you control that +# sets X-Forwarded-For to the real client IP. +# +# Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent, +# keyed by the server-resolved workspace id) bounds the owner's bill even if the +# per-IP limit is fully evaded. It is a COST backstop, not an access control, and +# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going +# offline is safer than an unbounded bill). Override the hourly cap below +# (default: 300 calls per workspace per rolling hour). +# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300 +# +# Per-request output-token ceiling for the anonymous assistant (default: 512). +# Worst-case output per accepted call = agent steps (5) × this value. +# SHARE_AI_MAX_OUTPUT_TOKENS=512 diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 736040b7..2d81467c 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -3,7 +3,7 @@ name: Develop on: push: branches: - - main + - develop workflow_dispatch: concurrency: @@ -18,7 +18,12 @@ env: IMAGE: ghcr.io/vvzvlad/gitmost jobs: + # Run the reusable test suite first so a failing test blocks the image build. + test: + uses: ./.github/workflows/test.yml + build: + needs: test runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7137d953..694df01b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,12 @@ env: IMAGE: ghcr.io/vvzvlad/gitmost jobs: + # Run the reusable test suite first so a failing test blocks the image build. + test: + uses: ./.github/workflows/test.yml + build: + needs: test strategy: matrix: include: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..955b0ac2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test + +on: + pull_request: + workflow_call: + workflow_dispatch: + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Required for the client suite, which resolves @docmost/editor-ext via its + # dist build (the server suite also rebuilds it through its own pretest). + - name: Build editor-ext + run: pnpm --filter @docmost/editor-ext build + + - name: Run tests + run: pnpm -r test diff --git a/.gitignore b/.gitignore index 6af27e98..e814fb29 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ lerna-debug.log* .nx/installation .nx/cache .claude/worktrees/ + +# TypeScript incremental build artifacts +*.tsbuildinfo diff --git a/CLAUDE.md b/AGENTS.md similarity index 58% rename from CLAUDE.md rename to AGENTS.md index 7e2713f1..a56358a1 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,6 +1,164 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file guides AI agents (Claude Code, opencode, …) working in this +repository. It has two layers: **how to run a task end-to-end** (the +sections below), and **how the codebase is built** (the technical sections +further down, formerly in `CLAUDE.md`). + +## Жизненный цикл задачи + +### 1. Старт: синхронизация с develop + +Перед началом **любой** работы обнови локальный `develop` и ветвись от него: + +```bash +git checkout develop +git fetch gitea +git pull --ff-only gitea develop +git checkout -b <короткое-имя-фичи> +``` + +Никогда не пилит фичу прямо в `develop` и не ветвись от устаревшего +`develop` — иначе PR будет содержать лишние коммиты или конфликтовать. + +### 2. Реализация + +Веди задачу по workflow из системного промпта (Phase 1 анализ → Phase 3 +реализация → Phase 4 review → Phase 5 верификация → Phase 6 отчёт). Большие +изменения делегируй в general subagent, ревьюй через review subagent. + +### 3. Коммит — ТОЛЬКО в Gitea и ТОЛЬКО от `claude_code` + +Это правило без исключений: + +- **Куда:** единственный remote для коммитов/пушей — **`gitea`** + (`gitea.vvzvlad.xyz`). **Никогда** не пушь в `origin` (GitHub-зеркало) и + тем более в `upstream` (оригинальный Docmost). GitHub-зеркало обновляется + CI-процессом владельца, не агентом. +- **От кого:** коммить **только** от агентского identity. Любой коммит, + у которого author или committer — `vvzvlad`, считается ошибкой и должен + быть переписан. + - **name:** `claude_code` + - **email:** `claude_code@vvzvlad.xyz` + +Используй `--reset-author` при amend, иначе git оставит оригинального +автора (по умолчанию config на этой машине — `vvzvlad`, поэтому проверяй +после каждого коммита): + +```bash +GIT_AUTHOR_NAME="claude_code" \ +GIT_AUTHOR_EMAIL="claude_code@vvzvlad.xyz" \ +GIT_COMMITTER_NAME="claude_code" \ +GIT_COMMITTER_EMAIL="claude_code@vvzvlad.xyz" \ +git commit --amend --no-edit --reset-author +``` + +Для обычного нового коммита достаточно один раз выставить локальный +config ветки и коммитить штатно: + +```bash +git config user.name "claude_code" +git config user.email "claude_code@vvzvlad.xyz" +``` + +Проверка перед push: + +```bash +git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>' +# обе строки должны показать claude_code +``` + +### 4. Push и PR в develop + +PR всегда в `develop`. Пароль `claude_code` лежит в macOS keychain как +**generic password** под service `gitea-claude-code` (не дублируй его как +internet-password для `gitea.vvzvlad.xyz` — это создаст конфликт с учёткой +владельца в git credential helper): + +```bash +AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w) +``` + +Push — через временную подстановку кредов в remote URL, после чего URL +обязательно возвращается в чистый вид (пароль не должен оседать в git +config / reflog): + +```bash +ORIG_URL=$(git remote get-url gitea) +SAFE_PASS=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$AGENT_PASS") +git remote set-url gitea "https://claude_code:${SAFE_PASS}@gitea.vvzvlad.xyz/vvzvlad/gitmost.git" +git push -u gitea +git remote set-url gitea "$ORIG_URL" +unset AGENT_PASS SAFE_PASS +``` + +PR создаётся через Gitea REST API (Basic Auth от `claude_code`): + +```bash +curl -s -X POST \ + -u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \ + -H "Content-Type: application/json" \ + -d @pr_body.json \ + "https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls" +``` + +`base: develop`, `head: `. В теле PR — что сделано, что вне scope, +результаты верификации (tsc/lint/tests). + +> Если push падает с `User permission denied for writing` — значит у +> `claude_code` нет коллабораторских прав на репо. Попроси владельца +> добавить (один раз, через Gitea UI или +> `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code` с +> `{"permission":"write"}` от его учётки). + +### 5. Мерж и cleanup + +- **Мерж PR в develop делает пользователь** (не агент). Агент не жмёт + кнопку merge. +- **После реализации задачи удали её план из `docs/backlog/.md`** — + это часть закрытия задачи, не пользовательская работа. Файлы в + `docs/backlog/` — это очередь работы, выполненное из неё вычищается. + Сделай это в отдельном коммите от того же `claude_code` в той же ветке + (или попроси пользователя удалить, если PR уже открыт и ты не хочешь + его перепушивать). +- Не закоммичен ли мусор в рабочем дереве? Проверь `git status` перед + финальным отчётом. + +## Релизный цикл: набор на новую версию + +Когда в `develop` накопилось достаточно изменений для релиза, запускается +**финальное ревью тремя скиллами-оркестраторами** перед мержем/тегом: + +1. **test-orchestrator** (skill `code-review-orchestrator` с фокусом на + тестовом покрытии) — проверяет, что новый код покрыт тестами и нет + регрессий в существующих. +2. **review-orchestrator** (skill `code-review-orchestrator`) — + мульти-аспектный код-ревью: безопасность, стабильность, соответствие + конвенциям, регрессии, перегруженность. +3. **red-team-orchestrator** (red-team скилл) — адверсариальный анализ + атакующих сценариев на затронутые компоненты. + +Порядок: оркестраторы возвращают списки находок → агент правит всё, что +они нашли (через subagent или сам, по правилам делегирования) → повторно +прогоняет ревью затронутых мест → режет тег по процедуре «Cutting a +release» ниже. + +## Шпаргалка по учёткам и endpoint'ам + +| Что | Значение | +| --- | --- | +| Единственный remote для коммитов | `gitea` → `https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` | +| Агентский user (Gitea/git) | `claude_code` | +| Агентский email | `claude_code@vvzvlad.xyz` | +| Пароль в keychain | `security find-generic-password -s gitea-claude-code -w` | +| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (тут `gitmost` — реальный slug репо на сервере) | +| Базовая ветка | `develop` | +| `origin` | GitHub-зеркало `vvzvlad/gitmost` — **не пушить**, обновляется CI владельца | +| `upstream` | Оригинальный Docmost — **не пушить никогда** | + +--- + +# Архитектура и кодовая база ## What this is @@ -58,7 +216,7 @@ pnpm --filter server migration:latest # apply all pending pnpm --filter server migration:down # revert last pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB ``` -Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`) and nullable columns — never drop/rewrite Docmost data. +Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data. **Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name. @@ -82,7 +240,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes - **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync. ### The two AI subsystems (the main fork additions) -1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. It authenticates as a service account configured via `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`; an admin enables it with a workspace toggle (Workspace settings → AI). Optionally protected by `MCP_TOKEN`. +1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer ` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry. 2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces: - `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration). - `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint. @@ -105,7 +263,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro ## CI / release -- `.github/workflows/develop.yml` — on push to `main`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`. +- `.github/workflows/develop.yml` — on push to `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`. - `.github/workflows/release.yml` — on `v*` tags (or manual dispatch), builds multi-arch (amd64 + arm64) images, pushes a manifest list to GHCR (`latest` + semver tags), and creates a draft GitHub Release with image tarballs. Uses the built-in `GITHUB_TOKEN` (not Docker Hub). - The `Dockerfile` is a multi-stage pnpm build; `APP_VERSION` is passed as a build arg because `.git` isn't in the build context. @@ -120,7 +278,6 @@ The git tag is the source of truth for the displayed version (UI reads `git desc 5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`. 6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). - ## Planning docs -`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation, arbitrary HTML embed). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas. +`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29058510..b1cb6ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.93.0] - 2026-06-21 + +This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles, +an anonymous AI assistant on public shares, server-side voice dictation, an +editor footnotes model, live page-template embeds, and sandboxed arbitrary-HTML +embeds — plus a large batch of security hardening and test coverage. + +### Breaking Changes + +- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard + no longer reads `Authorization: Bearer `; it now reads only the + `X-MCP-Token` header. The `Authorization` header is now reserved for per-user + HTTP Basic / Bearer access-JWT credentials, so each `/mcp` request + authenticates as a specific user (the `MCP_DOCMOST_*` service account is only + a fallback). Existing MCP clients (e.g. Claude Desktop) configured with + `Authorization: Bearer ` must be reconfigured to send + `X-MCP-Token: ` instead. See `MCP_TOKEN` in `.env.example`. As a + one-time aid, the server logs a single migration warning when it sees the + old-style header. + +### Added + +- **AI agent roles**: admin-defined assistant personas with an optional + per-role model override, selectable in chat. +- **Anonymous AI assistant on public shares**: public-share visitors can chat + with a selectable agent-role identity that reuses the internal chat + presentation, with per-request output-token caps and a fail-closed Redis + limiter. +- **Voice dictation (STT)**: server-side speech-to-text with a mic button in + the chat and the editor, OpenRouter STT support, an endpoint test, and real + provider-error surfacing. +- **Footnotes**: an editor footnotes model (inline references + a definitions + list). +- **Page templates**: live whole-page embed (MVP) with a template-marker icon + in the page tree and a working Refresh action. +- **Arbitrary HTML/CSS/JS embeds**: a sandboxed-iframe embed block gated by a + per-workspace toggle (default OFF); insertable by any member when the toggle + is on. +- Admin-only **"Analytics / tracker"** workspace setting: a raw HTML/JS snippet + injected into the `` of public share pages only (for analytics such as + Google Analytics or Yandex.Metrika), kept separate from the member-facing + HTML-embed feature. +- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the + embedded `/mcp` endpoint. +- **Page tree**: Expand all / Collapse all for the space tree, and + server-authoritative realtime tree updates. +- **AI chat UX**: a `get_current_page` tool for proxy-robust page context, a + current-context-size readout, an agent step cap raised 8→20 with a forced + final text answer, and auto-collapse of the chat window on page focus. +- **AI settings**: a Clear control inside the API-key field and an endpoint + status dot bound to "configured × enabled". +- **Client**: an always-visible space grid replacing the space-switcher popover, + removal of the sidebar Overview item, tighter comments-panel density, and no + auto-open of the comments panel when adding a comment. + +### Changed + +- HTML embed blocks now render inside a sandboxed iframe (separate origin) and, + when the workspace HTML-embed toggle is on, can be inserted by any member + (previously admin-only). Turning the toggle off hides existing embeds and + stops serving them on public share pages. +- Remove the server-side role-based stripping of HTML-embed blocks from the + write paths (collab/REST/MCP, page create/duplicate, import, transclusion + unsync); sandboxing makes per-write gating unnecessary. The only remaining + server-side strip is the public-share read path, which still honors the + workspace HTML-embed toggle. + +### Fixed + +- AI chat: preserve scroll position during streaming, record chats that fail on + their first turn, and resolve the current page for agent context behind + proxies. +- AI roles: guard `update()` against concurrent soft-delete; harden the model + override, role-name uniqueness, and id validation; sandwich the safety + framework around the role persona. +- Auth: handle null-password (SSO/LDAP-only) accounts without a bcrypt throw. +- Footnotes: survive duplicate-id definitions without collab divergence. +- HTML embed: fix stale iframe height and damp the resize loop; strip embeds at + serve time on authenticated read paths and the plain page-create path. +- Page templates: import `ThrottleModule` so collab boots, never strand an + in-flight page-embed id, and add defense-in-depth workspace checks. +- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event. +- Import: surface the real error cause from `/pages/import` instead of a generic + 400. + +### Security + +- MCP: close an SSO/MFA bypass on Basic auth and stop minting non-init sessions; + close a brute-force limiter check-then-act race. +- Public share: block restricted descendants in the anonymous assistant, cap + per-request output, fail closed when Redis is unavailable, and reject non-text + message parts to close a size-cap bypass. +- Make `trustProxy` env-configurable with a safe default. + +### Internal + +- CI: gate the `develop` and release image builds on the test suite, run the + suites on push/PR, and build the `:develop` image on push to `develop`. +- Docs: replace `CLAUDE.md` with `AGENTS.md` codifying the agent workflow and + the release procedure, add migration-ordering guidance, and prune implemented + plans. +- A large batch of new server/client test coverage. + ## [0.91.0] - 2026-06-18 Gitmost is a community-focused fork of Docmost. This release drops the @@ -92,5 +195,6 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand. - Build: drop the private EE submodule, retarget CI to GHCR, and update the Docker image to the GHCR registry. -[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD +[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD +[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0 [0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0 diff --git a/README.md b/README.md index 578790f0..b63b76f5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ community feature, with no enterprise license. Open it from the page header; the - ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs. - ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP). - ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it. +- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). +- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. +- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. ### In progress @@ -108,14 +111,11 @@ community feature, with no enterprise license. Open it from the page header; the ### Planned -- 🔭 **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). See [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Viewer comments** — let read-only viewers leave comments. -- 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Password-protected pages** — protect individual pages / shares with a password. - 🔭 **Windows / Linux app** — native desktop app for Windows and Linux. - 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md). - 🔭 **Offline mode** — offline sync & PWA support. -- 🔭 **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. See [docs/footnotes-plan.md](docs/footnotes-plan.md). - 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs. ## Getting started @@ -158,6 +158,11 @@ the existing data directory is reused as-is: start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the `page_embeddings` and AI tables); watch the logs for `Migration "..." executed successfully`. +> ⚠️ **Never change `APP_SECRET` after setup.** It does double duty: it signs JWTs *and* derives the +> AES-256-GCM key that encrypts stored AI-provider credentials (API keys). Rotating it makes every +> saved AI API key undecryptable (you'd have to re-enter them in AI settings) and invalidates all +> existing sessions. Pick it once, keep it stable, and back it up together with your database. + ### Notes - **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the diff --git a/README.ru.md b/README.ru.md index 0bd9a5de..cb0d12ad 100644 --- a/README.ru.md +++ b/README.ru.md @@ -102,6 +102,9 @@ real-time-коллаборации Docmost, поэтому запись нико - ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов. - ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP). - ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия. +- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). +- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. +- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. ### В процессе @@ -109,14 +112,11 @@ real-time-коллаборации Docmost, поэтому запись нико ### В планах -- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md). - 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение. -- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md). - 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем. - 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux. - 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md). - 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA. -- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md). - 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках. ## С чего начать @@ -159,6 +159,12 @@ dump/restore, существующий каталог данных переис новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы `page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`. +> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT +> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров +> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить +> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите +> неизменным и бэкапьте вместе с базой данных. + ## Возможности diff --git a/apps/client/package.json b/apps/client/package.json index 00a25bbe..0433c97f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.91.0", + "version": "0.93.0", "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..70353fee 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -183,6 +183,7 @@ "Successfully imported": "Successfully imported", "Successfully restored": "Successfully restored", "System settings": "System settings", + "Template": "Template", "Templates": "Templates", "Theme": "Theme", "To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.", @@ -473,6 +474,7 @@ "Make sub-pages public too": "Make sub-pages public too", "Allow search engines to index page": "Allow search engines to index page", "Open page": "Open page", + "Open source page": "Open source page", "Page": "Page", "Delete public share link": "Delete public share link", "Delete share": "Delete share", @@ -529,6 +531,7 @@ "Add 2FA method": "Add 2FA method", "Backup codes": "Backup codes", "Disable": "Disable", + "disabled": "disabled", "Invalid verification code": "Invalid verification code", "New backup codes have been generated": "New backup codes have been generated", "Failed to regenerate backup codes": "Failed to regenerate backup codes", @@ -977,6 +980,9 @@ "Page menu": "Page menu", "Expand": "Expand", "Collapse": "Collapse", + "Expand all": "Expand all", + "Collapse all": "Collapse all", + "Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}", "Comment menu": "Comment menu", "Group menu": "Group menu", "Show hidden breadcrumbs": "Show hidden breadcrumbs", @@ -1122,10 +1128,24 @@ "Page menu for {{name}}": "Page menu for {{name}}", "Create subpage of {{name}}": "Create subpage of {{name}}", "AI chat": "AI chat", + "Ask a question about this documentation.": "Ask a question about this documentation.", + "Ask a question…": "Ask a question…", + "Thinking…": "Thinking…", + "The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.", + "Public share assistant": "Public share assistant", + "Enabled": "Enabled", + "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.", + "Public assistant model": "Public assistant model", + "Defaults to the chat model": "Defaults to the chat model", + "Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.", + "Assistant identity": "Assistant identity", + "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.", + "Built-in assistant persona": "Built-in assistant persona", "Minimize": "Minimize", "Current context size": "Current context size", "AI agent": "AI agent", "AI agent is typing…": "AI agent is typing…", + "{{name}} is typing…": "{{name}} is typing…", "Send": "Send", "Stop": "Stop", "Chat menu": "Chat menu", @@ -1162,6 +1182,10 @@ "Voice dictation is not available yet.": "Voice dictation is not available yet.", "Test endpoint": "Test endpoint", "Save endpoints": "Save endpoints", + "Configured and enabled": "Configured and enabled", + "Configured but disabled": "Configured but disabled", + "Enabled but not configured": "Enabled but not configured", + "Not configured": "Not configured", "External tools": "External tools", "Gitmost as MCP client": "Gitmost as MCP client", "Servers the agent calls out to.": "Servers the agent calls out to.", @@ -1195,5 +1219,41 @@ "Request format": "Request format", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", - "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)" + "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)", + "Agent role": "Agent role", + "Universal assistant": "Universal assistant", + "Add role": "Add role", + "Edit role": "Edit role", + "Role name": "Role name", + "e.g. Proofreader": "e.g. Proofreader", + "Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.", + "Optional. A short note about what this role does.": "Optional. A short note about what this role does.", + "Instructions": "Instructions", + "The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.", + "Model provider override": "Model provider override", + "Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.", + "Model override": "Model override", + "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", + "e.g. gpt-4o-mini": "e.g. gpt-4o-mini", + "If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.", + "Agent roles": "Agent roles", + "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", + "No roles configured": "No roles configured", + "Delete role": "Delete role", + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?", + "HTML embed": "HTML embed", + "Edit HTML embed": "Edit HTML embed", + "HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace", + "Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS", + "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.", + "": "", + "Height (px, blank = auto)": "Height (px, blank = auto)", + "advanced": "advanced", + "Enable HTML embed": "Enable HTML embed", + "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.", + "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.", + "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.", + "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.", + "Analytics / tracker": "Analytics / tracker", + "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only." } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 25ff2530..8023df3b 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -183,6 +183,7 @@ "Successfully imported": "Успешно импортировано", "Successfully restored": "Успешно восстановлено", "System settings": "Системные настройки", + "Template": "Шаблон", "Templates": "Шаблоны", "Theme": "Тема", "To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.", @@ -391,6 +392,13 @@ "Toggle block": "Сворачиваемый блок", "Callout": "Выноска", "Insert callout notice.": "Вставить выноску с сообщением.", + "Footnote": "Сноска", + "Insert a footnote reference.": "Вставить ссылку на сноску.", + "Footnotes": "Примечания", + "Footnote {{number}}": "Сноска {{number}}", + "Go to footnote": "Перейти к сноске", + "Back to reference": "Вернуться к ссылке", + "Empty footnote": "Пустая сноска", "Math inline": "Строчная формула", "Insert inline math equation.": "Вставить математическое выражение в строку.", "Math block": "Блок формулы", @@ -471,6 +479,7 @@ "Make sub-pages public too": "Сделать подстраницы тоже общедоступными", "Allow search engines to index page": "Разрешить поисковым системам индексировать страницу", "Open page": "Открыть страницу", + "Open source page": "Открыть исходную страницу", "Page": "Страница", "Delete public share link": "Удалить публичную ссылку", "Delete share": "Удалить общий доступ", @@ -659,6 +668,9 @@ "AI search": "Поиск ИИ", "AI Answer": "Ответ ИИ", "Ask AI": "Спросить ИИ", + "AI agent": "AI-агент", + "AI agent is typing…": "AI-агент печатает…", + "{{name}} is typing…": "{{name}} печатает…", "AI is thinking...": "ИИ обрабатывает запрос...", "Thinking": "Думаю", "Ask a question...": "Задайте вопрос...", diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts index b3707cb9..027a8c50 100644 --- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null); // Whether the floating AI chat window is open. Non-persistent (resets per session). export const aiChatWindowOpenAtom = atom(false); +/** + * The agent role selected for the NEXT new chat. `null` = "Universal assistant" + * (no role). Consulted ONLY when creating a chat (its first message): the server + * persists it to ai_chats.role_id and the role is immutable afterwards. Reset to + * null when starting a new chat. It does NOT affect already-created chats. + */ +// Cast default for the same jotai overload reason as activeAiChatIdAtom above. +export const selectedAiRoleIdAtom = atom(null as string | null); + // The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE // ChatThread — so it survives the thread remount that happens when a brand-new // chat adopts its freshly created id after the first turn finishes. If it lived diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css index 71de2066..5758a018 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css @@ -57,6 +57,12 @@ display: none; } +/* In the collapsed state the header expands the window on click, so hint that + it is clickable (override the drag `grab` cursor). */ +.minimized .dragBar { + cursor: pointer; +} + .dragBar { display: flex; align-items: center; diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 122f80ff..1a150242 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from "react"; -import { Group, Loader, Tooltip } from "@mantine/core"; +import { Group, Loader, Select, Tooltip } from "@mantine/core"; import { IconArrowsDiagonal, IconCheck, @@ -18,13 +18,14 @@ import { IconX, } from "@tabler/icons-react"; import { useAtom, useSetAtom } from "jotai"; -import { useParams } from "react-router-dom"; +import { useMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, aiChatDraftAtom, + selectedAiRoleIdAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; @@ -32,10 +33,15 @@ import { AI_CHATS_RQ_KEY, useAiChatMessagesQuery, useAiChatsQuery, + useAiRolesQuery, } from "@/features/ai-chat/queries/ai-chat-query.ts"; import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts"; +import { + shouldCollapseOnOutsidePointer, + isHeaderClick, +} from "@/features/ai-chat/utils/collapse-helpers.ts"; import { useClipboard } from "@/hooks/use-clipboard"; import { notifications } from "@mantine/notifications"; import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; @@ -102,10 +108,16 @@ export default function AiChatWindow() { const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom); const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom); const setDraft = useSetAtom(aiChatDraftAtom); + // The role chosen for the next new chat (null = universal assistant). + const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom); // History section starts collapsed (matches the former panel's behavior). const [historyOpen, setHistoryOpen] = useState(false); const [minimized, setMinimized] = useState(false); + // Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag), + // which would otherwise close over a stale value. Kept in sync below. + const minimizedRef = useRef(minimized); + minimizedRef.current = minimized; const winRef = useRef(null); // Live window geometry (position + size); initialized lazily on first open so @@ -123,16 +135,29 @@ export default function AiChatWindow() { const adoptNewChat = useRef(false); const { data: chats } = useAiChatsQuery(); + // Roles for the new-chat picker (any member may list them). Only fetched while + // the window is open. + const { data: roles } = useAiRolesQuery(windowOpen); + // The new-chat picker only offers ENABLED roles. The list endpoint returns + // all live roles (so the admin settings section can manage disabled ones), so + // we filter to `enabled` here, client-side, for the composer picker only. + const enabledRoles = useMemo( + () => (roles ?? []).filter((r) => r.enabled === true), + [roles], + ); const { data: messageRows, isLoading: messagesLoading } = useAiChatMessagesQuery(activeChatId ?? undefined); - // The page the user is currently viewing, derived from the route (same - // source the breadcrumb uses). On a non-page route `pageSlug` is undefined, - // so the query is disabled and `openPage` is null. This is passed to the - // chat thread as context so the agent knows what "this page"/"the current - // page" refers to; the agent still reads/writes via its CASL-enforced page - // tools using the id. - const { pageSlug } = useParams(); + // The page the user is currently viewing. AiChatWindow lives in a pathless + // parent layout route, so useParams() can't see :pageSlug. Match the full + // pathname against the authenticated page route instead so "the current page" + // resolves regardless of where this component is mounted. On a non-page route + // the match is null, so `pageSlug` is undefined, the query is disabled and + // `openPage` is null. This is passed to the chat thread as context so the + // agent knows what "this page"/"the current page" refers to; the agent still + // reads/writes via its CASL-enforced page tools using the id. + const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug"); + const pageSlug = pageRouteMatch?.params?.pageSlug; const { data: openPageData } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); @@ -144,7 +169,9 @@ export default function AiChatWindow() { setActiveChatId(null); setHistoryOpen(false); setDraft(""); - }, [setActiveChatId, setDraft]); + // Default the picker back to "Universal assistant" for the fresh chat. + setSelectedRoleId(null); + }, [setActiveChatId, setDraft, setSelectedRoleId]); const selectChat = useCallback( (chatId: string): void => { @@ -238,8 +265,31 @@ export default function AiChatWindow() { useLayoutEffect(() => { if (!windowOpen) return; setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom())); + // Always show the window expanded on (re)open: a collapsed state from a + // previous open session must not stick. Runs before paint so the first + // frame is already expanded. The composer's autofocus is a focus INSIDE the + // window (not an outside mousedown), so it cannot self-collapse the window. + setMinimized(false); }, [windowOpen]); + // Auto-collapse the window into its header as soon as the user interacts with + // anything outside it (clicks the page/editor). Armed ONLY while the window is + // open and expanded, so it never fires repeatedly and never collapses on the + // open→reset transition. Capture phase so a page handler's stopPropagation in + // the bubble phase can't hide the event from us; the in-window/portal guards + // (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside + // the window or inside Mantine portals (kebab menu, delete-confirm modal). + useEffect(() => { + if (!windowOpen || minimized) return; + const onPointerDown = (e: MouseEvent): void => { + if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) { + setMinimized(true); + } + }; + document.addEventListener("mousedown", onPointerDown, true); + return () => document.removeEventListener("mousedown", onPointerDown, true); + }, [windowOpen, minimized]); + // Persist the user's resize into state so it survives close/reopen. Skipped // while minimized so the collapsed (auto) height is never captured. The // equality guard avoids an update loop. @@ -287,10 +337,21 @@ export default function AiChatWindow() { el.style.top = `${nt}px`; }; - const up = (): void => { + const up = (ev: MouseEvent): void => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.style.userSelect = ""; + // Treat a near-zero-movement press as a click (not a drag). When the + // window is minimized, a header click expands it; nothing to persist + // because the position did not change. minimizedRef avoids the stale + // `minimized` captured by useCallback([]). + if ( + minimizedRef.current && + isHeaderClick(sx, sy, ev.clientX, ev.clientY) + ) { + setMinimized(false); + return; + } const el2 = winRef.current; // Persist the final position back into state (preserving the size) so // re-renders keep it. @@ -334,14 +395,49 @@ export default function AiChatWindow() { height: minimized ? undefined : geom.height, }} > - {/* drag bar / header */} + {/* drag bar / header. Mouse users expand a minimized window by clicking + anywhere on the bar (the click-vs-drag logic in startDrag, which + excludes the buttons). The keyboard/screen-reader Expand affordance + lives on the title element below — NOT on this container — so we never + nest the Minimize/Close