Compare commits
95 Commits
1095c5679f
...
docs/manua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a32077a42 | ||
|
|
3b790852b3 | ||
|
|
1f5f2b60a8 | ||
|
|
c23ca101f1 | ||
|
|
c00e270756 | ||
|
|
f6a4df1b08 | ||
|
|
e423c35676 | ||
|
|
e598394f46 | ||
|
|
8f01a01122 | ||
|
|
14e26aab70 | ||
|
|
44fa11e6eb | ||
|
|
373c56c0d3 | ||
|
|
6a85680a7d | ||
|
|
39ce47a11f | ||
|
|
e826d7a008 | ||
|
|
0d2bff07ce | ||
|
|
6b09c43344 | ||
|
|
7093f184b2 | ||
|
|
7bcb5ffcca | ||
|
|
2bd75edacc | ||
|
|
c114806382 | ||
|
|
4f0da42d88 | ||
|
|
7ce1a24f82 | ||
|
|
89ac8fa37b | ||
|
|
cbd980f6e4 | ||
|
|
3c3fb0816a | ||
|
|
f543e79c3e | ||
|
|
1c9785997a | ||
|
|
b60190ff1e | ||
|
|
2846830bf7 | ||
|
|
f218852184 | ||
|
|
93d8c1f775 | ||
|
|
ef74058301 | ||
|
|
3a3d22ac55 | ||
|
|
4281a370b1 | ||
|
|
a16ef2346f | ||
|
|
01d7c2b465 | ||
|
|
6d0ee6c61f | ||
|
|
6347708605 | ||
|
|
fae8418fa2 | ||
|
|
8f994460ad | ||
|
|
c83343d3a3 | ||
|
|
4f035b8e19 | ||
|
|
0deded342d | ||
|
|
ebfb947ba2 | ||
|
|
43f8c9ab99 | ||
|
|
03e2f444ae | ||
|
|
4201f0a313 | ||
|
|
47c4e547e7 | ||
|
|
eb1e233d46 | ||
|
|
69f385ccb7 | ||
|
|
ccbd3e1962 | ||
|
|
18ef18fb6a | ||
|
|
810228a3e2 | ||
|
|
9a9b61b9a3 | ||
|
|
79c3c86b82 | ||
|
|
55625874c5 | ||
|
|
71d908c6b5 | ||
|
|
d188c9e876 | ||
|
|
59c2913d72 | ||
|
|
7171dfbdf0 | ||
|
|
4f8015b342 | ||
|
|
3d4ad664b3 | ||
|
|
cdcf3c0639 | ||
|
|
f3fa15e746 | ||
|
|
0bbf94c154 | ||
|
|
0cfc3c8f89 | ||
|
|
4df79aafd3 | ||
|
|
0b2af34029 | ||
|
|
74e2b7ad7f | ||
|
|
a86d0c7c3b | ||
|
|
569da822b6 | ||
|
|
f8e8ada581 | ||
|
|
4720705155 | ||
|
|
ce60498a90 | ||
| 4a22cc1955 | |||
|
|
b83a5d4597 | ||
|
|
d4658d4cb3 | ||
|
|
4105836a2d | ||
|
|
f5a45d5453 | ||
|
|
9fad6ab73b | ||
|
|
194924c3ba | ||
|
|
c7f0b51389 | ||
|
|
ebfe56a684 | ||
|
|
e12ddaa2c8 | ||
|
|
6397b500ba | ||
|
|
c3161a05dd | ||
|
|
77eeada693 | ||
|
|
06bfca5fdb | ||
|
|
04f05626ad | ||
|
|
f9757fda12 | ||
|
|
19cd73a5aa | ||
|
|
e6b1170553 | ||
|
|
2e0f4456e1 | ||
|
|
059f2bd7e5 |
@@ -147,8 +147,8 @@ MCP_DOCMOST_PASSWORD=
|
||||
# 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
|
||||
# (default: 100 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
|
||||
#
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,3 +45,6 @@ lerna-debug.log*
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||
apps/client/public/vad/
|
||||
|
||||
180
AGENTS.md
180
AGENTS.md
@@ -5,45 +5,48 @@ 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`).
|
||||
|
||||
## Жизненный цикл задачи
|
||||
## Task lifecycle
|
||||
|
||||
### 1. Старт: синхронизация с develop
|
||||
### 1. Start: sync with develop
|
||||
|
||||
Перед началом **любой** работы обнови локальный `develop` и ветвись от него:
|
||||
Before starting **any** work, update your local `develop` and branch off it:
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git fetch gitea
|
||||
git pull --ff-only gitea develop
|
||||
git checkout -b <короткое-имя-фичи>
|
||||
git checkout -b <short-feature-name>
|
||||
```
|
||||
|
||||
Никогда не пилит фичу прямо в `develop` и не ветвись от устаревшего
|
||||
`develop` — иначе PR будет содержать лишние коммиты или конфликтовать.
|
||||
Never build a feature directly on `develop`, and never branch off a stale
|
||||
`develop` — otherwise the PR will carry extra commits or conflict.
|
||||
|
||||
### 2. Реализация
|
||||
### 2. Implementation
|
||||
|
||||
Веди задачу по workflow из системного промпта (Phase 1 анализ → Phase 3
|
||||
реализация → Phase 4 review → Phase 5 верификация → Phase 6 отчёт). Большие
|
||||
изменения делегируй в general subagent, ревьюй через review subagent.
|
||||
Run the task through the workflow from the system prompt (Phase 1 analysis →
|
||||
Phase 3 implementation → Phase 4 review → Phase 5 verification → Phase 6
|
||||
report). Delegate large changes to a general subagent; review via the review
|
||||
subagent.
|
||||
|
||||
### 3. Коммит — ТОЛЬКО в Gitea и ТОЛЬКО от `claude_code`
|
||||
**Create worktrees only inside the `.claude` folder** (e.g.
|
||||
`.claude/worktrees/<name>`). Creating a git worktree anywhere else — the repo
|
||||
root, sibling directories, or temp folders — is forbidden.
|
||||
|
||||
Это правило без исключений:
|
||||
### 3. Commit — ONLY to Gitea and ONLY as `claude_code`
|
||||
|
||||
- **Куда:** единственный remote для коммитов/пушей — **`gitea`**
|
||||
(`gitea.vvzvlad.xyz`). **Никогда** не пушь в `origin` (GitHub-зеркало) и
|
||||
тем более в `upstream` (оригинальный Docmost). GitHub-зеркало обновляется
|
||||
CI-процессом владельца, не агентом.
|
||||
- **От кого:** коммить **только** от агентского identity. Любой коммит,
|
||||
у которого author или committer — `vvzvlad`, считается ошибкой и должен
|
||||
быть переписан.
|
||||
This rule has no exceptions:
|
||||
|
||||
- **Where:** the only remote for commits/pushes is **`gitea`**
|
||||
(`gitea.vvzvlad.xyz`). **Never** push to `origin` (the GitHub mirror), and
|
||||
especially not to `upstream` (the original Docmost). The GitHub mirror is
|
||||
updated by the owner's CI process, not by the agent.
|
||||
- **Who:** commit **only** as the agent identity. Any commit whose author or
|
||||
committer is `vvzvlad` is an error and must be rewritten.
|
||||
- **name:** `claude_code`
|
||||
- **email:** `claude_code@vvzvlad.xyz`
|
||||
|
||||
Используй `--reset-author` при amend, иначе git оставит оригинального
|
||||
автора (по умолчанию config на этой машине — `vvzvlad`, поэтому проверяй
|
||||
после каждого коммита):
|
||||
Use `--reset-author` when amending, otherwise git keeps the original author
|
||||
(the default config on this machine is `vvzvlad`, so check after every commit):
|
||||
|
||||
```bash
|
||||
GIT_AUTHOR_NAME="claude_code" \
|
||||
@@ -53,34 +56,33 @@ GIT_COMMITTER_EMAIL="claude_code@vvzvlad.xyz" \
|
||||
git commit --amend --no-edit --reset-author
|
||||
```
|
||||
|
||||
Для обычного нового коммита достаточно один раз выставить локальный
|
||||
config ветки и коммитить штатно:
|
||||
For a regular new commit, set the branch-local config once and commit normally:
|
||||
|
||||
```bash
|
||||
git config user.name "claude_code"
|
||||
git config user.email "claude_code@vvzvlad.xyz"
|
||||
```
|
||||
|
||||
Проверка перед push:
|
||||
Check before push:
|
||||
|
||||
```bash
|
||||
git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
||||
# обе строки должны показать claude_code <claude_code@vvzvlad.xyz>
|
||||
# both lines must show claude_code <claude_code@vvzvlad.xyz>
|
||||
```
|
||||
|
||||
### 4. Push и PR в develop
|
||||
### 4. Push and PR to develop
|
||||
|
||||
PR всегда в `develop`. Пароль `claude_code` лежит в macOS keychain как
|
||||
**generic password** под service `gitea-claude-code` (не дублируй его как
|
||||
internet-password для `gitea.vvzvlad.xyz` — это создаст конфликт с учёткой
|
||||
владельца в git credential helper):
|
||||
PRs always target `develop`. The `claude_code` password lives in the macOS
|
||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||
conflict with the owner's account in the git credential helper):
|
||||
|
||||
```bash
|
||||
AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w)
|
||||
```
|
||||
|
||||
Push — через временную подстановку кредов в remote URL, после чего URL
|
||||
обязательно возвращается в чистый вид (пароль не должен оседать в git
|
||||
Push by temporarily injecting the credentials into the remote URL, then always
|
||||
restore the URL to its clean form (the password must not linger in git
|
||||
config / reflog):
|
||||
|
||||
```bash
|
||||
@@ -92,7 +94,7 @@ git remote set-url gitea "$ORIG_URL"
|
||||
unset AGENT_PASS SAFE_PASS
|
||||
```
|
||||
|
||||
PR создаётся через Gitea REST API (Basic Auth от `claude_code`):
|
||||
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
@@ -102,63 +104,62 @@ curl -s -X POST \
|
||||
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
||||
```
|
||||
|
||||
`base: develop`, `head: <branch>`. В теле PR — что сделано, что вне scope,
|
||||
результаты верификации (tsc/lint/tests).
|
||||
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
||||
of scope, verification results (tsc/lint/tests).
|
||||
|
||||
> Если push падает с `User permission denied for writing` — значит у
|
||||
> `claude_code` нет коллабораторских прав на репо. Попроси владельца
|
||||
> добавить (один раз, через Gitea UI или
|
||||
> `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code` с
|
||||
> `{"permission":"write"}` от его учётки).
|
||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||
> the Gitea UI or `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code`
|
||||
> with `{"permission":"write"}` from their account).
|
||||
|
||||
### 5. Мерж и cleanup
|
||||
### 5. Merge and cleanup
|
||||
|
||||
- **Мерж PR в develop делает пользователь** (не агент). Агент не жмёт
|
||||
кнопку merge.
|
||||
- **После реализации задачи удали её план из `docs/backlog/<task>.md`** —
|
||||
это часть закрытия задачи, не пользовательская работа. Файлы в
|
||||
`docs/backlog/` — это очередь работы, выполненное из неё вычищается.
|
||||
Сделай это в отдельном коммите от того же `claude_code` в той же ветке
|
||||
(или попроси пользователя удалить, если PR уже открыт и ты не хочешь
|
||||
его перепушивать).
|
||||
- Не закоммичен ли мусор в рабочем дереве? Проверь `git status` перед
|
||||
финальным отчётом.
|
||||
- **The user merges the PR into develop** (not the agent). The agent does not
|
||||
press the merge button.
|
||||
- **After implementing a task, delete its plan from `docs/backlog/<task>.md`** —
|
||||
this is part of closing the task, not the user's work. Files in
|
||||
`docs/backlog/` are the work queue; completed items get cleaned out of it.
|
||||
Do this in a separate commit from the same `claude_code` on the same branch
|
||||
(or ask the user to delete it if the PR is already open and you don't want to
|
||||
repush it).
|
||||
- Any junk left uncommitted in the working tree? Check `git status` before the
|
||||
final report.
|
||||
|
||||
## Релизный цикл: набор на новую версию
|
||||
## Release cycle: staging a new version
|
||||
|
||||
Когда в `develop` накопилось достаточно изменений для релиза, запускается
|
||||
**финальное ревью тремя скиллами-оркестраторами** перед мержем/тегом:
|
||||
When enough changes have accumulated on `develop` for a release, a **final
|
||||
review by three orchestrator skills** runs before the merge/tag:
|
||||
|
||||
1. **test-orchestrator** (skill `code-review-orchestrator` с фокусом на
|
||||
тестовом покрытии) — проверяет, что новый код покрыт тестами и нет
|
||||
регрессий в существующих.
|
||||
2. **review-orchestrator** (skill `code-review-orchestrator`) —
|
||||
мульти-аспектный код-ревью: безопасность, стабильность, соответствие
|
||||
конвенциям, регрессии, перегруженность.
|
||||
3. **red-team-orchestrator** (red-team скилл) — адверсариальный анализ
|
||||
атакующих сценариев на затронутые компоненты.
|
||||
1. **test-orchestrator** (the `code-review-orchestrator` skill focused on test
|
||||
coverage) — verifies new code is covered by tests and there are no
|
||||
regressions in existing ones.
|
||||
2. **review-orchestrator** (the `code-review-orchestrator` skill) —
|
||||
multi-aspect code review: security, stability, convention conformance,
|
||||
regressions, over-complexity.
|
||||
3. **red-team-orchestrator** (the red-team skill) — adversarial analysis of
|
||||
attack scenarios against the affected components.
|
||||
|
||||
Порядок: оркестраторы возвращают списки находок → агент правит всё, что
|
||||
они нашли (через subagent или сам, по правилам делегирования) → повторно
|
||||
прогоняет ревью затронутых мест → режет тег по процедуре «Cutting a
|
||||
release» ниже.
|
||||
Order: the orchestrators return finding lists → the agent fixes everything they
|
||||
found (via a subagent or itself, per the delegation rules) → re-runs the review
|
||||
on the affected areas → cuts the tag per the "Cutting a release" procedure
|
||||
below.
|
||||
|
||||
## Шпаргалка по учёткам и endpoint'ам
|
||||
## Accounts & endpoints cheat sheet
|
||||
|
||||
| Что | Значение |
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Единственный 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 — **не пушить никогда** |
|
||||
| Only remote for commits | `gitea` → `https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` |
|
||||
| Agent user (Gitea/git) | `claude_code` |
|
||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
||||
| Base branch | `develop` |
|
||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||
| `upstream` | The original Docmost — **never push** |
|
||||
|
||||
---
|
||||
|
||||
# Архитектура и кодовая база
|
||||
# Architecture and codebase
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -277,6 +278,29 @@ The git tag is the source of truth for the displayed version (UI reads `git desc
|
||||
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
|
||||
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).
|
||||
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
|
||||
|
||||
#### Why develop keeps showing the *previous* version (and why step 7 matters)
|
||||
|
||||
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||
|
||||
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
|
||||
|
||||
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
|
||||
|
||||
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
|
||||
|
||||
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
|
||||
|
||||
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
|
||||
|
||||
Fix / checklist when develop still shows the old version after a back-merge:
|
||||
|
||||
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
|
||||
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
|
||||
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
|
||||
|
||||
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
|
||||
|
||||
## Planning docs
|
||||
|
||||
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -10,11 +10,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public share AI: default per-workspace hourly assistant cap lowered
|
||||
300 → 100.** The limiter falls back to this default whenever
|
||||
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
|
||||
never set the env var has its anonymous public-share assistant hourly cap
|
||||
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
|
||||
keep the previous limit. (#62)
|
||||
|
||||
## [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 <MCP_TOKEN>`; 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 <MCP_TOKEN>` must be reconfigured to send
|
||||
`X-MCP-Token: <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
|
||||
|
||||
- Admin-only "Analytics / tracker" workspace setting: a raw HTML/JS snippet
|
||||
- **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 `<head>` of public share pages only (for analytics such as
|
||||
Google Analytics or Yandex.Metrika).
|
||||
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
|
||||
|
||||
@@ -28,16 +86,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
server-side strip is the public-share read path, which still honors the
|
||||
workspace HTML-embed toggle.
|
||||
|
||||
### Breaking Changes
|
||||
### Fixed
|
||||
|
||||
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
|
||||
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
|
||||
`X-MCP-Token` header. Existing MCP clients (e.g. Claude Desktop) configured
|
||||
with `Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
|
||||
`X-MCP-Token: <MCP_TOKEN>` instead. The `Authorization` header is now
|
||||
reserved for per-user HTTP Basic / Bearer access JWT credentials. 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.
|
||||
- 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
|
||||
|
||||
@@ -121,5 +204,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
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"version": "0.93.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"dev": "node scripts/copy-vad-assets.mjs && vite",
|
||||
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@mantine/modals": "8.3.18",
|
||||
"@mantine/notifications": "8.3.18",
|
||||
"@mantine/spotlight": "8.3.18",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"@slidoapp/emoji-mart": "5.8.7",
|
||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||
@@ -53,6 +54,7 @@
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mitt": "3.0.1",
|
||||
"onnxruntime-web": "^1.27.0",
|
||||
"posthog-js": "1.372.2",
|
||||
"react": "18.3.1",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"Name": "Name",
|
||||
"New email": "New email",
|
||||
"New page": "New page",
|
||||
"New note": "New note",
|
||||
"Create in space": "Create in space",
|
||||
"New password": "New password",
|
||||
"No group found": "No group found",
|
||||
"No page history saved yet.": "No page history saved yet.",
|
||||
@@ -706,7 +708,6 @@
|
||||
"Authorization header": "Authorization header",
|
||||
"Tool allowlist": "Tool allowlist",
|
||||
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
|
||||
"Use Tavily preset": "Use Tavily preset",
|
||||
"Test": "Test",
|
||||
"Available tools": "Available tools",
|
||||
"No tools available": "No tools available",
|
||||
@@ -951,6 +952,7 @@
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Try again": "Try again",
|
||||
"Untitled chat": "Untitled chat",
|
||||
"No document": "No document",
|
||||
"You": "You",
|
||||
"What can I help you with?": "What can I help you with?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
|
||||
@@ -1144,9 +1146,13 @@
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
"{{name}} is typing…": "{{name}} is typing…",
|
||||
"Send": "Send",
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Stop": "Stop",
|
||||
"Chat menu": "Chat menu",
|
||||
"No chats yet.": "No chats yet.",
|
||||
@@ -1220,6 +1226,9 @@
|
||||
"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)",
|
||||
"Dictation language": "Dictation language",
|
||||
"Auto-detect": "Auto-detect",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
|
||||
"Agent role": "Agent role",
|
||||
"Universal assistant": "Universal assistant",
|
||||
"Add role": "Add role",
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"Name": "Имя",
|
||||
"New email": "Новый электронный адрес",
|
||||
"New page": "Новая страница",
|
||||
"New note": "Новая заметка",
|
||||
"Create in space": "Создать в пространстве",
|
||||
"New password": "Новый пароль",
|
||||
"No group found": "Группа не найдена",
|
||||
"No page history saved yet.": "История страниц ещё не сохранена.",
|
||||
@@ -669,8 +671,37 @@
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
@@ -926,6 +957,7 @@
|
||||
"Try a different search term.": "Попробуйте другой поисковый запрос.",
|
||||
"Try again": "Попробовать снова",
|
||||
"Untitled chat": "Чат без названия",
|
||||
"No document": "Без документа",
|
||||
"What can I help you with?": "Чем я могу вам помочь?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||
@@ -1097,5 +1129,8 @@
|
||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
|
||||
}
|
||||
|
||||
70
apps/client/scripts/copy-vad-assets.mjs
Normal file
70
apps/client/scripts/copy-vad-assets.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
// Self-host the @ricky0123/vad-web + onnxruntime-web runtime assets under
|
||||
// apps/client/public/vad/.
|
||||
//
|
||||
// WHY THIS EXISTS:
|
||||
// Both vad-web and onnxruntime-web resolve their assets by URL *at runtime* (the
|
||||
// VAD audio worklet + Silero model, and ORT's wasm/mjs backend). In vad-web
|
||||
// 0.0.30 the default baseAssetPath / onnxWASMBasePath is "./" — i.e. relative to
|
||||
// the current page URL — NOT a CDN. In this SPA that "./" request hits the
|
||||
// client-side catch-all route and gets served index.html (text/html), so the
|
||||
// onnxruntime ESM/wasm backend fails to initialize ("'text/html' is not a valid
|
||||
// JavaScript MIME type"). We fix that by copying the needed runtime files into
|
||||
// public/vad/ and pointing both path constants at the fixed absolute "/vad/".
|
||||
//
|
||||
// These copies are NOT committed (the ORT wasm is ~26 MB); this script runs
|
||||
// before `dev` and `build` (see package.json) to repopulate them from
|
||||
// node_modules. It is idempotent: it (re)creates the dir and overwrites.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = path.join(here, "..", "public", "vad");
|
||||
|
||||
// vad-web exposes ./package.json, so derive its dist dir from there.
|
||||
const vadDist = path.join(
|
||||
path.dirname(require.resolve("@ricky0123/vad-web/package.json")),
|
||||
"dist",
|
||||
);
|
||||
|
||||
// onnxruntime-web's "exports" map does NOT expose ./package.json, so resolving
|
||||
// it would throw ERR_PACKAGE_PATH_NOT_EXPORTED. It DOES export the exact asset
|
||||
// subpaths we need, so resolve those files directly.
|
||||
//
|
||||
// ORT ships several wasm backends and which one the app bundle references depends
|
||||
// on the resolver: Vite dev resolves the JSEP build (ort-wasm-simd-threaded.jsep.*)
|
||||
// while the production rolldown build resolves the plain build
|
||||
// (ort-wasm-simd-threaded.*). Ship BOTH variants so the runtime fetch hits a real
|
||||
// file under /vad/ regardless of which the bundle picked (each .mjs proxy fetches
|
||||
// its matching .wasm at init).
|
||||
const ortJsepMjs = require.resolve(
|
||||
"onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs",
|
||||
);
|
||||
const ortJsepWasm = require.resolve(
|
||||
"onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm",
|
||||
);
|
||||
const ortMjs = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.mjs");
|
||||
const ortWasm = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.wasm");
|
||||
|
||||
// [absolute source path, output filename]
|
||||
const files = [
|
||||
[path.join(vadDist, "vad.worklet.bundle.min.js"), "vad.worklet.bundle.min.js"],
|
||||
[path.join(vadDist, "silero_vad_v5.onnx"), "silero_vad_v5.onnx"],
|
||||
[ortJsepMjs, "ort-wasm-simd-threaded.jsep.mjs"],
|
||||
[ortJsepWasm, "ort-wasm-simd-threaded.jsep.wasm"],
|
||||
[ortMjs, "ort-wasm-simd-threaded.mjs"],
|
||||
[ortWasm, "ort-wasm-simd-threaded.wasm"],
|
||||
];
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
for (const [src, name] of files) {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error(`[copy-vad-assets] missing source: ${src}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.copyFileSync(src, path.join(outDir, name));
|
||||
console.log(`[copy-vad-assets] ${name}`);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
@@ -33,21 +34,3 @@
|
||||
that is ~9.3px, minus the font descent (~2px) ≈ 7px. */
|
||||
margin-bottom: rem(7px);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
padding: rem(8px) rem(12px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
text-decoration: none;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import classes from "./app-header.module.css";
|
||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
@@ -30,10 +29,6 @@ import {
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
|
||||
const links = [
|
||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||
];
|
||||
|
||||
export function AppHeader() {
|
||||
const { t } = useTranslation();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
@@ -47,12 +42,6 @@ export function AppHeader() {
|
||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
{t(link.label)}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
@@ -97,10 +86,6 @@ export function AppHeader() {
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group ml="xl" gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function Aside() {
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs />;
|
||||
component = <CommentListWithTabs onClose={closeAside} />;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
@@ -44,26 +44,27 @@ export default function Aside() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{tab === "comments" ? (
|
||||
component
|
||||
) : (
|
||||
<Box p={0} style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component &&
|
||||
(tab === "comments" ? (
|
||||
component
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" mb="sm">
|
||||
<Title order={2} size="h6" fw={500}>
|
||||
{t(title)}
|
||||
</Title>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
@@ -71,9 +72,8 @@ export default function Aside() {
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,12 +94,12 @@ export default function GlobalAppShell({
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 350,
|
||||
width: 420,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
padding="md"
|
||||
padding={{ base: "xs", sm: "md" }}
|
||||
>
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
@@ -138,7 +138,7 @@ export default function GlobalAppShell({
|
||||
id={ASIDE_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
className={classes.aside}
|
||||
p="md"
|
||||
p="sm"
|
||||
withBorder={false}
|
||||
aria-label={
|
||||
asideTab === "comments"
|
||||
|
||||
@@ -20,18 +20,29 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useMatch } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
|
||||
export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
// Detect the currently viewed space so the "Space settings" item is only
|
||||
// offered while the user is inside a space. The "/*" splat also matches the
|
||||
// bare "/s/:spaceSlug" route (the splat matches an empty segment).
|
||||
const spaceMatch = useMatch("/s/:spaceSlug/*");
|
||||
const spaceSlug = spaceMatch?.params?.spaceSlug;
|
||||
const [
|
||||
spaceSettingsOpened,
|
||||
{ open: openSpaceSettings, close: closeSpaceSettings },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
@@ -41,124 +52,143 @@ export default function TopMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Workspace settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
{t("Manage members")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>{t("Account")}</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
<>
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
>
|
||||
{t("My profile")}
|
||||
</Menu.Item>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||
leftSection={<IconBrush size={16} />}
|
||||
>
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Workspace settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
{spaceSlug && (
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
onClick={openSpaceSettings}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Light")}
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
{t("Manage members")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
{t("Logout")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>{t("Account")}</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
>
|
||||
{t("My profile")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||
leftSection={<IconBrush size={16} />}
|
||||
>
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Light")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
{t("Logout")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{spaceSlug && (
|
||||
<SpaceSettingsModal
|
||||
spaceId={spaceSlug}
|
||||
opened={spaceSettingsOpened}
|
||||
onClose={closeSpaceSettings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
prefetchSpaces,
|
||||
prefetchWorkspaceMembers,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||
@@ -141,8 +140,6 @@ export default function SettingsSidebar() {
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
|
||||
<AppVersion />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function BrandLogo({
|
||||
src={src}
|
||||
alt="Gitmost"
|
||||
className={className}
|
||||
draggable={false}
|
||||
style={{ height, width: "auto", display: "block", userSelect: "none" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Group, Loader, Select, Tooltip } from "@mantine/core";
|
||||
import { generateId } from "ai";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconCheck,
|
||||
@@ -31,6 +32,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
@@ -134,6 +136,26 @@ export default function AiChatWindow() {
|
||||
// can adopt it once the chat list refreshes after the first turn finishes.
|
||||
const adoptNewChat = useRef(false);
|
||||
|
||||
// Latch: the chat id whose full persisted history has finished loading while
|
||||
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
|
||||
// messages invalidation) never tears the live thread back down to the loader.
|
||||
const historyLoadedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Mount key for ChatThread + the chat the currently-mounted thread represents.
|
||||
// `threadKey` normally tracks the active chat, so selecting a different chat
|
||||
// (incl. from page history) remounts and re-seeds. The ONE exception is
|
||||
// in-place adoption of a brand-new chat's server id: the adopt effect moves
|
||||
// `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch
|
||||
// check below does not fire and the SAME thread stays mounted (its useChat
|
||||
// already holds the just-finished turn) instead of being re-seeded from
|
||||
// not-yet-persisted history.
|
||||
const [threadKey, setThreadKey] = useState<string>(
|
||||
() => activeChatId ?? `new-${generateId()}`,
|
||||
);
|
||||
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
|
||||
activeChatId,
|
||||
);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
@@ -145,6 +167,7 @@ export default function AiChatWindow() {
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
@@ -166,6 +189,9 @@ export default function AiChatWindow() {
|
||||
: null;
|
||||
|
||||
const startNewChat = useCallback((): void => {
|
||||
// Cancel any pending adoption so a just-finished new chat can't yank the user
|
||||
// back here after they explicitly started a fresh one.
|
||||
adoptNewChat.current = false;
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
@@ -175,11 +201,16 @@ export default function AiChatWindow() {
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
// Cancel any pending adoption so it can't override an explicit selection.
|
||||
adoptNewChat.current = false;
|
||||
setActiveChatId(chatId);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Reset the card-picked role so a stale pick can't leak into the existing
|
||||
// chat's header/assistant-name (which prefers the chat's persisted role).
|
||||
setSelectedRoleId(null);
|
||||
},
|
||||
[setActiveChatId, setDraft],
|
||||
[setActiveChatId, setDraft, setSelectedRoleId],
|
||||
);
|
||||
|
||||
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
||||
@@ -189,6 +220,18 @@ export default function AiChatWindow() {
|
||||
const onTurnFinished = useCallback(() => {
|
||||
if (activeChatId === null) adoptNewChat.current = true;
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
// Re-sync the persisted message rows for the active chat so the Markdown
|
||||
// export and the token counters reflect the turn that just finished. The
|
||||
// live thread renders from its own useChat store (stable threadKey / store
|
||||
// id), so refetching these rows never re-seeds or tears down the open
|
||||
// thread. For a brand-new chat activeChatId is still null here; that chat's
|
||||
// first row load happens right after id adoption, and every later turn hits
|
||||
// this invalidation with the adopted id.
|
||||
if (activeChatId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||
});
|
||||
}
|
||||
}, [activeChatId, queryClient]);
|
||||
|
||||
// The active chat object (for its title) and an export gate: only enable the
|
||||
@@ -199,6 +242,18 @@ export default function AiChatWindow() {
|
||||
);
|
||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
||||
|
||||
// The role to display in the header and as the assistant's name. Prefer the
|
||||
// persisted role of an existing chat (chat-list JOIN); fall back to the role
|
||||
// picked via a card click for a brand-new or just-adopted chat. selectChat
|
||||
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
|
||||
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
|
||||
if (activeChat?.roleName) {
|
||||
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
||||
}
|
||||
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
|
||||
return picked ? { name: picked.name, emoji: picked.emoji } : null;
|
||||
}, [activeChat, enabledRoles, selectedRoleId]);
|
||||
|
||||
// Build a Markdown export from the already-loaded persisted rows (no network
|
||||
// call) and copy it to the clipboard. The "Copied" notification is the
|
||||
// feedback.
|
||||
@@ -221,15 +276,54 @@ export default function AiChatWindow() {
|
||||
const newest = chats?.items?.[0];
|
||||
if (newest) {
|
||||
adoptNewChat.current = false;
|
||||
// In-place adoption: move the active chat AND the live-thread marker to the
|
||||
// new id together, so the threadKey derivation below sees no "switch" and
|
||||
// keeps the SAME mounted thread (its useChat already holds the finished
|
||||
// turn) instead of remounting and re-seeding from not-yet-persisted history.
|
||||
// ASSUMPTION: these two updates (jotai atom + useState) must land in ONE
|
||||
// render so the render-phase guard never observes the new activeChatId with
|
||||
// a stale liveThreadChatId (which would wrongly remount). React 18 automatic
|
||||
// batching inside this effect callback guarantees that; if the store/atom
|
||||
// mechanism ever changes, gate adoption on an explicit flag instead.
|
||||
setLiveThreadChatId(newest.id);
|
||||
setActiveChatId(newest.id);
|
||||
}
|
||||
}, [chats, setActiveChatId]);
|
||||
|
||||
// The thread is remounted when the active chat changes so initial messages
|
||||
// re-seed. For a new chat we key on "new"; adopting the id remounts the
|
||||
// thread with the persisted history loaded.
|
||||
const threadKey = activeChatId ?? "new";
|
||||
const waitingForHistory = activeChatId !== null && messagesLoading;
|
||||
// Adjust the derived thread state during render when the active chat genuinely
|
||||
// changes — the React-sanctioned alternative to an effect (it re-renders before
|
||||
// paint, no extra commit, and converges since the next render finds them equal).
|
||||
// In-place adoption of a new chat's id never reaches here because the adopt
|
||||
// effect moves liveThreadChatId in lockstep with activeChatId.
|
||||
if (activeChatId !== liveThreadChatId) {
|
||||
setLiveThreadChatId(activeChatId);
|
||||
setThreadKey(activeChatId ?? `new-${generateId()}`);
|
||||
}
|
||||
// Latch the active chat once its full history has loaded and its thread is
|
||||
// mounted, so a later background refetch (the post-turn messages
|
||||
// invalidation, which can transiently flip hasNextPage for a chat whose
|
||||
// message count is an exact multiple of the server page size) does not tear
|
||||
// the live thread down to a loader and lose its in-progress useChat state.
|
||||
if (
|
||||
activeChatId !== null &&
|
||||
threadKey === activeChatId &&
|
||||
!messagesLoading &&
|
||||
historyLoadedKeyRef.current !== activeChatId
|
||||
) {
|
||||
historyLoadedKeyRef.current = activeChatId;
|
||||
}
|
||||
|
||||
// Show the history loader only when freshly OPENING an existing chat (the key
|
||||
// equals the chat id) whose history has not been fully loaded yet. For a live
|
||||
// in-place thread that adopted its id, the key is still the "new-…" session
|
||||
// key, so we keep showing the live thread instead of unmounting it behind a
|
||||
// loader; and once a chat's history has loaded, a later background refetch no
|
||||
// longer tears the thread back down (see the latch above).
|
||||
const waitingForHistory =
|
||||
activeChatId !== null &&
|
||||
messagesLoading &&
|
||||
threadKey === activeChatId &&
|
||||
historyLoadedKeyRef.current !== activeChatId;
|
||||
|
||||
// Current context size for the active chat: how much the conversation now
|
||||
// occupies in the model's context window — NOT the cumulative tokens spent.
|
||||
@@ -430,12 +524,13 @@ export default function AiChatWindow() {
|
||||
{t("AI chat")}
|
||||
</span>
|
||||
|
||||
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||
chat is bound to a role that still exists. */}
|
||||
{activeChat?.roleName && (
|
||||
{/* Role badge (emoji + name). Shows the persisted role of an existing
|
||||
chat, or the role picked via a card for a brand-new chat. Hidden for
|
||||
a universal (no-role) chat. */}
|
||||
{currentRole && (
|
||||
<span className={classes.badge} title={t("Agent role")}>
|
||||
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
|
||||
{activeChat.roleName}
|
||||
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
|
||||
{currentRole.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -537,28 +632,10 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role picker — only for a NEW chat (before it is created). Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead.
|
||||
Defaults to "Universal assistant" (no role). */}
|
||||
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
|
||||
<div style={{ padding: "4px 8px 0" }}>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Agent role")}
|
||||
value={selectedRoleId ?? ""}
|
||||
onChange={(value) => setSelectedRoleId(value || null)}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={[
|
||||
{ value: "", label: t("Universal assistant") },
|
||||
...enabledRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The role picker for a NEW chat is rendered as the chat's empty-state
|
||||
(colored role cards centered in the empty window) by ChatThread
|
||||
itself — clicking a card starts the chat with that role. Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead. */}
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
@@ -574,6 +651,11 @@ export default function AiChatWindow() {
|
||||
openPage={openPage}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
// Role cards are the new-chat empty-state; offered only when this
|
||||
// is a brand-new chat. Clicking a card starts the chat with it.
|
||||
roles={activeChatId === null ? enabledRoles : undefined}
|
||||
onRolePicked={(role) => setSelectedRoleId(role.id)}
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -88,16 +88,18 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-3px);
|
||||
/* Bounce height is driven by --bounce so reduced-motion can dampen it
|
||||
(below) without disabling the animation outright. */
|
||||
transform: translateY(var(--bounce, -6px));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced-motion preferences: fall back to a static dimmed state. */
|
||||
/* Respect reduced-motion preferences: keep a smaller bounce instead of a full
|
||||
stop, so the "thinking" indicator still reads as active rather than frozen. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typingDots span {
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
--bounce: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +128,29 @@
|
||||
.conversationItemActive {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
|
||||
/* Pending messages queued by the user while a turn is still streaming. They
|
||||
are sent automatically, FIFO, once the current turn finishes. */
|
||||
.queuedList {
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.queuedItem {
|
||||
background: var(--mantine-color-gray-light);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.queuedIcon {
|
||||
flex: none;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.queuedText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,24 @@ import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string) => void;
|
||||
/** Called instead of `onSend` while a turn is streaming: the text is queued
|
||||
* and sent automatically once the current turn finishes. */
|
||||
onQueue: (text: string) => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent
|
||||
* is streaming, the send button becomes a Stop button (calls `stop()`); the
|
||||
* textarea stays usable so the user can draft the next turn.
|
||||
* Message composer. Enter submits, Shift+Enter inserts a newline. While the
|
||||
* agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
|
||||
* dropping it — it is sent automatically once the current turn finishes; the
|
||||
* Stop button (calls `stop()`) is also shown. The textarea stays usable so the
|
||||
* user can draft / queue the next turn while the agent is busy.
|
||||
*/
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
onQueue,
|
||||
onStop,
|
||||
isStreaming,
|
||||
disabled,
|
||||
@@ -30,17 +36,18 @@ export default function ChatInput({
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
|
||||
const send = (): void => {
|
||||
const submit = (): void => {
|
||||
const text = value.trim();
|
||||
if (!text || isStreaming || disabled) return;
|
||||
onSend(text);
|
||||
if (!text || disabled) return;
|
||||
if (isStreaming) onQueue(text);
|
||||
else onSend(text);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,28 +71,43 @@ export default function ChatInput({
|
||||
{isDictationEnabled && (
|
||||
<MicButton
|
||||
size="lg"
|
||||
streaming
|
||||
disabled={isStreaming || disabled}
|
||||
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
|
||||
/>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<Tooltip label={t("Stop")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
aria-label={t("Stop")}
|
||||
>
|
||||
<IconPlayerStopFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{value.trim().length > 0 && (
|
||||
<Tooltip label={t("Send when the agent finishes")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={submit}
|
||||
aria-label={t("Queue message")}
|
||||
>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t("Stop")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
aria-label={t("Stop")}
|
||||
>
|
||||
<IconPlayerStopFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label={t("Send")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={send}
|
||||
onClick={submit}
|
||||
disabled={disabled || value.trim().length === 0}
|
||||
aria-label={t("Send")}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { Alert, Box, Stack } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { ActionIcon, Alert, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconClockHour4, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
||||
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
||||
import {
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
/** The page the user is currently viewing, sent as chat context. */
|
||||
@@ -29,6 +39,15 @@ interface ChatThreadProps {
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
roleId?: string | null;
|
||||
/** Enabled roles for the new-chat empty state (only meaningful when
|
||||
* `chatId === null`). Rendered as the colored role cards. */
|
||||
roles?: IAiRole[];
|
||||
/** Notify the parent which role was picked via a card, so it can update the
|
||||
* header badge / assistant name for the brand-new chat. */
|
||||
onRolePicked?: (role: IAiRole) => void;
|
||||
/** Display name for the assistant label / typing line (the role name);
|
||||
* forwarded to MessageList. Absent => the generic "AI agent". */
|
||||
assistantName?: string;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
||||
* a new chat, adopts the freshly created chat id. */
|
||||
onTurnFinished: () => void;
|
||||
@@ -66,6 +85,9 @@ export default function ChatThread({
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -113,7 +135,55 @@ export default function ChatThread({
|
||||
// The id only needs to be stable per mount — the parent remounts this via
|
||||
// `key` on chat switch, which re-seeds cleanly.
|
||||
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
|
||||
const chatStoreId = chatId ?? stableIdRef.current;
|
||||
// Stable for the LIFETIME of this mount. When a brand-new chat adopts its
|
||||
// server id, the parent now updates the `chatId` prop WITHOUT remounting this
|
||||
// thread, so the store id must NOT follow `chatId`: recreating the useChat
|
||||
// store would wipe the live (just-finished) turn. The server still resolves
|
||||
// the real chat from `chatId` in the request body (see chatIdRef /
|
||||
// prepareSendMessagesRequest), so this purely-client store key can stay fixed.
|
||||
const chatStoreId = stableIdRef.current;
|
||||
|
||||
// Pending messages the user composed WHILE a turn was streaming. They are sent
|
||||
// automatically, FIFO, on successful turn completion (`onFinish`). The queue is
|
||||
// LOCAL state so it is scoped to this conversation: it is cleared when the user
|
||||
// deliberately switches chat / starts a new chat (the parent remounts this via
|
||||
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
|
||||
// message queued during a brand-new chat's first turn is not lost. On Stop or
|
||||
// error the queue is intentionally preserved (onFinish does not fire then) so
|
||||
// the user decides what to do with the pending messages.
|
||||
const [queued, setQueued] = useState<QueuedMessage[]>([]);
|
||||
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
|
||||
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
|
||||
const queuedRef = useRef<QueuedMessage[]>([]);
|
||||
const setQueue = useCallback((next: QueuedMessage[]) => {
|
||||
queuedRef.current = next;
|
||||
setQueued(next);
|
||||
}, []);
|
||||
|
||||
// Capture the latest `sendMessage` (returned by useChat below) so the flush
|
||||
// helper can call the current instance from the stable `onFinish` callback.
|
||||
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
const flushNext = useCallback(() => {
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return;
|
||||
setQueue(rest);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
}, [setQueue]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(text: string) => {
|
||||
setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
|
||||
},
|
||||
[setQueue],
|
||||
);
|
||||
const removeQueued = useCallback(
|
||||
(id: string) => {
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
},
|
||||
[setQueue],
|
||||
);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
@@ -147,37 +217,107 @@ export default function ChatThread({
|
||||
id: chatStoreId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
onFinish: () => onTurnFinished(),
|
||||
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand
|
||||
// new chat that fails on its first turn would never invalidate the chat list
|
||||
// nor adopt the server-created chat id (the server still creates the row and
|
||||
// saves the error message). Run the same post-turn path on error so the
|
||||
// failed chat appears in history immediately instead of after a manual
|
||||
// refresh. The error itself is still surfaced via `error` below.
|
||||
onError: () => onTurnFinished(),
|
||||
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
|
||||
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
|
||||
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
|
||||
// (chat-list refresh + new-chat id adoption must happen even on a failed
|
||||
// first turn), but flush the pending queue ONLY on a clean finish: auto-
|
||||
// sending after the user hit Stop — or blindly retrying after a failure —
|
||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||
// the user to decide.
|
||||
onFinish: ({ isAbort, isDisconnect, isError }) => {
|
||||
onTurnFinished();
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
},
|
||||
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
|
||||
// Log the raw failure here for devtools; the UI shows a friendly classified
|
||||
// banner via `error` below. We still call `onTurnFinished()` (idempotent with
|
||||
// the onFinish call) so a brand-new chat that fails its first turn is adopted
|
||||
// and the chat list refreshes immediately rather than after a manual refresh.
|
||||
onError: (streamError) => {
|
||||
// Surface the raw failure in the browser console (devtools) for debugging;
|
||||
// the UI separately shows a friendly classified banner (see errorView).
|
||||
console.error("AI chat stream error:", streamError);
|
||||
onTurnFinished();
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Classify the turn error into a heading + detail so the banner names the cause
|
||||
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
||||
// of a generic "Something went wrong".
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// Clicking a role card both binds the role to THIS new chat and immediately
|
||||
// starts the conversation. roleIdRef is set synchronously here because the
|
||||
// parent's selectedRoleId state update would only reach roleIdRef on the next
|
||||
// render — after this synchronous sendMessage has already read it.
|
||||
const handleRolePick = (role: IAiRole): void => {
|
||||
roleIdRef.current = role.id;
|
||||
onRolePicked?.(role);
|
||||
sendMessage({ text: t("Take a look at the current document") });
|
||||
};
|
||||
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
|
||||
const roleCardsEmptyState = showRoleCards ? (
|
||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Box className={classes.panel}>
|
||||
<MessageList messages={messages} isStreaming={isStreaming} />
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={roleCardsEmptyState}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
{errorView && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mb="xs"
|
||||
title={t("Something went wrong")}
|
||||
title={errorView.title}
|
||||
>
|
||||
{describeChatError(error.message ?? "", t)}
|
||||
{errorView.detail}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap={0} className={classes.inputWrapper}>
|
||||
{queued.length > 0 && (
|
||||
<Stack gap={4} className={classes.queuedList}>
|
||||
{queued.map((m) => (
|
||||
<Group
|
||||
key={m.id}
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
className={classes.queuedItem}
|
||||
>
|
||||
<IconClockHour4 size={14} className={classes.queuedIcon} />
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => removeQueued(m.id)}
|
||||
aria-label={t("Remove queued message")}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<ChatInput
|
||||
onSend={(text) => sendMessage({ text })}
|
||||
onQueue={enqueue}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
@@ -18,8 +18,31 @@ import {
|
||||
useRenameAiChatMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
/**
|
||||
* The dimmed second line of a chat row: how long ago the chat was created and
|
||||
* the document it was created in. Its own component so the self-updating
|
||||
* `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`).
|
||||
*/
|
||||
function ChatMetaLine({
|
||||
createdAt,
|
||||
pageTitle,
|
||||
}: {
|
||||
createdAt: string;
|
||||
pageTitle?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ago = useTimeAgo(createdAt);
|
||||
// e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document"
|
||||
return (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{ago} · {pageTitle || t("No document")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationListProps {
|
||||
activeChatId: string | null;
|
||||
onSelect: (chatId: string) => void;
|
||||
@@ -127,16 +150,24 @@ export default function ConversationList({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text
|
||||
size="sm"
|
||||
span
|
||||
title={chat.roleName}
|
||||
style={{ flex: "none" }}
|
||||
>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
|
||||
</Box>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -114,14 +114,18 @@ export default function MessageItem({
|
||||
{(() => {
|
||||
const errorText = (message.metadata as { error?: string } | undefined)?.error;
|
||||
if (!errorText) return null;
|
||||
// Same classified-error banner as the live chat: a heading naming the
|
||||
// cause plus a one-line detail.
|
||||
const errorView = describeChatError(errorText, t);
|
||||
return (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mt={4}
|
||||
title={errorView.title}
|
||||
>
|
||||
{describeChatError(errorText, t)}
|
||||
{errorView.detail}
|
||||
</Alert>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -43,23 +43,38 @@ interface MessageListProps {
|
||||
const BOTTOM_THRESHOLD = 40;
|
||||
|
||||
/**
|
||||
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
|
||||
* gap between sending and the first streamed content, so it shows only while a
|
||||
* turn is in flight AND the latest assistant message has nothing visible yet:
|
||||
* Whether to show the standalone "Thinking…" indicator. It bridges every
|
||||
* gap in a turn where the assistant is working but nothing visible is actively
|
||||
* being produced yet — so it shows while a turn is in flight AND the latest
|
||||
* assistant message's LAST part is not live output:
|
||||
* - the last message is still the user's (assistant hasn't started a row), or
|
||||
* - the last (assistant) message has no non-empty text and no tool part.
|
||||
* Once any text/tool part arrives, MessageItem renders it and this hides.
|
||||
* - the assistant row has no parts yet, or
|
||||
* - its last part is an empty/whitespace text part, or
|
||||
* - its last part is a finished/errored tool (the model is thinking about the
|
||||
* next step between tool calls).
|
||||
* It hides only while output is actively rendering: a non-empty streaming text
|
||||
* part, or a tool that is still running (ToolCallCard shows its own Loader).
|
||||
*/
|
||||
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
if (!isStreaming) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last) return true; // submitted with nothing rendered yet.
|
||||
if (last.role !== "assistant") return true; // assistant row not started.
|
||||
const hasVisible = last.parts.some(
|
||||
(p) =>
|
||||
(p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type),
|
||||
);
|
||||
return !hasVisible;
|
||||
const lastPart = last.parts[last.parts.length - 1];
|
||||
if (!lastPart) return true; // assistant row exists but has no parts yet.
|
||||
// The answer text is actively streaming in -> MessageItem renders it; no dots.
|
||||
if (lastPart.type === "text" && lastPart.text.trim().length > 0) return false;
|
||||
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
|
||||
if (
|
||||
isToolPart(lastPart.type) &&
|
||||
toolRunState((lastPart as unknown as ToolUiPart).state) === "running"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Otherwise the turn is in flight but nothing is actively producing visible
|
||||
// output yet: a finished/errored tool with no follow-up content, or an empty
|
||||
// trailing text part. The model is thinking between steps -> show the dots.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
/* flex-start keeps the first row reachable when the wrapped cards overflow and
|
||||
the container scrolls. With align-content: center, an overflowing top row is
|
||||
pushed out of the scrollable area and becomes unreachable. The parent Mantine
|
||||
Center still vertically centers the whole block when it fits. */
|
||||
align-content: flex-start;
|
||||
gap: 10px;
|
||||
/* Cap the height so a large number of roles scrolls instead of blowing out
|
||||
the empty chat area. */
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
/* Grow to fill the row so cards use the available window width instead of
|
||||
leaving large side gaps; the flex-basis sets how many fit per row before
|
||||
wrapping (≈2 columns at the default window width, more as it widens). */
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
min-height: 90px;
|
||||
padding: 12px 10px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The description: small and slightly muted, inheriting the card's color. We
|
||||
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
|
||||
with the card's inline color. */
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
line-height: 1.3;
|
||||
/* Break long unbreakable tokens (URLs, long foreign words) in the
|
||||
admin-configured description so they wrap instead of overflowing the card
|
||||
width now that the line clamp no longer caps the text. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import RoleCards from "./role-cards";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
|
||||
// does not implement. Provide a minimal stub so the provider can render.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const roles: IAiRole[] = [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Pirate",
|
||||
emoji: "🏴☠️",
|
||||
description: "Talks like a pirate",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "Grandpa",
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
function renderCards(onPick = vi.fn()) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RoleCards roles={roles} onPick={onPick} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return onPick;
|
||||
}
|
||||
|
||||
describe("RoleCards", () => {
|
||||
it("renders one card per role with name, emoji, and description", () => {
|
||||
renderCards();
|
||||
expect(screen.getByText("Pirate")).toBeDefined();
|
||||
expect(screen.getByText("Talks like a pirate")).toBeDefined();
|
||||
expect(screen.getByText("Grandpa")).toBeDefined();
|
||||
// The emoji is shown for the role that has one.
|
||||
expect(screen.getByText("🏴☠️")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render a Universal assistant card", () => {
|
||||
renderCards();
|
||||
expect(screen.queryByText("Universal assistant")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onPick with the role object when a card is clicked", () => {
|
||||
const onPick = renderCards();
|
||||
fireEvent.click(screen.getByText("Pirate"));
|
||||
expect(onPick).toHaveBeenCalledWith(roles[0]);
|
||||
});
|
||||
});
|
||||
78
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
78
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { UnstyledButton, Text } from "@mantine/core";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
|
||||
import classes from "@/features/ai-chat/components/role-cards.module.css";
|
||||
|
||||
interface RoleCardsProps {
|
||||
/** The enabled roles to render (one card each). */
|
||||
roles: IAiRole[];
|
||||
/** Called with the picked role when a card is clicked. The parent starts the
|
||||
* chat with this role (binds it and sends the opening message). */
|
||||
onPick: (role: IAiRole) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
|
||||
* they render correctly in both light and dark themes; the CSS module owns only
|
||||
* the layout. The card shows the emoji (if any), the role name, and a small
|
||||
* dimmed description line (if any).
|
||||
*/
|
||||
function RoleCard({
|
||||
color,
|
||||
name,
|
||||
emoji,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
color: string;
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
description?: string | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.card}
|
||||
style={{
|
||||
backgroundColor: `var(--mantine-color-${color}-light)`,
|
||||
color: `var(--mantine-color-${color}-light-color)`,
|
||||
}}
|
||||
title={description ?? name}
|
||||
onClick={onClick}
|
||||
>
|
||||
{emoji && <span className={classes.emoji}>{emoji}</span>}
|
||||
<Text size="sm" fw={600} lineClamp={2}>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="xs" className={classes.description}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored role cards rendered as the empty-state of a brand-new chat. There is
|
||||
* no Universal assistant card — the universal assistant is the implicit default
|
||||
* the user gets by simply typing into the composer without picking a card.
|
||||
* Clicking a card immediately STARTS the chat with that role (the parent binds
|
||||
* the role to the new chat and sends the opening message).
|
||||
*/
|
||||
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{roles.map((role, index) => (
|
||||
<RoleCard
|
||||
key={role.id}
|
||||
color={roleCardColor(index)}
|
||||
name={role.name}
|
||||
emoji={role.emoji}
|
||||
description={role.description}
|
||||
onClick={() => onPick(role)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { showTypingIndicator } from "@/features/ai-chat/components/message-list.
|
||||
/**
|
||||
* Pure-helper tests for the typing-indicator bridging logic that the internal
|
||||
* chat and the public share widget now share. This is the behavior that decides
|
||||
* whether the animated "AI agent is typing…" placeholder shows in the gap
|
||||
* whether the animated "Thinking…" placeholder shows in the gap
|
||||
* between sending and the first streamed token.
|
||||
*/
|
||||
const msg = (
|
||||
@@ -52,4 +52,34 @@ describe("showTypingIndicator", () => {
|
||||
showTypingIndicator([msg("assistant", [toolPart])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming after a tool has finished (thinking between steps)", () => {
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [doneTool])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when a finished tool is the last part after some text", () => {
|
||||
const text = { type: "text", text: "Let me check" } as unknown as UIMessage["parts"][number];
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [text, doneTool])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides while a tool is still running", () => {
|
||||
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [runningTool])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once the assistant streams non-empty text after a finished tool", () => {
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
const text = { type: "text", text: "The answer is 42" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [doneTool, text])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@ interface TypingIndicatorProps {
|
||||
* the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||
* as the assistant's bubble taking shape. The label and typing line use the
|
||||
* configured identity name when provided, otherwise the generic "AI agent".
|
||||
* as the assistant's bubble taking shape. The dimmed label uses the configured
|
||||
* identity name when provided (otherwise the generic "AI agent"), while the
|
||||
* typing line is always the generic "Thinking…" (it never includes the
|
||||
* role/identity name).
|
||||
*/
|
||||
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -38,7 +40,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
|
||||
{t("Thinking…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
@@ -75,6 +75,31 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
// (and both the Markdown export and the model-history seed) require the
|
||||
// COMPLETE thread, so keep pulling subsequent pages until the server reports
|
||||
// none remain. The isFetchingNextPage guard issues one request at a time;
|
||||
// when chatId is undefined the query is disabled and hasNextPage is false, so
|
||||
// this is a no-op. The isFetchNextPageError guard is critical: the app sets a
|
||||
// global `retry: false`, so a rejected fetchNextPage leaves hasNextPage true
|
||||
// and isFetchingNextPage false — without this guard the effect would re-fire
|
||||
// immediately and hammer the endpoint in a tight loop. isFetchNextPageError
|
||||
// latches the last next-page failure and clears once a fetch succeeds.
|
||||
useEffect(() => {
|
||||
if (
|
||||
query.hasNextPage &&
|
||||
!query.isFetchingNextPage &&
|
||||
!query.isFetchNextPageError
|
||||
) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
query.hasNextPage,
|
||||
query.isFetchingNextPage,
|
||||
query.isFetchNextPageError,
|
||||
query.fetchNextPage,
|
||||
]);
|
||||
|
||||
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
|
||||
if (!query.data) return undefined;
|
||||
return query.data.pages.flatMap((p) => p.items);
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface IAiChat {
|
||||
// Null when the chat has no role or the role was soft-deleted.
|
||||
roleName?: string | null;
|
||||
roleEmoji?: string | null;
|
||||
// The document the chat was created in (ai_chats.page_id). Null when started
|
||||
// outside any document.
|
||||
pageId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response: the origin page's title.
|
||||
// Null when there is no origin page (or it was hard-deleted).
|
||||
pageTitle?: string | null;
|
||||
}
|
||||
|
||||
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */
|
||||
|
||||
317
apps/client/src/features/ai-chat/utils/chat-markdown.test.ts
Normal file
317
apps/client/src/features/ai-chat/utils/chat-markdown.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Tests for the client-only Markdown export builder. The output embeds a live
|
||||
* `new Date().toISOString()` export timestamp; we never assert that value, only
|
||||
* the deterministic structure (headings, numbering, fenced blocks, totals).
|
||||
*
|
||||
* A pass-through translator keeps role/tool labels predictable so the
|
||||
* structural assertions are stable without an i18n runtime.
|
||||
*/
|
||||
const t = (key: string, values?: Record<string, unknown>): string => {
|
||||
if (values && typeof values.name === "string") {
|
||||
return key.replace("{{name}}", values.name);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
|
||||
return {
|
||||
id: partial.id ?? "id",
|
||||
role: partial.role ?? "user",
|
||||
content: partial.content ?? null,
|
||||
metadata: partial.metadata ?? null,
|
||||
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildChatMarkdown — structure", () => {
|
||||
it("emits the title heading, chat id and message count", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "My chat",
|
||||
chatId: "chat-123",
|
||||
rows: [],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("# My chat");
|
||||
expect(md).toContain("- Chat ID: `chat-123`");
|
||||
expect(md).toContain("- Messages: 0");
|
||||
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
|
||||
});
|
||||
|
||||
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
|
||||
expect(
|
||||
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
|
||||
).toContain("# Untitled chat");
|
||||
expect(
|
||||
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
|
||||
).toContain("# Untitled chat");
|
||||
});
|
||||
|
||||
it("numbers rows sequentially with role headings", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "hi" }),
|
||||
row({ role: "assistant", content: "hello" }),
|
||||
row({ role: "user", content: "again" }),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("## 3. You");
|
||||
// Heading numbering is strictly index+1, not e.g. role-relative.
|
||||
expect(md).not.toContain("## 0.");
|
||||
});
|
||||
|
||||
it("renders the per-row text content from `content` when no metadata.parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "plain body" })],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("plain body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — text parts", () => {
|
||||
it("skips empty / whitespace-only text parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "ignored-content",
|
||||
metadata: {
|
||||
parts: [
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "kept line" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any,
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("kept line");
|
||||
// Whitespace-only part contributed no block of its own.
|
||||
expect(md).not.toContain(" \n\n");
|
||||
// When metadata.parts exists, the plain `content` fallback is NOT used.
|
||||
expect(md).not.toContain("ignored-content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — tool parts", () => {
|
||||
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "p1" },
|
||||
output: { id: "p1", title: "Home" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
// Known tool name maps to its label key; raw name in backticks; done state.
|
||||
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
|
||||
expect(md).toContain("Input:");
|
||||
expect(md).toContain("Output:");
|
||||
// Fenced JSON blocks contain the stringified payloads.
|
||||
expect(md).toContain('"pageId": "p1"');
|
||||
expect(md).toContain('"title": "Home"');
|
||||
expect(md).toContain("```json");
|
||||
});
|
||||
|
||||
it("renders the generic label for an unknown tool and surfaces errorText", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-mysteryTool",
|
||||
state: "output-error",
|
||||
input: { a: 1 },
|
||||
errorText: "boom",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("**Tool: Ran tool mysteryTool** (`mysteryTool`) — error");
|
||||
expect(md).toContain("**Error:** boom");
|
||||
});
|
||||
|
||||
it("does not throw on a circular tool input (falls back to String)", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const circular: any = {};
|
||||
circular.self = circular;
|
||||
expect(() =>
|
||||
buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
input: circular,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — fence anti-breakout", () => {
|
||||
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
|
||||
// Tool input whose stringified string form contains a literal ``` run.
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
// A bare string passes through stringify() verbatim.
|
||||
input: "before ``` after",
|
||||
output: "x",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
// The fence around the 3-backtick content must use at least 4 backticks so
|
||||
// the embedded ``` run cannot terminate the block.
|
||||
expect(md).toContain("````json\nbefore ``` after\n````");
|
||||
// Robust anti-breakout check: the opening fence delimiter is strictly
|
||||
// longer than the longest backtick run inside the wrapped content. (A naive
|
||||
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
|
||||
// textually contains the 3-backtick substring.)
|
||||
const open = md.match(/(`{3,})json\nbefore/);
|
||||
expect(open).not.toBeNull();
|
||||
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
|
||||
});
|
||||
|
||||
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: "a ```` b",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("`````json\na ```` b\n`````");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — token totals", () => {
|
||||
it("prints the total-tokens line only when the summed usage is > 0", () => {
|
||||
const withTokens = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(withTokens).toContain("- Total tokens: 15");
|
||||
// Per-row usage footer too.
|
||||
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
});
|
||||
|
||||
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
|
||||
const noTokens = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "hi" }),
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(noTokens).not.toContain("- Total tokens:");
|
||||
});
|
||||
|
||||
it("uses totalTokens when present rather than summing in/out", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("- Total tokens: 99");
|
||||
});
|
||||
});
|
||||
@@ -6,48 +6,163 @@ import { describeChatError } from "./error-message";
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("describeChatError", () => {
|
||||
it('surfaces a provider "402: ..." stream error verbatim', () => {
|
||||
expect(describeChatError("402: Insufficient credits", t)).toBe(
|
||||
"402: Insufficient credits",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
|
||||
// A provider message mentioning the number 403 must be surfaced verbatim,
|
||||
// never folded into the "AI chat is disabled" gating message.
|
||||
const msg = "429: rate limited after 403 attempts";
|
||||
expect(describeChatError(msg, t)).toBe(msg);
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":403} body to the disabled message', () => {
|
||||
it('maps a {"statusCode":403} body to the disabled heading', () => {
|
||||
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"AI chat is disabled for this workspace.",
|
||||
);
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "AI chat is disabled",
|
||||
detail: "AI chat is disabled for this workspace.",
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":503} body to the not-configured message', () => {
|
||||
it('maps a {"statusCode":503} body to the not-configured heading', () => {
|
||||
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||
expect(describeChatError(body, t)).toBe(
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "AI provider not configured",
|
||||
detail:
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
).toBe("Lost connection to the AI provider");
|
||||
});
|
||||
|
||||
it('classifies "fetch failed" as a lost-connection error', () => {
|
||||
expect(describeChatError("fetch failed", t).title).toBe(
|
||||
"Lost connection to the AI provider",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "An error occurred."', () => {
|
||||
expect(describeChatError("An error occurred.", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
it("classifies ETIMEDOUT as a timeout", () => {
|
||||
expect(describeChatError("ETIMEDOUT", t).title).toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic message for "Internal server error"', () => {
|
||||
expect(describeChatError("Internal server error", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
it('classifies "504: Gateway Timeout" as a timeout', () => {
|
||||
expect(describeChatError("504: Gateway Timeout", t).title).toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic message for empty input", () => {
|
||||
expect(describeChatError("", t)).toBe(
|
||||
"The AI agent could not respond. Please try again.",
|
||||
it('classifies "429: Too Many Requests" as rate limited', () => {
|
||||
expect(describeChatError("429: Too Many Requests", t).title).toBe(
|
||||
"Rate limited by the AI provider",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT misclassify a body that merely contains "403" as disabled', () => {
|
||||
// Regression intent: a provider message mentioning the number 403 must never
|
||||
// be folded into the "AI chat is disabled" gating heading. Here the 429
|
||||
// signature wins (checked before any bare-403 logic exists), so it maps to
|
||||
// the rate-limit category instead.
|
||||
const view = describeChatError("429: rate limited after 403 attempts", t);
|
||||
expect(view.title).toBe("Rate limited by the AI provider");
|
||||
expect(view.title).not.toBe("AI chat is disabled");
|
||||
});
|
||||
|
||||
it("classifies a context-window overflow as too-large", () => {
|
||||
expect(
|
||||
describeChatError(
|
||||
"This model's maximum context length is 128000 tokens",
|
||||
t,
|
||||
).title,
|
||||
).toBe("The conversation is too large");
|
||||
});
|
||||
|
||||
it('classifies "402: Insufficient credits" as quota exceeded', () => {
|
||||
expect(describeChatError("402: Insufficient credits", t).title).toBe(
|
||||
"AI provider quota exceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies "401: Unauthorized" as an auth failure', () => {
|
||||
expect(describeChatError("401: Unauthorized", t).title).toBe(
|
||||
"AI provider authentication failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic heading + detail for empty input", () => {
|
||||
expect(describeChatError("", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "The AI agent could not respond. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the generic heading + detail for "An error occurred."', () => {
|
||||
expect(describeChatError("An error occurred.", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "The AI agent could not respond. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the generic heading + detail for "Internal server error"', () => {
|
||||
expect(describeChatError("Internal server error", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "The AI agent could not respond. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
|
||||
expect(describeChatError("418: I'm a teapot", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "418: I'm a teapot",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
|
||||
// The real status (500) leads the string; the "401" lives in the snippet and
|
||||
// must not trigger the auth category. The verbatim provider text is surfaced.
|
||||
const body =
|
||||
"500: Server error | response body: model gpt-4o-401-preview not found";
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: body,
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT treat a passing mention of billing as a quota error", () => {
|
||||
// "billing" is no longer a quota signature; the verbatim text is surfaced.
|
||||
const body = "502: Bad Gateway | response body: see our billing page";
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: body,
|
||||
});
|
||||
});
|
||||
|
||||
it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
|
||||
const view = describeChatError("429: rate limited after 403 attempts", t);
|
||||
expect(view.title).toBe("Rate limited by the AI provider");
|
||||
expect(view.title).not.toBe("AI chat is disabled");
|
||||
});
|
||||
|
||||
it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
|
||||
// The textual rate-limit phrase lives only in the response-body snippet, and
|
||||
// the leading 500 is not a classified numeric code, so it must not leak into
|
||||
// the rate-limit category. (The detail itself falls back to the generic line
|
||||
// here because the leading message contains "Internal Server Error", which
|
||||
// providerDetail suppresses — the title is what this case pins.)
|
||||
const body =
|
||||
"500: Internal Server Error | response body: rate limit info: see our docs";
|
||||
expect(describeChatError(body, t).title).toBe("Something went wrong");
|
||||
expect(describeChatError(body, t).title).not.toBe(
|
||||
"Rate limited by the AI provider",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
|
||||
// The 503 leads the string but is not a classified numeric code, and the
|
||||
// ETIMEDOUT signature appears only in the body, so it must not leak into the
|
||||
// timeout category; the verbatim text is surfaced under the generic heading.
|
||||
const body = "503: x | response body: ETIMEDOUT appears in this log line";
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: body,
|
||||
});
|
||||
expect(describeChatError(body, t).title).not.toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,174 @@
|
||||
/**
|
||||
* Turn an AI chat error message into a friendly inline string. Used for BOTH the
|
||||
* live `useChat().error` (its `.message`) and a persisted assistant error stored
|
||||
* in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
|
||||
* body carrying a numeric "statusCode" field (matched precisely, not by bare
|
||||
* substring, so a provider message that merely contains "403"/"503"/"disabled" is
|
||||
* never misclassified). Everything else — provider stream failures forwarded as
|
||||
* "<status>: <message>" (402 credits, 429 rate limit, ...) — is surfaced verbatim.
|
||||
* A classified AI chat error: a short bold heading naming the cause category and
|
||||
* a one-line human-readable detail / next step. Both strings are already passed
|
||||
* through `t`, so callers render them directly.
|
||||
*/
|
||||
export interface ChatErrorView {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an AI chat error message into a friendly heading + detail. Used for BOTH
|
||||
* the live `useChat().error` (its `.message`) and a persisted assistant error in
|
||||
* `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
|
||||
* body carrying a numeric "statusCode" (matched precisely, not by bare substring,
|
||||
* so a provider message that merely contains "403"/"503" is never misclassified).
|
||||
* Known provider/network failures (connection reset, timeout, rate limit, context
|
||||
* overflow, quota, auth) are mapped to a clear category; anything else falls back
|
||||
* to the raw provider detail (or a generic line) under the original heading.
|
||||
*/
|
||||
export function describeChatError(
|
||||
message: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
): ChatErrorView {
|
||||
const msg = message ?? "";
|
||||
|
||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||
return t("AI chat is disabled for this workspace.");
|
||||
return {
|
||||
title: t("AI chat is disabled"),
|
||||
detail: t("AI chat is disabled for this workspace."),
|
||||
};
|
||||
}
|
||||
if (/"statusCode"\s*:\s*503\b/.test(msg)) {
|
||||
return t("The AI provider is not configured. Ask an administrator to set it up.");
|
||||
return {
|
||||
title: t("AI provider not configured"),
|
||||
detail: t(
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return providerDetail(msg) ?? t("The AI agent could not respond. Please try again.");
|
||||
|
||||
const category = classifyProviderError(msg);
|
||||
if (category) {
|
||||
return { title: t(category.title), detail: t(category.detail) };
|
||||
}
|
||||
|
||||
// Unknown error: surface the raw provider detail when it is informative,
|
||||
// otherwise a generic line. The heading stays the original generic one.
|
||||
return {
|
||||
title: t("Something went wrong"),
|
||||
detail:
|
||||
providerDetail(msg) ??
|
||||
t("The AI agent could not respond. Please try again."),
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorCategory {
|
||||
/** English key for the bold heading. */
|
||||
title: string;
|
||||
/** English key for the one-line explanation. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider/network error string to a friendly category. Order matters: the
|
||||
* most specific signatures are tested first. Returns null when nothing matches,
|
||||
* so the caller can fall back to the raw provider text. The English keys returned
|
||||
* here are passed through `t` by the caller.
|
||||
*
|
||||
* The server formats provider errors as "<statusCode>: <message> | response body:
|
||||
* <snippet>" (see server-side describeProviderError), so the HTTP status is always
|
||||
* the LEADING token. We match a numeric code only when it leads the string, so a
|
||||
* number inside the response-body snippet never triggers a category; textual
|
||||
* signatures are matched only against the leading message (before the response
|
||||
* body), so a phrase inside the snippet never triggers a category either.
|
||||
*/
|
||||
function classifyProviderError(msg: string): ErrorCategory | null {
|
||||
const code = /^\s*(\d{3})\b/.exec(msg)?.[1] ?? "";
|
||||
// The server appends "| response body: <snippet>" to provider errors; match
|
||||
// textual signatures only against the leading provider message so a phrase
|
||||
// inside the response-body snippet never triggers a wrong category. The numeric
|
||||
// status code is read from the start of the full string above.
|
||||
const head = msg.split(/\|\s*response body:/i)[0];
|
||||
|
||||
// The browser's OWN fetch-failure messages — WebKit/Safari "Load failed",
|
||||
// Chrome "Failed to fetch", Firefox "NetworkError when attempting to fetch
|
||||
// resource". These mean the streaming connection between the browser and THIS
|
||||
// server (/api/ai-chat/stream) dropped mid-answer: the browser<->server link,
|
||||
// NOT the server<->AI-provider link, so do NOT blame the provider. A failed
|
||||
// fetch carries no status/body, so the browser has no further detail — the real
|
||||
// cause is in the server logs (the stream controller logs the disconnect) and
|
||||
// the reverse proxy (often buffering or timing out the long-lived SSE).
|
||||
if (/failed to fetch|load failed|networkerror/i.test(head)) {
|
||||
return {
|
||||
title: "Lost connection to the server",
|
||||
detail:
|
||||
"The streaming connection to the server dropped before the answer finished. The browser reports no further detail — the cause is in the server logs and the reverse proxy (often buffering or timing out the stream). Reload and try again.",
|
||||
};
|
||||
}
|
||||
// Connection dropped / provider unreachable. ECONNRESET is the production case:
|
||||
// the LLM socket was reset mid-stream (surfaced by the server's error
|
||||
// formatter). "terminated" is scoped to a connection/stream context so it does
|
||||
// not match benign "... was terminated" messages.
|
||||
if (
|
||||
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
title: "Lost connection to the AI provider",
|
||||
detail:
|
||||
"The connection to the AI provider dropped before the answer finished. Please try again.",
|
||||
};
|
||||
}
|
||||
// Timeout.
|
||||
if (
|
||||
code === "504" ||
|
||||
code === "408" ||
|
||||
/ETIMEDOUT|timed[\s-]?out|\btimeout\b/i.test(head)
|
||||
) {
|
||||
return {
|
||||
title: "The AI provider timed out",
|
||||
detail: "The AI provider took too long to respond. Please try again.",
|
||||
};
|
||||
}
|
||||
// Rate limited.
|
||||
if (code === "429" || /rate[\s-]?limit|too many requests/i.test(head)) {
|
||||
return {
|
||||
title: "Rate limited by the AI provider",
|
||||
detail:
|
||||
"The AI provider is rate-limiting requests. Wait a moment and try again.",
|
||||
};
|
||||
}
|
||||
// Context window / token budget exceeded.
|
||||
if (
|
||||
code === "413" ||
|
||||
/context[\s_-]?(?:length|window)|maximum context|context_length_exceeded|too many tokens|maximum[^.]*tokens|reduce the length/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
title: "The conversation is too large",
|
||||
detail:
|
||||
"The document and search results exceeded the model's context window. Start a new chat or narrow the request.",
|
||||
};
|
||||
}
|
||||
// Out of credits / quota / payment required.
|
||||
if (
|
||||
code === "402" ||
|
||||
/payment required|insufficient (?:credits|quota|funds|balance)|out of credits|quota (?:exceeded|exhausted)/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
title: "AI provider quota exceeded",
|
||||
detail:
|
||||
"The AI provider rejected the request because of credits or quota. Check the provider account.",
|
||||
};
|
||||
}
|
||||
// Authentication / bad API key.
|
||||
if (
|
||||
code === "401" ||
|
||||
/\bunauthorized\b|invalid api key|user not found|\bauthentication\b/i.test(head)
|
||||
) {
|
||||
return {
|
||||
title: "AI provider authentication failed",
|
||||
detail:
|
||||
"The AI provider rejected the credentials. Ask an administrator to check the API key.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
107
apps/client/src/features/ai-chat/utils/queue-helpers.test.ts
Normal file
107
apps/client/src/features/ai-chat/utils/queue-helpers.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enqueueMessage,
|
||||
dequeue,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "./queue-helpers";
|
||||
|
||||
describe("enqueueMessage", () => {
|
||||
it("appends a message to the end of the queue", () => {
|
||||
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
||||
const next = enqueueMessage(queue, { id: "b", text: "second" });
|
||||
expect(next).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
||||
enqueueMessage(queue, { id: "b", text: "second" });
|
||||
expect(queue).toEqual([{ id: "a", text: "first" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dequeue", () => {
|
||||
it("returns {head:null, rest:[]} for an empty queue", () => {
|
||||
expect(dequeue([])).toEqual({ head: null, rest: [] });
|
||||
});
|
||||
|
||||
it("returns the first item as head and the remainder as rest", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
const { head, rest } = dequeue(queue);
|
||||
expect(head).toEqual({ id: "a", text: "first" });
|
||||
expect(rest).toEqual([
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
dequeue(queue);
|
||||
expect(queue).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeQueuedById", () => {
|
||||
it("removes the matching id and leaves the others", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
const next = removeQueuedById(queue, "b");
|
||||
expect(next).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "c", text: "third" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an equivalent list when the id is not present", () => {
|
||||
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
||||
expect(removeQueuedById(queue, "missing")).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
removeQueuedById(queue, "a");
|
||||
expect(queue).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIFO order", () => {
|
||||
it("preserves order across enqueue -> dequeue", () => {
|
||||
let queue: QueuedMessage[] = [];
|
||||
queue = enqueueMessage(queue, { id: "1", text: "one" });
|
||||
queue = enqueueMessage(queue, { id: "2", text: "two" });
|
||||
queue = enqueueMessage(queue, { id: "3", text: "three" });
|
||||
|
||||
const order: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const { head, rest } = dequeue(queue);
|
||||
if (head) order.push(head.text);
|
||||
queue = rest;
|
||||
}
|
||||
expect(order).toEqual(["one", "two", "three"]);
|
||||
});
|
||||
});
|
||||
34
apps/client/src/features/ai-chat/utils/queue-helpers.ts
Normal file
34
apps/client/src/features/ai-chat/utils/queue-helpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Pure FIFO helpers for the AI-chat "send while the agent is busy" queue.
|
||||
// Kept side-effect free so they can be unit-tested without React.
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Append a message to the end of the queue (returns a new array). */
|
||||
export function enqueueMessage(
|
||||
queue: QueuedMessage[],
|
||||
message: QueuedMessage,
|
||||
): QueuedMessage[] {
|
||||
return [...queue, message];
|
||||
}
|
||||
|
||||
/** Split the queue into its first item (`head`) and the remainder (`rest`).
|
||||
* `head` is null when the queue is empty. Does not mutate the input. */
|
||||
export function dequeue(queue: QueuedMessage[]): {
|
||||
head: QueuedMessage | null;
|
||||
rest: QueuedMessage[];
|
||||
} {
|
||||
if (queue.length === 0) return { head: null, rest: [] };
|
||||
const [head, ...rest] = queue;
|
||||
return { head, rest };
|
||||
}
|
||||
|
||||
/** Remove the queued message with the given id (returns a new array). */
|
||||
export function removeQueuedById(
|
||||
queue: QueuedMessage[],
|
||||
id: string,
|
||||
): QueuedMessage[] {
|
||||
return queue.filter((m) => m.id !== id);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color";
|
||||
|
||||
describe("roleCardColor", () => {
|
||||
it("has a 10-color palette", () => {
|
||||
expect(ROLE_CARD_PALETTE).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("maps index 0 to the first palette color (blue)", () => {
|
||||
expect(roleCardColor(0)).toBe("blue");
|
||||
expect(roleCardColor(1)).toBe("grape");
|
||||
});
|
||||
|
||||
it("wraps around at the end of the palette", () => {
|
||||
expect(roleCardColor(10)).toBe("blue");
|
||||
expect(roleCardColor(11)).toBe("grape");
|
||||
});
|
||||
|
||||
it("is safe for negative indices", () => {
|
||||
expect(roleCardColor(-1)).toBe("violet");
|
||||
expect(roleCardColor(-10)).toBe("blue");
|
||||
});
|
||||
});
|
||||
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Fixed Mantine color palette for the new-chat role cards. Cards cycle through
|
||||
// these names by index; the colors are applied via theme-aware Mantine CSS vars
|
||||
// (`--mantine-color-<name>-light` etc.) so they are correct in both themes.
|
||||
// Universal assistant uses neutral `gray` separately (not part of this palette).
|
||||
export const ROLE_CARD_PALETTE = [
|
||||
"blue",
|
||||
"grape",
|
||||
"teal",
|
||||
"orange",
|
||||
"pink",
|
||||
"cyan",
|
||||
"lime",
|
||||
"indigo",
|
||||
"red",
|
||||
"violet",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Pick a palette color name for a role card by its index. Cycles through the
|
||||
* palette and is safe for negative indices.
|
||||
*/
|
||||
export function roleCardColor(index: number): string {
|
||||
const len = ROLE_CARD_PALETTE.length;
|
||||
return ROLE_CARD_PALETTE[((index % len) + len) % len];
|
||||
}
|
||||
@@ -126,7 +126,7 @@ function CommentListItem({
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
@@ -155,7 +155,7 @@ function CommentListItem({
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
<Text size="xs" fw={500} c="dimmed" lh={1.1}>
|
||||
{createdAtAgo}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Badge,
|
||||
Text,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||
import {
|
||||
@@ -26,12 +27,16 @@ import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
|
||||
import { IconArrowUp, IconMessageOff, IconX } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
|
||||
function CommentListWithTabs() {
|
||||
interface CommentListWithTabsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
@@ -194,28 +199,50 @@ function CommentListWithTabs() {
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Tabs.List justify="center">
|
||||
<Tabs.Tab
|
||||
value="open"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{activeComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Open")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="resolved"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{resolvedComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Resolved")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
{/* Header row: full-width centered tab list with the close button overlaid on the right. */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Tabs.List justify="center">
|
||||
<Tabs.Tab
|
||||
value="open"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{activeComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Open")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="resolved"
|
||||
leftSection={
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{resolvedComments.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{t("Resolved")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
{onClose && (
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
aria-label={t("Close")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "50%",
|
||||
// Nudge the close button slightly up to align with the tab labels.
|
||||
transform: "translateY(calc(-50% - 4px))",
|
||||
}}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
style={{ flex: "1 1 auto" }}
|
||||
@@ -365,7 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
flex: "0 0 auto",
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
paddingTop: "var(--mantine-spacing-sm)",
|
||||
paddingBottom: 25,
|
||||
paddingBottom: 10,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
@@ -374,7 +401,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
size="sm"
|
||||
avatarUrl={currentUser?.user?.avatarUrl}
|
||||
name={currentUser?.user?.name}
|
||||
style={{ flexShrink: 0, marginTop: 10 }}
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CommentEditor
|
||||
@@ -396,7 +423,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
onClick={handleSave}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
loading={isLoading}
|
||||
style={{ position: "absolute", right: 8, bottom: 30 }}
|
||||
style={{ position: "absolute", right: 8, bottom: 15 }}
|
||||
>
|
||||
<IconArrowUp size={16} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 2px;
|
||||
/* Breathing room below the comment header (author + timestamp) so the
|
||||
quote does not stick to the timestamp when it is the first block. */
|
||||
margin-top: 8px;
|
||||
/* Align the quote's left bar with the comment body text left edge
|
||||
(the comment editor insets its text by 6px). */
|
||||
margin-left: 6px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 6px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.recordingWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Translucent red halo that sits behind the stop button and scales with the
|
||||
live microphone level (scale set inline from audioLevel). Radius follows the
|
||||
ActionIcon's own radius so the halo matches the button's rounded-square
|
||||
outline instead of being a circle. */
|
||||
.pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: var(--mantine-color-red-5);
|
||||
opacity: 0.35;
|
||||
transform-origin: center;
|
||||
transform: scale(1);
|
||||
transition: transform 90ms linear;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { ActionIcon, Loader, Tooltip } from "@mantine/core";
|
||||
import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
||||
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
||||
import classes from "./mic-button.module.css";
|
||||
|
||||
interface MicButtonProps {
|
||||
onText: (text: string) => void;
|
||||
@@ -11,6 +13,14 @@ interface MicButtonProps {
|
||||
// Mantine ActionIcon size token; "lg" matches the chat composer, "md" the
|
||||
// editor toolbar.
|
||||
size?: "md" | "lg";
|
||||
// Optional Mantine color override for the idle/transcribing states (the
|
||||
// recording state stays red). Defaults to the theme primary when omitted.
|
||||
color?: string;
|
||||
// Optional explicit glyph size override; defaults to the size-token value.
|
||||
iconSize?: number;
|
||||
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||
// text progressively as the user pauses; otherwise use the batch controller.
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,35 +34,64 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
onStart,
|
||||
disabled,
|
||||
size = "lg",
|
||||
color,
|
||||
iconSize,
|
||||
streaming = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { status, start, stop } = useDictation({ onText, onStart });
|
||||
const iconSize = size === "lg" ? 18 : 16;
|
||||
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
||||
// active is a render-time choice, but both must be invoked every render. This
|
||||
// is safe because both controllers are inert until start() is called — neither
|
||||
// opens the mic on mount — so the unused one costs nothing.
|
||||
const batchCtl = useDictation({ onText, onStart });
|
||||
const streamingCtl = useStreamingDictation({ onText, onStart });
|
||||
const ctl = streaming ? streamingCtl : batchCtl;
|
||||
const { status, start, stop, audioLevel } = ctl;
|
||||
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
||||
|
||||
if (status === "recording") {
|
||||
// Live volume-driven halo: the scale follows the current mic level.
|
||||
const haloScale = 1 + Math.min(1, audioLevel) * 0.9;
|
||||
return (
|
||||
<Tooltip label={t("Stop recording")} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={stop}
|
||||
aria-label={t("Stop recording")}
|
||||
>
|
||||
<IconPlayerStopFilled size={iconSize} />
|
||||
</ActionIcon>
|
||||
<span className={classes.recordingWrap}>
|
||||
<span
|
||||
className={classes.pulse}
|
||||
style={{ transform: `scale(${haloScale})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={stop}
|
||||
aria-label={t("Stop recording")}
|
||||
style={{ position: "relative", zIndex: 1 }}
|
||||
>
|
||||
<IconPlayerStopFilled size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "transcribing" || status === "error") {
|
||||
if (
|
||||
status === "loading" ||
|
||||
status === "transcribing" ||
|
||||
status === "error"
|
||||
) {
|
||||
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||
// a confusing second click can't fire while the model loads.
|
||||
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
||||
return (
|
||||
<Tooltip label={t("Transcribing…")} withArrow>
|
||||
<Tooltip label={label} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
disabled
|
||||
aria-label={t("Transcribing…")}
|
||||
aria-label={label}
|
||||
>
|
||||
<Loader size="xs" />
|
||||
</ActionIcon>
|
||||
@@ -65,11 +104,12 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
onClick={() => void start()}
|
||||
disabled={disabled}
|
||||
aria-label={t("Start dictation")}
|
||||
>
|
||||
<IconMicrophone size={iconSize} />
|
||||
<IconMicrophone size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,15 @@ import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||
|
||||
export type DictationStatus = "idle" | "recording" | "transcribing" | "error";
|
||||
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
||||
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||
// hook and the mic button can show immediate feedback during that load.
|
||||
export type DictationStatus =
|
||||
| "idle"
|
||||
| "recording"
|
||||
| "transcribing"
|
||||
| "error"
|
||||
| "loading";
|
||||
|
||||
interface UseDictationOptions {
|
||||
onText: (text: string) => void;
|
||||
@@ -16,6 +24,8 @@ interface UseDictationResult {
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||
audioLevel: number;
|
||||
}
|
||||
|
||||
// Candidate container/codec combinations in preference order. The first one the
|
||||
@@ -56,6 +66,7 @@ export function useDictation(
|
||||
): UseDictationResult {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
|
||||
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
||||
// calls the current handlers without re-creating the recorder.
|
||||
@@ -70,6 +81,15 @@ export function useDictation(
|
||||
const canceledRef = useRef(false);
|
||||
const startingRef = useRef(false);
|
||||
|
||||
// Web Audio metering: derives a live input level from the captured stream.
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
// Exponentially smoothed level, and the last value pushed to React state.
|
||||
const smoothedLevelRef = useRef(0);
|
||||
const emittedLevelRef = useRef(0);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -82,6 +102,91 @@ export function useDictation(
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Tear the audio meter down fully. Safe to call multiple times and on any exit
|
||||
// path; defensive try/catch so cleanup never throws.
|
||||
const stopMeter = useCallback(() => {
|
||||
// Cancel the rAF first so getByteTimeDomainData can't run on a closed context.
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
try {
|
||||
sourceRef.current?.disconnect();
|
||||
sourceRef.current = null;
|
||||
analyserRef.current = null;
|
||||
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
|
||||
void audioContextRef.current.close();
|
||||
}
|
||||
audioContextRef.current = null;
|
||||
} catch (err) {
|
||||
// Cleanup must never throw; just log for diagnosis.
|
||||
console.warn("[dictation] audio meter teardown failed", err);
|
||||
}
|
||||
smoothedLevelRef.current = 0;
|
||||
emittedLevelRef.current = 0;
|
||||
setAudioLevel(0);
|
||||
}, []);
|
||||
|
||||
// Set up Web Audio metering on the already-captured stream. Reuses the existing
|
||||
// MediaStream — never requests a second mic. Failure here must not break
|
||||
// recording: on any error we warn and return, leaving the recorder running.
|
||||
const startMeter = useCallback((stream: MediaStream) => {
|
||||
try {
|
||||
const Ctor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
|
||||
const audioContext = new Ctor();
|
||||
// Some browsers start the context suspended; resume so the loop produces
|
||||
// data. Swallow rejection (e.g. context already closed by a fast
|
||||
// start/stop race) to avoid an unhandled promise rejection.
|
||||
audioContext.resume().catch(() => {});
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
// Connect ONLY to the analyser — never to destination, which would echo the
|
||||
// mic back to the speakers.
|
||||
source.connect(analyser);
|
||||
|
||||
audioContextRef.current = audioContext;
|
||||
sourceRef.current = source;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// Allocate the time-domain buffer once and reuse it on every tick.
|
||||
const data = new Uint8Array(analyser.fftSize);
|
||||
|
||||
const tick = () => {
|
||||
const a = analyserRef.current;
|
||||
if (!a) return;
|
||||
a.getByteTimeDomainData(data);
|
||||
// RMS of the centered waveform (samples are 0..255, midpoint 128).
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = (data[i] - 128) / 128;
|
||||
sumSquares += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / data.length);
|
||||
// Boost + clamp so normal speech maps to a visible 0..1 range.
|
||||
const level = Math.min(1, rms * 3);
|
||||
// Exponential smoothing to avoid jitter.
|
||||
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
|
||||
// Throttle React re-renders: only push when it changed meaningfully.
|
||||
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
|
||||
emittedLevelRef.current = smoothedLevelRef.current;
|
||||
setAudioLevel(smoothedLevelRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} catch (err) {
|
||||
// Web Audio unavailable or threw: recording continues without the meter.
|
||||
console.warn("[dictation] audio meter unavailable", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (): Promise<void> => {
|
||||
// Synchronous live guard: status is stale between renders, so also block on
|
||||
// refs to prevent a double-click from opening two MediaStreams (the first
|
||||
@@ -163,8 +268,9 @@ export function useDictation(
|
||||
const recordedMime = recorder.mimeType || mimeType || "audio/webm";
|
||||
const wasCanceled = canceledRef.current;
|
||||
|
||||
// Stop the mic tracks regardless of how we got here.
|
||||
// Stop the mic tracks and the audio meter regardless of how we got here.
|
||||
stopTracks();
|
||||
stopMeter();
|
||||
recorderRef.current = null;
|
||||
|
||||
if (wasCanceled) {
|
||||
@@ -237,34 +343,49 @@ export function useDictation(
|
||||
// Recording has truly begun; release the synchronous start guard.
|
||||
startingRef.current = false;
|
||||
|
||||
// Start the live audio meter on the stream we already acquired.
|
||||
startMeter(stream);
|
||||
|
||||
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (recorderRef.current?.state === "recording") {
|
||||
recorderRef.current.stop();
|
||||
}
|
||||
}, maxDurationMs);
|
||||
}, [status, t, clearTimer, stopTracks]);
|
||||
}, [status, t, clearTimer, stopTracks, startMeter, stopMeter]);
|
||||
|
||||
const stop = useCallback((): void => {
|
||||
clearTimer();
|
||||
const recorder = recorderRef.current;
|
||||
if (recorder && recorder.state === "recording") {
|
||||
// Normal path: onstop tears down tracks + meter and runs transcription.
|
||||
recorder.stop();
|
||||
} else {
|
||||
// No live recorder (e.g. the track ended on its own): tear everything
|
||||
// down directly so the meter/AudioContext and stream don't leak, and
|
||||
// recover the UI to idle.
|
||||
stopTracks();
|
||||
stopMeter();
|
||||
recorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setStatus("idle");
|
||||
}
|
||||
}, [clearTimer]);
|
||||
}, [clearTimer, stopTracks, stopMeter]);
|
||||
|
||||
const cancel = useCallback((): void => {
|
||||
clearTimer();
|
||||
canceledRef.current = true;
|
||||
const recorder = recorderRef.current;
|
||||
if (recorder && recorder.state === "recording") {
|
||||
// onstop sees canceledRef and skips transcription; it also stops tracks.
|
||||
// onstop sees canceledRef and skips transcription; it also stops tracks
|
||||
// and the meter.
|
||||
recorder.stop();
|
||||
} else {
|
||||
stopTracks();
|
||||
stopMeter();
|
||||
}
|
||||
setStatus("idle");
|
||||
}, [clearTimer, stopTracks]);
|
||||
}, [clearTimer, stopTracks, stopMeter]);
|
||||
|
||||
// Clean up on unmount: stop any live recorder/stream and clear the timers.
|
||||
useEffect(() => {
|
||||
@@ -280,8 +401,9 @@ export function useDictation(
|
||||
recorder.stop();
|
||||
}
|
||||
stopTracks();
|
||||
stopMeter();
|
||||
};
|
||||
}, [clearTimer, stopTracks]);
|
||||
}, [clearTimer, stopTracks, stopMeter]);
|
||||
|
||||
return { status, start, stop, cancel };
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
||||
|
||||
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
||||
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||
// only fetched when the user actually begins dictation.
|
||||
type MicVADInstance = {
|
||||
start: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
destroy: () => Promise<void>;
|
||||
};
|
||||
|
||||
interface UseStreamingDictationOptions {
|
||||
onText: (text: string) => void;
|
||||
onStart?: () => void;
|
||||
maxDurationMs?: number;
|
||||
}
|
||||
|
||||
interface UseStreamingDictationResult {
|
||||
status: DictationStatus;
|
||||
start: () => Promise<void>;
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||
audioLevel: number;
|
||||
}
|
||||
|
||||
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
||||
const VAD_SAMPLE_RATE = 16000;
|
||||
|
||||
// Asset paths for the VAD worklet/Silero model and the onnxruntime-web WASM
|
||||
// binaries. vad-web 0.0.30's default asset path is "./" (relative to the current
|
||||
// page URL), NOT a CDN — in this SPA that request hits the client-side catch-all
|
||||
// route and returns index.html (text/html), so the onnxruntime ESM/wasm backend
|
||||
// fails to initialize. We instead self-host the four needed files (the vad-web
|
||||
// worklet + `silero_vad_v5.onnx` model and the onnxruntime-web `*.jsep.mjs`/
|
||||
// `*.jsep.wasm`) under `apps/client/public/vad/` — populated by
|
||||
// `scripts/copy-vad-assets.mjs`, which runs before `dev`/`build` — and point both
|
||||
// paths at the fixed absolute "/vad/".
|
||||
const VAD_BASE_ASSET_PATH: string | undefined = "/vad/";
|
||||
const VAD_ONNX_WASM_BASE_PATH: string | undefined = "/vad/";
|
||||
|
||||
/**
|
||||
* Streaming variant of useDictation. Detects speech with a real (Silero) VAD and,
|
||||
* each time the speaker pauses, cuts that speech segment and POSTs it to the same
|
||||
* batch transcription endpoint, so text appears progressively as the user speaks.
|
||||
*
|
||||
* Returns the SAME shape as useDictation ({ status, start, stop, cancel,
|
||||
* audioLevel }) so MicButton can use either interchangeably. Refs hold the live
|
||||
* VAD instance / counters / timer so component re-renders never lose them, and
|
||||
* every exit path destroys the VAD and stops the MediaStream.
|
||||
*/
|
||||
export function useStreamingDictation(
|
||||
options: UseStreamingDictationOptions,
|
||||
): UseStreamingDictationResult {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
|
||||
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
||||
// current handlers without re-creating the VAD.
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const vadRef = useRef<MicVADInstance | null>(null);
|
||||
// AudioContext we create+resume inside the click gesture and inject into
|
||||
// MicVAD (see start()). We own it; MicVAD does not close an injected context.
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const canceledRef = useRef(false);
|
||||
const startingRef = useRef(false);
|
||||
// True while a recording session is active (VAD listening). Used to ignore late
|
||||
// VAD callbacks that fire after stop()/cancel().
|
||||
const activeRef = useRef(false);
|
||||
|
||||
// In-order emission: each segment gets a monotonically increasing seq when its
|
||||
// speech ends; completed transcriptions are buffered by seq and flushed in
|
||||
// order so out-of-order HTTP responses can't scramble the text.
|
||||
const nextSeqRef = useRef(0);
|
||||
const nextEmitSeqRef = useRef(0);
|
||||
const resultsRef = useRef<Map<number, string>>(new Map());
|
||||
// Number of transcription requests still in flight.
|
||||
const inFlightRef = useRef(0);
|
||||
// Session epoch: bumped when a NEW session starts (start) or everything is
|
||||
// hard-discarded (cancel). Each in-flight request captures the epoch at send
|
||||
// time; if the epoch has since changed, the request is stale and its
|
||||
// then/catch/finally are skipped so old text can't leak into a new session and
|
||||
// the in-flight counter can't be driven negative across sessions.
|
||||
const epochRef = useRef(0);
|
||||
|
||||
// Exponentially smoothed speech level, and the last value pushed to React state.
|
||||
const smoothedLevelRef = useRef(0);
|
||||
const emittedLevelRef = useRef(0);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset the level meter back to zero (refs + React state).
|
||||
const resetLevel = useCallback(() => {
|
||||
smoothedLevelRef.current = 0;
|
||||
emittedLevelRef.current = 0;
|
||||
setAudioLevel(0);
|
||||
}, []);
|
||||
|
||||
// Destroy the live VAD instance (which also releases the mic stream and audio
|
||||
// context it created). Safe to call multiple times and on any exit path;
|
||||
// defensive try/catch so teardown never throws.
|
||||
const destroyVad = useCallback(() => {
|
||||
const vad = vadRef.current;
|
||||
vadRef.current = null;
|
||||
if (vad) {
|
||||
try {
|
||||
// destroy() pauses + tears down the worklet/stream/context internally.
|
||||
// It returns a promise, so attach a .catch too: the surrounding
|
||||
// try/catch only catches synchronous throws, and a rejected destroy()
|
||||
// would otherwise surface as an unhandled rejection.
|
||||
void vad
|
||||
.destroy()
|
||||
.catch((err) =>
|
||||
console.warn("[dictation] VAD teardown failed", err),
|
||||
);
|
||||
} catch (err) {
|
||||
// Cleanup must never throw; just log for diagnosis.
|
||||
console.warn("[dictation] VAD teardown failed", err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Decide the status once recording has ended: stay "transcribing" while
|
||||
// requests are in flight, otherwise return to "idle".
|
||||
const settleAfterStop = useCallback(() => {
|
||||
if (inFlightRef.current > 0) {
|
||||
setStatus("transcribing");
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Drain the in-order result buffer: while the next expected seq is ready, trim
|
||||
// it, emit it if non-empty, and advance. Called after every resolved request.
|
||||
const drainResults = useCallback(() => {
|
||||
const results = resultsRef.current;
|
||||
while (results.has(nextEmitSeqRef.current)) {
|
||||
const text = results.get(nextEmitSeqRef.current)!;
|
||||
results.delete(nextEmitSeqRef.current);
|
||||
nextEmitSeqRef.current += 1;
|
||||
const trimmed = text.trim();
|
||||
// Whisper often returns a leading space; emit the trimmed value.
|
||||
if (trimmed.length > 0) optionsRef.current.onText(trimmed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Map a transcription error to a user-facing message, mirroring the batch hook.
|
||||
const transcriptionErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMsg = resp?.data?.message;
|
||||
if (serverMsg && serverMsg.trim().length > 0) {
|
||||
// The server already explains the cause (e.g. provider 404, bad format,
|
||||
// STT not configured) — show it verbatim.
|
||||
return serverMsg;
|
||||
}
|
||||
if (resp?.status === 503 || resp?.status === 403) {
|
||||
return t("Voice dictation is not configured");
|
||||
}
|
||||
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
||||
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
||||
// the session: log + one notification, then advance past that seq so later
|
||||
// segments still flush.
|
||||
const handleSegment = useCallback(
|
||||
(audio: Float32Array) => {
|
||||
const seq = nextSeqRef.current;
|
||||
nextSeqRef.current += 1;
|
||||
inFlightRef.current += 1;
|
||||
// Capture the epoch for this request synchronously at send time.
|
||||
const epoch = epochRef.current;
|
||||
|
||||
const wavBlob = encodeWavPcm16(audio, VAD_SAMPLE_RATE);
|
||||
void transcribeAudio(wavBlob, "speech.wav")
|
||||
.then((text) => {
|
||||
// Stale request from a previous session: drop it without touching any
|
||||
// current-session state.
|
||||
if (epoch !== epochRef.current) return;
|
||||
// Defend against a non-string server value before drainResults trims.
|
||||
resultsRef.current.set(seq, typeof text === "string" ? text : "");
|
||||
drainResults();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (epoch !== epochRef.current) return;
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] segment transcription failed", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: transcriptionErrorMessage(err),
|
||||
});
|
||||
// Skip this seq so later segments can still flush in order.
|
||||
if (nextEmitSeqRef.current === seq) {
|
||||
nextEmitSeqRef.current += 1;
|
||||
drainResults();
|
||||
} else {
|
||||
resultsRef.current.set(seq, "");
|
||||
drainResults();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (epoch !== epochRef.current) return;
|
||||
inFlightRef.current -= 1;
|
||||
// If recording already stopped, flip to idle once everything drained.
|
||||
if (!activeRef.current && inFlightRef.current === 0) {
|
||||
setStatus("idle");
|
||||
}
|
||||
});
|
||||
},
|
||||
[drainResults, transcriptionErrorMessage],
|
||||
);
|
||||
|
||||
const start = useCallback(async (): Promise<void> => {
|
||||
// Synchronous live guard: status is stale between renders, so also block on
|
||||
// refs to prevent a double-click from creating two VAD instances (the first
|
||||
// would leak its mic stream).
|
||||
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||
if (status !== "idle") return;
|
||||
startingRef.current = true;
|
||||
|
||||
// Notify the caller right when dictation begins (before any async work) so the
|
||||
// editor can snapshot the caret position.
|
||||
optionsRef.current.onStart?.();
|
||||
|
||||
// Reset per-session in-order emission state. Bump the epoch so any request
|
||||
// still in flight from a previous (stopped) session becomes stale and its
|
||||
// then/catch/finally are skipped — it can neither emit old text into this
|
||||
// new session nor decrement this session's freshly-zeroed in-flight counter.
|
||||
epochRef.current += 1;
|
||||
canceledRef.current = false;
|
||||
nextSeqRef.current = 0;
|
||||
nextEmitSeqRef.current = 0;
|
||||
resultsRef.current = new Map();
|
||||
inFlightRef.current = 0;
|
||||
resetLevel();
|
||||
|
||||
// Create and resume the AudioContext NOW, inside the click gesture, before
|
||||
// the (first-time-slow) model load below. A context first touched outside a
|
||||
// user gesture stays "suspended" and the VAD audio worklet never runs — that
|
||||
// is exactly why the first click did nothing and only the second (model
|
||||
// already cached, so MicVAD.new was fast enough to create the context inside
|
||||
// the gesture) started recording. We own this context and inject it into
|
||||
// MicVAD (which then will NOT close it); it is reused across start/stop and
|
||||
// closed only on unmount.
|
||||
const AudioCtor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (AudioCtor && !audioContextRef.current) {
|
||||
audioContextRef.current = new AudioCtor();
|
||||
}
|
||||
// Resume within the gesture; swallow rejection (e.g. already running/closed).
|
||||
void audioContextRef.current?.resume().catch(() => {});
|
||||
// Show immediate feedback while the model loads (see Part B).
|
||||
setStatus("loading");
|
||||
|
||||
let vad: MicVADInstance;
|
||||
try {
|
||||
// Lazy import so the heavy onnx model/worklet are only fetched on first use
|
||||
// and code-split out of the main bundle.
|
||||
const { MicVAD } = await import("@ricky0123/vad-web");
|
||||
|
||||
vad = await MicVAD.new({
|
||||
// Silero v5 model (smaller/faster than the legacy model).
|
||||
model: "v5",
|
||||
// vad-web 0.0.30 defaults startOnLoad:true, which opens the mic (calls
|
||||
// getUserMedia) inside new() and leaves the later vad.start() a no-op —
|
||||
// making its mic-permission error handling dead code. Force it off so the
|
||||
// mic is opened only by the explicit vad.start() below, where the real
|
||||
// getUserMedia errors are caught and mapped.
|
||||
startOnLoad: false,
|
||||
// Inject the AudioContext we created+resumed inside the click gesture so
|
||||
// the VAD worklet runs on a "running" context. When provided, the library
|
||||
// uses it and does NOT take ownership/close it.
|
||||
...(audioContextRef.current
|
||||
? { audioContext: audioContextRef.current }
|
||||
: {}),
|
||||
// Only pass asset paths when defined; otherwise the library uses its
|
||||
// bundled CDN defaults.
|
||||
...(VAD_BASE_ASSET_PATH !== undefined
|
||||
? { baseAssetPath: VAD_BASE_ASSET_PATH }
|
||||
: {}),
|
||||
...(VAD_ONNX_WASM_BASE_PATH !== undefined
|
||||
? { onnxWASMBasePath: VAD_ONNX_WASM_BASE_PATH }
|
||||
: {}),
|
||||
// --- VAD tuning (all tunable) ---
|
||||
// Probability over which a frame counts as speech.
|
||||
positiveSpeechThreshold: 0.5,
|
||||
// Probability under which a frame counts as non-speech (~0.15 below the
|
||||
// positive threshold, per Silero guidance).
|
||||
negativeSpeechThreshold: 0.35,
|
||||
// Silence to wait through before ending a segment (the "don't cut
|
||||
// immediately" delay). Each ended segment is ONE transcription request, so
|
||||
// cutting on short gaps over-fragments normal speech into a flood of tiny
|
||||
// requests (and trips the server's per-user rate limit). Wait ~1.5s — a
|
||||
// real sentence/thought boundary — so request count tracks actual pauses,
|
||||
// not every inter-word gap. Higher = fewer requests but more latency
|
||||
// before text appears. NOTE: vad-web 0.0.30 takes this in ms, not frames
|
||||
// (one Silero frame is ~32ms at 16k).
|
||||
redemptionMs: 1500,
|
||||
// Audio kept before speech start (left padding so the first word isn't
|
||||
// clipped) — ~0.3s.
|
||||
preSpeechPadMs: 320,
|
||||
// Ignore sub-100ms blips like clicks.
|
||||
minSpeechMs: 96,
|
||||
onFrameProcessed: (probabilities: { isSpeech: number }) => {
|
||||
// Drive the level meter from the speech probability. Light exponential
|
||||
// smoothing + a throttle so React state isn't updated every frame; this
|
||||
// powers the existing button halo. Reuses the VAD's own frame
|
||||
// probabilities — no second AudioContext/AnalyserNode.
|
||||
if (!activeRef.current) return;
|
||||
const level = Math.min(1, Math.max(0, probabilities.isSpeech));
|
||||
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
|
||||
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
|
||||
emittedLevelRef.current = smoothedLevelRef.current;
|
||||
setAudioLevel(smoothedLevelRef.current);
|
||||
}
|
||||
},
|
||||
onSpeechStart: () => {
|
||||
// No-op: the segment is only handled once it ends.
|
||||
},
|
||||
onSpeechEnd: (audio: Float32Array) => {
|
||||
// A pause was detected — cut this segment and transcribe it. Ignore late
|
||||
// callbacks that fire after stop()/cancel().
|
||||
if (!activeRef.current || canceledRef.current) return;
|
||||
handleSegment(audio);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// With startOnLoad:false, new() loads the model/worklet/wasm but does NOT
|
||||
// open the mic, so a throw here is an asset/init failure (model fetch,
|
||||
// worklet, onnxruntime wasm), not a mic-permission error. Map it as a
|
||||
// generic "could not start" with the underlying detail. (The mic-permission
|
||||
// name checks are kept in the vad.start() catch below, where getUserMedia
|
||||
// actually runs.)
|
||||
console.error("[dictation] VAD init failed", err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${detail}`,
|
||||
});
|
||||
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||
// don't leak it.
|
||||
destroyVad();
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
vadRef.current = vad;
|
||||
// Accept frames once start() resolves; the VAD callbacks already guard on
|
||||
// activeRef, so setting it before start() is safe.
|
||||
activeRef.current = true;
|
||||
|
||||
try {
|
||||
// With startOnLoad:false this is where getUserMedia actually runs, so map
|
||||
// mic-permission errors here the same way the batch hook does; otherwise
|
||||
// fall back to a generic "could not start" message.
|
||||
await vad.start();
|
||||
} catch (err) {
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] VAD.start failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
message = t("Microphone access denied");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
message = t("No microphone found");
|
||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
||||
message = t("Microphone is unavailable or already in use");
|
||||
} else {
|
||||
message = `${t("Could not start recording")}: ${detail}`;
|
||||
}
|
||||
notifications.show({ color: "red", message });
|
||||
activeRef.current = false;
|
||||
destroyVad();
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("recording");
|
||||
// Recording has truly begun; release the synchronous start guard.
|
||||
startingRef.current = false;
|
||||
|
||||
// Optional overall safety cap: auto-stop after maxDurationMs like the batch
|
||||
// hook does.
|
||||
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (activeRef.current) stopRef.current();
|
||||
}, maxDurationMs);
|
||||
}, [status, t, resetLevel, destroyVad, handleSegment]);
|
||||
|
||||
const stop = useCallback((): void => {
|
||||
clearTimer();
|
||||
if (!activeRef.current && !vadRef.current) {
|
||||
// Nothing is running; make sure the UI is idle.
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
// Mark inactive first so late onSpeechEnd/onFrameProcessed callbacks are
|
||||
// ignored. Any speech segment that has NOT yet ended (user clicks Stop
|
||||
// mid-utterance) is dropped — acceptable for v1; users normally pause before
|
||||
// stopping.
|
||||
activeRef.current = false;
|
||||
destroyVad();
|
||||
resetLevel();
|
||||
settleAfterStop();
|
||||
}, [clearTimer, destroyVad, resetLevel, settleAfterStop]);
|
||||
|
||||
// Keep stop() reachable from the maxDuration timer closure (which is created
|
||||
// before stop is defined) without re-creating the VAD.
|
||||
const stopRef = useRef(stop);
|
||||
stopRef.current = stop;
|
||||
|
||||
const cancel = useCallback((): void => {
|
||||
clearTimer();
|
||||
canceledRef.current = true;
|
||||
activeRef.current = false;
|
||||
// Hard discard: bump the epoch so any in-flight request becomes stale and is
|
||||
// ignored the moment it resolves (no emit, no counter touch).
|
||||
epochRef.current += 1;
|
||||
// Drop pending results / queue; in-flight requests will resolve into a now-
|
||||
// empty buffer and be ignored.
|
||||
resultsRef.current = new Map();
|
||||
nextSeqRef.current = 0;
|
||||
nextEmitSeqRef.current = 0;
|
||||
inFlightRef.current = 0;
|
||||
destroyVad();
|
||||
resetLevel();
|
||||
setStatus("idle");
|
||||
}, [clearTimer, destroyVad, resetLevel]);
|
||||
|
||||
// Clean up on unmount: destroy the VAD, stop the mic stream, clear the timer.
|
||||
// Defensive try/catch lives inside destroyVad so teardown never throws.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimer();
|
||||
activeRef.current = false;
|
||||
canceledRef.current = true;
|
||||
destroyVad();
|
||||
// Close the AudioContext we own (MicVAD never closes an injected one).
|
||||
if (
|
||||
audioContextRef.current &&
|
||||
audioContextRef.current.state !== "closed"
|
||||
) {
|
||||
void audioContextRef.current.close().catch(() => {});
|
||||
}
|
||||
audioContextRef.current = null;
|
||||
};
|
||||
}, [clearTimer, destroyVad]);
|
||||
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
}
|
||||
32
apps/client/src/features/dictation/utils/encode-wav.ts
Normal file
32
apps/client/src/features/dictation/utils/encode-wav.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Encode mono Float32 PCM samples into a 16-bit PCM WAV blob (audio/wav).
|
||||
// The server STT endpoint whitelists audio/wav, so this is sent as-is.
|
||||
export function encodeWavPcm16(samples: Float32Array, sampleRate = 16000): Blob {
|
||||
const bytesPerSample = 2;
|
||||
const blockAlign = bytesPerSample; // mono
|
||||
const dataSize = samples.length * bytesPerSample;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
const writeStr = (offset: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
||||
};
|
||||
writeStr(0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeStr(8, "WAVE");
|
||||
writeStr(12, "fmt ");
|
||||
view.setUint32(16, 16, true); // PCM fmt chunk size
|
||||
view.setUint16(20, 1, true); // audio format = PCM
|
||||
view.setUint16(22, 1, true); // channels = mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeStr(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||
import { MoreInsertsGroup } from "./groups/more-inserts-group";
|
||||
import { HistoryGroup } from "./groups/history-group";
|
||||
import { AskAiGroup } from "./groups/ask-ai-group";
|
||||
import { DictationGroup } from "./groups/dictation-group";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
@@ -31,7 +30,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
|
||||
if (!editor || !state) return null;
|
||||
|
||||
@@ -67,12 +65,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
|
||||
<div className={classes.divider} />
|
||||
<HistoryGroup editor={editor} state={state} />
|
||||
{isDictationEnabled && (
|
||||
<>
|
||||
<div className={classes.divider} />
|
||||
<DictationGroup editor={editor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.spacer} aria-hidden />
|
||||
|
||||
@@ -4,45 +4,62 @@ import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
color?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export const DictationGroup: FC<Props> = ({ editor }) => {
|
||||
export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
// Caret snapshot taken when dictation starts (where the first segment lands).
|
||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||
// Running insertion point: after each inserted segment we remember the caret
|
||||
// end so the NEXT segment appends right after it, contiguously, regardless of
|
||||
// where the user's caret currently is. Null until the first segment lands.
|
||||
const insertPosRef = useRef<number | null>(null);
|
||||
|
||||
const handleStart = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
rangeRef.current = { from, to };
|
||||
// New session: forget any insertion point from a previous dictation so the
|
||||
// first segment uses the fresh snapshot above.
|
||||
insertPosRef.current = null;
|
||||
};
|
||||
|
||||
const handleText = (text: string) => {
|
||||
// The editor may be gone by the time async transcription returns; bail out
|
||||
// instead of operating on a destroyed instance.
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
const snapshot = rangeRef.current;
|
||||
rangeRef.current = null;
|
||||
// The document may have shrunk during transcription (e.g. a collaborative
|
||||
// edit), so clamp the snapshot into the current bounds before inserting.
|
||||
// edit), so clamp any position into the current bounds before inserting.
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const clamp = (p: number) => Math.max(0, Math.min(p, docSize));
|
||||
// First segment lands at the snapshotted caret range; subsequent segments
|
||||
// land at a zero-length range at the running insertion point so they stay
|
||||
// contiguous even if the user clicked elsewhere mid-dictation.
|
||||
const snapshot = rangeRef.current;
|
||||
const range =
|
||||
insertPosRef.current !== null
|
||||
? { from: clamp(insertPosRef.current), to: clamp(insertPosRef.current) }
|
||||
: snapshot
|
||||
? { from: clamp(snapshot.from), to: clamp(snapshot.to) }
|
||||
: null;
|
||||
try {
|
||||
if (snapshot) {
|
||||
// Insert at the snapshotted caret; a trailing space keeps words
|
||||
// separated (the hook already trims the transcribed text).
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from: clamp(snapshot.from), to: clamp(snapshot.to) },
|
||||
`${text} `,
|
||||
)
|
||||
.run();
|
||||
if (range) {
|
||||
// Insert at the resolved range; a trailing space keeps words separated
|
||||
// (the hook already trims the transcribed text).
|
||||
editor.chain().focus().insertContentAt(range, `${text} `).run();
|
||||
} else {
|
||||
// No snapshot and no running point (shouldn't happen normally) — fall
|
||||
// back to the current caret.
|
||||
editor.chain().focus().insertContent(`${text} `).run();
|
||||
}
|
||||
// Remember where the inserted text ends so the next segment appends right
|
||||
// after it, independent of later user caret moves.
|
||||
insertPosRef.current = editor.state.selection.to;
|
||||
} catch {
|
||||
// The snapshot drifted out of range; fall back to the current caret.
|
||||
// The range drifted out of bounds; fall back to the current caret.
|
||||
try {
|
||||
editor.chain().focus().insertContent(`${text} `).run();
|
||||
insertPosRef.current = editor.state.selection.to;
|
||||
} catch {
|
||||
// The editor may have been destroyed; ignore so a dead editor can't
|
||||
// surface an uncaught error.
|
||||
@@ -53,9 +70,12 @@ export const DictationGroup: FC<Props> = ({ editor }) => {
|
||||
return (
|
||||
<MicButton
|
||||
size="md"
|
||||
streaming
|
||||
onStart={handleStart}
|
||||
onText={handleText}
|
||||
disabled={!editor.isEditable}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getSuggestionItems } from "./menu-items";
|
||||
|
||||
// Coverage for the filter/sort half of `getSuggestionItems` (distinct from the
|
||||
// HTML-embed gating suite). A slash query is matched against each item three
|
||||
// ways — fuzzy on the title, substring on the description, and substring on the
|
||||
// searchTerms — and matched items are sorted so title-substring hits float to
|
||||
// the top of their group. We also cover `excludeItems`.
|
||||
//
|
||||
// `getSuggestionItems` -> `isHtmlEmbedFeatureEnabled` reads the persisted
|
||||
// `currentUser` localStorage entry, so a working in-memory Storage stub is a
|
||||
// prerequisite (installed by vitest.setup.ts). We persist a `currentUser` with
|
||||
// the HTML-embed toggle OFF (the production default) so the gated "HTML embed"
|
||||
// item never leaks into these non-HTML queries.
|
||||
|
||||
const KEY = "currentUser";
|
||||
|
||||
function flatTitles(groups: ReturnType<typeof getSuggestionItems>): string[] {
|
||||
return Object.values(groups)
|
||||
.flat()
|
||||
.map((item) => item.title);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Default workspace state: HTML-embed feature OFF (matches production default).
|
||||
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("getSuggestionItems — filter and sort", () => {
|
||||
it("fuzzy-matches a title (non-contiguous characters)", () => {
|
||||
// "tdo" is not a substring of "to-do list" but matches fuzzily (t..d..o).
|
||||
const titles = flatTitles(getSuggestionItems({ query: "tdo" }));
|
||||
expect(titles).toContain("To-do list");
|
||||
});
|
||||
|
||||
it("matches via the description when the title does not match", () => {
|
||||
// "numbering" only appears in the description "Create a list with numbering.",
|
||||
// not in the "Numbered list" title nor its searchTerms.
|
||||
const titles = flatTitles(getSuggestionItems({ query: "numbering" }));
|
||||
expect(titles).toContain("Numbered list");
|
||||
});
|
||||
|
||||
it("matches via searchTerms when title and description do not match", () => {
|
||||
// "blockquote" is only present in the "Quote" item's searchTerms.
|
||||
const titles = flatTitles(getSuggestionItems({ query: "blockquote" }));
|
||||
expect(titles).toContain("Quote");
|
||||
});
|
||||
|
||||
it("sorts title-substring matches before non-title (description) matches", () => {
|
||||
// For "page": several titles contain "page" (e.g. "Page break"), while
|
||||
// "Synced block" matches only through its description (".. across pages.").
|
||||
// The sort tie-break must place every title hit ahead of the non-title hit.
|
||||
const titles = flatTitles(getSuggestionItems({ query: "page" }));
|
||||
|
||||
const syncedIndex = titles.indexOf("Synced block");
|
||||
const pageBreakIndex = titles.indexOf("Page break");
|
||||
|
||||
// Sanity: both items survived the filter for this query.
|
||||
expect(syncedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(pageBreakIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The title match ("Page break") sorts before the description-only match.
|
||||
expect(pageBreakIndex).toBeLessThan(syncedIndex);
|
||||
});
|
||||
|
||||
it("removes a named item via excludeItems", () => {
|
||||
const withBullet = flatTitles(getSuggestionItems({ query: "list" }));
|
||||
expect(withBullet).toContain("Bullet list");
|
||||
|
||||
const withoutBullet = flatTitles(
|
||||
getSuggestionItems({
|
||||
query: "list",
|
||||
excludeItems: new Set(["Bullet list"]),
|
||||
}),
|
||||
);
|
||||
expect(withoutBullet).not.toContain("Bullet list");
|
||||
// Other "list" matches remain unaffected by the exclusion.
|
||||
expect(withoutBullet).toContain("Numbered list");
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
userAtom,
|
||||
workspaceAtom,
|
||||
} from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
@@ -24,7 +27,11 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
pageEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -65,6 +72,8 @@ export function FullEditor({
|
||||
canComment,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
@@ -104,6 +113,9 @@ export function FullEditor({
|
||||
<PageByline
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
@@ -118,11 +130,24 @@ export function FullEditor({
|
||||
type PageBylineProps = {
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
editable?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDictationEnabled?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({ creator, contributors }: PageBylineProps) {
|
||||
function PageByline({
|
||||
creator,
|
||||
contributors,
|
||||
editable,
|
||||
isEditMode,
|
||||
isDictationEnabled,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const showDictation = Boolean(
|
||||
isDictationEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -197,16 +222,23 @@ function PageByline({ creator, contributors }: PageBylineProps) {
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<Tooltip label={t("Details")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Details")}
|
||||
{...detailsTriggerProps}
|
||||
>
|
||||
<IconInfoCircle size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label={t("Details")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Details")}
|
||||
{...detailsTriggerProps}
|
||||
>
|
||||
<IconInfoCircle size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* Shown only in edit mode when workspace dictation is enabled, so
|
||||
dictation stays reachable even when the fixed toolbar is hidden. */}
|
||||
{showDictation && editor && (
|
||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,17 @@ export function TitleEditor({
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
// Do not overwrite the title while the user is actively editing it. The
|
||||
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
|
||||
// carry a title that lags behind what the user has just typed; resetting
|
||||
// content from it here would drop in-progress characters and jump the
|
||||
// cursor. Apply external title changes only when the field is not focused.
|
||||
if (
|
||||
titleEditor &&
|
||||
!titleEditor.isDestroyed &&
|
||||
!titleEditor.isFocused &&
|
||||
title !== titleEditor.getText()
|
||||
) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
110
apps/client/src/features/home/components/new-note-button.tsx
Normal file
110
apps/client/src/features/home/components/new-note-button.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Button, Menu, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { SpaceRole } from "@/lib/types.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
// The /spaces list endpoint returns membership.role but NOT membership.permissions
|
||||
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
|
||||
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
|
||||
// for the current user when their role is ADMIN or WRITER.
|
||||
function canCreatePage(space: ISpace): boolean {
|
||||
const role = space.membership?.role;
|
||||
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
|
||||
}
|
||||
|
||||
// Prominent home-screen action to create a new note (page). Because the home
|
||||
// screen has no active space, the target space is resolved from the user's
|
||||
// writable spaces: created directly when there is one, picked from a dropdown
|
||||
// when there are several.
|
||||
export default function NewNoteButton() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
|
||||
|
||||
const createNote = async (space: ISpace) => {
|
||||
try {
|
||||
// `spaceId` is accepted by the create-page endpoint but is not part of
|
||||
// the shared `IPageInput` type; cast to satisfy the mutation signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
} as any);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
// useCreatePageMutation already surfaces a red notification on error.
|
||||
}
|
||||
};
|
||||
|
||||
// No writable space → nothing to create in; render nothing.
|
||||
if (writableSpaces.length === 0) return null;
|
||||
|
||||
const isPending = createPageMutation.isPending;
|
||||
|
||||
// Exactly one writable space → create directly, no picker needed.
|
||||
if (writableSpaces.length === 1) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
loading={isPending}
|
||||
onClick={() => createNote(writableSpaces[0])}
|
||||
>
|
||||
{t("New note")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple writable spaces → pick the target space from a dropdown.
|
||||
return (
|
||||
<Menu shadow="md" width="target" position="bottom-start">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("New note")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Create in space")}</Menu.Label>
|
||||
{writableSpaces.map((space) => (
|
||||
<Menu.Item
|
||||
key={space.id}
|
||||
disabled={isPending}
|
||||
leftSection={
|
||||
<CustomAvatar
|
||||
name={space.name}
|
||||
avatarUrl={space.logo}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
onClick={() => createNote(space)}
|
||||
>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
93
apps/client/src/features/label/utils/label-colors.test.ts
Normal file
93
apps/client/src/features/label/utils/label-colors.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||
|
||||
/**
|
||||
* Tests for the deterministic label-color hashing. `hashName` is not exported,
|
||||
* so we exercise it through `getLabelColor`. We assert determinism, that light
|
||||
* and dark schemes resolve to the SAME palette key (so a label's "blue" stays
|
||||
* "blue" across themes), that the returned color is always a real palette
|
||||
* entry, and that a realistic sample of names does not all collapse into one
|
||||
* bucket (guards the murmur fmix finalizer that de-clusters the % bucket).
|
||||
*/
|
||||
|
||||
// The 8 distinct light-scheme bg colors, used to recover a name's bucket index.
|
||||
const LIGHT_BGS = [
|
||||
"#eef1f5", // slate
|
||||
"#e6f0ff", // blue
|
||||
"#e3f5ea", // green
|
||||
"#fbf0d9", // amber
|
||||
"#fde6e6", // red
|
||||
"#efe9fb", // purple
|
||||
"#fce6ee", // pink
|
||||
"#daf1ee", // teal
|
||||
];
|
||||
|
||||
const DARK_BGS = [
|
||||
"#2a3140",
|
||||
"#152a52",
|
||||
"#143b27",
|
||||
"#3d2c0e",
|
||||
"#401a1a",
|
||||
"#2a1f4d",
|
||||
"#3c1a2a",
|
||||
"#103633",
|
||||
];
|
||||
|
||||
describe("getLabelColor — determinism", () => {
|
||||
it("returns the same color object shape for the same name", () => {
|
||||
const a = getLabelColor("bug");
|
||||
const b = getLabelColor("bug");
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toMatchObject({
|
||||
bg: expect.any(String),
|
||||
fg: expect.any(String),
|
||||
dot: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("is stable across many repeated calls", () => {
|
||||
const first = getLabelColor("enhancement");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(getLabelColor("enhancement")).toEqual(first);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLabelColor — scheme parity", () => {
|
||||
it("light and dark resolve to the SAME palette key for a given name", () => {
|
||||
const names = ["bug", "enhancement", "wontfix", "duplicate", "p1", "docs"];
|
||||
for (const name of names) {
|
||||
const lightIdx = LIGHT_BGS.indexOf(getLabelColor(name, "light").bg);
|
||||
const darkIdx = DARK_BGS.indexOf(getLabelColor(name, "dark").bg);
|
||||
expect(lightIdx).toBeGreaterThanOrEqual(0); // it is a real palette entry
|
||||
expect(darkIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(darkIdx).toBe(lightIdx); // same bucket across themes
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to the light scheme", () => {
|
||||
expect(getLabelColor("bug")).toEqual(getLabelColor("bug", "light"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLabelColor — index bounds & distribution", () => {
|
||||
it("always returns a color whose bg is one of the 8 palette entries", () => {
|
||||
const names = Array.from({ length: 200 }, (_, i) => `label-${i}`);
|
||||
for (const name of names) {
|
||||
expect(LIGHT_BGS).toContain(getLabelColor(name).bg);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles the empty string without crashing and within bounds", () => {
|
||||
expect(LIGHT_BGS).toContain(getLabelColor("").bg);
|
||||
});
|
||||
|
||||
it("a sample of distinct names does not all collide into one bucket", () => {
|
||||
const names = Array.from({ length: 64 }, (_, i) => `name-${i}-${i * 7}`);
|
||||
const buckets = new Set(names.map((n) => getLabelColor(n).bg));
|
||||
// The fmix finalizer should spread these across multiple buckets, not 1.
|
||||
expect(buckets.size).toBeGreaterThan(1);
|
||||
// Realistically a 64-name sample lands in most/all of the 8 buckets.
|
||||
expect(buckets.size).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
47
apps/client/src/features/label/utils/normalize-label.test.ts
Normal file
47
apps/client/src/features/label/utils/normalize-label.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
|
||||
|
||||
/**
|
||||
* `normalizeLabelName` = trim + collapse ALL whitespace runs to a single hyphen
|
||||
* + lowercase. Used to canonicalize label names so "Bug Fix" and " bug fix "
|
||||
* map to the same key.
|
||||
*/
|
||||
describe("normalizeLabelName", () => {
|
||||
it("trims leading and trailing whitespace", () => {
|
||||
expect(normalizeLabelName(" bug ")).toBe("bug");
|
||||
});
|
||||
|
||||
it("lowercases", () => {
|
||||
expect(normalizeLabelName("BUG")).toBe("bug");
|
||||
expect(normalizeLabelName("MixedCase")).toBe("mixedcase");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace runs to a single hyphen", () => {
|
||||
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
|
||||
expect(normalizeLabelName("a b c")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it("combines trim + collapse + lowercase", () => {
|
||||
expect(normalizeLabelName(" Bug Fix ")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("treats tab and newline as whitespace", () => {
|
||||
expect(normalizeLabelName("bug\tfix")).toBe("bug-fix");
|
||||
expect(normalizeLabelName("bug\nfix")).toBe("bug-fix");
|
||||
expect(normalizeLabelName("bug\r\nfix")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("treats unicode whitespace (no-break space) as a separator", () => {
|
||||
// U+00A0 NO-BREAK SPACE is matched by the \s class.
|
||||
expect(normalizeLabelName("bug fix")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("leaves an already-normalized name unchanged", () => {
|
||||
expect(normalizeLabelName("bug-fix")).toBe("bug-fix");
|
||||
});
|
||||
|
||||
it("returns empty string for whitespace-only input", () => {
|
||||
expect(normalizeLabelName(" ")).toBe("");
|
||||
expect(normalizeLabelName("")).toBe("");
|
||||
});
|
||||
});
|
||||
134
apps/client/src/features/notification/notification.utils.test.ts
Normal file
134
apps/client/src/features/notification/notification.utils.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
getTimeGroup,
|
||||
groupNotificationsByTime,
|
||||
} from "@/features/notification/notification.utils.ts";
|
||||
import type { INotification } from "@/features/notification/types/notification.types.ts";
|
||||
|
||||
/**
|
||||
* `getTimeGroup` classifies a timestamp into today / yesterday / this_week /
|
||||
* older using LOCAL-time day boundaries derived from `now`. To stay timezone-
|
||||
* independent, the boundary anchors are computed exactly the way the SUT does
|
||||
* (local midnight of today, minus 1 day, minus 7 days) and inputs are offset
|
||||
* from those anchors by a safe margin. `groupNotificationsByTime` buckets a
|
||||
* list, drops empty groups, and preserves input order within each group, in the
|
||||
* fixed order today -> yesterday -> this_week -> older.
|
||||
*/
|
||||
const FIXED_NOW = new Date("2026-06-21T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Local midnight of "today" relative to the frozen clock.
|
||||
function startOfTodayLocal(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
// An ISO string `offsetMs` away from local midnight of today.
|
||||
function fromTodayStart(offsetMs: number): string {
|
||||
return new Date(startOfTodayLocal().getTime() + offsetMs).toISOString();
|
||||
}
|
||||
|
||||
function notif(id: string, createdAt: string): INotification {
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe("getTimeGroup — boundary classification", () => {
|
||||
it("classifies a time after today's midnight as 'today'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(HOUR))).toBe("today");
|
||||
});
|
||||
|
||||
it("classifies exactly today's midnight as 'today' (inclusive lower bound)", () => {
|
||||
expect(getTimeGroup(fromTodayStart(0))).toBe("today");
|
||||
});
|
||||
|
||||
it("classifies the slice between yesterday-midnight and today-midnight as 'yesterday'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(-HOUR))).toBe("yesterday");
|
||||
expect(getTimeGroup(fromTodayStart(-DAY))).toBe("yesterday"); // start of yesterday, inclusive
|
||||
});
|
||||
|
||||
it("classifies 2..7 days before today as 'this_week'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(-DAY - HOUR))).toBe("this_week");
|
||||
expect(getTimeGroup(fromTodayStart(-7 * DAY))).toBe("this_week"); // start of week, inclusive
|
||||
});
|
||||
|
||||
it("classifies anything before the 7-day window as 'older'", () => {
|
||||
expect(getTimeGroup(fromTodayStart(-7 * DAY - HOUR))).toBe("older");
|
||||
expect(getTimeGroup(fromTodayStart(-30 * DAY))).toBe("older");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupNotificationsByTime", () => {
|
||||
const labels = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
this_week: "This week",
|
||||
older: "Older",
|
||||
};
|
||||
|
||||
it("returns groups in the order today -> yesterday -> this_week -> older", () => {
|
||||
// Provide rows out of order to prove ordering comes from the group order,
|
||||
// not input order.
|
||||
const result = groupNotificationsByTime(
|
||||
[
|
||||
notif("old", fromTodayStart(-30 * DAY)),
|
||||
notif("today", fromTodayStart(HOUR)),
|
||||
notif("week", fromTodayStart(-3 * DAY)),
|
||||
notif("yest", fromTodayStart(-HOUR)),
|
||||
],
|
||||
labels,
|
||||
);
|
||||
expect(result.map((g) => g.key)).toEqual([
|
||||
"today",
|
||||
"yesterday",
|
||||
"this_week",
|
||||
"older",
|
||||
]);
|
||||
expect(result.map((g) => g.label)).toEqual([
|
||||
"Today",
|
||||
"Yesterday",
|
||||
"This week",
|
||||
"Older",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves input order within a single group", () => {
|
||||
const result = groupNotificationsByTime(
|
||||
[
|
||||
notif("t1", fromTodayStart(HOUR)),
|
||||
notif("t2", fromTodayStart(2 * HOUR)),
|
||||
notif("t3", fromTodayStart(3 * HOUR)),
|
||||
],
|
||||
labels,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].key).toBe("today");
|
||||
expect(result[0].notifications.map((n) => n.id)).toEqual(["t1", "t2", "t3"]);
|
||||
});
|
||||
|
||||
it("drops empty groups", () => {
|
||||
const result = groupNotificationsByTime(
|
||||
[notif("only-today", fromTodayStart(HOUR))],
|
||||
labels,
|
||||
);
|
||||
expect(result.map((g) => g.key)).toEqual(["today"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for no notifications", () => {
|
||||
expect(groupNotificationsByTime([], labels)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import { notifications } from "@mantine/notifications";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
@@ -143,7 +142,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
const { data: page, isLoading } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
@@ -189,7 +187,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
};
|
||||
|
||||
const handleDeletePage = () => {
|
||||
openDeleteModal({ onConfirm: () => handleDelete(page.id) });
|
||||
handleDelete(page.id);
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type MoveToTrashNotificationProps = {
|
||||
message: string;
|
||||
undoLabel: string;
|
||||
onUndo: () => void;
|
||||
};
|
||||
|
||||
// Builds the body of the "page moved to trash" toast: the status text plus an
|
||||
// inline Undo action that restores the page from trash. Returned as a ReactNode
|
||||
// so it can be passed as the `message` of a Mantine notification from a
|
||||
// non-TSX module (page-query.ts).
|
||||
export function moveToTrashNotificationMessage({
|
||||
message,
|
||||
undoLabel,
|
||||
onUndo,
|
||||
}: MoveToTrashNotificationProps): ReactNode {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Button variant="subtle" size="compact-sm" onClick={onUndo}>
|
||||
{undoLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
99
apps/client/src/features/page/page.utils.test.ts
Normal file
99
apps/client/src/features/page/page.utils.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildPageUrl, buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
/**
|
||||
* URL builders. A page URL is `${titleSlug}-${slugId}` where the title is
|
||||
* slugified (lowercase, dashed) after truncating to the first 70 chars, and an
|
||||
* empty title becomes "untitled". `buildPageUrl` prefixes `/p/` when no space
|
||||
* name is given and `/s/{space}/p/` otherwise. `buildSharedPageUrl` prefixes
|
||||
* `/share/p/` when no shareId and `/share/{shareId}/p/` otherwise. An anchorId
|
||||
* is appended as `#...`.
|
||||
*/
|
||||
describe("buildPageUrl", () => {
|
||||
it("uses /p/{slug} when spaceName is undefined", () => {
|
||||
expect(buildPageUrl(undefined as unknown as string, "abc123", "Hello World")).toBe(
|
||||
"/p/hello-world-abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses /s/{space}/p/{slug} when spaceName is provided", () => {
|
||||
expect(buildPageUrl("eng", "abc123", "Hello World")).toBe(
|
||||
"/s/eng/p/hello-world-abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("slugifies (lowercases + dashes) the title", () => {
|
||||
expect(buildPageUrl("eng", "id1", "My Cool PAGE!")).toBe(
|
||||
"/s/eng/p/my-cool-page-id1",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses 'untitled' for an empty title", () => {
|
||||
expect(buildPageUrl("eng", "id1", "")).toBe("/s/eng/p/untitled-id1");
|
||||
});
|
||||
|
||||
it("uses 'untitled' when no title is passed at all", () => {
|
||||
expect(buildPageUrl("eng", "id1")).toBe("/s/eng/p/untitled-id1");
|
||||
});
|
||||
|
||||
it("truncates the title to the first 70 chars before slugifying", () => {
|
||||
// 80 'a' then a space then "tail". Only the first 70 chars feed slugify, so
|
||||
// the slug is 70 a's (the space and "tail" past char 70 are dropped).
|
||||
const longTitle = "a".repeat(80) + " tail";
|
||||
const url = buildPageUrl("eng", "id1", longTitle);
|
||||
expect(url).toBe(`/s/eng/p/${"a".repeat(70)}-id1`);
|
||||
expect(url).not.toContain("tail");
|
||||
});
|
||||
|
||||
it("appends the anchorId as a #fragment", () => {
|
||||
expect(buildPageUrl("eng", "id1", "Page", "section-2")).toBe(
|
||||
"/s/eng/p/page-id1#section-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the fragment when no anchorId is given", () => {
|
||||
expect(buildPageUrl("eng", "id1", "Page")).not.toContain("#");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSharedPageUrl", () => {
|
||||
it("uses /share/p/{slug} when shareId is absent", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({ shareId: "", pageSlugId: "id1", pageTitle: "Doc" }),
|
||||
).toBe("/share/p/doc-id1");
|
||||
});
|
||||
|
||||
it("uses /share/{shareId}/p/{slug} when shareId is present", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({ shareId: "s9", pageSlugId: "id1", pageTitle: "Doc" }),
|
||||
).toBe("/share/s9/p/doc-id1");
|
||||
});
|
||||
|
||||
it("falls back to 'untitled' for an empty title", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({ shareId: "s9", pageSlugId: "id1", pageTitle: "" }),
|
||||
).toBe("/share/s9/p/untitled-id1");
|
||||
});
|
||||
|
||||
it("appends the anchorId as a #fragment", () => {
|
||||
expect(
|
||||
buildSharedPageUrl({
|
||||
shareId: "s9",
|
||||
pageSlugId: "id1",
|
||||
pageTitle: "Doc",
|
||||
anchorId: "h1",
|
||||
}),
|
||||
).toBe("/share/s9/p/doc-id1#h1");
|
||||
});
|
||||
|
||||
it("truncates the title to the first 70 chars before slugifying", () => {
|
||||
const longTitle = "b".repeat(80) + " tail";
|
||||
const url = buildSharedPageUrl({
|
||||
shareId: "s9",
|
||||
pageSlugId: "id1",
|
||||
pageTitle: longTitle,
|
||||
});
|
||||
expect(url).toBe(`/share/s9/p/${"b".repeat(70)}-id1`);
|
||||
expect(url).not.toContain("tail");
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,12 @@ import { buildTree } from "@/features/page/tree/utils";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||
|
||||
export function usePageQuery(
|
||||
pageInput: Partial<IPageInput>,
|
||||
@@ -118,10 +119,29 @@ export function useUpdatePageMutation() {
|
||||
|
||||
export function useRemovePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
// Reuse the existing restore flow for the toast's Undo action. Its side
|
||||
// effects (tree re-insert, cache updates, websocket emit, success toast) live
|
||||
// in its useMutation-level onSuccess, so they still run after the originating
|
||||
// tree node / page header has unmounted by the time Undo is clicked.
|
||||
const restorePageMutation = useRestorePageMutation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||
onSuccess: (_, pageId) => {
|
||||
notifications.show({ message: t("Page moved to trash") });
|
||||
// Replace the former pre-delete confirmation dialog with an Undo action
|
||||
// surfaced directly in the "moved to trash" toast.
|
||||
const notificationId = `page-moved-to-trash-${pageId}`;
|
||||
notifications.show({
|
||||
id: notificationId,
|
||||
autoClose: 8000,
|
||||
message: moveToTrashNotificationMessage({
|
||||
message: t("Page moved to trash"),
|
||||
undoLabel: t("Undo"),
|
||||
onUndo: () => {
|
||||
notifications.hide(notificationId);
|
||||
restorePageMutation.mutate(pageId);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
||||
@@ -173,7 +193,8 @@ export function useMovePageMutation() {
|
||||
|
||||
export function useRestorePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const setTreeData = useSetAtom(treeDataAtom);
|
||||
const store = useStore();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
return useMutation({
|
||||
@@ -181,8 +202,13 @@ export function useRestorePageMutation() {
|
||||
onSuccess: async (restoredPage) => {
|
||||
notifications.show({ message: t("Page restored successfully") });
|
||||
|
||||
// Undo can fire from the trash toast after the originating tree node /
|
||||
// page header has unmounted, so a render-time `treeData` closure would be
|
||||
// stale. Read the live tree imperatively from the store at execution time.
|
||||
const currentTree = store.get(treeDataAtom);
|
||||
|
||||
// Check if the page already exists in the tree (it shouldn't)
|
||||
if (!treeModel.find(treeData, restoredPage.id)) {
|
||||
if (!treeModel.find(currentTree, restoredPage.id)) {
|
||||
// Create the tree node data with hasChildren from backend
|
||||
const nodeData: SpaceTreeNode = {
|
||||
id: restoredPage.id,
|
||||
@@ -201,17 +227,22 @@ export function useRestorePageMutation() {
|
||||
let index = 0;
|
||||
|
||||
if (parentId) {
|
||||
const parentNode = treeModel.find(treeData, parentId);
|
||||
const parentNode = treeModel.find(currentTree, parentId);
|
||||
if (parentNode) {
|
||||
index = parentNode.children?.length || 0;
|
||||
}
|
||||
} else {
|
||||
// Root level page
|
||||
index = treeData.length;
|
||||
index = currentTree.length;
|
||||
}
|
||||
|
||||
// Add the node to the tree
|
||||
setTreeData(treeModel.insert(treeData, parentId, nodeData, index));
|
||||
// Add the node to the tree via a functional updater, re-checking
|
||||
// existence against the freshest state for idempotency.
|
||||
setTreeData((prev) =>
|
||||
treeModel.find(prev, restoredPage.id)
|
||||
? prev
|
||||
: treeModel.insert(prev, parentId, nodeData, index),
|
||||
);
|
||||
|
||||
// Emit websocket event to sync with other users
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
@@ -47,7 +46,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const { handleDelete } = useTreeMutation(node.spaceId);
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
@@ -257,9 +255,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteModal({
|
||||
onConfirm: () => handleDelete(node.id),
|
||||
});
|
||||
handleDelete(node.id);
|
||||
}}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
|
||||
@@ -88,6 +88,10 @@ export default function ShareAiWidget({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Same classified-error banner as the internal chat: name the cause instead of a
|
||||
// generic heading.
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
@@ -173,18 +177,18 @@ export default function ShareAiWidget({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
{errorView && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mx="sm"
|
||||
mb="xs"
|
||||
title={t("Something went wrong")}
|
||||
title={errorView.title}
|
||||
>
|
||||
{/* Surface the real cause (provider/gating message) instead of a
|
||||
{/* Surface the real cause (provider/gating category) instead of a
|
||||
generic line — same helper the internal chat uses. */}
|
||||
{describeChatError(error.message ?? "", t)}
|
||||
{errorView.detail}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
122
apps/client/src/features/share/utils.test.ts
Normal file
122
apps/client/src/features/share/utils.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSharedPageTree,
|
||||
isPageInTree,
|
||||
type SharedPageTreeNode,
|
||||
} from "@/features/share/utils.ts";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
/**
|
||||
* `buildSharedPageTree` nests pages by `parentPageId` (keyed on `page.id`),
|
||||
* promotes orphans (parent absent) to top level, marks `hasChildren`, and sorts
|
||||
* siblings recursively by `position`. `isPageInTree` walks the tree matching on
|
||||
* `slugId`. We build minimal page records (only the fields the builder reads).
|
||||
*/
|
||||
function page(p: Partial<IPage> & { id: string }): IPage {
|
||||
return {
|
||||
id: p.id,
|
||||
slugId: p.slugId ?? `slug-${p.id}`,
|
||||
title: p.title ?? p.id,
|
||||
icon: p.icon ?? "",
|
||||
position: p.position ?? "a0",
|
||||
spaceId: p.spaceId ?? "space-1",
|
||||
parentPageId: p.parentPageId ?? (null as unknown as string),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("buildSharedPageTree — nesting & sorting", () => {
|
||||
it("nests children under their parent and sorts siblings by position", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "root", slugId: "root-s", position: "a0" }),
|
||||
page({ id: "c2", slugId: "c2-s", parentPageId: "root", position: "a2" }),
|
||||
page({ id: "c1", slugId: "c1-s", parentPageId: "root", position: "a1" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
const root = tree[0];
|
||||
expect(root.slugId).toBe("root-s");
|
||||
expect(root.hasChildren).toBe(true);
|
||||
expect(root.children.map((c) => c.slugId)).toEqual(["c1-s", "c2-s"]);
|
||||
});
|
||||
|
||||
it("sorts top-level siblings by position", () => {
|
||||
// Positions: a-s=a1, c-s=a2, b-s=a3 -> sorted order is a1, a2, a3.
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "b", slugId: "b-s", position: "a3" }),
|
||||
page({ id: "a", slugId: "a-s", position: "a1" }),
|
||||
page({ id: "c", slugId: "c-s", position: "a2" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
expect(tree.map((n) => n.slugId)).toEqual(["a-s", "c-s", "b-s"]);
|
||||
});
|
||||
|
||||
it("sorts recursively at depth", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "root", slugId: "root-s", position: "a0" }),
|
||||
page({ id: "mid", slugId: "mid-s", parentPageId: "root", position: "a0" }),
|
||||
page({ id: "g2", slugId: "g2-s", parentPageId: "mid", position: "a5" }),
|
||||
page({ id: "g1", slugId: "g1-s", parentPageId: "mid", position: "a1" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
const mid = tree[0].children[0];
|
||||
expect(mid.slugId).toBe("mid-s");
|
||||
expect(mid.hasChildren).toBe(true);
|
||||
expect(mid.children.map((c) => c.slugId)).toEqual(["g1-s", "g2-s"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSharedPageTree — orphans & flags", () => {
|
||||
it("promotes a page whose parent is absent to a top-level node (no crash)", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "x", slugId: "x-s", parentPageId: "missing-parent" }),
|
||||
page({ id: "y", slugId: "y-s" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
const slugs = tree.map((n) => n.slugId).sort();
|
||||
expect(slugs).toEqual(["x-s", "y-s"]);
|
||||
});
|
||||
|
||||
it("leaves hasChildren false for leaf nodes", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "leaf", slugId: "leaf-s" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
expect(tree[0].hasChildren).toBe(false);
|
||||
expect(tree[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses 'untitled' as the label for an empty title", () => {
|
||||
const tree = buildSharedPageTree([
|
||||
page({ id: "z", slugId: "z-s", title: "" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
expect(tree[0].label).toBe("untitled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPageInTree", () => {
|
||||
const tree: SharedPageTreeNode[] = buildSharedPageTree([
|
||||
page({ id: "root", slugId: "root-s", position: "a0" }),
|
||||
page({ id: "child", slugId: "child-s", parentPageId: "root", position: "a1" }),
|
||||
page({ id: "grand", slugId: "grand-s", parentPageId: "child", position: "a1" }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
it("returns true for a top-level slugId", () => {
|
||||
expect(isPageInTree(tree, "root-s")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a deeply nested slugId", () => {
|
||||
expect(isPageInTree(tree, "grand-s")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an unknown slugId", () => {
|
||||
expect(isPageInTree(tree, "does-not-exist")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty tree", () => {
|
||||
expect(isPageInTree([], "root-s")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,6 @@ export function SpaceSidebar() {
|
||||
spaceName={space?.name}
|
||||
spaceSlug={space?.slug}
|
||||
spaceIcon={space?.logo}
|
||||
onSettings={openSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: rem(4px) var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.spaceName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import classes from "./switch-space.module.css";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import { Text, UnstyledButton } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import {
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
useGetSpacesQuery,
|
||||
} from "@/features/space/queries/space-query.ts";
|
||||
import { ISpace } from "../../types/space.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
@@ -19,7 +17,6 @@ interface SwitchSpaceProps {
|
||||
spaceName: string;
|
||||
spaceSlug: string;
|
||||
spaceIcon?: string;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
export function SwitchSpace({
|
||||
@@ -27,9 +24,7 @@ export function SwitchSpace({
|
||||
spaceName,
|
||||
spaceSlug,
|
||||
spaceIcon,
|
||||
onSettings,
|
||||
}: SwitchSpaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// Load every space the user belongs to (API caps limit at 100) and render
|
||||
// them as an always-visible grid instead of the previous searchable popover.
|
||||
@@ -59,31 +54,6 @@ export function SwitchSpace({
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<Group gap={6} wrap="nowrap" className={classes.header}>
|
||||
<CustomAvatar
|
||||
name={spaceName}
|
||||
avatarUrl={spaceIcon}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<Text className={classes.spaceName} size="md" fw={600} lineClamp={1}>
|
||||
{spaceName}
|
||||
</Text>
|
||||
<Tooltip label={t("Space settings")} withArrow position="top">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={onSettings}
|
||||
aria-label={t("Space settings")}
|
||||
>
|
||||
<IconSettings size={18} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<div className={classes.grid}>
|
||||
{spaces.map((space: ISpace) => (
|
||||
<UnstyledButton
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
IAiMcpServer,
|
||||
IAiMcpServerCreate,
|
||||
IAiMcpServerUpdate,
|
||||
McpTransport,
|
||||
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -62,13 +61,6 @@ function buildInitialValues(server?: IAiMcpServer): FormValues {
|
||||
};
|
||||
}
|
||||
|
||||
// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
|
||||
const TAVILY_PRESET = {
|
||||
name: "Tavily",
|
||||
transport: "http" as McpTransport,
|
||||
url: "https://mcp.tavily.com/mcp/",
|
||||
};
|
||||
|
||||
export default function AiMcpServerForm({
|
||||
server,
|
||||
onClose,
|
||||
@@ -155,28 +147,11 @@ export default function AiMcpServerForm({
|
||||
form.setFieldValue("authHeader", "");
|
||||
}
|
||||
|
||||
function applyTavilyPreset() {
|
||||
form.setFieldValue("name", TAVILY_PRESET.name);
|
||||
form.setFieldValue("transport", TAVILY_PRESET.transport);
|
||||
form.setFieldValue("url", TAVILY_PRESET.url);
|
||||
// Prefill the Bearer prefix; the admin pastes their Tavily key after it.
|
||||
form.setFieldValue("authHeader", "Bearer ");
|
||||
setHeadersCleared(false);
|
||||
}
|
||||
|
||||
const testResult = testMutation.data;
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{!isEdit && (
|
||||
<Group justify="flex-start">
|
||||
<Button variant="default" size="compact-sm" onClick={applyTavilyPreset}>
|
||||
{t("Use Tavily preset")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t("Server name")}
|
||||
{...form.getInputProps("name")}
|
||||
@@ -193,6 +168,11 @@ export default function AiMcpServerForm({
|
||||
|
||||
<PasswordInput
|
||||
label={t("Authorization header")}
|
||||
// Clarify that the value is sent verbatim as the Authorization header,
|
||||
// so the user supplies the full scheme (no implicit Bearer prefix).
|
||||
description={t(
|
||||
"Sent verbatim as the value of the Authorization header (e.g. \"Bearer <token>\" or \"Basic <base64>\").",
|
||||
)}
|
||||
// Placeholder hints whether headers are stored; the value is never shown.
|
||||
placeholder={hasHeaders ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
|
||||
@@ -42,6 +42,40 @@ import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiMcpServers from "./ai-mcp-servers.tsx";
|
||||
|
||||
// Curated ISO-639-1 dictation languages for the STT card. The empty-value
|
||||
// "Auto-detect" entry is prepended in render (it needs translation). Values
|
||||
// are sent verbatim to the transcription model as the language hint.
|
||||
const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ru", label: "Russian — Русский" },
|
||||
{ value: "uk", label: "Ukrainian — Українська" },
|
||||
{ value: "de", label: "German — Deutsch" },
|
||||
{ value: "fr", label: "French — Français" },
|
||||
{ value: "es", label: "Spanish — Español" },
|
||||
{ value: "it", label: "Italian — Italiano" },
|
||||
{ value: "pt", label: "Portuguese — Português" },
|
||||
{ value: "nl", label: "Dutch — Nederlands" },
|
||||
{ value: "pl", label: "Polish — Polski" },
|
||||
{ value: "tr", label: "Turkish — Türkçe" },
|
||||
{ value: "cs", label: "Czech — Čeština" },
|
||||
{ value: "sv", label: "Swedish — Svenska" },
|
||||
{ value: "fi", label: "Finnish — Suomi" },
|
||||
{ value: "da", label: "Danish — Dansk" },
|
||||
{ value: "no", label: "Norwegian — Norsk" },
|
||||
{ value: "ro", label: "Romanian — Română" },
|
||||
{ value: "hu", label: "Hungarian — Magyar" },
|
||||
{ value: "el", label: "Greek — Ελληνικά" },
|
||||
{ value: "he", label: "Hebrew — עברית" },
|
||||
{ value: "ar", label: "Arabic — العربية" },
|
||||
{ value: "hi", label: "Hindi — हिन्दी" },
|
||||
{ value: "id", label: "Indonesian — Bahasa Indonesia" },
|
||||
{ value: "vi", label: "Vietnamese — Tiếng Việt" },
|
||||
{ value: "th", label: "Thai — ไทย" },
|
||||
{ value: "ja", label: "Japanese — 日本語" },
|
||||
{ value: "ko", label: "Korean — 한국어" },
|
||||
{ value: "zh", label: "Chinese — 中文" },
|
||||
];
|
||||
|
||||
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
|
||||
// the user-editable fields. `apiKey` / `embeddingApiKey` are write-only buffers
|
||||
// (empty means "leave unchanged" unless explicitly cleared).
|
||||
@@ -63,6 +97,8 @@ const formSchema = z.object({
|
||||
sttModel: z.string(),
|
||||
sttBaseUrl: z.string(),
|
||||
sttApiStyle: z.enum(["multipart", "json"]),
|
||||
// ISO-639-1 dictation language; empty = auto-detect.
|
||||
sttLanguage: z.string(),
|
||||
sttApiKey: z.string(),
|
||||
});
|
||||
|
||||
@@ -233,6 +269,7 @@ export default function AiProviderSettings() {
|
||||
sttModel: "",
|
||||
sttBaseUrl: "",
|
||||
sttApiStyle: "multipart" as SttApiStyle,
|
||||
sttLanguage: "",
|
||||
sttApiKey: "",
|
||||
},
|
||||
});
|
||||
@@ -254,6 +291,7 @@ export default function AiProviderSettings() {
|
||||
sttModel: settings.sttModel ?? "",
|
||||
sttBaseUrl: settings.sttBaseUrl ?? "",
|
||||
sttApiStyle: settings.sttApiStyle ?? "multipart",
|
||||
sttLanguage: settings.sttLanguage ?? "",
|
||||
sttApiKey: "",
|
||||
});
|
||||
form.resetDirty();
|
||||
@@ -288,6 +326,7 @@ export default function AiProviderSettings() {
|
||||
sttModel: values.sttModel,
|
||||
sttBaseUrl: values.sttBaseUrl,
|
||||
sttApiStyle: values.sttApiStyle,
|
||||
sttLanguage: values.sttLanguage,
|
||||
};
|
||||
|
||||
// Key semantics (never send the stored key back) — see resolveKeyField:
|
||||
@@ -923,6 +962,22 @@ export default function AiProviderSettings() {
|
||||
{...form.getInputProps("sttApiStyle")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
mt="sm"
|
||||
label={t("Dictation language")}
|
||||
description={t(
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
|
||||
)}
|
||||
data={[
|
||||
{ value: "", label: t("Auto-detect") },
|
||||
...STT_LANGUAGE_OPTIONS,
|
||||
]}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("sttLanguage")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
mt="sm"
|
||||
label={t("Base URL")}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface IAiSettings {
|
||||
sttModel?: string;
|
||||
sttBaseUrl?: string;
|
||||
sttApiStyle?: SttApiStyle;
|
||||
// ISO-639-1 dictation language; empty = auto-detect.
|
||||
sttLanguage?: string;
|
||||
hasSttApiKey: boolean;
|
||||
// RAG indexing coverage (pages indexed for semantic search).
|
||||
indexedPages: number;
|
||||
@@ -60,6 +62,8 @@ export interface IAiSettingsUpdate {
|
||||
sttModel?: string;
|
||||
sttBaseUrl?: string;
|
||||
sttApiStyle?: SttApiStyle;
|
||||
// ISO-639-1 dictation language; empty = auto-detect.
|
||||
sttLanguage?: string;
|
||||
// Write-only STT key (same semantics as `apiKey` / `embeddingApiKey`).
|
||||
sttApiKey?: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ i18n
|
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
// i18n maintenance policy:
|
||||
// - en-US is the source of truth for all UI strings (keys are the English text).
|
||||
// - en-US and ru-RU are the fully-maintained locales; in particular, the
|
||||
// AI-chat string set is kept complete in both so the UI never renders
|
||||
// mixed-language (no per-key en-US fallback within a single widget).
|
||||
// - The other 10 locales (fr-FR, de-DE, es-ES, nl-NL, ja-JP, zh-CN, ko-KR,
|
||||
// pt-BR, it-IT, uk-UA) are partial and intentionally rely on the
|
||||
// `fallbackLng: "en-US"` fallback below until translations are
|
||||
// contributed (e.g. via Crowdin).
|
||||
fallbackLng: "en-US",
|
||||
debug: false,
|
||||
showSupportNotice: false,
|
||||
|
||||
BIN
apps/client/src/lib/app-route.safe-redirect.test.ts
Normal file
BIN
apps/client/src/lib/app-route.safe-redirect.test.ts
Normal file
Binary file not shown.
13
apps/client/src/pages/dashboard/home.module.css
Normal file
13
apps/client/src/pages/dashboard/home.module.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.container {
|
||||
/* Default top padding for tablet/desktop (replaces the former pt="xl") */
|
||||
padding-top: var(--mantine-spacing-xl);
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
.container {
|
||||
/* On phones drop the extra side padding (AppShell already provides the
|
||||
outer gap) and shrink the top gap below the header. */
|
||||
padding-inline: 0;
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Container, Space } from "@mantine/core";
|
||||
import HomeTabs from "@/features/home/components/home-tabs";
|
||||
import NewNoteButton from "@/features/home/components/new-note-button";
|
||||
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./home.module.css";
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,7 +17,11 @@ export default function Home() {
|
||||
{t("Home")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size={"900"} pt="xl">
|
||||
<Container size={"900"} className={classes.container}>
|
||||
<NewNoteButton />
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
<SpaceCarousel />
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
@@ -12,6 +12,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: [],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
51
apps/client/vitest.setup.ts
Normal file
51
apps/client/vitest.setup.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Vitest global setup (test-infra only — no production app source).
|
||||
//
|
||||
// Under Node 25 / jsdom 25 / vitest 4 the jsdom `localStorage` exposed on the
|
||||
// global is not a usable Storage: its methods (`setItem`/`getItem`/...) are not
|
||||
// callable, so any code touching `localStorage` throws `... is not a function`.
|
||||
// Production code such as `isHtmlEmbedFeatureEnabled()` reads
|
||||
// `localStorage.getItem("currentUser")`, which made dependent tests fail.
|
||||
//
|
||||
// We install a correct in-memory Storage stub on the global BEFORE tests run so
|
||||
// the Web Storage contract holds: string coercion of keys/values, `null` for
|
||||
// missing keys, working `length`/`key(index)`, and `clear()`.
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Minimal, spec-faithful in-memory implementation of the Web Storage API.
|
||||
function createStorage(): Storage {
|
||||
let store = new Map<string, string>();
|
||||
|
||||
const storage: Storage = {
|
||||
get length(): number {
|
||||
return store.size;
|
||||
},
|
||||
clear(): void {
|
||||
store = new Map<string, string>();
|
||||
},
|
||||
getItem(key: string): string | null {
|
||||
// Missing keys must return `null`, not `undefined`.
|
||||
const value = store.get(String(key));
|
||||
return value === undefined ? null : value;
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
// Web Storage coerces both key and value to strings.
|
||||
store.set(String(key), String(value));
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
store.delete(String(key));
|
||||
},
|
||||
key(index: number): string | null {
|
||||
// Insertion order matches Map iteration order; out-of-range => null.
|
||||
const keys = Array.from(store.keys());
|
||||
return index >= 0 && index < keys.length ? keys[index] : null;
|
||||
},
|
||||
};
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
// Install on the jsdom global. `vi.stubGlobal` also reflects onto `window`
|
||||
// (jsdom shares `globalThis` and `window`), so both `localStorage` and
|
||||
// `window.localStorage` resolve to the same working stub.
|
||||
vi.stubGlobal("localStorage", createStorage());
|
||||
vi.stubGlobal("sessionStorage", createStorage());
|
||||
@@ -25,6 +25,7 @@
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
|
||||
243
apps/server/src/collaboration/collaboration.util.spec.ts
Normal file
243
apps/server/src/collaboration/collaboration.util.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as Y from 'yjs';
|
||||
import {
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToNode,
|
||||
prosemirrorNodeToYElement,
|
||||
} from './collaboration.util';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
// Collect every node type name in a ProseMirror Node, in document order.
|
||||
const collectTypes = (node: Node): string[] => {
|
||||
const types: string[] = [];
|
||||
node.descendants((n) => {
|
||||
types.push(n.type.name);
|
||||
});
|
||||
return types;
|
||||
};
|
||||
|
||||
// Yjs types throw "Invalid access" until attached to a document, so every
|
||||
// produced Y element must be inserted into a fragment before it is inspected.
|
||||
const attach = (json: any): any => {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const element = prosemirrorNodeToYElement(json);
|
||||
fragment.insert(0, [element as any]);
|
||||
return element;
|
||||
};
|
||||
|
||||
describe('getPageId', () => {
|
||||
it('extracts the uuid from a "page.<uuid>" document name', () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(getPageId(`page.${uuid}`)).toBe(uuid);
|
||||
});
|
||||
|
||||
it('returns undefined when the name has no separator', () => {
|
||||
// Auth keying depends on this: a malformed name must not yield a stray id.
|
||||
expect(getPageId('justaname')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the second segment only, ignoring extra dotted parts', () => {
|
||||
expect(getPageId('page.abc.def')).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns an empty string for a trailing dot', () => {
|
||||
expect(getPageId('page.')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptyParagraphDoc', () => {
|
||||
it('returns true for a doc with a single empty paragraph', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({ type: 'doc', content: [{ type: 'paragraph' }] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a single paragraph with an empty content array', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [] }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a paragraph containing text', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a doc with more than one child', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }, { type: 'paragraph' }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the single child is not a paragraph', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({
|
||||
type: 'doc',
|
||||
content: [{ type: 'heading', attrs: { level: 1 } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the root is not a "doc"', () => {
|
||||
expect(
|
||||
isEmptyParagraphDoc({ type: 'paragraph', content: [] } as any),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null / undefined input', () => {
|
||||
expect(isEmptyParagraphDoc(null as any)).toBe(false);
|
||||
expect(isEmptyParagraphDoc(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnknownNodes (via jsonToNode fallback)', () => {
|
||||
it('drops an unknown leaf node while keeping known siblings', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'keep' }] },
|
||||
{ type: 'totallyUnknownLeaf', attrs: {} },
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
// Only the paragraph + its text remain; the unknown leaf is gone.
|
||||
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
|
||||
expect(node.textContent).toBe('keep');
|
||||
});
|
||||
|
||||
it('unwraps an unknown WRAPPER, flattening its children (no content loss)', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'unknownWrapper',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'inside' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
// The wrapper disappears but its paragraph child is lifted up, not deleted.
|
||||
expect(collectTypes(node)).toEqual(['paragraph', 'text']);
|
||||
expect(node.textContent).toBe('inside');
|
||||
});
|
||||
|
||||
it('leaves an entirely known document untouched', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: 'text', text: 'b' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
expect(collectTypes(node)).toEqual([
|
||||
'paragraph',
|
||||
'text',
|
||||
'heading',
|
||||
'text',
|
||||
]);
|
||||
expect(node.textContent).toBe('ab');
|
||||
});
|
||||
|
||||
it('drops an unknown inline nested inside a known node', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'weirdInline' },
|
||||
{ type: 'text', text: 'b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const node = jsonToNode(json);
|
||||
// The unknown inline is silently removed; surrounding text survives.
|
||||
expect(node.textContent).toBe('ab');
|
||||
expect(collectTypes(node)).toEqual(['paragraph', 'text', 'text']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prosemirrorNodeToYElement', () => {
|
||||
it('produces a Y.XmlText carrying mark attrs as format on a marked text node', () => {
|
||||
const ytext = attach({
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
marks: [{ type: 'bold', attrs: { level: 2 } }, { type: 'italic' }],
|
||||
});
|
||||
const delta = ytext.toDelta();
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].insert).toBe('hi');
|
||||
// mark.attrs is used when present, otherwise `true` (the `|| true` path).
|
||||
expect(delta[0].attributes).toEqual({
|
||||
bold: { level: 2 },
|
||||
italic: true,
|
||||
});
|
||||
expect(ytext.length).toBe(2);
|
||||
});
|
||||
|
||||
it('produces a plain Y.XmlText with no format for an unmarked text node', () => {
|
||||
const ytext = attach({ type: 'text', text: 'plain' });
|
||||
const delta = ytext.toDelta();
|
||||
expect(delta).toEqual([{ insert: 'plain' }]);
|
||||
expect(ytext.length).toBe(5);
|
||||
});
|
||||
|
||||
it('sets element attributes, skipping null and undefined values', () => {
|
||||
const element = attach({
|
||||
type: 'paragraph',
|
||||
attrs: { textAlign: 'left', indent: 0, anchorId: null, ghost: undefined },
|
||||
content: [{ type: 'text', text: 'abc' }],
|
||||
});
|
||||
expect(element.nodeName).toBe('paragraph');
|
||||
expect(element.getAttribute('textAlign')).toBe('left');
|
||||
// indent is 0 (falsy but defined) -> must still be set.
|
||||
expect(element.getAttribute('indent')).toBe(0);
|
||||
// null / undefined attrs are skipped, never set.
|
||||
expect(element.getAttribute('anchorId')).toBeUndefined();
|
||||
expect(element.getAttribute('ghost')).toBeUndefined();
|
||||
expect(element.getAttributes()).toEqual({ textAlign: 'left', indent: 0 });
|
||||
});
|
||||
|
||||
it('creates an element with no attributes when attrs is absent', () => {
|
||||
const element = attach({ type: 'horizontalRule' });
|
||||
expect(element.nodeName).toBe('horizontalRule');
|
||||
expect(element.getAttributes()).toEqual({});
|
||||
expect(element.length).toBe(0);
|
||||
});
|
||||
|
||||
it('recurses into nested content preserving order', () => {
|
||||
const element = attach({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
|
||||
],
|
||||
});
|
||||
// Two child paragraphs, in original order.
|
||||
expect(element.length).toBe(2);
|
||||
expect(element.get(0).get(0).toString()).toBe('one');
|
||||
expect(element.get(1).get(0).toString()).toBe('two');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthenticationExtension } from './authentication.extension';
|
||||
import { SpaceRole } from '../../common/helpers/types/permission';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
|
||||
/**
|
||||
* Unit tests for the collab read-only downgrade matrix in
|
||||
* `AuthenticationExtension.onAuthenticate`. This is a security boundary: a wrong
|
||||
* branch here is either a collab-auth bypass (writer on a page they may only
|
||||
* read) or a denial. We mock every repo and inspect both the thrown exception
|
||||
* type and the `connectionConfig.readOnly` flag the extension mutates.
|
||||
*/
|
||||
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const USER_ID = 'user-1';
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
const SPACE_ID = 'space-1';
|
||||
|
||||
const buildUser = (overrides: Partial<any> = {}) => ({
|
||||
id: USER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
name: 'Alice',
|
||||
avatarUrl: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildPage = (overrides: Partial<any> = {}) => ({
|
||||
id: PAGE_ID,
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Default jwt payload: a plain human collab token (no agent provenance claims).
|
||||
const buildJwt = (overrides: Partial<any> = {}) => ({
|
||||
sub: USER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
type: JwtType.COLLAB,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AuthenticationExtension.onAuthenticate', () => {
|
||||
let ext: AuthenticationExtension;
|
||||
let tokenService: { verifyJwt: jest.Mock };
|
||||
let userRepo: { findById: jest.Mock };
|
||||
let pageRepo: { findById: jest.Mock };
|
||||
let spaceMemberRepo: { getUserSpaceRoles: jest.Mock };
|
||||
let pagePermissionRepo: { canUserEditPage: jest.Mock };
|
||||
|
||||
// Build the hocuspocus onAuthenticate payload. connectionConfig.readOnly
|
||||
// starts false; the extension flips it to true on a read-only downgrade.
|
||||
const buildData = (token = 'tok') => ({
|
||||
documentName: `page.${PAGE_ID}`,
|
||||
token,
|
||||
connectionConfig: { readOnly: false },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tokenService = { verifyJwt: jest.fn().mockResolvedValue(buildJwt()) };
|
||||
userRepo = { findById: jest.fn().mockResolvedValue(buildUser()) };
|
||||
pageRepo = { findById: jest.fn().mockResolvedValue(buildPage()) };
|
||||
spaceMemberRepo = {
|
||||
getUserSpaceRoles: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ userId: USER_ID, role: SpaceRole.WRITER }]),
|
||||
};
|
||||
pagePermissionRepo = {
|
||||
// No page-level restriction by default → defer to space role.
|
||||
canUserEditPage: jest.fn().mockResolvedValue({
|
||||
hasAnyRestriction: false,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
};
|
||||
|
||||
ext = new AuthenticationExtension(
|
||||
tokenService as any,
|
||||
userRepo as any,
|
||||
pageRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
);
|
||||
// Silence the extension's logger (it warns/debugs on denial branches).
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('invalid token → UnauthorizedException (no repo lookups happen)', async () => {
|
||||
tokenService.verifyJwt.mockRejectedValue(new Error('bad sig'));
|
||||
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(userRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('user not found → Unauthorized', async () => {
|
||||
userRepo.findById.mockResolvedValue(null);
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('user disabled (deactivatedAt set) → Unauthorized', async () => {
|
||||
userRepo.findById.mockResolvedValue(
|
||||
buildUser({ deactivatedAt: new Date() }),
|
||||
);
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('page not found → NotFoundException', async () => {
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('no space role → Unauthorized', async () => {
|
||||
spaceMemberRepo.getUserSpaceRoles.mockResolvedValue([]);
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('page-level restriction canAccess=false → Unauthorized (restricted-page denial)', async () => {
|
||||
pagePermissionRepo.canUserEditPage.mockResolvedValue({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: false,
|
||||
});
|
||||
await expect(ext.onAuthenticate(buildData() as any)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('restriction canAccess=true & canEdit=false → readOnly (no restricted-page write)', async () => {
|
||||
pagePermissionRepo.canUserEditPage.mockResolvedValue({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: false,
|
||||
});
|
||||
const data = buildData();
|
||||
const ctx = await ext.onAuthenticate(data as any);
|
||||
|
||||
expect(data.connectionConfig.readOnly).toBe(true);
|
||||
expect(ctx.actor).toBe('user');
|
||||
});
|
||||
|
||||
it('restriction canAccess=true & canEdit=true → writable (readOnly stays false)', async () => {
|
||||
pagePermissionRepo.canUserEditPage.mockResolvedValue({
|
||||
hasAnyRestriction: true,
|
||||
canAccess: true,
|
||||
canEdit: true,
|
||||
});
|
||||
const data = buildData();
|
||||
await ext.onAuthenticate(data as any);
|
||||
|
||||
expect(data.connectionConfig.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('no restriction + space READER → readOnly', async () => {
|
||||
spaceMemberRepo.getUserSpaceRoles.mockResolvedValue([
|
||||
{ userId: USER_ID, role: SpaceRole.READER },
|
||||
]);
|
||||
const data = buildData();
|
||||
await ext.onAuthenticate(data as any);
|
||||
|
||||
expect(data.connectionConfig.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('no restriction + space WRITER → writable', async () => {
|
||||
const data = buildData();
|
||||
await ext.onAuthenticate(data as any);
|
||||
expect(data.connectionConfig.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('soft-deleted page (deletedAt set) → readOnly even for a WRITER', async () => {
|
||||
// A writer must NOT be able to mutate a page in the trash via collab.
|
||||
pageRepo.findById.mockResolvedValue(buildPage({ deletedAt: new Date() }));
|
||||
const data = buildData();
|
||||
await ext.onAuthenticate(data as any);
|
||||
|
||||
expect(data.connectionConfig.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('agent JWT (actor=agent + aiChatId) propagates into the connection context', async () => {
|
||||
tokenService.verifyJwt.mockResolvedValue(
|
||||
buildJwt({ actor: 'agent', aiChatId: 'chat-7' }),
|
||||
);
|
||||
const ctx = await ext.onAuthenticate(buildData() as any);
|
||||
|
||||
expect(ctx.actor).toBe('agent');
|
||||
expect(ctx.aiChatId).toBe('chat-7');
|
||||
expect(ctx.user.id).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it('human JWT (no provenance claims) → actor=user, aiChatId=null', async () => {
|
||||
const ctx = await ext.onAuthenticate(buildData() as any);
|
||||
|
||||
expect(ctx.actor).toBe('user');
|
||||
expect(ctx.aiChatId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
computeHistoryJob,
|
||||
resolveSource,
|
||||
} from './persistence.extension';
|
||||
import {
|
||||
HISTORY_FAST_INTERVAL,
|
||||
HISTORY_FAST_THRESHOLD,
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
|
||||
// A fixed clock + fixed createdAt make pageAge deterministic.
|
||||
const NOW = 1_700_000_000_000;
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
|
||||
const pageAged = (ageMs: number) => ({
|
||||
id: PAGE_ID,
|
||||
createdAt: new Date(NOW - ageMs),
|
||||
});
|
||||
|
||||
describe('computeHistoryJob', () => {
|
||||
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
|
||||
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
|
||||
// The worker re-reads the page row at run time, so any non-zero delay risks
|
||||
// snapshotting content a later human edit has already overwritten. This is
|
||||
// the load-bearing assertion of this spec — do not relax it.
|
||||
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
|
||||
expect(delay).toBe(0);
|
||||
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
||||
});
|
||||
|
||||
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
|
||||
// Even when the page is far older than the fast threshold, the agent path
|
||||
// must short-circuit to 0 — age-based debounce is a human-only concern.
|
||||
const { jobId, delay } = computeHistoryJob(
|
||||
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
|
||||
'agent',
|
||||
NOW,
|
||||
);
|
||||
expect(delay).toBe(0);
|
||||
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
||||
});
|
||||
|
||||
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
|
||||
const { jobId, delay } = computeHistoryJob(
|
||||
pageAged(HISTORY_FAST_THRESHOLD - 1),
|
||||
'user',
|
||||
NOW,
|
||||
);
|
||||
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
||||
expect(jobId).toBe(PAGE_ID);
|
||||
});
|
||||
|
||||
it('human edit on an OLD page (age > threshold) → standard interval', () => {
|
||||
const { jobId, delay } = computeHistoryJob(
|
||||
pageAged(HISTORY_FAST_THRESHOLD + 1),
|
||||
'user',
|
||||
NOW,
|
||||
);
|
||||
expect(delay).toBe(HISTORY_INTERVAL);
|
||||
expect(jobId).toBe(PAGE_ID);
|
||||
});
|
||||
|
||||
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
|
||||
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
|
||||
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
|
||||
const { delay } = computeHistoryJob(
|
||||
pageAged(HISTORY_FAST_THRESHOLD),
|
||||
'user',
|
||||
NOW,
|
||||
);
|
||||
expect(delay).toBe(HISTORY_INTERVAL);
|
||||
});
|
||||
|
||||
it('treats any non-"agent" source string as human', () => {
|
||||
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
|
||||
// the agent branch keys strictly on === 'agent'.
|
||||
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
|
||||
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
||||
expect(jobId).toBe(PAGE_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSource (truth table)', () => {
|
||||
// (sticky, actor) → expected. Marker is OR of the sticky flag and actor==='agent'.
|
||||
it('sticky=false, actor=user → user', () => {
|
||||
expect(resolveSource(false, 'user')).toBe('user');
|
||||
});
|
||||
|
||||
it('sticky=true, actor=user → agent (sticky wins)', () => {
|
||||
expect(resolveSource(true, 'user')).toBe('agent');
|
||||
});
|
||||
|
||||
it('sticky=false, actor=agent → agent (current writer is the agent)', () => {
|
||||
expect(resolveSource(false, 'agent')).toBe('agent');
|
||||
});
|
||||
|
||||
it('sticky=true, actor=agent → agent', () => {
|
||||
expect(resolveSource(true, 'agent')).toBe('agent');
|
||||
});
|
||||
|
||||
it('sticky=false, actor=undefined → user (human collab path omits the claim)', () => {
|
||||
expect(resolveSource(false, undefined)).toBe('user');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import { tiptapExtensions } from '../collaboration.util';
|
||||
|
||||
/**
|
||||
* Integration test for `onStoreDocument`'s Approach-A boundary snapshot.
|
||||
*
|
||||
* The data-loss risk: when an AGENT store lands over a page whose persisted
|
||||
* state was authored by a HUMAN, the agent overwrites that human content. If we
|
||||
* do not pin the human revision as its own history version BEFORE the agent's
|
||||
* updatePage, the last human edit is lost. This test pins the ordering
|
||||
* (saveHistory(oldHumanPage) strictly before updatePage) and the idempotency
|
||||
* skip when content is unchanged.
|
||||
*
|
||||
* We pass a REAL Y.Doc as the `document` arg (so TiptapTransformer.fromYdoc
|
||||
* yields real content) and stub repos/queues + an executeTx-compatible db whose
|
||||
* transaction().execute() invokes the callback with a trx stub.
|
||||
*/
|
||||
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const USER_ID = 'human-1';
|
||||
|
||||
// Build a real Y.Doc carrying the given tiptap JSON in the 'default' fragment.
|
||||
// hocuspocus augments the live document with broadcastStateless(); the bare
|
||||
// Y.Doc lacks it, so stub it for the post-store broadcast.
|
||||
const ydocFor = (json: any) => {
|
||||
const ydoc = TiptapTransformer.toYdoc(json, 'default', tiptapExtensions);
|
||||
(ydoc as any).broadcastStateless = jest.fn();
|
||||
return ydoc;
|
||||
};
|
||||
|
||||
const doc = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot', () => {
|
||||
let ext: PersistenceExtension;
|
||||
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
|
||||
let pageHistoryRepo: {
|
||||
saveHistory: jest.Mock;
|
||||
findPageLastHistory: jest.Mock;
|
||||
};
|
||||
let aiQueue: { add: jest.Mock };
|
||||
let historyQueue: { add: jest.Mock };
|
||||
let notificationQueue: { add: jest.Mock };
|
||||
let collabHistory: { addContributors: jest.Mock };
|
||||
let transclusionService: {
|
||||
syncPageTransclusions: jest.Mock;
|
||||
syncPageReferences: jest.Mock;
|
||||
syncPageTemplateReferences: jest.Mock;
|
||||
};
|
||||
let callOrder: string[];
|
||||
|
||||
// db whose transaction().execute(fn) runs fn with a trx stub — this lets the
|
||||
// real executeTx() helper drive the callback without a database.
|
||||
const trxStub = { __trx: true };
|
||||
const db = {
|
||||
transaction: () => ({
|
||||
execute: (fn: (trx: any) => Promise<any>) => fn(trxStub),
|
||||
}),
|
||||
};
|
||||
|
||||
// The persisted page row the transaction reads (OLD, human-authored state).
|
||||
const persistedHumanPage = (newAgentText: string) => ({
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
createdAt: new Date('2020-01-01T00:00:00Z'),
|
||||
lastUpdatedSource: 'user', // prior revision was human
|
||||
// content differs from the new agent doc so the update branch runs.
|
||||
content: doc('OLD HUMAN'),
|
||||
_newAgentText: newAgentText,
|
||||
});
|
||||
|
||||
const buildData = (document: any, actor: 'user' | 'agent') => ({
|
||||
documentName: `page.${PAGE_ID}`,
|
||||
document,
|
||||
context: { user: { id: USER_ID, name: 'Alice' }, actor },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
callOrder = [];
|
||||
pageRepo = {
|
||||
findById: jest.fn(),
|
||||
updatePage: jest.fn().mockImplementation(async () => {
|
||||
callOrder.push('updatePage');
|
||||
}),
|
||||
};
|
||||
pageHistoryRepo = {
|
||||
saveHistory: jest.fn().mockImplementation(async () => {
|
||||
callOrder.push('saveHistory');
|
||||
}),
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('agent store over a human page pins saveHistory(oldHumanPage) BEFORE updatePage', async () => {
|
||||
const document = ydocFor(doc('NEW AGENT CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
|
||||
// No human baseline snapshot exists yet → boundary snapshot must run.
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'agent') as any);
|
||||
|
||||
// Boundary snapshot fired, and strictly before the agent overwrite.
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
||||
const saved = pageHistoryRepo.saveHistory.mock.calls[0][0];
|
||||
expect(saved.content).toEqual(doc('OLD HUMAN')); // the OLD human revision
|
||||
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
||||
|
||||
// The agent's new content is tagged 'agent' on the update.
|
||||
const update = pageRepo.updatePage.mock.calls[0][0];
|
||||
expect(update.lastUpdatedSource).toBe('agent');
|
||||
});
|
||||
|
||||
it('skips the boundary snapshot when the human baseline is already pinned', async () => {
|
||||
const document = ydocFor(doc('NEW AGENT CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
|
||||
// Latest history already equals the current human state → no duplicate.
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||
content: doc('OLD HUMAN'),
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'agent') as any);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('human store does NOT trigger the boundary snapshot (no source transition)', async () => {
|
||||
const document = ydocFor(doc('NEW HUMAN CONTENT'));
|
||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
||||
});
|
||||
|
||||
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
||||
// The Y.Doc content equals the persisted content deeply → early skip.
|
||||
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
||||
// the persisted content from fromYdoc to make the deep-equal skip genuine.
|
||||
const document = ydocFor(doc('SAME CONTENT'));
|
||||
const normalized = TiptapTransformer.fromYdoc(document, 'default');
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('SAME CONTENT'),
|
||||
content: normalized,
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'agent') as any);
|
||||
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,52 @@ import {
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
|
||||
/**
|
||||
* Resolve the provenance source for a coalesced snapshot.
|
||||
*
|
||||
* The snapshot is tagged 'agent' if any agent edit landed in the coalescing
|
||||
* window (sticky marker) OR if the current writer is the agent; otherwise
|
||||
* 'user'. Pure so the §15 H2 marker logic is unit-testable in isolation.
|
||||
*/
|
||||
export function resolveSource(
|
||||
stickyTouched: boolean,
|
||||
contextActor?: string,
|
||||
): 'agent' | 'user' {
|
||||
return stickyTouched || contextActor === 'agent' ? 'agent' : 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
|
||||
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
|
||||
* (caller passes `Date.now()`) for determinism.
|
||||
*
|
||||
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
|
||||
* delay MUST stay 0 — the worker re-reads the page row at run time, so any
|
||||
* delay risks reading content a later human edit has already overwritten
|
||||
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
|
||||
* the job from coalescing with the bare-page.id human job.
|
||||
* - Human edits: age-based debounce so rapid human edits coalesce into one
|
||||
* snapshot; job id is the bare `page.id`.
|
||||
*
|
||||
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
|
||||
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
|
||||
*/
|
||||
export function computeHistoryJob(
|
||||
page: Pick<Page, 'id' | 'createdAt'>,
|
||||
source: string,
|
||||
now: number,
|
||||
): { jobId: string; delay: number } {
|
||||
const isAgent = source === 'agent';
|
||||
const pageAge = now - new Date(page.createdAt).getTime();
|
||||
const delay = isAgent
|
||||
? 0
|
||||
: pageAge < HISTORY_FAST_THRESHOLD
|
||||
? HISTORY_FAST_INTERVAL
|
||||
: HISTORY_INTERVAL;
|
||||
const jobId = isAgent ? `${page.id}-agent` : page.id;
|
||||
return { jobId, delay };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
private readonly logger = new Logger(PersistenceExtension.name);
|
||||
@@ -129,9 +175,10 @@ export class PersistenceExtension implements Extension {
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
// agent event in the same window). §15 H2.
|
||||
const agentTouched =
|
||||
this.consumeAgentTouched(documentName) || context?.actor === 'agent';
|
||||
const lastUpdatedSource = agentTouched ? 'agent' : 'user';
|
||||
const lastUpdatedSource = resolveSource(
|
||||
this.consumeAgentTouched(documentName),
|
||||
context?.actor,
|
||||
);
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
@@ -311,24 +358,13 @@ export class PersistenceExtension implements Extension {
|
||||
page: Page,
|
||||
lastUpdatedSource: string,
|
||||
): Promise<void> {
|
||||
// Agent edits get an immediate, source-keyed history job: they snapshot
|
||||
// deterministically as 'agent' and a later human edit (jobId = page.id)
|
||||
// cannot coalesce/retag them. Human edits keep the age-based debounce so
|
||||
// rapid human edits still coalesce into one snapshot.
|
||||
// NOTE: the agent delay MUST stay 0 — the worker re-reads the page row at
|
||||
// run time, so any delay would risk reading content a later human edit has
|
||||
// already overwritten (mis-tagged snapshot). 0 minimizes that window.
|
||||
const isAgent = lastUpdatedSource === 'agent';
|
||||
const pageAge = Date.now() - new Date(page.createdAt).getTime();
|
||||
const delay = isAgent
|
||||
? 0
|
||||
: pageAge < HISTORY_FAST_THRESHOLD
|
||||
? HISTORY_FAST_INTERVAL
|
||||
: HISTORY_INTERVAL;
|
||||
// BullMQ forbids ':' in custom job IDs (it is the Redis key separator), so
|
||||
// use '-' here. page.id is a UUID, so `${page.id}-agent` cannot collide with
|
||||
// any human job whose id is a bare page.id.
|
||||
const jobId = isAgent ? `${page.id}-agent` : page.id;
|
||||
// Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
|
||||
// doc comment for the agent-delay-0 / age-based-debounce invariants).
|
||||
const { jobId, delay } = computeHistoryJob(
|
||||
page,
|
||||
lastUpdatedSource,
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
await this.historyQueue.add(
|
||||
QueueJob.PAGE_HISTORY,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { HistoryProcessor } from './history.processor';
|
||||
import { QueueJob } from '../../integrations/queue/constants';
|
||||
|
||||
/**
|
||||
* Unit tests for `HistoryProcessor.process`. This worker is the last line of
|
||||
* defense for the page-history snapshot, so we pin the data-loss-sensitive
|
||||
* paths: duplicate/empty history skipping (isDeepStrictEqual), and — critically
|
||||
* — that a saveHistory failure RESTORES the contributors it popped (otherwise
|
||||
* the contributor set is silently lost) before rethrowing.
|
||||
*/
|
||||
|
||||
const PAGE_ID = 'page-1';
|
||||
const SPACE_ID = 'space-1';
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
|
||||
// A non-empty content doc (distinct from the empty-paragraph doc).
|
||||
const filledContent = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
||||
};
|
||||
const emptyContent = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
const buildPage = (overrides: Partial<any> = {}) => ({
|
||||
id: PAGE_ID,
|
||||
spaceId: SPACE_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
content: filledContent,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildJob = (overrides: Partial<any> = {}) =>
|
||||
({
|
||||
name: QueueJob.PAGE_HISTORY,
|
||||
data: { pageId: PAGE_ID },
|
||||
...overrides,
|
||||
}) as unknown as Job<any, void>;
|
||||
|
||||
describe('HistoryProcessor.process', () => {
|
||||
let proc: HistoryProcessor;
|
||||
let pageHistoryRepo: { findPageLastHistory: jest.Mock; saveHistory: jest.Mock };
|
||||
let pageRepo: { findById: jest.Mock };
|
||||
let collabHistory: {
|
||||
clearContributors: jest.Mock;
|
||||
popContributors: jest.Mock;
|
||||
addContributors: jest.Mock;
|
||||
};
|
||||
let watcherService: { addPageWatchers: jest.Mock };
|
||||
let notificationQueue: { add: jest.Mock };
|
||||
let generalQueue: { add: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
pageHistoryRepo = {
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageRepo = { findById: jest.fn().mockResolvedValue(buildPage()) };
|
||||
collabHistory = {
|
||||
clearContributors: jest.fn().mockResolvedValue(undefined),
|
||||
popContributors: jest.fn().mockResolvedValue(['u1', 'u2']),
|
||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
watcherService = {
|
||||
addPageWatchers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
||||
// matches the constructor and avoids the Nest DI container.
|
||||
proc = new HistoryProcessor(
|
||||
pageHistoryRepo as any,
|
||||
pageRepo as any,
|
||||
collabHistory as any,
|
||||
watcherService as any,
|
||||
notificationQueue as any,
|
||||
generalQueue as any,
|
||||
);
|
||||
jest.spyOn(proc['logger'], 'debug').mockImplementation(() => undefined);
|
||||
jest.spyOn(proc['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(proc['logger'], 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('ignores jobs whose name is not PAGE_HISTORY (no page lookup)', async () => {
|
||||
await proc.process(buildJob({ name: 'some.other.job' }));
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('page not found → clearContributors and return (no save)', async () => {
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await proc.process(buildJob());
|
||||
|
||||
expect(collabHistory.clearContributors).toHaveBeenCalledWith(PAGE_ID);
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(collabHistory.popContributors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('first history + empty content → skip and clear contributors (no save)', async () => {
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||
pageRepo.findById.mockResolvedValue(buildPage({ content: emptyContent }));
|
||||
|
||||
await proc.process(buildJob());
|
||||
|
||||
expect(collabHistory.clearContributors).toHaveBeenCalledWith(PAGE_ID);
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('content unchanged vs last history → no save (isDeepStrictEqual skip)', async () => {
|
||||
// Last history holds a deep-equal-but-distinct copy of current content.
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||
content: JSON.parse(JSON.stringify(filledContent)),
|
||||
});
|
||||
|
||||
await proc.process(buildJob());
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(collabHistory.popContributors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('content changed → addPageWatchers + saveHistory + backlinks queue', async () => {
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
|
||||
await proc.process(buildJob());
|
||||
|
||||
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
||||
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
||||
['u1', 'u2'],
|
||||
PAGE_ID,
|
||||
SPACE_ID,
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: PAGE_ID }),
|
||||
{ contributorIds: ['u1', 'u2'] },
|
||||
);
|
||||
expect(generalQueue.add).toHaveBeenCalledWith(
|
||||
QueueJob.PAGE_BACKLINKS,
|
||||
expect.objectContaining({ pageId: PAGE_ID, workspaceId: WORKSPACE_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('first history (lastHistory null) with non-empty content → saves, no PAGE_UPDATED notification', async () => {
|
||||
// popContributors yields users, but lastHistory?.content is falsy so the
|
||||
// notification branch (needs a prior version) must be skipped.
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||
|
||||
await proc.process(buildJob());
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalled();
|
||||
expect(notificationQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changed content WITH prior history + contributors → queues PAGE_UPDATED notification', async () => {
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
|
||||
await proc.process(buildJob());
|
||||
|
||||
expect(notificationQueue.add).toHaveBeenCalledWith(
|
||||
QueueJob.PAGE_UPDATED,
|
||||
expect.objectContaining({
|
||||
pageId: PAGE_ID,
|
||||
actorIds: ['u1', 'u2'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('saveHistory throws → contributors RESTORED (addContributors) AND error rethrown', async () => {
|
||||
// The data-loss guard: if the snapshot save fails after popContributors,
|
||||
// the popped ids MUST be returned to the pending set, then the error
|
||||
// propagates so BullMQ retries. Assert BOTH halves.
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
const boom = new Error('db down');
|
||||
pageHistoryRepo.saveHistory.mockRejectedValue(boom);
|
||||
|
||||
await expect(proc.process(buildJob())).rejects.toThrow('db down');
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
|
||||
'u1',
|
||||
'u2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||
content: { type: 'doc', content: [] },
|
||||
});
|
||||
generalQueue.add.mockRejectedValue(new Error('redis backlinks down'));
|
||||
notificationQueue.add.mockRejectedValue(new Error('redis notif down'));
|
||||
|
||||
// The downstream queue failures are caught internally; process resolves.
|
||||
await expect(proc.process(buildJob())).resolves.toBeUndefined();
|
||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
309
apps/server/src/common/helpers/prosemirror/extractors.spec.ts
Normal file
309
apps/server/src/common/helpers/prosemirror/extractors.spec.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
extractUserMentionIdsFromJson,
|
||||
getAttachmentIds,
|
||||
extractMentions,
|
||||
extractUserMentions,
|
||||
extractPageMentions,
|
||||
removeMarkTypeFromDoc,
|
||||
} from './utils';
|
||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||
|
||||
// Real UUIDs (uuid.validate must accept these).
|
||||
const UUID_A = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const UUID_B = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
const UUID_C = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
// Helper builders that mirror the real ProseMirror JSON shapes.
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content });
|
||||
const paragraph = (...content: any[]) => ({ type: 'paragraph', content });
|
||||
const mention = (attrs: Record<string, any>) => ({ type: 'mention', attrs });
|
||||
|
||||
describe('extractUserMentionIdsFromJson', () => {
|
||||
it('collects entityIds for user mentions only', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
mention({ entityType: 'user', entityId: UUID_B }),
|
||||
),
|
||||
);
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A, UUID_B]);
|
||||
});
|
||||
|
||||
it('dedups the same entityId', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
),
|
||||
);
|
||||
// Mutation guard: a non-dedup impl would return [UUID_A, UUID_A].
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
|
||||
expect(extractUserMentionIdsFromJson(json)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters OUT non-user entityTypes (page mentions ignored)', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'page', entityId: UUID_A }),
|
||||
mention({ entityType: 'user', entityId: UUID_B }),
|
||||
),
|
||||
);
|
||||
// Cross-contamination guard: page mention must not leak in.
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_B]);
|
||||
});
|
||||
|
||||
it('skips a user mention with no entityId', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ entityType: 'user' }),
|
||||
mention({ entityType: 'user', entityId: UUID_A }),
|
||||
),
|
||||
);
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
|
||||
});
|
||||
|
||||
it('returns [] for null / undefined node', () => {
|
||||
expect(extractUserMentionIdsFromJson(null)).toEqual([]);
|
||||
expect(extractUserMentionIdsFromJson(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles a mention node with missing attrs without throwing', () => {
|
||||
const json = doc(paragraph({ type: 'mention' }));
|
||||
expect(() => extractUserMentionIdsFromJson(json)).not.toThrow();
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([]);
|
||||
});
|
||||
|
||||
it('walks deeply nested content', () => {
|
||||
const json = doc(
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
paragraph(mention({ entityType: 'user', entityId: UUID_A })),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(extractUserMentionIdsFromJson(json)).toEqual([UUID_A]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachmentIds', () => {
|
||||
it('collects attachmentIds from image, video and attachment nodes', () => {
|
||||
const json = doc(
|
||||
{ type: 'image', attrs: { src: 'a', attachmentId: UUID_A } },
|
||||
{ type: 'video', attrs: { src: 'b', attachmentId: UUID_B } },
|
||||
{
|
||||
type: 'attachment',
|
||||
attrs: {
|
||||
url: 'c',
|
||||
name: 'file',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1,
|
||||
attachmentId: UUID_C,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(getAttachmentIds(json).sort()).toEqual(
|
||||
[UUID_A, UUID_B, UUID_C].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips an invalid (non-UUID) attachmentId', () => {
|
||||
const json = doc(
|
||||
{ type: 'image', attrs: { src: 'a', attachmentId: 'not-a-uuid' } },
|
||||
{ type: 'image', attrs: { src: 'b', attachmentId: UUID_A } },
|
||||
);
|
||||
// Guard: a non-UUID must never leak into downstream queries.
|
||||
expect(getAttachmentIds(json)).toEqual([UUID_A]);
|
||||
});
|
||||
|
||||
it('dedups the same attachmentId across nodes', () => {
|
||||
const json = doc(
|
||||
{ type: 'image', attrs: { src: 'a', attachmentId: UUID_A } },
|
||||
{ type: 'image', attrs: { src: 'b', attachmentId: UUID_A } },
|
||||
);
|
||||
expect(getAttachmentIds(json)).toEqual([UUID_A]);
|
||||
});
|
||||
|
||||
it('ignores non-attachment node types', () => {
|
||||
const json = doc(
|
||||
paragraph({ type: 'text', text: 'hi' }),
|
||||
// A paragraph carrying an attachmentId-like attr must NOT be collected.
|
||||
{ ...paragraph(), attrs: { attachmentId: UUID_A } },
|
||||
);
|
||||
expect(getAttachmentIds(json)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for an empty doc with no attachments', () => {
|
||||
expect(getAttachmentIds(doc(paragraph()))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMentions / extractUserMentions / extractPageMentions', () => {
|
||||
it('extractMentions dedups by id (NOT by entityId)', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'Alice',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
// Same id, different label -> must be dropped as a duplicate.
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'Alice again',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
// Different id but SAME entityId -> must be KEPT (dedup key is id).
|
||||
mention({
|
||||
id: 'mention-2',
|
||||
label: 'Alice elsewhere',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = extractMentions(json);
|
||||
// Dedup key footgun: if it deduped by entityId we'd only get 1.
|
||||
expect(result.map((m) => m.id)).toEqual(['mention-1', 'mention-2']);
|
||||
});
|
||||
|
||||
it('extractMentions skips a mention missing id', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({ label: 'no id', entityType: 'user', entityId: UUID_A }),
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'has id',
|
||||
entityType: 'user',
|
||||
entityId: UUID_A,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = extractMentions(json);
|
||||
expect(result.map((m) => m.id)).toEqual(['mention-1']);
|
||||
});
|
||||
|
||||
it('extractMentions preserves the full mention shape', () => {
|
||||
const json = doc(
|
||||
paragraph(
|
||||
mention({
|
||||
id: 'mention-1',
|
||||
label: 'Bob',
|
||||
entityType: 'user',
|
||||
entityId: UUID_B,
|
||||
creatorId: UUID_C,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [m] = extractMentions(json);
|
||||
expect(m).toMatchObject({
|
||||
id: 'mention-1',
|
||||
label: 'Bob',
|
||||
entityType: 'user',
|
||||
entityId: UUID_B,
|
||||
creatorId: UUID_C,
|
||||
});
|
||||
});
|
||||
|
||||
it('extractUserMentions keeps only entityType === user', () => {
|
||||
const list = [
|
||||
{ id: '1', label: 'u', entityType: 'user', entityId: UUID_A, creatorId: 'c' },
|
||||
{ id: '2', label: 'p', entityType: 'page', entityId: UUID_B, creatorId: 'c' },
|
||||
] as any;
|
||||
const users = extractUserMentions(list);
|
||||
expect(users.map((m) => m.id)).toEqual(['1']);
|
||||
expect(users.every((m) => m.entityType === 'user')).toBe(true);
|
||||
});
|
||||
|
||||
it('extractPageMentions dedups by entityId and filters to page', () => {
|
||||
const list = [
|
||||
{ id: 'a', label: 'p', entityType: 'page', entityId: UUID_A, creatorId: 'c' },
|
||||
// Same entityId, different id -> must be dropped (dedup key is entityId).
|
||||
{ id: 'b', label: 'p2', entityType: 'page', entityId: UUID_A, creatorId: 'c' },
|
||||
// A user mention that happens to share the entityId -> filtered out.
|
||||
{ id: 'c', label: 'u', entityType: 'user', entityId: UUID_A, creatorId: 'c' },
|
||||
{ id: 'd', label: 'p3', entityType: 'page', entityId: UUID_B, creatorId: 'c' },
|
||||
] as any;
|
||||
const pages = extractPageMentions(list);
|
||||
// Dedup key footgun: dedup is by entityId here, not by id.
|
||||
expect(pages.map((m) => m.entityId)).toEqual([UUID_A, UUID_B]);
|
||||
expect(pages.map((m) => m.id)).toEqual(['a', 'd']);
|
||||
expect(pages.every((m) => m.entityType === 'page')).toBe(true);
|
||||
});
|
||||
|
||||
it('extractUserMentions / extractPageMentions return [] for an empty list', () => {
|
||||
expect(extractUserMentions([])).toEqual([]);
|
||||
expect(extractPageMentions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMarkTypeFromDoc', () => {
|
||||
it('removes the named mark across the whole doc', () => {
|
||||
const node = jsonToNode(
|
||||
doc(
|
||||
paragraph({ type: 'text', text: 'first', marks: [{ type: 'bold' }] }),
|
||||
paragraph({ type: 'text', text: 'second', marks: [{ type: 'bold' }] }),
|
||||
),
|
||||
);
|
||||
const result = removeMarkTypeFromDoc(node, 'bold');
|
||||
// No text node anywhere should still carry marks after removal.
|
||||
const json = result.toJSON();
|
||||
const marksLeft: any[] = [];
|
||||
result.descendants((n) => {
|
||||
if (n.marks.length > 0) marksLeft.push(n.marks);
|
||||
});
|
||||
expect(marksLeft).toEqual([]);
|
||||
expect(JSON.stringify(json)).not.toContain('"type":"bold"');
|
||||
// Text content survives, only the mark is gone.
|
||||
expect(result.textContent).toBe('firstsecond');
|
||||
});
|
||||
|
||||
it('leaves other marks intact when removing one mark type', () => {
|
||||
const node = jsonToNode(
|
||||
doc(
|
||||
paragraph({
|
||||
type: 'text',
|
||||
text: 'styled',
|
||||
marks: [{ type: 'bold' }, { type: 'italic' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = removeMarkTypeFromDoc(node, 'bold');
|
||||
const serialized = JSON.stringify(result.toJSON());
|
||||
expect(serialized).not.toContain('"bold"');
|
||||
expect(serialized).toContain('"italic"');
|
||||
});
|
||||
|
||||
it('returns the doc unchanged (no throw) for an unknown mark name', () => {
|
||||
const node = jsonToNode(
|
||||
doc(paragraph({ type: 'text', text: 'x', marks: [{ type: 'bold' }] })),
|
||||
);
|
||||
let result!: ReturnType<typeof removeMarkTypeFromDoc>;
|
||||
// Guard: the `!markType` branch must short-circuit, never throw.
|
||||
expect(() => {
|
||||
result = removeMarkTypeFromDoc(node, 'noSuchMarkAnywhere');
|
||||
}).not.toThrow();
|
||||
// Returns the SAME node reference (no transform applied).
|
||||
expect(result).toBe(node);
|
||||
expect(JSON.stringify(result.toJSON())).toContain('"bold"');
|
||||
});
|
||||
|
||||
it('is a no-op on a doc that has no marks', () => {
|
||||
const node = jsonToNode(
|
||||
doc(paragraph({ type: 'text', text: 'plain' })),
|
||||
);
|
||||
const result = removeMarkTypeFromDoc(node, 'bold');
|
||||
expect(result.textContent).toBe('plain');
|
||||
expect(JSON.stringify(result.toJSON())).not.toContain('marks');
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,12 @@ import { getHTMLFromFragment } from './getHTMLFromFragment';
|
||||
* ```
|
||||
*/
|
||||
export function generateHTML(doc: JSONContent, extensions: Extensions): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error(
|
||||
'generateHTML can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
|
||||
);
|
||||
}
|
||||
// No global-`window` guard here: this helper is server-only and self-contained
|
||||
// (it serializes via `getHTMLFromFragment`, which creates its own happy-dom
|
||||
// `Window` and never reads the global `window`). A guard on `typeof window`
|
||||
// would be a false positive whenever a global `window` is injected into the
|
||||
// Node process (e.g. by the in-process MCP module, which sets `global.window`
|
||||
// via jsdom).
|
||||
|
||||
const schema = getSchema(extensions);
|
||||
const contentNode = Node.fromJSON(schema, doc);
|
||||
|
||||
@@ -21,11 +21,11 @@ export function generateJSON(
|
||||
extensions: Extensions,
|
||||
options?: ParseOptions,
|
||||
): Record<string, any> {
|
||||
if (typeof window !== 'undefined') {
|
||||
throw new Error(
|
||||
'generateJSON can only be used in a Node environment\nIf you want to use this in a browser environment, use the `@tiptap/html` import instead.',
|
||||
);
|
||||
}
|
||||
// No global-`window` guard here: this helper is server-only and self-contained
|
||||
// (it creates its own happy-dom `Window` below and never reads the global
|
||||
// `window`). A guard on `typeof window` would be a false positive whenever a
|
||||
// global `window` is injected into the Node process (e.g. by the in-process
|
||||
// MCP module, which sets `global.window` via jsdom).
|
||||
|
||||
const localWindow = new Window();
|
||||
const localDOMParser = new localWindow.DOMParser();
|
||||
|
||||
52
apps/server/src/common/helpers/security-headers.spec.ts
Normal file
52
apps/server/src/common/helpers/security-headers.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { resolveFrameHeader } from './security-headers';
|
||||
|
||||
describe('resolveFrameHeader', () => {
|
||||
describe('iframe embedding disabled (clickjacking protection)', () => {
|
||||
it('returns X-Frame-Options SAMEORIGIN and ignores origins', () => {
|
||||
expect(resolveFrameHeader(false, [])).toEqual({
|
||||
name: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
});
|
||||
});
|
||||
|
||||
it('still returns X-Frame-Options even when origins are configured', () => {
|
||||
// A wrong branch could leak a permissive CSP here; origins must be ignored
|
||||
// when embedding is disabled so clickjacking protection stays intact.
|
||||
const result = resolveFrameHeader(false, [
|
||||
'https://a.com',
|
||||
'https://b.com',
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
name: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
});
|
||||
expect(result?.name).not.toBe('Content-Security-Policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('iframe embedding allowed', () => {
|
||||
it('returns null when there are no allowed origins', () => {
|
||||
expect(resolveFrameHeader(true, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds a frame-ancestors CSP for a single origin', () => {
|
||||
expect(resolveFrameHeader(true, ['https://a.com'])).toEqual({
|
||||
name: 'Content-Security-Policy',
|
||||
value: "frame-ancestors 'self' https://a.com",
|
||||
});
|
||||
});
|
||||
|
||||
it('space-joins multiple origins after self', () => {
|
||||
expect(
|
||||
resolveFrameHeader(true, [
|
||||
'https://a.com',
|
||||
'https://b.com',
|
||||
'https://c.com',
|
||||
]),
|
||||
).toEqual({
|
||||
name: 'Content-Security-Policy',
|
||||
value: "frame-ancestors 'self' https://a.com https://b.com https://c.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
245
apps/server/src/common/helpers/utils.security.spec.ts
Normal file
245
apps/server/src/common/helpers/utils.security.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
redactSensitiveUrl,
|
||||
extractBearerTokenFromHeader,
|
||||
parseRedisUrl,
|
||||
normalizePostgresUrl,
|
||||
diffAuditTrackedFields,
|
||||
isUserDisabled,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
* Build a minimal FastifyRequest-shaped object carrying just the authorization
|
||||
* header, which is all extractBearerTokenFromHeader reads.
|
||||
*/
|
||||
function reqWithAuth(authorization?: string): FastifyRequest {
|
||||
return { headers: { authorization } } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
describe('redactSensitiveUrl', () => {
|
||||
it('strips the query string from a sensitive (SSO) URL', () => {
|
||||
expect(
|
||||
redactSensitiveUrl('/api/sso/google/callback?code=secret&state=pii'),
|
||||
).toBe('/api/sso/google/callback');
|
||||
});
|
||||
|
||||
it('returns a sensitive URL unchanged when it has no query string', () => {
|
||||
expect(redactSensitiveUrl('/api/sso/google/callback')).toBe(
|
||||
'/api/sso/google/callback',
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT strip the query string from a non-sensitive URL', () => {
|
||||
// A mutation that redacts everything would break legitimate logging here.
|
||||
expect(redactSensitiveUrl('/api/pages/list?page=2&token=abc')).toBe(
|
||||
'/api/pages/list?page=2&token=abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty string without throwing and returns it unchanged', () => {
|
||||
expect(redactSensitiveUrl('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles undefined input without throwing', () => {
|
||||
expect(
|
||||
redactSensitiveUrl(undefined as unknown as string),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearerTokenFromHeader', () => {
|
||||
it('extracts the token from a Bearer scheme', () => {
|
||||
expect(extractBearerTokenFromHeader(reqWithAuth('Bearer xyz'))).toBe('xyz');
|
||||
});
|
||||
|
||||
it('is case-insensitive on the scheme', () => {
|
||||
// Impl lowercases the scheme before comparing, so lowercase "bearer" works.
|
||||
expect(extractBearerTokenFromHeader(reqWithAuth('bearer xyz'))).toBe('xyz');
|
||||
expect(extractBearerTokenFromHeader(reqWithAuth('BEARER xyz'))).toBe('xyz');
|
||||
});
|
||||
|
||||
it('rejects a non-Bearer scheme (auth bypass guard)', () => {
|
||||
expect(
|
||||
extractBearerTokenFromHeader(reqWithAuth('Basic xyz')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the header is missing', () => {
|
||||
expect(extractBearerTokenFromHeader(reqWithAuth(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty header', () => {
|
||||
expect(extractBearerTokenFromHeader(reqWithAuth(''))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the scheme has no token', () => {
|
||||
expect(
|
||||
extractBearerTokenFromHeader(reqWithAuth('Bearer')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRedisUrl', () => {
|
||||
it('parses a full URL into host/port/password/db/family', () => {
|
||||
expect(parseRedisUrl('redis://user:pass@host:6379/3?family=6')).toEqual({
|
||||
host: 'host',
|
||||
port: 6379,
|
||||
password: 'pass',
|
||||
db: 3,
|
||||
family: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults db to 0 when there is no /db path segment', () => {
|
||||
const cfg = parseRedisUrl('redis://localhost:6379');
|
||||
expect(cfg.db).toBe(0);
|
||||
expect(cfg.host).toBe('localhost');
|
||||
expect(cfg.port).toBe(6379);
|
||||
// No family query → undefined (not parsed).
|
||||
expect(cfg.family).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to db 0 for a non-numeric db segment', () => {
|
||||
expect(parseRedisUrl('redis://localhost:6379/abc').db).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty-string password when the URL has no credentials', () => {
|
||||
// Quirk: WHATWG URL exposes a missing password as '' (empty string),
|
||||
// not undefined, so the helper propagates ''.
|
||||
const cfg = parseRedisUrl('redis://localhost:6379/1');
|
||||
expect(cfg.password).toBe('');
|
||||
expect(cfg.db).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePostgresUrl', () => {
|
||||
it('removes sslmode=no-verify but keeps other sslmode values', () => {
|
||||
expect(
|
||||
normalizePostgresUrl(
|
||||
'postgres://u:p@host:5432/db?sslmode=no-verify',
|
||||
),
|
||||
).toBe('postgres://u:p@host:5432/db');
|
||||
|
||||
expect(
|
||||
normalizePostgresUrl('postgres://u:p@host:5432/db?sslmode=require'),
|
||||
).toBe('postgres://u:p@host:5432/db?sslmode=require');
|
||||
});
|
||||
|
||||
it('removes the schema param while preserving unrelated params', () => {
|
||||
expect(
|
||||
normalizePostgresUrl(
|
||||
'postgres://u:p@host:5432/db?schema=public&application_name=app',
|
||||
),
|
||||
).toBe('postgres://u:p@host:5432/db?application_name=app');
|
||||
});
|
||||
|
||||
it('returns a URL with no query string untouched', () => {
|
||||
expect(normalizePostgresUrl('postgres://u:p@host:5432/db')).toBe(
|
||||
'postgres://u:p@host:5432/db',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffAuditTrackedFields', () => {
|
||||
const fields = ['name', 'email', 'settings'] as const;
|
||||
|
||||
it('returns a before/after entry for a changed tracked field', () => {
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{ name: 'new' },
|
||||
{ name: 'old' },
|
||||
{ name: 'new' },
|
||||
),
|
||||
).toEqual({ before: { name: 'old' }, after: { name: 'new' } });
|
||||
});
|
||||
|
||||
it('skips a field whose value is unchanged', () => {
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{ name: 'same' },
|
||||
{ name: 'same' },
|
||||
{ name: 'same' },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('skips a field that is absent from the dto (undefined guard)', () => {
|
||||
// before/after differ, but the dto does not carry this field → not tracked.
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{},
|
||||
{ name: 'old' },
|
||||
{ name: 'new' },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when nothing changed across all fields', () => {
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{ name: 'a', email: 'b@x' },
|
||||
{ name: 'a', email: 'b@x' },
|
||||
{ name: 'a', email: 'b@x' },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats null and undefined as equal (no false diff)', () => {
|
||||
// before has explicit null, after omits the key (undefined) → both ?? null.
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{ email: 'present' },
|
||||
{ email: null },
|
||||
{},
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('compares object-valued fields structurally via JSON.stringify', () => {
|
||||
// Distinct object references with equal contents must NOT register a diff.
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{ settings: { theme: 'dark' } },
|
||||
{ settings: { theme: 'dark' } },
|
||||
{ settings: { theme: 'dark' } },
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
diffAuditTrackedFields(
|
||||
fields,
|
||||
{ settings: { theme: 'dark' } },
|
||||
{ settings: { theme: 'light' } },
|
||||
{ settings: { theme: 'dark' } },
|
||||
),
|
||||
).toEqual({
|
||||
before: { settings: { theme: 'light' } },
|
||||
after: { settings: { theme: 'dark' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUserDisabled', () => {
|
||||
it('returns false for an active user', () => {
|
||||
expect(isUserDisabled({ deactivatedAt: null, deletedAt: null })).toBe(false);
|
||||
expect(isUserDisabled({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a deactivated user', () => {
|
||||
expect(
|
||||
isUserDisabled({ deactivatedAt: new Date('2026-01-01'), deletedAt: null }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a deleted user', () => {
|
||||
expect(
|
||||
isUserDisabled({ deactivatedAt: null, deletedAt: new Date('2026-01-01') }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -161,7 +161,16 @@ export class AiChatController {
|
||||
// cannot simply remove it once `stream()` returns).
|
||||
const controller = new AbortController();
|
||||
const onClose = (): void => {
|
||||
if (!res.raw.writableEnded) controller.abort();
|
||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||
// so log it here before aborting the agent loop.
|
||||
if (!res.raw.writableEnded) {
|
||||
this.logger.warn(
|
||||
'AI chat stream: client disconnected before completion; aborting turn',
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
req.raw.once('close', onClose);
|
||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||
@@ -228,25 +237,14 @@ export class AiChatController {
|
||||
}
|
||||
if (!file) throw new BadRequestException('No audio uploaded');
|
||||
|
||||
// Whitelist audio container types produced by browser MediaRecorder
|
||||
// (Chrome/FF: webm/opus, Safari: mp4) plus common STT-accepted formats.
|
||||
const allowedMime = new Set([
|
||||
'audio/webm',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/wave',
|
||||
'audio/m4a',
|
||||
'audio/x-m4a',
|
||||
]);
|
||||
// MediaRecorder mimetypes carry parameters (e.g. "audio/webm;codecs=opus");
|
||||
// compare only the base type.
|
||||
const baseMime = file.mimetype.split(';')[0].trim().toLowerCase();
|
||||
if (!allowedMime.has(baseMime)) {
|
||||
// Resolve + whitelist the upload's container type (MediaRecorder mimetypes
|
||||
// carry parameters, e.g. "audio/webm;codecs=opus"). A non-whitelisted type
|
||||
// is rejected; an allowed one yields the STT container-format hint.
|
||||
const resolved = resolveAudioFormat(file.mimetype);
|
||||
if (!resolved.ok) {
|
||||
throw new BadRequestException('Unsupported audio format');
|
||||
}
|
||||
const { format } = resolved;
|
||||
|
||||
let buf: Buffer;
|
||||
try {
|
||||
@@ -259,20 +257,6 @@ export class AiChatController {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Container hint for JSON-style STT providers (e.g. OpenRouter); multipart
|
||||
// endpoints ignore it.
|
||||
const formatMap: Record<string, string> = {
|
||||
'audio/webm': 'webm',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mp4': 'mp4',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/x-wav': 'wav',
|
||||
'audio/wave': 'wav',
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/x-m4a': 'm4a',
|
||||
};
|
||||
const format = formatMap[baseMime] ?? 'webm';
|
||||
let text: string;
|
||||
try {
|
||||
text = await this.aiTranscription.transcribe(workspace.id, buf, format);
|
||||
@@ -302,3 +286,39 @@ export class AiChatController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist audio container types produced by browser MediaRecorder (Chrome/FF:
|
||||
* webm/opus, Safari: mp4) plus common STT-accepted formats. The value maps each
|
||||
* allowed base mime to the container-format hint passed to JSON-style STT
|
||||
* providers (e.g. OpenRouter); multipart endpoints ignore the hint.
|
||||
*/
|
||||
const AUDIO_FORMAT_MAP: Record<string, string> = {
|
||||
'audio/webm': 'webm',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mp4': 'mp4',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/x-wav': 'wav',
|
||||
'audio/wave': 'wav',
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/x-m4a': 'm4a',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve and whitelist an uploaded clip's mimetype. MediaRecorder mimetypes
|
||||
* carry parameters (e.g. "audio/webm;codecs=opus"), so the base type is split
|
||||
* out (lowercased, trimmed) before the whitelist check. Returns ok=false for a
|
||||
* non-whitelisted container; otherwise the base mime and its STT format hint.
|
||||
* Pure — the caller throws BadRequestException on !ok.
|
||||
*/
|
||||
export function resolveAudioFormat(
|
||||
mimetype: string,
|
||||
): { ok: true; baseMime: string; format: string } | { ok: false } {
|
||||
const baseMime = mimetype.split(';')[0].trim().toLowerCase();
|
||||
const format = AUDIO_FORMAT_MAP[baseMime];
|
||||
if (format === undefined) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, baseMime, format };
|
||||
}
|
||||
|
||||
@@ -82,3 +82,82 @@ describe('buildSystemPrompt role layering', () => {
|
||||
expect(prompt).toContain(SAFETY_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for the "current page" context injected by buildSystemPrompt. When
|
||||
* the client supplies an openedPage with a non-blank id, a CONTEXT line names
|
||||
* the page (title or "Untitled") and its pageId so the agent can resolve "this
|
||||
* page". When no usable id is present, nothing is added. The line always sits
|
||||
* inside the safety sandwich, before the trailing SAFETY copy.
|
||||
*/
|
||||
describe('buildSystemPrompt current-page context', () => {
|
||||
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||
const SAFETY_MARKER = 'Operating rules (always in effect)';
|
||||
|
||||
it('includes the page title and pageId when both are present', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: 'Audio Tract' },
|
||||
});
|
||||
expect(prompt).toContain('currently viewing the page');
|
||||
expect(prompt).toContain('pageId: pg-123');
|
||||
expect(prompt).toContain('"Audio Tract"');
|
||||
});
|
||||
|
||||
it('falls back to "Untitled" when the title is missing', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123' },
|
||||
});
|
||||
expect(prompt).toContain('pageId: pg-123');
|
||||
expect(prompt).toContain('"Untitled"');
|
||||
});
|
||||
|
||||
it('falls back to "Untitled" when the title is only whitespace', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: ' ' },
|
||||
});
|
||||
expect(prompt).toContain('pageId: pg-123');
|
||||
expect(prompt).toContain('"Untitled"');
|
||||
});
|
||||
|
||||
it('adds no page context when openedPage is null', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, openedPage: null });
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('adds no page context when openedPage is omitted', () => {
|
||||
const prompt = buildSystemPrompt({ workspace });
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('adds no page context when openedPage has no id', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, openedPage: { title: 'x' } });
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('adds no page context when the id is only whitespace', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: ' ' },
|
||||
});
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('places the page context inside the safety sandwich (before the closing SAFETY)', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: 'Audio Tract' },
|
||||
});
|
||||
const pageIdx = prompt.indexOf('currently viewing the page');
|
||||
const firstSafety = prompt.indexOf(SAFETY_MARKER);
|
||||
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
|
||||
expect(pageIdx).toBeGreaterThan(firstSafety);
|
||||
expect(pageIdx).toBeLessThan(lastSafety);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
||||
{} as never, // tools
|
||||
{} as never, // mcpClients
|
||||
aiAgentRoleRepo as never,
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
);
|
||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import {
|
||||
streamText,
|
||||
@@ -14,6 +14,8 @@ import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import {
|
||||
User,
|
||||
Workspace,
|
||||
@@ -126,6 +128,8 @@ export class AiChatService {
|
||||
private readonly tools: AiChatToolsService,
|
||||
private readonly mcpClients: McpClientsService,
|
||||
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccess: PageAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -195,12 +199,44 @@ export class AiChatService {
|
||||
}
|
||||
}
|
||||
if (!chatId) {
|
||||
// Resolve the origin document for the history list. body.openPage.id is
|
||||
// attacker-controllable, so validate it before persisting: it must be a
|
||||
// real page in THIS workspace that the user is allowed to read. Anything
|
||||
// else (foreign workspace, inaccessible/restricted, or non-existent) is
|
||||
// dropped to null — persisting it would leak the page's title via the
|
||||
// chat-list join, or violate the page_id FK on insert (this runs after
|
||||
// res.hijack(), so a DB error would break the stream).
|
||||
let originPageId: string | null = null;
|
||||
const candidatePageId = body.openPage?.id;
|
||||
if (candidatePageId) {
|
||||
const page = await this.pageRepo.findById(candidatePageId);
|
||||
if (page && page.workspaceId === workspace.id) {
|
||||
try {
|
||||
await this.pageAccess.validateCanView(page, user);
|
||||
originPageId = page.id;
|
||||
} catch (e) {
|
||||
// Fail-closed: no provenance on any failure. A ForbiddenException is
|
||||
// the expected "user cannot read this page" case; log anything else
|
||||
// (e.g. a DB error) so a real fault is not masked as "no access".
|
||||
if (!(e instanceof ForbiddenException)) {
|
||||
this.logger.warn(
|
||||
`origin page access check failed: ${
|
||||
e instanceof Error ? e.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
originPageId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const chat = await this.aiChatRepo.insert({
|
||||
creatorId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
// Bind the chat to the resolved role (if any) at creation time. The role
|
||||
// is immutable afterwards (later turns read it from this column).
|
||||
roleId: role?.id ?? null,
|
||||
// Validated above: a real, readable page in this workspace, else null.
|
||||
pageId: originPageId,
|
||||
});
|
||||
chatId = chat.id;
|
||||
isNewChat = true;
|
||||
@@ -394,6 +430,14 @@ export class AiChatService {
|
||||
// Client disconnected / request aborted: persist the partial answer,
|
||||
// including any completed tool steps so the turn replays faithfully.
|
||||
const text = steps.map((s) => s.text ?? '').join('');
|
||||
// Unlike onError/onFinish, this terminal path otherwise writes nothing,
|
||||
// so an aborted turn (client disconnect / proxy drop / stop()) would be
|
||||
// invisible in the logs. Log it (warn) so the abort is traceable, with
|
||||
// the step count and how much partial text was produced before the cut.
|
||||
this.logger.warn(
|
||||
`AI chat stream aborted (chat ${chatId}) after ${steps.length} ` +
|
||||
`step(s), ${text.length} chars partial text; persisting partial turn.`,
|
||||
);
|
||||
await persistAssistant({
|
||||
text,
|
||||
toolCalls: serializeSteps(steps),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { EmbeddingIndexerService } from './embedding-indexer.service';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { AiService } from '../../../integrations/ai/ai.service';
|
||||
|
||||
/**
|
||||
* Unit tests for EmbeddingIndexerService.reindexWorkspace's batch control flow.
|
||||
*
|
||||
* The constructor body only stores its deps, so the service can be unit-built
|
||||
* with lightweight mocks — no Nest module graph. We stub only the methods that
|
||||
* reindexWorkspace actually touches:
|
||||
* - aiService.getEmbeddingModel -> a model string so the up-front configured
|
||||
* check passes,
|
||||
* - pageRepo.getIdsByWorkspace -> three page ids,
|
||||
* - service.reindexPage -> spied per test to drive the per-page outcome.
|
||||
*
|
||||
* The point under test is the catch block: a FATAL provider error (auth/billing)
|
||||
* must abort the whole batch (re-throw, stop iterating), while a non-fatal error
|
||||
* keeps per-page isolation (failed++, continue to the next page).
|
||||
*/
|
||||
describe('EmbeddingIndexerService.reindexWorkspace fail-fast', () => {
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
|
||||
function makeService() {
|
||||
const pageRepo = {
|
||||
getIdsByWorkspace: jest.fn().mockResolvedValue(['p1', 'p2', 'p3']),
|
||||
};
|
||||
const pageEmbeddingRepo = {};
|
||||
const aiService = {
|
||||
getEmbeddingModel: jest.fn().mockResolvedValue('some-model'),
|
||||
};
|
||||
const db = {};
|
||||
|
||||
const service = new EmbeddingIndexerService(
|
||||
pageRepo as unknown as PageRepo,
|
||||
pageEmbeddingRepo as unknown as PageEmbeddingRepo,
|
||||
aiService as unknown as AiService,
|
||||
db as unknown as KyselyDB,
|
||||
);
|
||||
return { service, pageRepo, aiService };
|
||||
}
|
||||
|
||||
it('aborts after the first page on a FATAL (401) provider error', async () => {
|
||||
const { service } = makeService();
|
||||
// A 401 "User not found" recurs identically on every page -> must abort.
|
||||
const reindexPage = jest
|
||||
.spyOn(service, 'reindexPage')
|
||||
.mockRejectedValue({ statusCode: 401, message: 'User not found' });
|
||||
|
||||
await expect(service.reindexWorkspace(WORKSPACE_ID)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
});
|
||||
// Aborted on the first page: pages 2 and 3 were never attempted.
|
||||
expect(reindexPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps per-page isolation on a non-fatal error (plain Error, no statusCode)', async () => {
|
||||
const { service } = makeService();
|
||||
// No statusCode -> non-fatal -> isolate per page and continue.
|
||||
const reindexPage = jest
|
||||
.spyOn(service, 'reindexPage')
|
||||
.mockRejectedValue(new Error('boom'));
|
||||
|
||||
// Resolves (does not throw) even though every page failed.
|
||||
await expect(service.reindexWorkspace(WORKSPACE_ID)).resolves.toBeUndefined();
|
||||
// All three pages were attempted despite the failures.
|
||||
expect(reindexPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('processes every page on the all-success path', async () => {
|
||||
const { service } = makeService();
|
||||
const reindexPage = jest
|
||||
.spyOn(service, 'reindexPage')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.reindexWorkspace(WORKSPACE_ID)).resolves.toBeUndefined();
|
||||
expect(reindexPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,10 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AiService } from '../../../integrations/ai/ai.service';
|
||||
import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception';
|
||||
import { describeProviderError } from '../../../integrations/ai/ai-error.util';
|
||||
import {
|
||||
describeProviderError,
|
||||
isFatalProviderError,
|
||||
} from '../../../integrations/ai/ai-error.util';
|
||||
import { jsonToText } from '../../../collaboration/collaboration.util';
|
||||
|
||||
// NOTE: the `page_embeddings.embedding` column is now dimension-agnostic
|
||||
@@ -229,8 +232,19 @@ export class EmbeddingIndexerService {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Per-page isolation: one failure (incl. an embedding timeout) must not
|
||||
// abort the whole batch.
|
||||
// A fatal provider error (invalid/missing key, no credits) recurs
|
||||
// identically on EVERY remaining page. Abort the whole batch instead of
|
||||
// issuing hundreds of doomed requests against the provider.
|
||||
if (isFatalProviderError(err)) {
|
||||
this.logger.error(
|
||||
`reindexWorkspace: aborting at [${position}/${total}] for workspace ` +
|
||||
`${workspaceId} — fatal provider error, remaining pages would fail ` +
|
||||
`identically: ${describeProviderError(err)}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
// Per-page isolation: one non-fatal failure (incl. an embedding timeout)
|
||||
// must not abort the whole batch.
|
||||
failed++;
|
||||
this.logger.error(
|
||||
`reindexWorkspace: [${position}/${total}] failed to reindex page ${pageId} ` +
|
||||
|
||||
@@ -367,6 +367,28 @@ export class McpClientsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the SSRF connect-time rule to a set of DNS-resolved addresses: block if
|
||||
* ANY resolved address is disallowed by `isIpAllowed`, and block an EMPTY set
|
||||
* (nothing safe to connect to). Only an all-public, non-empty set is allowed.
|
||||
*
|
||||
* This is the connect-time half of the DNS-rebinding defense: the dispatcher's
|
||||
* lookup hands net/tls.connect ONLY a set that passed this check, so the kernel
|
||||
* can never connect to an address that did not pass the guard. Pure — no I/O.
|
||||
*/
|
||||
export function validateResolvedAddresses(
|
||||
addrs: readonly LookupAddress[],
|
||||
): { ok: boolean; blockedHost?: string } {
|
||||
if (addrs.length === 0) {
|
||||
return { ok: false };
|
||||
}
|
||||
const blocked = addrs.find((a) => !isIpAllowed(a.address).ok);
|
||||
if (blocked) {
|
||||
return { ok: false, blockedHost: blocked.address };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSRF-pinned undici dispatcher. Its custom connect.lookup resolves
|
||||
* the host, validates EVERY resolved address with the same ssrf-guard, and
|
||||
@@ -388,22 +410,15 @@ function buildPinnedDispatcher(): Agent {
|
||||
return;
|
||||
}
|
||||
const addrs = addresses as LookupAddress[];
|
||||
if (addrs.length === 0) {
|
||||
callback(
|
||||
new Error(`No address resolved for ${hostname}`),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blocked = addrs.find((a) => !isIpAllowed(a.address).ok);
|
||||
if (blocked) {
|
||||
const verdict = validateResolvedAddresses(addrs);
|
||||
if (!verdict.ok) {
|
||||
// Refuse the connection: net/tls.connect never sees this address.
|
||||
callback(
|
||||
new Error(`Blocked address for ${hostname}`),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
// An empty set is treated as blocked (nothing safe to connect to).
|
||||
const reason =
|
||||
addrs.length === 0
|
||||
? `No address resolved for ${hostname}`
|
||||
: `Blocked address for ${hostname}`;
|
||||
callback(new Error(reason), '', 0);
|
||||
return;
|
||||
}
|
||||
// undici/net invoke this lookup with `all: true`, so the callback
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type Tool } from 'ai';
|
||||
import { McpClientsService } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* Tool-name namespacing / collision tests.
|
||||
*
|
||||
* REACHABILITY NOTE: the helpers `namespace` / `sanitizeName` / `capName` /
|
||||
* `disambiguate` are module-private (not exported) and `mergeNamespaced` is a
|
||||
* PRIVATE method. The smallest reachable public path that exercises all of them
|
||||
* is `toolsFor()` -> getOrBuildEntry -> buildEntry -> connect/tools() ->
|
||||
* mergeNamespaced. We drive that path: stub the repo's `listEnabled` to return
|
||||
* fake servers and spy on the private `connect` to return fake MCP clients whose
|
||||
* `tools()` we control. We then inspect the merged tool KEYS on the returned
|
||||
* toolset — the observable result of namespacing.
|
||||
*
|
||||
* What we assert (all SECURITY/correctness-relevant):
|
||||
* - two servers each exposing a tool `search` -> BOTH survive under distinct
|
||||
* namespaced keys (no silent overwrite);
|
||||
* - a tool name with spaces/unicode -> sanitized to ^[a-zA-Z0-9_-]+;
|
||||
* - an over-long name -> capped to the provider limit (<= 64);
|
||||
* - duplicate names WITHIN one server (collide after sanitize/truncate) ->
|
||||
* disambiguated, so the second is not overwritten.
|
||||
*/
|
||||
const MAX_TOOL_NAME_LENGTH = 64;
|
||||
|
||||
function fakeTool(): Tool {
|
||||
return { description: 'x', inputSchema: undefined } as unknown as Tool;
|
||||
}
|
||||
|
||||
interface FakeServer {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
url: string;
|
||||
headersEnc: string | null;
|
||||
toolAllowlist: string[] | null;
|
||||
}
|
||||
|
||||
function server(over: Partial<FakeServer> & { id: string; name: string }): FakeServer {
|
||||
return {
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
headersEnc: null,
|
||||
toolAllowlist: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a service whose repo returns `servers` and whose `connect` returns a
|
||||
* fake client exposing `toolsByServerId[server.id]` from tools(). Returns the
|
||||
* merged keys produced by toolsFor.
|
||||
*/
|
||||
async function mergedKeysFor(
|
||||
servers: FakeServer[],
|
||||
toolsByServerId: Record<string, Record<string, Tool>>,
|
||||
): Promise<string[]> {
|
||||
const repoStub = {
|
||||
listEnabled: jest.fn().mockResolvedValue(servers),
|
||||
};
|
||||
const service = new McpClientsService(repoStub as never, {} as never);
|
||||
|
||||
// Map each connect() call (by server identity) to a fake client. connect is
|
||||
// private; spy on it via a typed any-cast.
|
||||
jest
|
||||
.spyOn(service as unknown as { connect: (s: FakeServer) => unknown }, 'connect')
|
||||
.mockImplementation((s: FakeServer) =>
|
||||
Promise.resolve({
|
||||
tools: () => Promise.resolve(toolsByServerId[s.id] ?? {}),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
);
|
||||
|
||||
const toolset = await service.toolsFor('ws-1');
|
||||
// Release the lease so the service does not hold the fake clients open.
|
||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||
return Object.keys(toolset.tools);
|
||||
}
|
||||
|
||||
describe('external MCP tool-name namespacing (via toolsFor)', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('keeps tools from two servers that both expose `search` (no overwrite)', async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[
|
||||
server({ id: 'id-alpha', name: 'alpha' }),
|
||||
server({ id: 'id-beta', name: 'beta' }),
|
||||
],
|
||||
{
|
||||
'id-alpha': { search: fakeTool() },
|
||||
'id-beta': { search: fakeTool() },
|
||||
},
|
||||
);
|
||||
|
||||
// Two distinct keys survive -> no silent overwrite.
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
// The server name is prefixed onto each tool.
|
||||
expect(keys).toContain('alpha_search');
|
||||
expect(keys.some((k) => k !== 'alpha_search')).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitizes spaces/unicode in names to the allowed charset', async () => {
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'My Server!' })],
|
||||
{ 'id-1': { 'search the wiki ✨': fakeTool() } },
|
||||
);
|
||||
|
||||
expect(keys).toHaveLength(1);
|
||||
// Only ^[a-zA-Z0-9_-]+ characters remain (no spaces, no unicode).
|
||||
expect(keys[0]).toMatch(/^[a-zA-Z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('caps an over-long name to the provider length limit', async () => {
|
||||
const longName = 'a'.repeat(200);
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'svr' })],
|
||||
{ 'id-1': { [longName]: fakeTool() } },
|
||||
);
|
||||
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0].length).toBeLessThanOrEqual(MAX_TOOL_NAME_LENGTH);
|
||||
});
|
||||
|
||||
it('disambiguates two names that collide after sanitize/truncate within one server', async () => {
|
||||
// Both names sanitize to the same value ("a_b") -> the second must be
|
||||
// suffix-disambiguated, not overwritten.
|
||||
const keys = await mergedKeysFor(
|
||||
[server({ id: 'id-1', name: 'svr' })],
|
||||
{ 'id-1': { 'a b': fakeTool(), 'a@b': fakeTool() } },
|
||||
);
|
||||
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { McpServersService } from './mcp-servers.service';
|
||||
import { AiMcpServer } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Encrypted-header leak guard for the admin-facing view (§8.10): `toView` is
|
||||
* private, so we drive it through the public `list()` (which maps every row
|
||||
* with toView). The contract: a row with `headersEnc` set surfaces ONLY
|
||||
* `hasHeaders:true` and NEVER the `headersEnc` blob; a row without headers
|
||||
* surfaces `hasHeaders:false`. The blob must never reach an admin response.
|
||||
*/
|
||||
function row(overrides: Partial<AiMcpServer>): AiMcpServer {
|
||||
return {
|
||||
id: 'srv-1',
|
||||
name: 'Tavily',
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
enabled: true,
|
||||
toolAllowlist: null,
|
||||
headersEnc: null,
|
||||
...overrides,
|
||||
} as unknown as AiMcpServer;
|
||||
}
|
||||
|
||||
describe('McpServersService.toView (via list) — encrypted-header leak guard', () => {
|
||||
function buildService(rows: AiMcpServer[]): McpServersService {
|
||||
const repoStub = {
|
||||
listByWorkspace: jest.fn().mockResolvedValue(rows),
|
||||
};
|
||||
// secretBox + clients are unused by the list/toView path; pass stubs to
|
||||
// satisfy the constructor.
|
||||
return new McpServersService(
|
||||
repoStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
it('exposes hasHeaders:true and NO headersEnc when auth headers are set', async () => {
|
||||
const service = buildService([
|
||||
row({ headersEnc: 'ENCRYPTED-SECRET-BLOB' }),
|
||||
]);
|
||||
|
||||
const [view] = await service.list('ws-1');
|
||||
|
||||
expect(view.hasHeaders).toBe(true);
|
||||
// The encrypted blob must NEVER appear in the view, under any key.
|
||||
expect('headersEnc' in view).toBe(false);
|
||||
expect(Object.values(view)).not.toContain('ENCRYPTED-SECRET-BLOB');
|
||||
});
|
||||
|
||||
it('exposes hasHeaders:false when no auth headers are set', async () => {
|
||||
const service = buildService([row({ headersEnc: null })]);
|
||||
|
||||
const [view] = await service.list('ws-1');
|
||||
|
||||
expect(view.hasHeaders).toBe(false);
|
||||
expect('headersEnc' in view).toBe(false);
|
||||
});
|
||||
|
||||
it('projects only the public fields', async () => {
|
||||
const service = buildService([
|
||||
row({
|
||||
id: 'srv-9',
|
||||
name: 'My MCP',
|
||||
transport: 'sse',
|
||||
url: 'https://mcp.example.com/',
|
||||
enabled: false,
|
||||
toolAllowlist: ['search'],
|
||||
headersEnc: 'BLOB',
|
||||
}),
|
||||
]);
|
||||
|
||||
const [view] = await service.list('ws-1');
|
||||
|
||||
expect(view).toEqual({
|
||||
id: 'srv-9',
|
||||
name: 'My MCP',
|
||||
transport: 'sse',
|
||||
url: 'https://mcp.example.com/',
|
||||
enabled: false,
|
||||
toolAllowlist: ['search'],
|
||||
hasHeaders: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type LookupAddress } from 'node:dns';
|
||||
import { validateResolvedAddresses } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* Unit tests for validateResolvedAddresses — the connect-time half of the SSRF
|
||||
* DNS-rebinding defense. It applies the REAL `isIpAllowed` rule (imported
|
||||
* transitively via the service) and must block if ANY resolved address is
|
||||
* disallowed, treat an EMPTY set as blocked, and unwrap IPv4-mapped IPv6.
|
||||
*
|
||||
* These tests intentionally use real public/private literals (no DNS, no mock)
|
||||
* so they exercise the actual ssrf-guard classification.
|
||||
*/
|
||||
function addr(address: string, family = 4): LookupAddress {
|
||||
return { address, family };
|
||||
}
|
||||
|
||||
describe('validateResolvedAddresses', () => {
|
||||
it('allows an all-public set', () => {
|
||||
const res = validateResolvedAddresses([
|
||||
addr('8.8.8.8'),
|
||||
addr('1.1.1.1'),
|
||||
addr('2001:4860:4860::8888', 6),
|
||||
]);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when ONE address among many is private (any-private-blocks)', () => {
|
||||
const res = validateResolvedAddresses([
|
||||
addr('8.8.8.8'),
|
||||
addr('1.1.1.1'),
|
||||
addr('10.0.0.5'), // private 10/8 hidden among public addresses
|
||||
addr('1.0.0.1'),
|
||||
]);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.blockedHost).toBe('10.0.0.5');
|
||||
});
|
||||
|
||||
it('blocks an empty set (nothing safe to connect to)', () => {
|
||||
expect(validateResolvedAddresses([]).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks an IPv4-mapped IPv6 private address', () => {
|
||||
const res = validateResolvedAddresses([addr('::ffff:10.0.0.1', 6)]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks the cloud metadata link-local address', () => {
|
||||
const res = validateResolvedAddresses([
|
||||
addr('8.8.8.8'),
|
||||
addr('169.254.169.254'),
|
||||
]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression sentinel: if the "any private blocks" rule were weakened to
|
||||
* "all private blocks" / "first address wins", this mixed set (public first,
|
||||
* private second) would wrongly pass. The assertion below FAILS in that case.
|
||||
*/
|
||||
it('FAILS if the any-private rule is weakened (sentinel)', () => {
|
||||
const res = validateResolvedAddresses([
|
||||
addr('8.8.8.8'), // public first
|
||||
addr('192.168.1.1'), // private second — must still block the whole set
|
||||
]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -386,7 +386,7 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () =>
|
||||
it('falls back to the default for an unparseable / NaN value', () => {
|
||||
process.env[ENV] = 'not-a-number';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(100);
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ import type { Redis } from 'ioredis';
|
||||
*/
|
||||
|
||||
/** Default cap: anonymous share-AI calls allowed per workspace per window. */
|
||||
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 300;
|
||||
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 100;
|
||||
/** Default window length: one rolling hour. */
|
||||
export const SHARE_AI_WORKSPACE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
|
||||
53
apps/server/src/core/ai-chat/resolve-audio-format.spec.ts
Normal file
53
apps/server/src/core/ai-chat/resolve-audio-format.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { resolveAudioFormat } from './ai-chat.controller';
|
||||
|
||||
/**
|
||||
* Unit tests for resolveAudioFormat — the transcribe-endpoint mime whitelist.
|
||||
* It splits the base mime off any MediaRecorder parameters, lowercases/trims it,
|
||||
* checks it against the whitelist, and maps it to the STT container-format hint.
|
||||
* A non-whitelisted container yields { ok: false } (the controller then throws
|
||||
* BadRequestException).
|
||||
*/
|
||||
describe('resolveAudioFormat', () => {
|
||||
it('strips MediaRecorder parameters to the base mime (audio/webm;codecs=opus)', () => {
|
||||
const res = resolveAudioFormat('audio/webm;codecs=opus');
|
||||
expect(res).toEqual({ ok: true, baseMime: 'audio/webm', format: 'webm' });
|
||||
});
|
||||
|
||||
it('normalizes uppercase / surrounding whitespace', () => {
|
||||
const res = resolveAudioFormat(' AUDIO/MP4 ; codecs=mp4a ');
|
||||
expect(res).toEqual({ ok: true, baseMime: 'audio/mp4', format: 'mp4' });
|
||||
});
|
||||
|
||||
it('handles the Safari/iOS audio/x-m4a container', () => {
|
||||
expect(resolveAudioFormat('audio/x-m4a')).toEqual({
|
||||
ok: true,
|
||||
baseMime: 'audio/x-m4a',
|
||||
format: 'm4a',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a disallowed container (audio/aiff)', () => {
|
||||
expect(resolveAudioFormat('audio/aiff')).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('maps every whitelisted container to its STT format hint', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['audio/webm', 'webm'],
|
||||
['audio/ogg', 'ogg'],
|
||||
['audio/mp4', 'mp4'],
|
||||
['audio/mpeg', 'mp3'],
|
||||
['audio/wav', 'wav'],
|
||||
['audio/x-wav', 'wav'],
|
||||
['audio/wave', 'wav'],
|
||||
['audio/m4a', 'm4a'],
|
||||
['audio/x-m4a', 'm4a'],
|
||||
];
|
||||
for (const [mime, format] of cases) {
|
||||
expect(resolveAudioFormat(mime)).toEqual({
|
||||
ok: true,
|
||||
baseMime: mime,
|
||||
format,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||
import * as loader from './docmost-client.loader';
|
||||
import type { DocmostClientLike } from './docmost-client.loader';
|
||||
// The real zod-agnostic shared tool-spec registry. It has no runtime deps, so
|
||||
// importing the TS source directly keeps these mocks honest: the service builds
|
||||
// the shared tools from exactly the specs the package ships, not a hand-stub.
|
||||
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||
|
||||
// loadDocmostMcp now resolves to { DocmostClient, sharedToolSpecs }. Every mock
|
||||
// below must supply sharedToolSpecs or the service throws while building the
|
||||
// shared tools. Factor the resolved-value shape so the three mock sites stay in
|
||||
// sync.
|
||||
const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
|
||||
DocmostClient,
|
||||
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
|
||||
});
|
||||
|
||||
/**
|
||||
* Guardrail test (§14 [H4]): the adapter's `deletePage` write tool must be a
|
||||
@@ -37,11 +50,11 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
|
||||
beforeEach(() => {
|
||||
deletePageCalls.length = 0;
|
||||
// Intercept the ESM loader so `new DocmostClient(config)` returns our fake.
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
});
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
// The new semanticSearch deps (aiService + repos) are not exercised by the
|
||||
// deletePage guardrail tests; pass stubs to satisfy the constructor arity.
|
||||
service = new AiChatToolsService(
|
||||
@@ -144,11 +157,11 @@ describe('AiChatToolsService expanded toolset guardrails', () => {
|
||||
let service: AiChatToolsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
});
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
@@ -252,11 +265,11 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
||||
patchNodeCalls.length = 0;
|
||||
insertNodeCalls.length = 0;
|
||||
updatePageJsonCalls.length = 0;
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||
DocmostClient: function () {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
});
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
|
||||
@@ -11,7 +11,10 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
|
||||
import {
|
||||
loadDocmostMcp,
|
||||
type DocmostClientLike,
|
||||
type SharedToolSpec,
|
||||
} from './docmost-client.loader';
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
|
||||
/**
|
||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||
@@ -82,13 +85,29 @@ export class AiChatToolsService {
|
||||
aiChatId,
|
||||
});
|
||||
|
||||
const { DocmostClient } = await loadDocmostMcp();
|
||||
const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp();
|
||||
const client: DocmostClientLike = new DocmostClient({
|
||||
apiUrl,
|
||||
getToken,
|
||||
getCollabToken,
|
||||
});
|
||||
|
||||
// Build an ai-SDK tool from a shared, zod-agnostic spec. The spec owns the
|
||||
// canonical description + (optional) schema builder, which is invoked with
|
||||
// THIS layer's zod (v4); only the execute body is supplied per call. No-arg
|
||||
// specs (no buildShape) get an empty object schema.
|
||||
const sharedTool = (
|
||||
spec: SharedToolSpec,
|
||||
execute: Tool['execute'],
|
||||
): Tool =>
|
||||
tool({
|
||||
description: spec.description,
|
||||
inputSchema: spec.buildShape
|
||||
? z.object(spec.buildShape(z) as z.ZodRawShape)
|
||||
: z.object({}),
|
||||
execute,
|
||||
});
|
||||
|
||||
return {
|
||||
searchPages: tool({
|
||||
description:
|
||||
@@ -197,21 +216,8 @@ export class AiChatToolsService {
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
|
||||
// Keep the best (first — hits are ordered by fused score desc) chunk
|
||||
// per page, capped to `cap`.
|
||||
const seen = new Set<string>();
|
||||
const results: { id: string; title: string; snippet: string }[] = [];
|
||||
for (const hit of hits) {
|
||||
if (!accessibleSet.has(hit.pageId)) continue;
|
||||
if (seen.has(hit.pageId)) continue;
|
||||
seen.add(hit.pageId);
|
||||
results.push({
|
||||
id: hit.pageId,
|
||||
title: hit.title ?? '',
|
||||
snippet: snippet(hit.content),
|
||||
});
|
||||
if (results.length >= cap) break;
|
||||
}
|
||||
return results;
|
||||
// per page, dropping any page the user cannot access, capped to `cap`.
|
||||
return selectAccessibleHits(hits, accessibleSet, cap);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -222,14 +228,7 @@ export class AiChatToolsService {
|
||||
'or null if the user is not currently on a page. Call this first whenever ' +
|
||||
'the user refers to the current page without giving an explicit id.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
if (!openedPage?.id) {
|
||||
return { page: null };
|
||||
}
|
||||
return {
|
||||
page: { id: openedPage.id, title: openedPage.title ?? '' },
|
||||
};
|
||||
},
|
||||
execute: async () => resolveCurrentPageResult(openedPage),
|
||||
}),
|
||||
|
||||
getPage: tool({
|
||||
@@ -371,12 +370,29 @@ export class AiChatToolsService {
|
||||
|
||||
createComment: tool({
|
||||
description:
|
||||
'Add a comment to a page, or reply to an existing top-level comment ' +
|
||||
'(one level only — the backend rejects replies to replies). ' +
|
||||
'Reversible via the comment UI.',
|
||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
||||
'comment (one level only — the backend rejects replies to replies). ' +
|
||||
'The comment is anchored inline to the given exact `selection` text ' +
|
||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
||||
"parent's anchor and take no selection. If the call fails with a " +
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. Reversible via the ' +
|
||||
'comment UI.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().describe('The comment body as Markdown.'),
|
||||
selection: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.optional()
|
||||
.describe(
|
||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
||||
'formatting boundaries). Required for a new top-level comment; ' +
|
||||
'omit only when replying via parentCommentId.',
|
||||
),
|
||||
parentCommentId: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -385,14 +401,22 @@ export class AiChatToolsService {
|
||||
'of replies only).',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, content, parentCommentId }) => {
|
||||
execute: async ({ pageId, content, selection, parentCommentId }) => {
|
||||
// createComment(pageId, content, type, selection?, parentCommentId?).
|
||||
// Page-type comment (no inline selection); replies inherit the anchor.
|
||||
// Top-level comments are inline and must carry a selection to anchor
|
||||
// on; replies inherit the parent's anchor (no selection). Throwing
|
||||
// here surfaces a tool error to the model (Vercel `ai` SDK) so the
|
||||
// agent retries with a better selection — do not catch/suppress it.
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
|
||||
);
|
||||
}
|
||||
const result = await client.createComment(
|
||||
pageId,
|
||||
content,
|
||||
'page',
|
||||
undefined,
|
||||
'inline',
|
||||
selection,
|
||||
parentCommentId,
|
||||
);
|
||||
const data = (result?.data ?? {}) as { id?: string };
|
||||
@@ -421,20 +445,15 @@ export class AiChatToolsService {
|
||||
|
||||
// --- READ tools (added) ---
|
||||
|
||||
getWorkspace: tool({
|
||||
description:
|
||||
'Fetch metadata about the current workspace (name, settings).',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => await client.getWorkspace(),
|
||||
}),
|
||||
getWorkspace: sharedTool(
|
||||
sharedToolSpecs.getWorkspace,
|
||||
async () => await client.getWorkspace(),
|
||||
),
|
||||
|
||||
listSpaces: tool({
|
||||
description:
|
||||
'List the spaces the current user can access. Returns the array ' +
|
||||
'of spaces (id, name, slug, ...).',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => await client.getSpaces(),
|
||||
}),
|
||||
listSpaces: sharedTool(
|
||||
sharedToolSpecs.listSpaces,
|
||||
async () => await client.getSpaces(),
|
||||
),
|
||||
|
||||
listPages: tool({
|
||||
description:
|
||||
@@ -482,43 +501,20 @@ export class AiChatToolsService {
|
||||
await client.listSidebarPages(spaceId, pageId),
|
||||
}),
|
||||
|
||||
getOutline: tool({
|
||||
description:
|
||||
"Compact outline of a page's top-level blocks, with block ids. Use " +
|
||||
'it to locate sections/tables and grab block ids before drilling in ' +
|
||||
'with getNode / patchNode / insertNode.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.getOutline(pageId),
|
||||
}),
|
||||
getOutline: sharedTool(
|
||||
sharedToolSpecs.getOutline,
|
||||
async ({ pageId }) => await client.getOutline(pageId),
|
||||
),
|
||||
|
||||
getPageJson: tool({
|
||||
description:
|
||||
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
|
||||
'marks). Use this when you need exact structure for node-level edits.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.getPageJson(pageId),
|
||||
}),
|
||||
getPageJson: sharedTool(
|
||||
sharedToolSpecs.getPageJson,
|
||||
async ({ pageId }) => await client.getPageJson(pageId),
|
||||
),
|
||||
|
||||
getNode: tool({
|
||||
description:
|
||||
"Fetch a single block's full ProseMirror subtree (lossless) by " +
|
||||
'reference.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z
|
||||
.string()
|
||||
.describe(
|
||||
'A block id from getOutline, or "#<index>" to select a ' +
|
||||
'top-level block by its outline index (e.g. a table).',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, nodeId }) =>
|
||||
await client.getNode(pageId, nodeId),
|
||||
}),
|
||||
getNode: sharedTool(
|
||||
sharedToolSpecs.getNode,
|
||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
getTable: tool({
|
||||
description:
|
||||
@@ -575,27 +571,16 @@ export class AiChatToolsService {
|
||||
await client.checkNewComments(spaceId, since, parentPageId),
|
||||
}),
|
||||
|
||||
listShares: tool({
|
||||
description:
|
||||
'List all public shares in the workspace, each with its public URL.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => await client.listShares(),
|
||||
}),
|
||||
listShares: sharedTool(
|
||||
sharedToolSpecs.listShares,
|
||||
async () => await client.listShares(),
|
||||
),
|
||||
|
||||
listPageHistory: tool({
|
||||
description:
|
||||
'List the saved versions (history snapshots) of a page, newest ' +
|
||||
'first. Returns one cursor-paginated page of results.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
cursor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional pagination cursor from a previous call.'),
|
||||
}),
|
||||
execute: async ({ pageId, cursor }) =>
|
||||
listPageHistory: sharedTool(
|
||||
sharedToolSpecs.listPageHistory,
|
||||
async ({ pageId, cursor }) =>
|
||||
await client.listPageHistory(pageId, cursor),
|
||||
}),
|
||||
),
|
||||
|
||||
getPageHistory: tool({
|
||||
description:
|
||||
@@ -608,24 +593,11 @@ export class AiChatToolsService {
|
||||
await client.getPageHistory(historyId),
|
||||
}),
|
||||
|
||||
diffPageVersions: tool({
|
||||
description:
|
||||
'Diff two versions of a page and return the change set. from/to ' +
|
||||
"each accept a historyId or 'current' (or omit for current).",
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
from: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("A historyId, or 'current'/omit for current content."),
|
||||
to: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("A historyId, or 'current'/omit for current content."),
|
||||
}),
|
||||
execute: async ({ pageId, from, to }) =>
|
||||
diffPageVersions: sharedTool(
|
||||
sharedToolSpecs.diffPageVersions,
|
||||
async ({ pageId, from, to }) =>
|
||||
await client.diffPageVersions(pageId, from, to),
|
||||
}),
|
||||
),
|
||||
|
||||
exportPageMarkdown: tool({
|
||||
description:
|
||||
@@ -643,46 +615,10 @@ export class AiChatToolsService {
|
||||
|
||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||
|
||||
editPageText: tool({
|
||||
description:
|
||||
'Surgical find/replace inside a page\'s text, preserving all block ' +
|
||||
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
|
||||
'replacement inherits marks from the unchanged common prefix/suffix ' +
|
||||
'(so editing plain text next to a bold word keeps it bold, and ' +
|
||||
'editing inside a bold word keeps the new text bold). Each find must ' +
|
||||
'match exactly once unless replaceAll is set. The batch applies what ' +
|
||||
'it can and returns applied[] + failed[] plus a verify change-report ' +
|
||||
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
|
||||
'your edit landed; do not assume success); a fully-unmatched batch ' +
|
||||
'writes nothing and errors. find and replace are LITERAL text, not ' +
|
||||
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
|
||||
'formatting marks: a formatting change — find/replace that differ only ' +
|
||||
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
|
||||
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
|
||||
'failed[]. To change bold/italic/strike/code/link, read the block with ' +
|
||||
'getPageJson and use patchNode (or updatePageJson) to set its marks. ' +
|
||||
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
||||
'world",replace:"Hello there"}] (crosses a bold boundary). Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to edit.'),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
find: z.string().describe('Exact text to find.'),
|
||||
replace: z.string().describe('Replacement text.'),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Replace every occurrence (default: one match).'),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe('One or more find/replace edits.'),
|
||||
}),
|
||||
execute: async ({ pageId, edits }) =>
|
||||
await client.editPageText(pageId, edits),
|
||||
}),
|
||||
editPageText: sharedTool(
|
||||
sharedToolSpecs.editPageText,
|
||||
async ({ pageId, edits }) => await client.editPageText(pageId, edits),
|
||||
),
|
||||
|
||||
patchNode: tool({
|
||||
description:
|
||||
@@ -711,14 +647,7 @@ export class AiChatToolsService {
|
||||
// Parity with the standalone MCP server (index.ts patch_node): the
|
||||
// model sometimes serializes the node as a JSON string. Parse it
|
||||
// before the client's typeof-object guard rejects it.
|
||||
let parsedNode = node;
|
||||
if (typeof node === 'string') {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error('node was a string but not valid JSON');
|
||||
}
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.patchNode(pageId, nodeId, parsedNode);
|
||||
},
|
||||
}),
|
||||
@@ -770,14 +699,7 @@ export class AiChatToolsService {
|
||||
// Parity with the standalone MCP server (index.ts insert_node): the
|
||||
// model sometimes serializes the node as a JSON string. Parse it
|
||||
// before the client's typeof-object guard rejects it.
|
||||
let parsedNode = node;
|
||||
if (typeof node === 'string') {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error('node was a string but not valid JSON');
|
||||
}
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
@@ -786,17 +708,10 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
deleteNode: tool({
|
||||
description:
|
||||
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
|
||||
'previous version is kept in page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z.string().describe('The block id to remove.'),
|
||||
}),
|
||||
execute: async ({ pageId, nodeId }) =>
|
||||
await client.deleteNode(pageId, nodeId),
|
||||
}),
|
||||
deleteNode: sharedTool(
|
||||
sharedToolSpecs.deleteNode,
|
||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
updatePageJson: tool({
|
||||
description:
|
||||
@@ -826,14 +741,9 @@ export class AiChatToolsService {
|
||||
let doc;
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
} else if (typeof content === 'string') {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error('content was a string but not valid JSON');
|
||||
}
|
||||
} else {
|
||||
doc = content;
|
||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
||||
doc = parseNodeArg(content, 'content was a string but not valid JSON');
|
||||
}
|
||||
return await client.updatePageJson(pageId, doc, title);
|
||||
},
|
||||
@@ -890,35 +800,17 @@ export class AiChatToolsService {
|
||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
||||
}),
|
||||
|
||||
copyPageContent: tool({
|
||||
description:
|
||||
"Replace the target page's BODY with the source page's body " +
|
||||
'(title/slug are kept). Runs server-side — no document passes ' +
|
||||
'through the model. Reversible: the target keeps page history.',
|
||||
inputSchema: z.object({
|
||||
sourcePageId: z.string().describe('The id of the source page.'),
|
||||
targetPageId: z
|
||||
.string()
|
||||
.describe('The id of the target page to overwrite.'),
|
||||
}),
|
||||
execute: async ({ sourcePageId, targetPageId }) =>
|
||||
copyPageContent: sharedTool(
|
||||
sharedToolSpecs.copyPageContent,
|
||||
async ({ sourcePageId, targetPageId }) =>
|
||||
await client.copyPageContent(sourcePageId, targetPageId),
|
||||
}),
|
||||
),
|
||||
|
||||
importPageMarkdown: tool({
|
||||
description:
|
||||
"Replace a page's body from Docmost-flavoured Markdown (as produced " +
|
||||
'by exportPageMarkdown). Reversible: the previous version is kept in ' +
|
||||
'page history.',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to overwrite.'),
|
||||
markdown: z
|
||||
.string()
|
||||
.describe('Docmost-flavoured Markdown for the page body.'),
|
||||
}),
|
||||
execute: async ({ pageId, markdown }) =>
|
||||
importPageMarkdown: sharedTool(
|
||||
sharedToolSpecs.importPageMarkdown,
|
||||
async ({ pageId, markdown }) =>
|
||||
await client.importPageMarkdown(pageId, markdown),
|
||||
}),
|
||||
),
|
||||
|
||||
sharePage: tool({
|
||||
description:
|
||||
@@ -936,27 +828,15 @@ export class AiChatToolsService {
|
||||
await client.sharePage(pageId, searchIndexing),
|
||||
}),
|
||||
|
||||
unsharePage: tool({
|
||||
description:
|
||||
'Remove the public share of a page (reverses sharePage).',
|
||||
inputSchema: z.object({
|
||||
pageId: z.string().describe('The id of the page to unshare.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.unsharePage(pageId),
|
||||
}),
|
||||
unsharePage: sharedTool(
|
||||
sharedToolSpecs.unsharePage,
|
||||
async ({ pageId }) => await client.unsharePage(pageId),
|
||||
),
|
||||
|
||||
restorePageVersion: tool({
|
||||
description:
|
||||
'Restore a past version by writing its content back as the current ' +
|
||||
'page content. Itself reversible: it creates a new history snapshot.',
|
||||
inputSchema: z.object({
|
||||
historyId: z
|
||||
.string()
|
||||
.describe('The id of the history version to restore.'),
|
||||
}),
|
||||
execute: async ({ historyId }) =>
|
||||
await client.restorePageVersion(historyId),
|
||||
}),
|
||||
restorePageVersion: sharedTool(
|
||||
sharedToolSpecs.restorePageVersion,
|
||||
async ({ historyId }) => await client.restorePageVersion(historyId),
|
||||
),
|
||||
|
||||
transformPage: tool({
|
||||
description:
|
||||
@@ -984,6 +864,44 @@ export class AiChatToolsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** A single hybrid-search hit: the minimal shape selectAccessibleHits needs. */
|
||||
export interface SearchHitLike {
|
||||
pageId: string;
|
||||
title: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-filter hybrid-search hits into the agent-facing result list. This is the
|
||||
* CASL leak guard for the in-process hybrid search: the hits come from a direct
|
||||
* pgvector + full-text query that does NOT get CASL for free, so an accessible
|
||||
* SPACE does not imply every page in it is accessible (restricted pages).
|
||||
*
|
||||
* Given `hits` (ordered by fused score desc), the `accessibleSet` of page ids
|
||||
* the user may read, and `cap`, it keeps the BEST (first) chunk per page, drops
|
||||
* any page not in `accessibleSet`, and caps the output at `cap`. Pure — no I/O.
|
||||
*/
|
||||
export function selectAccessibleHits(
|
||||
hits: readonly SearchHitLike[],
|
||||
accessibleSet: Set<string>,
|
||||
cap: number,
|
||||
): { id: string; title: string; snippet: string }[] {
|
||||
const seen = new Set<string>();
|
||||
const results: { id: string; title: string; snippet: string }[] = [];
|
||||
for (const hit of hits) {
|
||||
if (!accessibleSet.has(hit.pageId)) continue;
|
||||
if (seen.has(hit.pageId)) continue;
|
||||
seen.add(hit.pageId);
|
||||
results.push({
|
||||
id: hit.pageId,
|
||||
title: hit.title ?? '',
|
||||
snippet: snippet(hit.content),
|
||||
});
|
||||
if (results.length >= cap) break;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a search highlight/snippet to a token-efficient length. The highlight
|
||||
* may contain `<b>` markers from the search backend; they are harmless to the
|
||||
|
||||
43
apps/server/src/core/ai-chat/tools/current-page.util.spec.ts
Normal file
43
apps/server/src/core/ai-chat/tools/current-page.util.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
|
||||
/**
|
||||
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
|
||||
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
|
||||
* otherwise { page: { id, title } } with title defaulting to ''.
|
||||
*/
|
||||
describe('resolveCurrentPageResult', () => {
|
||||
it('returns { page: null } when openedPage is undefined', () => {
|
||||
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns { page: null } when openedPage is null', () => {
|
||||
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns { page: null } when openedPage has no id', () => {
|
||||
expect(resolveCurrentPageResult({})).toEqual({ page: null });
|
||||
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns { page: null } when id is an empty string', () => {
|
||||
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns the page id and title when both are present', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
|
||||
page: { id: 'p1', title: 'Hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults title to "" when it is missing', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
||||
page: { id: 'p1', title: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicit empty-string title as ""', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
||||
page: { id: 'p1', title: '' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user