Compare commits
277 Commits
24bf0ab18f
...
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 | ||
| e5bc82c7f1 | |||
|
|
5418e259a6 | ||
|
|
10bff229d6 | ||
|
|
9797751b0a | ||
|
|
ba37907f50 | ||
|
|
267bafdd73 | ||
|
|
b21433af4e | ||
|
|
85fd4afa85 | ||
|
|
d9fa804197 | ||
|
|
e8775c45b0 | ||
|
|
ec4622a1b8 | ||
|
|
33c52045a2 | ||
|
|
85db20f9f2 | ||
| 084eafd0bb | |||
|
|
455a554054 | ||
|
|
7e26239c3f | ||
|
|
bc0c49db05 | ||
|
|
b5ce51581f | ||
|
|
0fbaebd108 | ||
|
|
18105ff6db | ||
|
|
3936c482d9 | ||
|
|
a20f4c3876 | ||
|
|
31fcb764d7 | ||
|
|
3f46496192 | ||
|
|
cecb560fce | ||
|
|
c596e17a40 | ||
|
|
3953ecdb17 | ||
|
|
3147b6ddf4 | ||
|
|
7c57a386b2 | ||
|
|
a2ded7ecfb | ||
|
|
bed3d3d286 | ||
|
|
c486750b2a | ||
|
|
8016b1c540 | ||
|
|
d45ca00bcc | ||
|
|
a11c87c4dc | ||
|
|
6928817cee | ||
|
|
c78177c28b | ||
|
|
b597841cf0 | ||
|
|
317fdb9424 | ||
|
|
40f68e95fb | ||
|
|
342bb47b30 | ||
|
|
e9ceb0f899 | ||
|
|
c0d312d8f5 | ||
|
|
5215913533 | ||
|
|
e52f069fc6 | ||
|
|
ff342ca705 | ||
|
|
afbc6b2202 | ||
|
|
099d31f594 | ||
|
|
212bcea4d7 | ||
|
|
05a7a4001f | ||
|
|
5344a9bdde | ||
|
|
d79f709742 | ||
|
|
2b4ec0bfcc | ||
|
|
e19849d980 | ||
|
|
20b9f61c3e | ||
|
|
81823fce1e | ||
|
|
b98c9d51c6 | ||
|
|
75c7c29cc8 | ||
|
|
64818cf9df | ||
|
|
262a0707d9 | ||
|
|
70c26f356a | ||
|
|
881610f5df | ||
|
|
4bf6d9f36b | ||
|
|
0944e0f455 | ||
|
|
d7681b4fb6 | ||
|
|
d105397dcf | ||
|
|
8b8b05e005 | ||
|
|
4f5a08cba0 | ||
|
|
3695dbdf7f | ||
|
|
ab51239cab | ||
|
|
4fa8882c58 | ||
|
|
eae68ba11f | ||
|
|
730486ad12 | ||
|
|
5f3a3d3ec0 | ||
|
|
f63719a21c | ||
|
|
877806e0ce | ||
|
|
0caceb614b | ||
|
|
987a4fd32e | ||
|
|
d96f94a80a | ||
|
|
8414114dc8 | ||
|
|
41efacbe3d | ||
|
|
4348608ee4 | ||
|
|
bd377ca4a8 | ||
|
|
e0aac5aa04 | ||
|
|
f6e216cb87 | ||
|
|
90d3fab483 | ||
|
|
1f457b060c | ||
|
|
424761753e | ||
|
|
b7ea8c850e | ||
|
|
8191c37daa | ||
|
|
39f3eacf89 | ||
|
|
bc1ea792f5 | ||
|
|
98769155d3 | ||
|
|
4f46f91db4 | ||
|
|
692c0abe13 | ||
|
|
c5f44a6eee | ||
|
|
a6ba19f0dc | ||
|
|
ada1dce739 | ||
|
|
8ee4279d30 | ||
|
|
6a052b88b4 | ||
|
|
79d096ed7a | ||
|
|
a15cccf557 | ||
|
|
22887c474a | ||
|
|
4536d27ad2 | ||
|
|
a85dd607bd | ||
|
|
b8655ae52c | ||
|
|
c9eb495688 | ||
|
|
859223db1a | ||
|
|
b53b0c651e | ||
|
|
be17391e18 | ||
|
|
19ae6a0efa | ||
|
|
7a03321d43 | ||
|
|
2b3fc926cc | ||
|
|
e9e9f74ec6 | ||
|
|
52efd37fd9 | ||
|
|
d80a419963 | ||
|
|
6128920264 | ||
|
|
cf29a0fc11 | ||
|
|
4fe42ead56 | ||
|
|
41f3944e79 | ||
|
|
46688074d8 | ||
|
|
f650d2591b | ||
|
|
f72e44c9b7 | ||
|
|
8fcce6a674 | ||
|
|
c718b2a6de | ||
|
|
0c46f60ddf | ||
|
|
90e9b0a3f4 | ||
|
|
4c1d1aa2ee | ||
|
|
4b31128e24 | ||
|
|
127d26c057 | ||
|
|
45cf4140eb | ||
|
|
ec128d54b4 | ||
|
|
cedea4072b | ||
|
|
1e650262a4 | ||
|
|
f1980cf425 | ||
|
|
965cbb32e5 | ||
|
|
0b969c8675 | ||
|
|
b20ffd1b91 | ||
|
|
949a251553 | ||
|
|
234ae759f5 | ||
|
|
151bd7a0e0 | ||
|
|
689f435630 | ||
|
|
1982ef0f23 | ||
|
|
4bfb143288 | ||
|
|
f8bb4b37ce | ||
|
|
d11cf0112f | ||
|
|
36ae4bd3d3 | ||
|
|
be2530a0b9 | ||
|
|
587a940959 | ||
|
|
71fc58dbed | ||
|
|
9aff427ad8 | ||
|
|
caac5c7f36 | ||
|
|
3672093f56 | ||
|
|
20a1780977 | ||
|
|
cac7abc395 | ||
|
|
4430784094 | ||
|
|
680995247a | ||
|
|
5d5f61fc6e | ||
|
|
52c5be4fa4 | ||
|
|
394d3e58fc | ||
|
|
ceee2a76ca | ||
|
|
bfd79b94bc | ||
|
|
932a4080f7 | ||
|
|
e0b3b3d9a5 | ||
|
|
1c83a8ae15 | ||
|
|
4d17befb0d | ||
|
|
42671c0901 | ||
|
|
39ae89264d | ||
|
|
393bca4dab | ||
|
|
bd28dbfe2b | ||
|
|
31d6498b24 | ||
|
|
046132afc7 | ||
|
|
b7b1fb773e | ||
|
|
acf3df9e9d | ||
|
|
1483e021d1 | ||
|
|
4a00dfc3b2 | ||
|
|
87ce969a6f | ||
|
|
30c3189220 | ||
|
|
fb01c07b71 | ||
|
|
b197cbedef | ||
|
|
b38b71eb51 | ||
|
|
b81819ef63 | ||
|
|
059f2bd7e5 |
78
.env.example
78
.env.example
@@ -2,6 +2,38 @@
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# --- Security / reverse proxy ---
|
||||
# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via
|
||||
# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only
|
||||
# from proxies on the configured trusted networks. Deploy this app behind a
|
||||
# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For`
|
||||
# with the real client IP. If XFF is trusted from an untrusted source, any
|
||||
# per-IP throttling — including the /mcp Basic brute-force limiter — can be
|
||||
# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs.
|
||||
# (The /mcp limiter keeps a global per-email key as an IP-independent backstop,
|
||||
# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.)
|
||||
#
|
||||
# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For.
|
||||
# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted
|
||||
# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip.
|
||||
# This is the safe default for the common case where the reverse proxy runs on
|
||||
# loopback or a private network; req.ip still resolves to the real client.
|
||||
# WARNING: this changed the previous default of trust-all. If your reverse proxy
|
||||
# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the
|
||||
# proxy's IP — set TRUST_PROXY accordingly. Accepted values:
|
||||
# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites
|
||||
# X-Forwarded-For; otherwise clients can spoof their IP)
|
||||
# - false never trust X-Forwarded-For (req.ip is the socket peer)
|
||||
# - <int> number of trusted proxy hops in front of the app
|
||||
# - <list> comma-separated CIDR/IP list of trusted proxies, e.g.
|
||||
# `127.0.0.1, 10.0.0.0/8`
|
||||
# TRUST_PROXY=
|
||||
|
||||
# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that
|
||||
# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you
|
||||
# change APP_SECRET after setup, every stored AI API key becomes undecryptable —
|
||||
# you must re-enter them in AI settings — and all existing sessions/JWTs are
|
||||
# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB.
|
||||
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||
|
||||
@@ -69,15 +101,55 @@ DEBUG_DB=false
|
||||
# Log http requests
|
||||
LOG_HTTP=false
|
||||
|
||||
# MCP server (community): service account the embedded MCP uses to talk to this Docmost instance
|
||||
# MCP server (community): the embedded /mcp endpoint authenticates PER USER.
|
||||
# An MCP client authenticates with one of:
|
||||
# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own
|
||||
# Docmost login/password. The server validates the credentials and the MCP
|
||||
# session then acts under that user's permissions (edits attributed to them).
|
||||
# - Bearer access JWT: `Authorization: Bearer <access-jwt>` (the user's
|
||||
# `authToken` cookie value). Validated as an ACCESS token.
|
||||
#
|
||||
# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR
|
||||
# Bearer credentials and these are set, the MCP session falls back to this
|
||||
# shared service account (back-compat; useful for CI/scripts). Leave BLANK to
|
||||
# require per-user credentials.
|
||||
MCP_DOCMOST_EMAIL=
|
||||
MCP_DOCMOST_PASSWORD=
|
||||
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
|
||||
# Optional bearer token to protect the /mcp endpoint. If unset, /mcp relies on
|
||||
# the workspace MCP toggle and network isolation (do not expose the port publicly).
|
||||
# Optional shared guard for the /mcp endpoint. When set, every /mcp request must
|
||||
# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now
|
||||
# carries the per-user credentials). When unset, /mcp relies on the per-user
|
||||
# credentials above plus the workspace MCP toggle and network isolation (do not
|
||||
# expose the port publicly).
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
|
||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
# share at POST /api/shares/ai/stream. The assistant is read-only and hard-scoped
|
||||
# to the single share tree, but every call spends real tokens on the workspace
|
||||
# owner's configured AI provider.
|
||||
#
|
||||
# DEPLOYMENT REQUIREMENT: the per-IP rate limit on this endpoint is only
|
||||
# effective behind a trusted reverse proxy that OVERWRITES (not appends)
|
||||
# X-Forwarded-For with the real client IP. The app runs with trustProxy, so
|
||||
# without such a proxy an attacker can rotate X-Forwarded-For to evade the
|
||||
# per-IP limit. Put this endpoint (and the app) behind a proxy you control that
|
||||
# sets X-Forwarded-For to the real client IP.
|
||||
#
|
||||
# Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent,
|
||||
# keyed by the server-resolved workspace id) bounds the owner's bill even if the
|
||||
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
|
||||
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
|
||||
# offline is safer than an unbounded bill). Override the hourly cap below
|
||||
# (default: 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.
|
||||
# SHARE_AI_MAX_OUTPUT_TOKENS=512
|
||||
|
||||
7
.github/workflows/develop.yml
vendored
7
.github/workflows/develop.yml
vendored
@@ -3,7 +3,7 @@ name: Develop
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -18,7 +18,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -19,7 +19,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
|
||||
40
.github/workflows/test.yml
vendored
Normal file
40
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Required for the client suite, which resolves @docmost/editor-ext via its
|
||||
# dist build (the server suite also rebuilds it through its own pretest).
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r test
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -42,3 +42,9 @@ lerna-debug.log*
|
||||
.nx/installation
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||
apps/client/public/vad/
|
||||
|
||||
188
AGENTS.md
188
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
|
||||
|
||||
@@ -216,7 +217,7 @@ pnpm --filter server migration:latest # apply all pending
|
||||
pnpm --filter server migration:down # revert last
|
||||
pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB
|
||||
```
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`) and nullable columns — never drop/rewrite Docmost data.
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||
|
||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||
|
||||
@@ -240,7 +241,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
|
||||
|
||||
### The two AI subsystems (the main fork additions)
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. It authenticates as a service account configured via `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`; an admin enables it with a workspace toggle (Workspace settings → AI). Optionally protected by `MCP_TOKEN`.
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
|
||||
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
|
||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
@@ -263,7 +264,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
## CI / release
|
||||
|
||||
- `.github/workflows/develop.yml` — on push to `main`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||
- `.github/workflows/develop.yml` — on push to `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||
- `.github/workflows/release.yml` — on `v*` tags (or manual dispatch), builds multi-arch (amd64 + arm64) images, pushes a manifest list to GHCR (`latest` + semver tags), and creates a draft GitHub Release with image tarballs. Uses the built-in `GITHUB_TOKEN` (not Docker Hub).
|
||||
- The `Dockerfile` is a multi-stage pnpm build; `APP_VERSION` is passed as a build arg because `.git` isn't in the build context.
|
||||
|
||||
@@ -277,7 +278,30 @@ 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
|
||||
|
||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation, arbitrary HTML embed). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||
|
||||
115
CHANGELOG.md
115
CHANGELOG.md
@@ -10,6 +10,118 @@ 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
|
||||
|
||||
- **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), kept separate from the member-facing
|
||||
HTML-embed feature.
|
||||
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
|
||||
embedded `/mcp` endpoint.
|
||||
- **Page tree**: Expand all / Collapse all for the space tree, and
|
||||
server-authoritative realtime tree updates.
|
||||
- **AI chat UX**: a `get_current_page` tool for proxy-robust page context, a
|
||||
current-context-size readout, an agent step cap raised 8→20 with a forced
|
||||
final text answer, and auto-collapse of the chat window on page focus.
|
||||
- **AI settings**: a Clear control inside the API-key field and an endpoint
|
||||
status dot bound to "configured × enabled".
|
||||
- **Client**: an always-visible space grid replacing the space-switcher popover,
|
||||
removal of the sidebar Overview item, tighter comments-panel density, and no
|
||||
auto-open of the comments panel when adding a comment.
|
||||
|
||||
### Changed
|
||||
|
||||
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
|
||||
when the workspace HTML-embed toggle is on, can be inserted by any member
|
||||
(previously admin-only). Turning the toggle off hides existing embeds and
|
||||
stops serving them on public share pages.
|
||||
- Remove the server-side role-based stripping of HTML-embed blocks from the
|
||||
write paths (collab/REST/MCP, page create/duplicate, import, transclusion
|
||||
unsync); sandboxing makes per-write gating unnecessary. The only remaining
|
||||
server-side strip is the public-share read path, which still honors the
|
||||
workspace HTML-embed toggle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- AI chat: preserve scroll position during streaming, record chats that fail on
|
||||
their first turn, and resolve the current page for agent context behind
|
||||
proxies.
|
||||
- AI roles: guard `update()` against concurrent soft-delete; harden the model
|
||||
override, role-name uniqueness, and id validation; sandwich the safety
|
||||
framework around the role persona.
|
||||
- Auth: handle null-password (SSO/LDAP-only) accounts without a bcrypt throw.
|
||||
- Footnotes: survive duplicate-id definitions without collab divergence.
|
||||
- HTML embed: fix stale iframe height and damp the resize loop; strip embeds at
|
||||
serve time on authenticated read paths and the plain page-create path.
|
||||
- Page templates: import `ThrottleModule` so collab boots, never strand an
|
||||
in-flight page-embed id, and add defense-in-depth workspace checks.
|
||||
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
|
||||
- Import: surface the real error cause from `/pages/import` instead of a generic
|
||||
400.
|
||||
|
||||
### Security
|
||||
|
||||
- MCP: close an SSO/MFA bypass on Basic auth and stop minting non-init sessions;
|
||||
close a brute-force limiter check-then-act race.
|
||||
- Public share: block restricted descendants in the anonymous assistant, cap
|
||||
per-request output, fail closed when Redis is unavailable, and reject non-text
|
||||
message parts to close a size-cap bypass.
|
||||
- Make `trustProxy` env-configurable with a safe default.
|
||||
|
||||
### Internal
|
||||
|
||||
- CI: gate the `develop` and release image builds on the test suite, run the
|
||||
suites on push/PR, and build the `:develop` image on push to `develop`.
|
||||
- Docs: replace `CLAUDE.md` with `AGENTS.md` codifying the agent workflow and
|
||||
the release procedure, add migration-ordering guidance, and prune implemented
|
||||
plans.
|
||||
- A large batch of new server/client test coverage.
|
||||
|
||||
## [0.91.0] - 2026-06-18
|
||||
|
||||
Gitmost is a community-focused fork of Docmost. This release drops the
|
||||
@@ -92,5 +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
|
||||
|
||||
11
README.md
11
README.md
@@ -101,6 +101,9 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs.
|
||||
- ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP).
|
||||
- ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it.
|
||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||
|
||||
### In progress
|
||||
|
||||
@@ -108,14 +111,11 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
|
||||
### Planned
|
||||
|
||||
- 🔭 **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). See [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
||||
- 🔭 **Viewer comments** — let read-only viewers leave comments.
|
||||
- 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
||||
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
|
||||
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
|
||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Offline mode** — offline sync & PWA support.
|
||||
- 🔭 **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. See [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
||||
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.
|
||||
|
||||
## Getting started
|
||||
@@ -158,6 +158,11 @@ the existing data directory is reused as-is:
|
||||
start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the
|
||||
`page_embeddings` and AI tables); watch the logs for `Migration "..." executed successfully`.
|
||||
|
||||
> ⚠️ **Never change `APP_SECRET` after setup.** It does double duty: it signs JWTs *and* derives the
|
||||
> AES-256-GCM key that encrypts stored AI-provider credentials (API keys). Rotating it makes every
|
||||
> saved AI API key undecryptable (you'd have to re-enter them in AI settings) and invalidates all
|
||||
> existing sessions. Pick it once, keep it stable, and back it up together with your database.
|
||||
|
||||
### Notes
|
||||
|
||||
- **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the
|
||||
|
||||
12
README.ru.md
12
README.ru.md
@@ -102,6 +102,9 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов.
|
||||
- ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP).
|
||||
- ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия.
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
|
||||
### В процессе
|
||||
|
||||
@@ -109,14 +112,11 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
|
||||
### В планах
|
||||
|
||||
- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
||||
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
||||
- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
||||
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
||||
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
||||
- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
||||
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
||||
|
||||
## С чего начать
|
||||
@@ -159,6 +159,12 @@ dump/restore, существующий каталог данных переис
|
||||
новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы
|
||||
`page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`.
|
||||
|
||||
> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT
|
||||
> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров
|
||||
> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить
|
||||
> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите
|
||||
> неизменным и бэкапьте вместе с базой данных.
|
||||
|
||||
|
||||
## Возможности
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.91.0",
|
||||
"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.",
|
||||
@@ -183,6 +185,7 @@
|
||||
"Successfully imported": "Successfully imported",
|
||||
"Successfully restored": "Successfully restored",
|
||||
"System settings": "System settings",
|
||||
"Template": "Template",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
@@ -473,6 +476,7 @@
|
||||
"Make sub-pages public too": "Make sub-pages public too",
|
||||
"Allow search engines to index page": "Allow search engines to index page",
|
||||
"Open page": "Open page",
|
||||
"Open source page": "Open source page",
|
||||
"Page": "Page",
|
||||
"Delete public share link": "Delete public share link",
|
||||
"Delete share": "Delete share",
|
||||
@@ -529,6 +533,7 @@
|
||||
"Add 2FA method": "Add 2FA method",
|
||||
"Backup codes": "Backup codes",
|
||||
"Disable": "Disable",
|
||||
"disabled": "disabled",
|
||||
"Invalid verification code": "Invalid verification code",
|
||||
"New backup codes have been generated": "New backup codes have been generated",
|
||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
||||
@@ -703,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",
|
||||
@@ -948,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}}",
|
||||
@@ -977,6 +982,9 @@
|
||||
"Page menu": "Page menu",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Expand all": "Expand all",
|
||||
"Collapse all": "Collapse all",
|
||||
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
|
||||
"Comment menu": "Comment menu",
|
||||
"Group menu": "Group menu",
|
||||
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||
@@ -1122,11 +1130,29 @@
|
||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||
"AI chat": "AI chat",
|
||||
"Ask a question about this documentation.": "Ask a question about this documentation.",
|
||||
"Ask a question…": "Ask a question…",
|
||||
"Thinking…": "Thinking…",
|
||||
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
|
||||
"Public share assistant": "Public share assistant",
|
||||
"Enabled": "Enabled",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||
"Public assistant model": "Public assistant model",
|
||||
"Defaults to the chat model": "Defaults to the chat model",
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.",
|
||||
"Assistant identity": "Assistant identity",
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"AI agent": "AI agent",
|
||||
"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.",
|
||||
@@ -1162,6 +1188,10 @@
|
||||
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||
"Test endpoint": "Test endpoint",
|
||||
"Save endpoints": "Save endpoints",
|
||||
"Configured and enabled": "Configured and enabled",
|
||||
"Configured but disabled": "Configured but disabled",
|
||||
"Enabled but not configured": "Enabled but not configured",
|
||||
"Not configured": "Not configured",
|
||||
"External tools": "External tools",
|
||||
"Gitmost as MCP client": "Gitmost as MCP client",
|
||||
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
||||
@@ -1195,5 +1225,44 @@
|
||||
"Request format": "Request format",
|
||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
||||
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)"
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)",
|
||||
"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",
|
||||
"Edit role": "Edit role",
|
||||
"Role name": "Role name",
|
||||
"e.g. Proofreader": "e.g. Proofreader",
|
||||
"Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.",
|
||||
"Optional. A short note about what this role does.": "Optional. A short note about what this role does.",
|
||||
"Instructions": "Instructions",
|
||||
"The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.",
|
||||
"Model provider override": "Model provider override",
|
||||
"Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.",
|
||||
"Model override": "Model override",
|
||||
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
||||
"Agent roles": "Agent roles",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
"No roles configured": "No roles configured",
|
||||
"Delete role": "Delete role",
|
||||
"Are you sure you want to delete this role?": "Are you sure you want to delete this role?",
|
||||
"HTML embed": "HTML embed",
|
||||
"Edit HTML embed": "Edit HTML embed",
|
||||
"HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace",
|
||||
"Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
"<script>...</script>": "<script>...</script>",
|
||||
"Height (px, blank = auto)": "Height (px, blank = auto)",
|
||||
"advanced": "advanced",
|
||||
"Enable HTML embed": "Enable HTML embed",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||
"Analytics / tracker": "Analytics / tracker",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only."
|
||||
}
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"Name": "Имя",
|
||||
"New email": "Новый электронный адрес",
|
||||
"New page": "Новая страница",
|
||||
"New note": "Новая заметка",
|
||||
"Create in space": "Создать в пространстве",
|
||||
"New password": "Новый пароль",
|
||||
"No group found": "Группа не найдена",
|
||||
"No page history saved yet.": "История страниц ещё не сохранена.",
|
||||
@@ -183,6 +185,7 @@
|
||||
"Successfully imported": "Успешно импортировано",
|
||||
"Successfully restored": "Успешно восстановлено",
|
||||
"System settings": "Системные настройки",
|
||||
"Template": "Шаблон",
|
||||
"Templates": "Шаблоны",
|
||||
"Theme": "Тема",
|
||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||
@@ -391,6 +394,13 @@
|
||||
"Toggle block": "Сворачиваемый блок",
|
||||
"Callout": "Выноска",
|
||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||
"Footnote": "Сноска",
|
||||
"Insert a footnote reference.": "Вставить ссылку на сноску.",
|
||||
"Footnotes": "Примечания",
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
@@ -471,6 +481,7 @@
|
||||
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
|
||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
||||
"Open page": "Открыть страницу",
|
||||
"Open source page": "Открыть исходную страницу",
|
||||
"Page": "Страница",
|
||||
"Delete public share link": "Удалить публичную ссылку",
|
||||
"Delete share": "Удалить общий доступ",
|
||||
@@ -659,6 +670,38 @@
|
||||
"AI search": "Поиск ИИ",
|
||||
"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...": "Задайте вопрос...",
|
||||
@@ -914,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.",
|
||||
@@ -1085,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" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null);
|
||||
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
||||
export const aiChatWindowOpenAtom = atom<boolean>(false);
|
||||
|
||||
/**
|
||||
* The agent role selected for the NEXT new chat. `null` = "Universal assistant"
|
||||
* (no role). Consulted ONLY when creating a chat (its first message): the server
|
||||
* persists it to ai_chats.role_id and the role is immutable afterwards. Reset to
|
||||
* null when starting a new chat. It does NOT affect already-created chats.
|
||||
*/
|
||||
// Cast default for the same jotai overload reason as activeAiChatIdAtom above.
|
||||
export const selectedAiRoleIdAtom = atom(null as string | null);
|
||||
|
||||
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
|
||||
// ChatThread — so it survives the thread remount that happens when a brand-new
|
||||
// chat adopts its freshly created id after the first turn finishes. If it lived
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* In the collapsed state the header expands the window on click, so hint that
|
||||
it is clickable (override the drag `grab` cursor). */
|
||||
.minimized .dragBar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dragBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { generateId } from "ai";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
@@ -18,24 +19,31 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
@@ -102,10 +110,16 @@ export default function AiChatWindow() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
// The role chosen for the next new chat (null = universal assistant).
|
||||
const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// History section starts collapsed (matches the former panel's behavior).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
// Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag),
|
||||
// which would otherwise close over a stale value. Kept in sync below.
|
||||
const minimizedRef = useRef(minimized);
|
||||
minimizedRef.current = minimized;
|
||||
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
// Live window geometry (position + size); initialized lazily on first open so
|
||||
@@ -122,17 +136,51 @@ 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.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
// The new-chat picker only offers ENABLED roles. The list endpoint returns
|
||||
// all live roles (so the admin settings section can manage disabled ones), so
|
||||
// we filter to `enabled` here, client-side, for the composer picker only.
|
||||
const enabledRoles = useMemo(
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// The page the user is currently viewing, derived from the route (same
|
||||
// source the breadcrumb uses). On a non-page route `pageSlug` is undefined,
|
||||
// so the query is disabled and `openPage` is null. This is passed to the
|
||||
// chat thread as context so the agent knows what "this page"/"the current
|
||||
// page" refers to; the agent still reads/writes via its CASL-enforced page
|
||||
// tools using the id.
|
||||
const { pageSlug } = useParams();
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
// resolves regardless of where this component is mounted. On a non-page route
|
||||
// the match is null, so `pageSlug` is undefined, the query is disabled and
|
||||
// `openPage` is null. This is passed to the chat thread as context so the
|
||||
// agent knows what "this page"/"the current page" refers to; the agent still
|
||||
// reads/writes via its CASL-enforced page tools using the id.
|
||||
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||
const { data: openPageData } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
@@ -141,18 +189,28 @@ 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("");
|
||||
}, [setActiveChatId, setDraft]);
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
|
||||
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
|
||||
@@ -162,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
|
||||
@@ -172,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.
|
||||
@@ -194,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.
|
||||
@@ -238,8 +359,31 @@ export default function AiChatWindow() {
|
||||
useLayoutEffect(() => {
|
||||
if (!windowOpen) return;
|
||||
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
|
||||
// Always show the window expanded on (re)open: a collapsed state from a
|
||||
// previous open session must not stick. Runs before paint so the first
|
||||
// frame is already expanded. The composer's autofocus is a focus INSIDE the
|
||||
// window (not an outside mousedown), so it cannot self-collapse the window.
|
||||
setMinimized(false);
|
||||
}, [windowOpen]);
|
||||
|
||||
// Auto-collapse the window into its header as soon as the user interacts with
|
||||
// anything outside it (clicks the page/editor). Armed ONLY while the window is
|
||||
// open and expanded, so it never fires repeatedly and never collapses on the
|
||||
// open→reset transition. Capture phase so a page handler's stopPropagation in
|
||||
// the bubble phase can't hide the event from us; the in-window/portal guards
|
||||
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
|
||||
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const onPointerDown = (e: MouseEvent): void => {
|
||||
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
|
||||
setMinimized(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown, true);
|
||||
}, [windowOpen, minimized]);
|
||||
|
||||
// Persist the user's resize into state so it survives close/reopen. Skipped
|
||||
// while minimized so the collapsed (auto) height is never captured. The
|
||||
// equality guard avoids an update loop.
|
||||
@@ -287,10 +431,21 @@ export default function AiChatWindow() {
|
||||
el.style.top = `${nt}px`;
|
||||
};
|
||||
|
||||
const up = (): void => {
|
||||
const up = (ev: MouseEvent): void => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = "";
|
||||
// Treat a near-zero-movement press as a click (not a drag). When the
|
||||
// window is minimized, a header click expands it; nothing to persist
|
||||
// because the position did not change. minimizedRef avoids the stale
|
||||
// `minimized` captured by useCallback([]).
|
||||
if (
|
||||
minimizedRef.current &&
|
||||
isHeaderClick(sx, sy, ev.clientX, ev.clientY)
|
||||
) {
|
||||
setMinimized(false);
|
||||
return;
|
||||
}
|
||||
const el2 = winRef.current;
|
||||
// Persist the final position back into state (preserving the size) so
|
||||
// re-renders keep it.
|
||||
@@ -334,14 +489,50 @@ export default function AiChatWindow() {
|
||||
height: minimized ? undefined : geom.height,
|
||||
}}
|
||||
>
|
||||
{/* drag bar / header */}
|
||||
{/* drag bar / header. Mouse users expand a minimized window by clicking
|
||||
anywhere on the bar (the click-vs-drag logic in startDrag, which
|
||||
excludes the buttons). The keyboard/screen-reader Expand affordance
|
||||
lives on the title element below — NOT on this container — so we never
|
||||
nest the Minimize/Close <button>s inside an element with
|
||||
role="button" (invalid ARIA: nested interactive controls). */}
|
||||
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
style={{ flex: "none" }}
|
||||
/>
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
{/* When minimized, the title doubles as the keyboard Expand button:
|
||||
it carries role/tabIndex/aria-label and an Enter/Space handler, and
|
||||
unlike the dragBar it contains no nested <button>s. When expanded it
|
||||
is a plain, non-focusable label. */}
|
||||
<span
|
||||
className={classes.title}
|
||||
role={minimized ? "button" : undefined}
|
||||
tabIndex={minimized ? 0 : undefined}
|
||||
aria-label={minimized ? t("Expand") : undefined}
|
||||
onKeyDown={
|
||||
minimized
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setMinimized(false);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("AI chat")}
|
||||
</span>
|
||||
|
||||
{/* 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")}>
|
||||
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
|
||||
{currentRole.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{contextTokens > 0 && (
|
||||
@@ -400,7 +591,16 @@ export default function AiChatWindow() {
|
||||
>
|
||||
<div
|
||||
className={classes.historyHeader}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={historyOpen}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setHistoryOpen((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
@@ -432,6 +632,11 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</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}>
|
||||
{waitingForHistory ? (
|
||||
@@ -444,6 +649,13 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
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. */
|
||||
@@ -25,6 +35,19 @@ interface ChatThreadProps {
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* 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;
|
||||
@@ -61,6 +84,10 @@ export default function ChatThread({
|
||||
chatId,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -84,6 +111,12 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
const roleIdRef = useRef<string | null>(roleId ?? null);
|
||||
roleIdRef.current = roleId ?? null;
|
||||
|
||||
// Stable `useChat` store key for the lifetime of THIS mount.
|
||||
//
|
||||
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
||||
@@ -102,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(
|
||||
() =>
|
||||
@@ -119,6 +200,9 @@ export default function ChatThread({
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
@@ -133,30 +217,107 @@ export default function ChatThread({
|
||||
id: chatStoreId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
onFinish: () => 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;
|
||||
@@ -115,11 +138,36 @@ export default function ConversationList({
|
||||
classes.conversationItem,
|
||||
isActive && classes.conversationItemActive,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Activate on Enter/Space like a native button; the inner menu
|
||||
// button stops propagation so its own keys never reach this row.
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(chat.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
<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>
|
||||
</Group>
|
||||
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
|
||||
</Box>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -3,18 +3,31 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
* links stay clickable). The anonymous public share passes true so internal
|
||||
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the dimmed assistant label. Defaults to "AI agent" when
|
||||
* absent; the public share passes the configured identity (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +42,12 @@ function isToolPart(type: string): boolean {
|
||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
||||
* text parts on each delta is what makes the answer stream in progressively.
|
||||
*/
|
||||
export default function MessageItem({ message }: MessageItemProps) {
|
||||
export default function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -50,7 +68,7 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
@@ -58,7 +76,9 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
const html = renderChatMarkdown(part.text);
|
||||
const html = renderChatMarkdown(part.text, {
|
||||
neutralizeInternalLinks,
|
||||
});
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
@@ -78,7 +98,13 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
}
|
||||
|
||||
if (isToolPart(part.type)) {
|
||||
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -88,14 +114,18 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
{(() => {
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
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, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
/**
|
||||
* Content shown when the transcript is empty and no turn is in flight.
|
||||
* Defaults to the internal chat's prompt. The public share passes its own
|
||||
* documentation-focused copy. This is purely the empty-state text; the
|
||||
* streaming/typing/markdown/tool-card paths below are shared verbatim.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
|
||||
* citation links. Defaults to true (internal chat). The public share passes
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
* Defaults to false (internal chat). The public share passes true so internal
|
||||
* UUIDs/routes don't leak as clickable links to anonymous readers.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the assistant's dimmed row label and typing indicator.
|
||||
* Defaults to "AI agent" when absent. The public share passes the configured
|
||||
* identity (agent role) name; the internal chat omits it.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
// Distance (px) from the bottom within which the viewport still counts as
|
||||
@@ -21,23 +43,38 @@ function isToolPart(type: string): boolean {
|
||||
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).
|
||||
*/
|
||||
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +82,14 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
|
||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||
* earlier messages, streamed deltas no longer yank them back down.
|
||||
*/
|
||||
export default function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||
export default function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
// Whether the viewport is currently pinned to the bottom. Starts true so the
|
||||
@@ -108,9 +152,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
if (messages.length === 0 && !typing) {
|
||||
return (
|
||||
<Center className={classes.messages}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
{emptyState ?? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -119,9 +165,15 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
))}
|
||||
{typing && <TypingIndicator />}
|
||||
{typing && <TypingIndicator assistantName={assistantName} />}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* 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 "Thinking…" placeholder shows in the gap
|
||||
* between sending and the first streamed token.
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: UIMessage["parts"],
|
||||
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
|
||||
|
||||
describe("showTypingIndicator", () => {
|
||||
it("is hidden when not streaming", () => {
|
||||
expect(showTypingIndicator([], false)).toBe(false);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming with no messages yet (just submitted)", () => {
|
||||
expect(showTypingIndicator([], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the last message is still the user's", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the assistant row has no visible content", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides once the assistant streams non-empty text", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once a tool part appears (even before any text)", () => {
|
||||
const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolUiPart;
|
||||
/**
|
||||
* Whether to render page citation links. Defaults to true (the internal chat,
|
||||
* where the reader is authenticated and the `/p/{id}` links resolve). The
|
||||
* public share passes false: an anonymous reader cannot open internal pages,
|
||||
* so the links would 404/redirect to login. Suppressing them keeps the card
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
|
||||
* agent DID (the agent writes without confirmation — D2), its run state
|
||||
* (running / done / error), and citation link(s) to any referenced page(s).
|
||||
*/
|
||||
export default function ToolCallCard({ part }: ToolCallCardProps) {
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = toolCitations(part);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
/**
|
||||
* Display name for the dimmed label and the "… is typing…" line. Defaults to
|
||||
* "AI agent" when absent; the public share passes the configured identity
|
||||
* (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "AI agent is typing…" placeholder shown while a turn is in flight but the
|
||||
* latest assistant message has no visible content yet (no rendered text/tool
|
||||
* parts). It covers the gap between sending and the first streamed token, and is
|
||||
* replaced by the real assistant message once content starts arriving.
|
||||
* Live "… is typing…" placeholder shown while a turn is in flight but the latest
|
||||
* assistant message has no visible content yet (no rendered text/tool parts). It
|
||||
* covers the gap between sending and the first streamed token, and is replaced by
|
||||
* the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
|
||||
* so it reads as the assistant's bubble taking shape.
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||
* 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() {
|
||||
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = resolveAssistantName(assistantName);
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
{name ?? t("AI agent")}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<span className={classes.typingDots} aria-hidden="true">
|
||||
@@ -26,7 +40,7 @@ export default function TypingIndicator() {
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("AI agent is typing…")}
|
||||
{t("Thinking…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -4,22 +4,30 @@ 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 {
|
||||
createAiRole,
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoles,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -67,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);
|
||||
@@ -114,3 +147,79 @@ export function useDeleteAiChatMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the workspace's agent roles. Available to any workspace member (used by
|
||||
* the chat-creation role picker and the admin management section). `enabled`
|
||||
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
||||
*/
|
||||
export function useAiRolesQuery(enabled: boolean = true) {
|
||||
return useQuery<IAiRole[], Error>({
|
||||
queryKey: AI_ROLES_RQ_KEY,
|
||||
queryFn: () => getAiRoles(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
||||
mutationFn: (data) => createAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Created successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
||||
mutationFn: (data) => updateAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// The role badge denormalized onto the chat list may have changed.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ success: true }, Error, string>({
|
||||
mutationFn: (id) => deleteAiRole(id),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Deleted successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -46,3 +49,33 @@ export async function renameAiChat(data: {
|
||||
export async function deleteAiChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai-chat/delete", { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** List the workspace's agent roles. */
|
||||
export async function getAiRoles(): Promise<IAiRole[]> {
|
||||
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Create a role (admin). */
|
||||
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update a role (admin). */
|
||||
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Soft-delete a role (admin). */
|
||||
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,69 @@ export interface IAiChat {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
// The agent role bound to this chat, if any (immutable after creation).
|
||||
roleId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response (the bound role's badge).
|
||||
// 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`). */
|
||||
export type AiRoleDriver = "openai" | "gemini" | "ollama";
|
||||
|
||||
/** Optional per-role model override (mirrors `model_config`). */
|
||||
export interface IAiRoleModelConfig {
|
||||
driver?: AiRoleDriver;
|
||||
chatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent role (mirrors the server role views). A role replaces the agent's
|
||||
* persona (instructions) and may optionally override the model. The safety
|
||||
* framework is always still applied server-side.
|
||||
*
|
||||
* The list endpoint returns the FULL view to admins and a reduced picker view to
|
||||
* ordinary members, so the admin-only fields (`instructions`, `modelConfig`,
|
||||
* `createdAt`, `updatedAt`) are optional here — present only for admins.
|
||||
*/
|
||||
export interface IAiRole {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
export interface IAiRoleUpdate {
|
||||
id: string;
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveAssistantName } from "./assistant-name";
|
||||
|
||||
describe("resolveAssistantName", () => {
|
||||
it("returns a real name unchanged", () => {
|
||||
expect(resolveAssistantName("Ada")).toBe("Ada");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from a real name", () => {
|
||||
expect(resolveAssistantName(" Ada ")).toBe("Ada");
|
||||
});
|
||||
|
||||
it("returns null for a whitespace-only name (the reason for .trim())", () => {
|
||||
expect(resolveAssistantName(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the name is undefined", () => {
|
||||
expect(resolveAssistantName(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(resolveAssistantName("")).toBeNull();
|
||||
});
|
||||
});
|
||||
16
apps/client/src/features/ai-chat/utils/assistant-name.ts
Normal file
16
apps/client/src/features/ai-chat/utils/assistant-name.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Pure helper for resolving the assistant's display name. Kept free of React so
|
||||
// it can be unit-tested in isolation (see assistant-name.test.ts) and shared by
|
||||
// the components that render the assistant identity (TypingIndicator, MessageItem).
|
||||
|
||||
/**
|
||||
* Resolve the assistant's display name from the optional configured identity.
|
||||
*
|
||||
* Returns the trimmed name when it has visible (non-whitespace) characters, or
|
||||
* `null` when the name is absent or whitespace-only. Callers fall back to a
|
||||
* generic "AI agent" label on `null`. The `.trim()` is why a name of " " must
|
||||
* resolve to `null` rather than rendering an empty label.
|
||||
*/
|
||||
export function resolveAssistantName(assistantName?: string): string | null {
|
||||
const name = assistantName?.trim();
|
||||
return name ? name : null;
|
||||
}
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "./collapse-helpers";
|
||||
|
||||
describe("shouldCollapseOnOutsidePointer", () => {
|
||||
let windowEl: HTMLDivElement;
|
||||
let inside: HTMLSpanElement;
|
||||
let portal: HTMLDivElement;
|
||||
let portalChild: HTMLButtonElement;
|
||||
let page: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// The floating window with a child node.
|
||||
windowEl = document.createElement("div");
|
||||
inside = document.createElement("span");
|
||||
windowEl.appendChild(inside);
|
||||
|
||||
// A Mantine-style portal (data-portal="true") with a child (e.g. a menu item).
|
||||
portal = document.createElement("div");
|
||||
portal.setAttribute("data-portal", "true");
|
||||
portalChild = document.createElement("button");
|
||||
portal.appendChild(portalChild);
|
||||
|
||||
// An unrelated page element.
|
||||
page = document.createElement("div");
|
||||
|
||||
document.body.append(windowEl, portal, page);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("returns false for a target inside the window", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(inside, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(windowEl, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a target inside a Mantine portal", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(portal, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(portalChild, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a target on the page (outside window and portals)", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, windowEl)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when there is no window element", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a non-Element target", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(null, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(document, windowEl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeaderClick", () => {
|
||||
it("treats a zero-movement press as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 100, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement within the threshold as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 103, 97)).toBe(true);
|
||||
expect(isHeaderClick(100, 100, 104, 104)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement beyond the threshold (either axis) as a drag", () => {
|
||||
expect(isHeaderClick(100, 100, 105, 100)).toBe(false);
|
||||
expect(isHeaderClick(100, 100, 100, 105)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a custom threshold", () => {
|
||||
expect(isHeaderClick(0, 0, 8, 0, 10)).toBe(true);
|
||||
expect(isHeaderClick(0, 0, 11, 0, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Pure helpers for the AI chat window auto-collapse behavior. Kept free of React
|
||||
// so they can be unit-tested in isolation (see collapse-helpers.test.ts).
|
||||
|
||||
/**
|
||||
* Decide whether an outside pointer (mousedown) should collapse the chat window.
|
||||
*
|
||||
* Returns true only when the pointer target is genuinely "on the page": NOT
|
||||
* inside the window element AND NOT inside a Mantine portal. Mantine renders
|
||||
* dropdown menus (chat-list kebab), modals (delete-confirm), tooltips and
|
||||
* notifications into portals tagged with `data-portal="true"`; clicks on those
|
||||
* are part of operating the chat, so they must not collapse it.
|
||||
*/
|
||||
export function shouldCollapseOnOutsidePointer(
|
||||
target: EventTarget | null,
|
||||
windowEl: HTMLElement | null,
|
||||
): boolean {
|
||||
if (!windowEl) return false;
|
||||
if (!(target instanceof Element)) return false;
|
||||
// Inside the window itself -> not an "away" interaction (drag, resize, typing).
|
||||
if (windowEl.contains(target)) return false;
|
||||
// Inside a Mantine portal the chat owns (kebab menu, confirm modal, tooltip,
|
||||
// notifications). data-portal="true" reliably excludes all of them.
|
||||
if (target.closest("[data-portal]")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-vs-drag discrimination for the window header: a press whose pointer
|
||||
* moved less than `threshold` px on both axes between mousedown and mouseup is
|
||||
* treated as a click (which expands a collapsed window), not a drag (which
|
||||
* repositions it).
|
||||
*/
|
||||
export function isHeaderClick(
|
||||
downX: number,
|
||||
downY: number,
|
||||
upX: number,
|
||||
upY: number,
|
||||
threshold = 4,
|
||||
): boolean {
|
||||
return Math.abs(upX - downX) <= threshold && Math.abs(upY - downY) <= threshold;
|
||||
}
|
||||
168
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
168
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeChatError } from "./error-message";
|
||||
|
||||
// Identity translator: assert on the raw English key so the tests do not depend
|
||||
// on the i18n catalog.
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("describeChatError", () => {
|
||||
it('maps a {"statusCode":403} body to the disabled heading', () => {
|
||||
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||
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 heading', () => {
|
||||
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||
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("classifies ETIMEDOUT as a timeout", () => {
|
||||
expect(describeChatError("ETIMEDOUT", t).title).toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies "504: Gateway Timeout" as a timeout', () => {
|
||||
expect(describeChatError("504: Gateway Timeout", t).title).toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
/**
|
||||
* Tests for the internal-link neutralization used by the anonymous public
|
||||
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
|
||||
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
|
||||
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
|
||||
* routes. With the flag ON those links are made inert (href removed) while the
|
||||
* visible text and the rest of the markdown formatting are preserved; genuinely
|
||||
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
|
||||
* with a safe rel/target, while absolute links back to our OWN origin are
|
||||
* neutralized too. With the flag OFF (internal default) links keep their href so
|
||||
* the authenticated chat is unchanged.
|
||||
*/
|
||||
|
||||
/** Parse the rendered HTML and return the first <a> element (or null). */
|
||||
function firstAnchor(html: string): HTMLAnchorElement | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.querySelector("a");
|
||||
}
|
||||
|
||||
describe("renderChatMarkdown — internal link neutralization", () => {
|
||||
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
// Visible link text is preserved.
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes bare-fragment links when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[here](#section)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[y](https://example.com/x)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("https://example.com/x");
|
||||
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a!.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
|
||||
// An LLM can emit an absolute URL back at our own host (e.g.
|
||||
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
|
||||
// relative `/p/...` link, not kept clickable as if it were external.
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
|
||||
// javascript:, data:, and protocol-relative `//...` must never stay
|
||||
// clickable on the anonymous share — they are not genuinely external
|
||||
// http(s) links to a different host, so the href is dropped (or sanitized
|
||||
// away entirely by DOMPurify).
|
||||
for (const markdown of [
|
||||
"[a](javascript:alert(1))",
|
||||
"[b](data:text/html,<script>alert(1)</script>)",
|
||||
"[c](//evil.com/x)",
|
||||
]) {
|
||||
const html = renderChatMarkdown(markdown, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
// Either the anchor was stripped of its href, or DOMPurify removed the
|
||||
// unsafe href outright; in both cases nothing dangerous remains.
|
||||
if (a !== null) {
|
||||
expect(a.hasAttribute("href")).toBe(false);
|
||||
expect(a.hasAttribute("target")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
|
||||
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`);
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe(ownOrigin);
|
||||
});
|
||||
|
||||
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
|
||||
// A neutralizing render first, then an internal render: the internal link
|
||||
// must survive (the hook is removed after the share render).
|
||||
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,66 @@
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface RenderChatMarkdownOptions {
|
||||
/**
|
||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||
* Used by the anonymous public share: the assistant's answer can contain
|
||||
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
|
||||
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
|
||||
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
|
||||
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
|
||||
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
|
||||
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
|
||||
* neutralized too. Defaults to false — the internal chat keeps internal links
|
||||
* clickable for authenticated users.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
|
||||
* anonymous reader to follow. A link qualifies only if it is absolute
|
||||
* `http(s)://` AND its host differs from the app's own origin
|
||||
* (`window.location.host`): absolute links back to our OWN host (e.g.
|
||||
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
|
||||
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
|
||||
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
|
||||
* parse) is treated as internal/unsafe and neutralized — fail closed.
|
||||
*/
|
||||
function isExternalHttpUrl(href: string): boolean {
|
||||
const value = href.trim();
|
||||
if (!/^https?:\/\//i.test(value)) return false;
|
||||
try {
|
||||
// External only if it points at a DIFFERENT host than the app's own origin.
|
||||
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
|
||||
// internal and must be neutralized, same as relative `/p/...` links.
|
||||
return new URL(value).host !== window.location.host;
|
||||
} catch {
|
||||
return false; // unparseable -> treat as internal/unsafe, neutralize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
|
||||
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
|
||||
* for the duration of a single sanitize call (added then removed in
|
||||
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
|
||||
*/
|
||||
function neutralizeInternalLinksHook(node: Element): void {
|
||||
if (node.nodeName !== "A") return;
|
||||
const href = node.getAttribute("href");
|
||||
if (href !== null && isExternalHttpUrl(href)) {
|
||||
// Genuinely external link: keep it, but force a safe rel/target.
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
node.setAttribute("target", "_blank");
|
||||
return;
|
||||
}
|
||||
// Internal/relative/fragment link (or no href): make it inert text. Drop the
|
||||
// href and any target so it is no longer clickable; the visible text stays.
|
||||
node.removeAttribute("href");
|
||||
node.removeAttribute("target");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
||||
@@ -12,9 +72,31 @@ import DOMPurify from "dompurify";
|
||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
||||
* for that branch (the caller renders the raw text fallback instead).
|
||||
*/
|
||||
export function renderChatMarkdown(markdown: string): string {
|
||||
export function renderChatMarkdown(
|
||||
markdown: string,
|
||||
options: RenderChatMarkdownOptions = {},
|
||||
): string {
|
||||
if (!markdown) return "";
|
||||
const html = markdownToHtml(markdown);
|
||||
if (typeof html !== "string") return "";
|
||||
return DOMPurify.sanitize(html);
|
||||
|
||||
if (!options.neutralizeInternalLinks) {
|
||||
// Internal chat: unchanged behavior, no hook registered.
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Public share: register the neutralization hook only for THIS sanitize call,
|
||||
// then remove it immediately so it can never affect the internal chat (hooks
|
||||
// are global on the shared DOMPurify instance).
|
||||
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
|
||||
try {
|
||||
return DOMPurify.sanitize(html);
|
||||
} finally {
|
||||
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
|
||||
// robust to any other afterSanitizeAttributes hook registered in future.
|
||||
DOMPurify.removeHook(
|
||||
"afterSanitizeAttributes",
|
||||
neutralizeInternalLinksHook,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
|
||||
describe("toolCitations", () => {
|
||||
it("emits one citation per searchPages item with a /p/{id} href", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [
|
||||
{ id: "p1", title: "First" },
|
||||
{ id: "p2", title: "Second" },
|
||||
],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p1", title: "First", href: "/p/p1" },
|
||||
{ pageId: "p2", title: "Second", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops searchPages items missing an id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-searchPages",
|
||||
state: "output-available",
|
||||
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "p2", title: "Kept", href: "/p/p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
|
||||
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-updatePageContent",
|
||||
state: "output-available",
|
||||
input: { pageId: "host-1", title: "From input" },
|
||||
output: { pageId: "host-1" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers output.id over input.pageId when both exist", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "input-id", title: "Input title" },
|
||||
output: { id: "output-id", title: "Output title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([
|
||||
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the state is not output-available", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
output: { id: "p1", title: "Pending" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a page-op output with no resolvable id", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: {},
|
||||
output: { title: "Only a title" },
|
||||
};
|
||||
expect(toolCitations(part)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-denied" to error', () => {
|
||||
expect(toolRunState("output-denied")).toBe("error");
|
||||
});
|
||||
|
||||
it('maps "output-available" to done', () => {
|
||||
expect(toolRunState("output-available")).toBe("done");
|
||||
});
|
||||
|
||||
it('maps "input-available" to running', () => {
|
||||
expect(toolRunState("input-available")).toBe("running");
|
||||
});
|
||||
|
||||
it("maps undefined to running", () => {
|
||||
expect(toolRunState(undefined)).toBe("running");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@
|
||||
*
|
||||
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
|
||||
* its `state` is one of input-streaming / input-available / output-available /
|
||||
* output-error (we only surface running / done / error). The server tools are:
|
||||
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
|
||||
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
|
||||
* output-error (we only surface running / done / error). The full toolset the
|
||||
* server exposes lives in `ai-chat-tools.service.ts` (the agent now exposes the
|
||||
* complete Docmost toolset); friendly action-log labels exist ONLY for the
|
||||
* tools listed in `toolLabelKey` below — every other tool falls through to the
|
||||
* generic "Ran tool {{name}}" label.
|
||||
*/
|
||||
|
||||
/** A tool UI part as it arrives from `useChat` / persisted history. */
|
||||
@@ -38,6 +40,11 @@ export interface ToolCitation {
|
||||
href: string;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
export function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
|
||||
export function getToolName(part: ToolUiPart): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "";
|
||||
|
||||
@@ -116,8 +116,8 @@ function CommentListItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
<Group>
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
@@ -126,7 +126,7 @@ function CommentListItem({
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" 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>
|
||||
@@ -177,7 +177,7 @@ function CommentListItem({
|
||||
tabIndex={0}
|
||||
aria-label={t("Jump to comment selection")}
|
||||
>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
<Text size="xs">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
@@ -121,8 +126,8 @@ function CommentListWithTabs() {
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
p="xs"
|
||||
mb="xs"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
@@ -145,7 +150,7 @@ function CommentListWithTabs() {
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<Divider my={2} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
.wrapper {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.focused-thread {
|
||||
border: 2px solid #8d7249;
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 4px;
|
||||
/* 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: 8px;
|
||||
padding: 6px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
@@ -32,6 +33,9 @@
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* Denser comments: override the global 16px ProseMirror body size with 14px
|
||||
and tighten the rhythm vs. the comment header. Scoped to the comment
|
||||
editor only - the page editor is unaffected. */
|
||||
.ProseMirror :global(.ProseMirror){
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
@@ -39,7 +43,9 @@
|
||||
word-break: break-word;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,48 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for a single footnote definition: a decorative number marker, the
|
||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||
* The number is derived from the document (not stored).
|
||||
*/
|
||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
const { node, editor } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
// Read the cached number from the numbering plugin (computed once per doc
|
||||
// change) rather than recomputing the whole map on every render.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
editor.commands.scrollToReference(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
data-footnote-def=""
|
||||
data-id={id}
|
||||
className={classes.definition}
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
>
|
||||
<span className={classes.definitionMarker} contentEditable={false}>
|
||||
{number}.
|
||||
</span>
|
||||
<NodeViewContent className={classes.definitionContent} />
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
onClick={handleBack}
|
||||
role="button"
|
||||
aria-label={t("Back to reference")}
|
||||
title={t("Back to reference")}
|
||||
>
|
||||
↩
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
getFootnoteNumber,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* Read the plain text of the footnote definition with `id` directly from the
|
||||
* editor state. No sub-editor: the popover is read-only.
|
||||
*/
|
||||
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
|
||||
let text = "";
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
text = node.textContent;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function FootnoteReferenceView(props: NodeViewProps) {
|
||||
const { node, editor, selected } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Number is derived (not stored). Read it from the numbering plugin's cached
|
||||
// map (computed once per doc change) instead of walking the whole document on
|
||||
// every render — recomputing per NodeView per render was O(n^2) per keystroke.
|
||||
const number = getFootnoteNumber(editor.state, id) ?? "?";
|
||||
const defText = open ? getDefinitionText(editor, id) : "";
|
||||
|
||||
const position = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
computePosition(anchor, popup, {
|
||||
placement: "top",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
|
||||
const cleanup = autoUpdate(anchor, popup, position);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (
|
||||
popup.contains(e.target as Node) ||
|
||||
anchor.contains(e.target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
};
|
||||
}, [open, position]);
|
||||
|
||||
const handleGoTo = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
editor.commands.scrollToFootnote(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" style={{ display: "inline" }}>
|
||||
<sup
|
||||
ref={(el) => (anchorRef.current = el)}
|
||||
data-footnote-ref=""
|
||||
data-id={id}
|
||||
className={`${classes.reference} ${selected ? classes.selected : ""}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
// The decoration sets --footnote-number; provide a fallback inline.
|
||||
style={{ ["--footnote-number" as any]: `"${number}"` }}
|
||||
aria-label={t("Footnote {{number}}", { number })}
|
||||
role="button"
|
||||
/>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={classes.popover}
|
||||
role="tooltip"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className={classes.popoverHeader}>
|
||||
<span className={classes.popoverNumber}>
|
||||
{t("Footnote {{number}}", { number })}
|
||||
</span>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={handleGoTo}
|
||||
aria-label={t("Go to footnote")}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className={classes.popoverBody}>
|
||||
{defText || t("Empty footnote")}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Superscript reference marker. The visible number comes from the numbering
|
||||
plugin decoration which sets the --footnote-number CSS variable. */
|
||||
.reference {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-weight: 500;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference::after {
|
||||
content: var(--footnote-number, "");
|
||||
}
|
||||
|
||||
.reference:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference.selected {
|
||||
background-color: var(--mantine-color-blue-1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Read-only popover shown on hover/click of a reference. */
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-default-color);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.popoverHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverNumber {
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.popoverBody {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
.list {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
/* Tight number→text spacing (~one space) so it reads like "1. text"
|
||||
instead of leaving a wide gap after the period. */
|
||||
gap: 0.4em;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
/* Right-align within the narrow column so the period sits next to the text
|
||||
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
user-select: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for the bottom footnotes container. Renders a visual separator and a
|
||||
* localized heading, then the editable list of definitions via NodeViewContent.
|
||||
*/
|
||||
export default function FootnotesListView(_props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.list} contentEditable={false}>
|
||||
<div className={classes.listHeading}>{t("Footnotes")}</div>
|
||||
</div>
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit,
|
||||
clampHeight,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
HTML_EMBED_SANDBOX,
|
||||
isTrustedHeightMessage,
|
||||
MAX_IFRAME_HEIGHT,
|
||||
MIN_IFRAME_HEIGHT,
|
||||
shouldRender,
|
||||
} from "./html-embed-sandbox";
|
||||
|
||||
describe("buildSandboxSrcdoc", () => {
|
||||
it("embeds the user source verbatim", () => {
|
||||
const out = buildSandboxSrcdoc("<div id='x'>hello</div>");
|
||||
expect(out).toContain("<div id='x'>hello</div>");
|
||||
});
|
||||
|
||||
it("injects the height-postMessage bootstrap after the source", () => {
|
||||
const out = buildSandboxSrcdoc("<p>body</p>");
|
||||
// The bootstrap is appended AFTER the source.
|
||||
expect(out.indexOf("<p>body</p>")).toBeLessThan(
|
||||
out.indexOf(HTML_EMBED_HEIGHT_MESSAGE),
|
||||
);
|
||||
// It reports its height to the parent via postMessage with the agreed type.
|
||||
expect(out).toContain("parent.postMessage");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
// It observes resizes so the parent can keep the iframe sized to fit.
|
||||
expect(out).toContain("ResizeObserver");
|
||||
expect(out).toContain('addEventListener("load"');
|
||||
});
|
||||
|
||||
it("handles an empty source (still injects the bootstrap)", () => {
|
||||
const out = buildSandboxSrcdoc("");
|
||||
expect(out).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRender (render policy)", () => {
|
||||
it("read-only renders regardless of the workspace toggle", () => {
|
||||
// isEditable=false → the server already gated the content.
|
||||
expect(shouldRender(false, false)).toBe(true);
|
||||
expect(shouldRender(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("editable + toggle OFF does NOT render", () => {
|
||||
expect(shouldRender(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("editable + toggle ON renders", () => {
|
||||
expect(shouldRender(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampHeight", () => {
|
||||
it("clamps below the lower bound up to MIN_IFRAME_HEIGHT", () => {
|
||||
expect(clampHeight(0)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(-100)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(MIN_IFRAME_HEIGHT - 1)).toBe(MIN_IFRAME_HEIGHT);
|
||||
});
|
||||
|
||||
it("clamps above the upper bound down to MAX_IFRAME_HEIGHT", () => {
|
||||
expect(clampHeight(MAX_IFRAME_HEIGHT + 1)).toBe(MAX_IFRAME_HEIGHT);
|
||||
expect(clampHeight(999999)).toBe(MAX_IFRAME_HEIGHT);
|
||||
});
|
||||
|
||||
it("passes a value within range through unchanged", () => {
|
||||
expect(clampHeight(150)).toBe(150);
|
||||
expect(clampHeight(MIN_IFRAME_HEIGHT)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(MAX_IFRAME_HEIGHT)).toBe(MAX_IFRAME_HEIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrustedHeightMessage (resize message guard)", () => {
|
||||
// Stand-ins for window objects; identity is all the guard compares.
|
||||
const ownWindow = {} as Window;
|
||||
const foreignWindow = {} as Window;
|
||||
const iframeEl = { contentWindow: ownWindow };
|
||||
|
||||
const validData = { type: HTML_EMBED_HEIGHT_MESSAGE, height: 300 };
|
||||
|
||||
it("accepts a same-source message with a finite numeric height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage({ source: ownWindow, data: validData }, iframeEl),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a message from a DIFFERENT source (foreign window)", () => {
|
||||
// A page can postMessage anything; only our own iframe's contentWindow is
|
||||
// trusted. This is the core security check.
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: foreignWindow, data: validData },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong-type message even from the right source", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: ownWindow, data: { type: "something-else", height: 300 } },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: ownWindow, data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: NaN } },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an Infinity height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{
|
||||
source: ownWindow,
|
||||
data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: Infinity },
|
||||
},
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when the iframe element / contentWindow is null", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage({ source: ownWindow, data: validData }, null),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: null, data: validData },
|
||||
{ contentWindow: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iframe sandbox attributes", () => {
|
||||
it("uses EXACTLY allow-scripts allow-popups allow-forms (no allow-same-origin)", () => {
|
||||
expect(HTML_EMBED_SANDBOX).toBe("allow-scripts allow-popups allow-forms");
|
||||
// The critical security invariant: opaque origin => no session/cookie access.
|
||||
expect(HTML_EMBED_SANDBOX).not.toContain("allow-same-origin");
|
||||
});
|
||||
|
||||
it("the NodeView renders the embed via srcDoc (not src), set to the sandbox doc", () => {
|
||||
// The iframe carries the generated srcdoc; it never loads an external URL.
|
||||
const srcdoc = buildSandboxSrcdoc("<p>hi</p>");
|
||||
expect(srcdoc).toContain("<p>hi</p>");
|
||||
expect(srcdoc).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
|
||||
expect(canEdit(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot edit when the toggle is OFF", () => {
|
||||
expect(canEdit(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot edit in read-only mode (no edit affordance)", () => {
|
||||
expect(canEdit(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Pure helpers for the HTML embed node view. Kept out of the React component so
|
||||
* the sandbox srcdoc builder and the render/edit policy can be unit-tested
|
||||
* against a bare environment with no Tiptap/Mantine providers.
|
||||
*/
|
||||
|
||||
/** postMessage type the sandboxed iframe uses to report its content height. */
|
||||
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
|
||||
|
||||
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
||||
// page layout, and a sensible default before the first height message arrives.
|
||||
export const MIN_IFRAME_HEIGHT = 40;
|
||||
export const MAX_IFRAME_HEIGHT = 4000;
|
||||
export const DEFAULT_IFRAME_HEIGHT = 150;
|
||||
|
||||
/**
|
||||
* Sandbox tokens for the embed iframe. Intentionally does NOT include
|
||||
* `allow-same-origin`: the content must run in an opaque ("null") origin so it
|
||||
* cannot read the viewer's cookies/session/API.
|
||||
*/
|
||||
export const HTML_EMBED_SANDBOX = "allow-scripts allow-popups allow-forms";
|
||||
|
||||
/** Clamp a reported/configured height into the sane iframe bounds. */
|
||||
export function clampHeight(h: number): number {
|
||||
return Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for the auto-resize `message` handler. Returns the clamped numeric
|
||||
* height ONLY when the event is a trusted resize report; otherwise null.
|
||||
*
|
||||
* Trusted means ALL of:
|
||||
* - `event.source` is this iframe's own `contentWindow` (the sandboxed srcdoc
|
||||
* has an opaque "null" origin, so we cannot match by `event.origin` — we
|
||||
* match by source instead). A message from any OTHER window is rejected.
|
||||
* - the payload `type` is exactly our agreed resize message type.
|
||||
* - the reported `height` is a finite number (rejects NaN/Infinity).
|
||||
*/
|
||||
export function isTrustedHeightMessage(
|
||||
event: Pick<MessageEvent, "source" | "data">,
|
||||
iframeEl: { contentWindow: Window | null } | null,
|
||||
): boolean {
|
||||
// Reject when there is no contentWindow to match against; otherwise a `null`
|
||||
// event.source would spuriously equal a `null` contentWindow.
|
||||
if (!iframeEl?.contentWindow) return false;
|
||||
if (event.source !== iframeEl.contentWindow) return false;
|
||||
const data = event.data as { type?: string; height?: number } | null;
|
||||
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return false;
|
||||
return Number.isFinite(Number(data.height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `srcdoc` document for the sandboxed embed iframe.
|
||||
*
|
||||
* The user's `source` is placed verbatim, then a small bootstrap <script> is
|
||||
* appended at the end of the body. The iframe is rendered with a sandbox that
|
||||
* does NOT include `allow-same-origin`, so this content runs in an opaque
|
||||
* ("null") origin and cannot read the viewer's cookies/session/API — it is
|
||||
* harmless. The bootstrap measures the document height and reports it to the
|
||||
* parent via postMessage on load and whenever the content resizes, so the
|
||||
* parent can size the iframe to fit (auto-resize mode).
|
||||
*/
|
||||
export function buildSandboxSrcdoc(source: string): string {
|
||||
const bootstrap = `
|
||||
<script>
|
||||
(function () {
|
||||
var lastSent = -1;
|
||||
var scheduled = false;
|
||||
function measure() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
return Math.max(
|
||||
doc ? doc.scrollHeight : 0,
|
||||
body ? body.scrollHeight : 0
|
||||
);
|
||||
}
|
||||
function flush() {
|
||||
scheduled = false;
|
||||
var height = measure();
|
||||
// Only report when the height actually changed by more than 1px. This
|
||||
// damps the iframe self-measure feedback loop: content sized to the iframe
|
||||
// viewport would otherwise oscillate as the parent resizes the frame in
|
||||
// response to each report.
|
||||
if (Math.abs(height - lastSent) <= 1) return;
|
||||
lastSent = height;
|
||||
parent.postMessage(
|
||||
{ type: ${JSON.stringify(HTML_EMBED_HEIGHT_MESSAGE)}, height: height },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
function reportHeight() {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(flush);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
window.addEventListener("load", reportHeight);
|
||||
// Report an initial height now (runs during parse, before load/images
|
||||
// settle); the load handler and ResizeObserver refine it as content changes.
|
||||
reportHeight();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
try {
|
||||
var ro = new ResizeObserver(reportHeight);
|
||||
ro.observe(document.documentElement);
|
||||
} catch (e) {
|
||||
// ResizeObserver unavailable/failed: the load handler still reports once.
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
return `${source || ""}${bootstrap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render policy split by editor mode:
|
||||
* - READ-ONLY / public-share view: the SERVER already decided whether to
|
||||
* include the embed (it strips htmlEmbed from shared content when the
|
||||
* workspace master toggle is OFF). An anonymous viewer has no workspace and
|
||||
* thus reads `featureEnabled` as false, so we must NOT gate rendering on it
|
||||
* here — we render exactly the `source` the server chose to serve.
|
||||
* - EDITABLE editor: gate on the per-workspace master toggle so an author sees
|
||||
* the inert placeholder when the feature is OFF.
|
||||
*/
|
||||
export function shouldRender(
|
||||
isEditable: boolean,
|
||||
featureEnabled: boolean,
|
||||
): boolean {
|
||||
return !isEditable || featureEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edit affordance is only meaningful in edit mode and is offered only when
|
||||
* the workspace master toggle is ON. The block renders in a sandboxed iframe
|
||||
* (no same-origin access), so authoring is allowed to ANY member — there is no
|
||||
* admin requirement.
|
||||
*/
|
||||
export function canEdit(isEditable: boolean, featureEnabled: boolean): boolean {
|
||||
return isEditable && featureEnabled;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.htmlEmbedNodeView {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Fallback container used only for the empty, non-editor case. */
|
||||
.htmlEmbedContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The sandboxed iframe the embed source is rendered into. */
|
||||
.htmlEmbedFrame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Edit affordance overlay, only shown while editing the document. */
|
||||
.htmlEmbedToolbar {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.htmlEmbedNodeView:hover .htmlEmbedToolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Placeholder card shown when the source is empty (edit mode only). */
|
||||
.htmlEmbedPlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--mantine-color-gray-4);
|
||||
border-radius: 8px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
|
||||
@mixin dark {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
}
|
||||
|
||||
.htmlEmbedSelected {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { IconCode, IconEdit } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import classes from "./html-embed-view.module.css";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit as computeCanEdit,
|
||||
clampHeight,
|
||||
DEFAULT_IFRAME_HEIGHT,
|
||||
HTML_EMBED_SANDBOX,
|
||||
isTrustedHeightMessage,
|
||||
MAX_IFRAME_HEIGHT,
|
||||
MIN_IFRAME_HEIGHT,
|
||||
shouldRender as computeShouldRender,
|
||||
} from "./html-embed-sandbox.ts";
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
const { source, height } = node.attrs as {
|
||||
source: string;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
// The HTML embed renders inside a SANDBOXED iframe (no same-origin access), so
|
||||
// the workspace toggle is a feature switch, not a security gate. When OFF (the
|
||||
// default) we render a neutral placeholder in the editor and nothing else.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
|
||||
|
||||
const shouldRender = computeShouldRender(
|
||||
editor.isEditable,
|
||||
htmlEmbedEnabled,
|
||||
);
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string>(source || "");
|
||||
const [draftHeight, setDraftHeight] = useState<number | "">(height ?? "");
|
||||
|
||||
// True when the author pinned an explicit height; otherwise we auto-resize to
|
||||
// the iframe's reported content height.
|
||||
const hasFixedHeight = typeof height === "number" && Number.isFinite(height);
|
||||
|
||||
// Auto-resize height tracked in state. Seeded to the default and updated from
|
||||
// the iframe's postMessage reports (see effect below) regardless of mode, so
|
||||
// switching a fixed-height embed back to auto immediately reflects the last
|
||||
// reported content height instead of staying pinned to the old fixed value.
|
||||
const [autoHeight, setAutoHeight] = useState<number>(DEFAULT_IFRAME_HEIGHT);
|
||||
|
||||
const srcdoc = useMemo(() => buildSandboxSrcdoc(source || ""), [source]);
|
||||
|
||||
// Auto-resize: accept height messages ONLY from this iframe's own content
|
||||
// window. The sandboxed srcdoc has an opaque ("null") origin, so we cannot
|
||||
// match by event.origin — we match by event.source instead. We track the
|
||||
// reported height even while a fixed height is in effect, so toggling back to
|
||||
// auto shows the current content height with no iframe reload.
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (!isTrustedHeightMessage(event, iframeRef.current)) return;
|
||||
const next = Number((event.data as { height?: number }).height);
|
||||
setAutoHeight(clampHeight(next));
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
const effectiveHeight = hasFixedHeight ? clampHeight(height) : autoHeight;
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
setDraft(source || "");
|
||||
setDraftHeight(height ?? "");
|
||||
setModalOpen(true);
|
||||
}, [source, height]);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (editor.isEditable) {
|
||||
updateAttributes({
|
||||
source: draft,
|
||||
height: draftHeight === "" ? null : Number(draftHeight),
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
}, [draft, draftHeight, editor.isEditable, updateAttributes]);
|
||||
|
||||
// The edit affordance is only meaningful in edit mode and is offered only when
|
||||
// the workspace master toggle is ON. Any member can edit (sandboxed = safe).
|
||||
const canEdit = computeCanEdit(editor.isEditable, htmlEmbedEnabled);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
data-drag-handle
|
||||
className={clsx(classes.htmlEmbedNodeView, {
|
||||
[classes.htmlEmbedSelected]: selected,
|
||||
})}
|
||||
>
|
||||
{canEdit && (
|
||||
<div className={classes.htmlEmbedToolbar}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="sm"
|
||||
aria-label={t("Edit HTML embed")}
|
||||
onClick={openEditor}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldRender ? (
|
||||
// Feature disabled for this workspace AND we're in the editable editor:
|
||||
// render a neutral placeholder so an existing embed is visibly inert for
|
||||
// the author. Read-only / share viewers never hit this branch
|
||||
// (`shouldRender` is always true there) — they render exactly the
|
||||
// source the server chose to serve.
|
||||
<div className={classes.htmlEmbedPlaceholder}>
|
||||
<IconCode size={18} />
|
||||
<Text size="sm">
|
||||
{t("HTML embed is disabled in this workspace")}
|
||||
</Text>
|
||||
</div>
|
||||
) : source ? (
|
||||
// Raw HTML/CSS/JS rendered inside a sandboxed iframe (no same-origin):
|
||||
// scripts run in an opaque origin and cannot touch the viewer's
|
||||
// session/cookies/API.
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={classes.htmlEmbedFrame}
|
||||
sandbox={HTML_EMBED_SANDBOX}
|
||||
srcDoc={srcdoc}
|
||||
title={t("HTML embed")}
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ height: effectiveHeight }}
|
||||
/>
|
||||
) : canEdit ? (
|
||||
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
|
||||
<IconCode size={18} />
|
||||
<Text size="sm">{t("Click to add HTML / CSS / JS")}</Text>
|
||||
</div>
|
||||
) : (
|
||||
// Empty source, non-editor: render nothing visible.
|
||||
<div className={classes.htmlEmbedContent} />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={t("Edit HTML embed")}
|
||||
size="lg"
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={10}
|
||||
maxRows={24}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
placeholder={t("<script>...</script>")}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
data-autofocus
|
||||
/>
|
||||
<NumberInput
|
||||
mt="md"
|
||||
label={t("Height (px, blank = auto)")}
|
||||
value={draftHeight}
|
||||
onChange={(value) =>
|
||||
setDraftHeight(
|
||||
value === "" || value === null ? "" : Number(value),
|
||||
)
|
||||
}
|
||||
min={MIN_IFRAME_HEIGHT}
|
||||
max={MAX_IFRAME_HEIGHT}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={onSave}>{t("Save")}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
const okResult: PageTemplateLookup = {
|
||||
sourcePageId: "p1",
|
||||
slugId: "slug-p1",
|
||||
title: "Template",
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("decideEmbedState", () => {
|
||||
it("returns no_source when sourcePageId is null", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: null,
|
||||
chain: [],
|
||||
hostPageId: null,
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("no_source");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId is already in the ancestor chain", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: ["root", "p1"],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns cycle when sourcePageId equals the host page id (top-level self-embed)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "host",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns too_deep when chain length reaches PAGE_EMBED_MAX_DEPTH", () => {
|
||||
const chain = Array.from({ length: PAGE_EMBED_MAX_DEPTH }, (_, i) => `a${i}`);
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("too_deep");
|
||||
});
|
||||
|
||||
it("cycle wins over too_deep when both apply (cycle checked first)", () => {
|
||||
const chain = Array.from(
|
||||
{ length: PAGE_EMBED_MAX_DEPTH },
|
||||
(_, i) => `a${i}`,
|
||||
);
|
||||
chain[0] = "p1"; // also a cycle
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain,
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("cycle");
|
||||
});
|
||||
|
||||
it("returns unavailable when no lookup context is mounted", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: false,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("returns loading when available but the result is not back yet", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: null,
|
||||
}),
|
||||
).toBe("loading");
|
||||
});
|
||||
|
||||
it("returns no_access when the result status is no_access", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "no_access" },
|
||||
}),
|
||||
).toBe("no_access");
|
||||
});
|
||||
|
||||
it("returns not_found when the result status is not_found", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: { sourcePageId: "p1", status: "not_found" },
|
||||
}),
|
||||
).toBe("not_found");
|
||||
});
|
||||
|
||||
it("returns ok for a resolved template (happy path)", () => {
|
||||
expect(
|
||||
decideEmbedState({
|
||||
sourcePageId: "p1",
|
||||
chain: [],
|
||||
hostPageId: "host",
|
||||
available: true,
|
||||
result: okResult,
|
||||
}),
|
||||
).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
/**
|
||||
* The render outcome of a single pageEmbed node, decided BEFORE rendering a
|
||||
* nested editor. Kept pure (no React) so the cycle / depth / access / not-found
|
||||
* branch logic is unit-testable in isolation; the node view maps each outcome
|
||||
* to a placeholder or the embedded content.
|
||||
*/
|
||||
export type EmbedState =
|
||||
| "no_source" // no sourcePageId picked yet
|
||||
| "cycle" // self-embed or an ancestor already shows this page
|
||||
| "too_deep" // nesting depth limit reached
|
||||
| "unavailable" // no lookup context (e.g. public share)
|
||||
| "loading" // context present, result not back yet
|
||||
| "ok" // resolved template content to render
|
||||
| "no_access" // server says the viewer can't see the page
|
||||
| "not_found"; // server says the page no longer exists
|
||||
|
||||
export interface DecideEmbedStateInput {
|
||||
sourcePageId: string | null;
|
||||
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
|
||||
chain: string[];
|
||||
/** Host page id; a top-level self-embed must be caught against it. */
|
||||
hostPageId: string | null;
|
||||
/** Whether a lookup context is mounted (false on public shares in MVP). */
|
||||
available: boolean;
|
||||
/** The lookup result, or null while still loading. */
|
||||
result: PageTemplateLookup | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what a pageEmbed should render. The order matters: cycle and depth
|
||||
* guards run first (before any lookup is even consulted), then availability,
|
||||
* then the resolved result. Mirrors the branch ladder in PageEmbedBody.
|
||||
*/
|
||||
export function decideEmbedState({
|
||||
sourcePageId,
|
||||
chain,
|
||||
hostPageId,
|
||||
available,
|
||||
result,
|
||||
}: DecideEmbedStateInput): EmbedState {
|
||||
if (!sourcePageId) return "no_source";
|
||||
|
||||
// Self-embed or a source already present in the ancestor chain → cycle.
|
||||
const isCycle = chain.includes(sourcePageId) || hostPageId === sourcePageId;
|
||||
if (isCycle) return "cycle";
|
||||
|
||||
if (chain.length >= PAGE_EMBED_MAX_DEPTH) return "too_deep";
|
||||
|
||||
if (!available) return "unavailable";
|
||||
if (!result) return "loading";
|
||||
|
||||
if (!("status" in result)) return "ok";
|
||||
if (result.status === "no_access") return "no_access";
|
||||
return "not_found";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
} from "./page-embed-ancestry-context";
|
||||
|
||||
/**
|
||||
* Tiny probe that renders the current ancestry context as serialized data
|
||||
* attributes so tests can assert the accumulated chain / threaded hostPageId
|
||||
* without mounting the heavy Tiptap node view.
|
||||
*/
|
||||
function AncestryProbe({ testId = "probe" }: { testId?: string }) {
|
||||
const { chain, hostPageId } = usePageEmbedAncestry();
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
data-chain={chain.join(",")}
|
||||
data-chain-length={String(chain.length)}
|
||||
data-host={hostPageId ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PageEmbedAncestryProvider", () => {
|
||||
it("defaults to an empty chain and null host with no provider", () => {
|
||||
render(<AncestryProbe />);
|
||||
const probe = screen.getByTestId("probe");
|
||||
expect(probe.getAttribute("data-chain")).toBe("");
|
||||
expect(probe.getAttribute("data-chain-length")).toBe("0");
|
||||
expect(probe.getAttribute("data-host")).toBe("");
|
||||
});
|
||||
|
||||
it("accumulates sourcePageId into the chain across nested providers", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId="b">
|
||||
<PageEmbedAncestryProvider sourcePageId="c">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// Chain is built outermost -> innermost.
|
||||
expect(probe.getAttribute("data-chain")).toBe("a,b,c");
|
||||
expect(probe.getAttribute("data-chain-length")).toBe("3");
|
||||
});
|
||||
|
||||
it("threads the host page id from the outermost provider down the tree", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host-page">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="ignored">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// The first host wins (parent.hostPageId ?? hostPageId); deeper hosts are
|
||||
// ignored so the original host is preserved for self-embed detection.
|
||||
expect(probe.getAttribute("data-host")).toBe("host-page");
|
||||
});
|
||||
|
||||
it("does not add an entry to the chain when sourcePageId is missing", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
|
||||
<PageEmbedAncestryProvider sourcePageId={null}>
|
||||
<PageEmbedAncestryProvider>
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
// null / undefined sources are pass-through: chain stays ["a"], host kept.
|
||||
expect(probe.getAttribute("data-chain")).toBe("a");
|
||||
expect(probe.getAttribute("data-host")).toBe("host");
|
||||
});
|
||||
|
||||
it("adopts a host provided only at a deeper level when the root had none", () => {
|
||||
render(
|
||||
<PageEmbedAncestryProvider sourcePageId="a">
|
||||
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="late-host">
|
||||
<AncestryProbe />
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedAncestryProvider>,
|
||||
);
|
||||
const probe = screen.getByTestId("probe");
|
||||
expect(probe.getAttribute("data-host")).toBe("late-host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { createContext, useContext, useMemo } from "react";
|
||||
|
||||
/** Hard cap on nesting depth for whole-page embeds (cycle/runaway guard). */
|
||||
export const PAGE_EMBED_MAX_DEPTH = 5;
|
||||
|
||||
type AncestryValue = {
|
||||
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
|
||||
chain: string[];
|
||||
/** Includes the host page id so a top-level self-embed is also caught. */
|
||||
hostPageId: string | null;
|
||||
};
|
||||
|
||||
const PageEmbedAncestryContext = createContext<AncestryValue>({
|
||||
chain: [],
|
||||
hostPageId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Carries the ancestor `sourcePageId` chain down the nested read-only editors.
|
||||
* The node view reads it to detect cycles (current id already in the chain) and
|
||||
* to enforce a hard depth limit before mounting a deeper nested editor.
|
||||
*/
|
||||
export function PageEmbedAncestryProvider({
|
||||
sourcePageId,
|
||||
hostPageId,
|
||||
children,
|
||||
}: {
|
||||
sourcePageId?: string | null;
|
||||
hostPageId?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const parent = useContext(PageEmbedAncestryContext);
|
||||
const value = useMemo<AncestryValue>(() => {
|
||||
const nextHost = parent.hostPageId ?? hostPageId ?? null;
|
||||
if (!sourcePageId) {
|
||||
return { chain: parent.chain, hostPageId: nextHost };
|
||||
}
|
||||
return {
|
||||
chain: [...parent.chain, sourcePageId],
|
||||
hostPageId: nextHost,
|
||||
};
|
||||
}, [parent, sourcePageId, hostPageId]);
|
||||
|
||||
return (
|
||||
<PageEmbedAncestryContext.Provider value={value}>
|
||||
{children}
|
||||
</PageEmbedAncestryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageEmbedAncestry() {
|
||||
return useContext(PageEmbedAncestryContext);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { EditorProvider } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { UniqueID } from "@docmost/editor-ext";
|
||||
|
||||
type Props = {
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only nested renderer for embedded whole-page content. Same pattern as
|
||||
* the transclusion read-only renderer: drop uniqueID/globalDragHandle, never
|
||||
* write back, and isolate pointer/drag events from the host editor. Nested
|
||||
* `pageEmbed`/`transclusionReference` nodes inside the content render with
|
||||
* their own views (the cycle/depth guard lives in the node view itself).
|
||||
*/
|
||||
export default function PageEmbedContent({ content }: Props) {
|
||||
const extensions = useMemo(() => {
|
||||
const filtered = mainExtensions.filter(
|
||||
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
|
||||
);
|
||||
return [
|
||||
...filtered,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph", "transclusionSource"],
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={stop}
|
||||
onClick={stop}
|
||||
onDragStart={stop}
|
||||
onDragOver={stop}
|
||||
onDrop={stop}
|
||||
>
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={extensions}
|
||||
content={content as any}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
// Mock the API module the provider calls. Hoisted by vitest before the import.
|
||||
const lookupTemplate = vi.fn();
|
||||
vi.mock("@/features/page-embed/services/page-embed-api", () => ({
|
||||
lookupTemplate: (...args: unknown[]) => lookupTemplate(...args),
|
||||
}));
|
||||
|
||||
// Imported AFTER the mock is declared so the provider picks up the mock.
|
||||
import {
|
||||
PageEmbedLookupProvider,
|
||||
usePageEmbedLookup,
|
||||
} from "./page-embed-lookup-context";
|
||||
|
||||
function ok(id: string): PageTemplateLookup {
|
||||
return {
|
||||
sourcePageId: id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `T-${id}`,
|
||||
icon: null,
|
||||
content: { type: "doc" },
|
||||
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
// Probe that subscribes to a sourceId and exposes its latest result + refresh.
|
||||
function Probe({
|
||||
id,
|
||||
sink,
|
||||
}: {
|
||||
id: string;
|
||||
sink: (api: ReturnType<typeof usePageEmbedLookup>) => void;
|
||||
}) {
|
||||
const api = usePageEmbedLookup(id);
|
||||
sink(api);
|
||||
return <div>{api.result ? "loaded" : "pending"}</div>;
|
||||
}
|
||||
|
||||
describe("PageEmbedLookupProvider (batching / dedup / refresh)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
lookupTemplate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dedups two subscribers for the same id into a single lookup call; both get the result", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
let b: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
<Probe id="p1" sink={(x) => (b = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Subscriptions run in effects + the 10ms debounce batches them together.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate).toHaveBeenCalledWith({ sourcePageIds: ["p1"] });
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
expect(b!.result).toEqual(ok("p1"));
|
||||
});
|
||||
|
||||
it("batches two distinct ids subscribed within the window into one call", async () => {
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1"), ok("p2")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={() => {}} />
|
||||
<Probe id="p2" sink={() => {}} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(lookupTemplate.mock.calls[0][0]).toEqual({
|
||||
sourcePageIds: ["p1", "p2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() clears the cache and re-fetches", async () => {
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// refresh resolves once the next batch flush completes.
|
||||
await act(async () => {
|
||||
const p = a!.refresh();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
|
||||
expect(lookupTemplate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("a rejected lookup resolves refresh() waiters, clears inFlight, and logs the error (not swallowed)", async () => {
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
|
||||
lookupTemplate.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
render(
|
||||
<PageEmbedLookupProvider>
|
||||
<Probe id="p1" sink={(x) => (a = x)} />
|
||||
</PageEmbedLookupProvider>,
|
||||
);
|
||||
|
||||
// Initial subscription enqueues a lookup that rejects.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
// The error message is surfaced, not swallowed.
|
||||
expect(errSpy.mock.calls[0][0]).toContain("[pageEmbed] template lookup failed");
|
||||
|
||||
// inFlight was cleared on failure, so a refresh re-enqueues and resolves.
|
||||
lookupTemplate.mockResolvedValueOnce({ items: [ok("p1")] });
|
||||
let resolved = false;
|
||||
await act(async () => {
|
||||
const p = a!.refresh().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await p;
|
||||
});
|
||||
expect(resolved).toBe(true);
|
||||
expect(a!.result).toEqual(ok("p1"));
|
||||
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { lookupTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
type ContextValue = {
|
||||
subscribe: (s: {
|
||||
sourcePageId: string;
|
||||
setResult: (r: PageTemplateLookup) => void;
|
||||
}) => () => void;
|
||||
refresh: (sourcePageId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const PageEmbedLookupContext = createContext<ContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Batching/de-dup lookup context for whole-page embeds (pageEmbed). Mirrors the
|
||||
* transclusion lookup context but keys purely on `sourcePageId`. On public
|
||||
* shares there is no lookup in MVP, so the context simply isn't mounted (the
|
||||
* node view renders a placeholder when the context is absent).
|
||||
*
|
||||
* NOTE (intentional near-duplicate of `transclusion-lookup-context.tsx`): this
|
||||
* provider duplicates that file's batching / de-dup / cache machinery; only the
|
||||
* lookup key (sourcePageId here vs sourcePageId+transclusionId there) and the
|
||||
* API call differ. Unifying them now would mean a generic, parameterised lookup
|
||||
* provider — a larger client refactor that isn't worth it for just two
|
||||
* consumers. Per Gitea #94, extract a shared generic provider when a THIRD
|
||||
* lookup consumer appears; until then keep the two in sync by hand. (Tracked,
|
||||
* deliberately deferred — not forgotten.)
|
||||
*/
|
||||
export function PageEmbedLookupProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const subscribersRef = useRef(new Map<string, Array<(r: PageTemplateLookup) => void>>());
|
||||
const queueRef = useRef(new Set<string>());
|
||||
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resultCacheRef = useRef(new Map<string, PageTemplateLookup>());
|
||||
const inFlightRef = useRef(new Set<string>());
|
||||
const pendingRef = useRef(new Map<string, Array<() => void>>());
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
tickRef.current = null;
|
||||
const ids = Array.from(queueRef.current);
|
||||
queueRef.current.clear();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
for (const id of ids) inFlightRef.current.add(id);
|
||||
|
||||
const resolveWaiters = (id: string) => {
|
||||
const waiters = pendingRef.current.get(id);
|
||||
if (!waiters) return;
|
||||
pendingRef.current.delete(id);
|
||||
for (const w of waiters) w();
|
||||
};
|
||||
|
||||
try {
|
||||
const { items } = await lookupTemplate({ sourcePageIds: ids });
|
||||
const returned = new Set<string>();
|
||||
for (const r of items) {
|
||||
returned.add(r.sourcePageId);
|
||||
resultCacheRef.current.set(r.sourcePageId, r);
|
||||
inFlightRef.current.delete(r.sourcePageId);
|
||||
const subs = subscribersRef.current.get(r.sourcePageId);
|
||||
if (subs) {
|
||||
for (const set of subs) set(r);
|
||||
}
|
||||
resolveWaiters(r.sourcePageId);
|
||||
}
|
||||
// Harden against a partial/short server response: any requested id not
|
||||
// present in `items` would otherwise stay in `inFlightRef` forever
|
||||
// (subscribe/refresh are guarded by `!inFlightRef.has(id)`) and its
|
||||
// refresh() promise would never resolve. Clear + resolve those ids,
|
||||
// mirroring the catch branch, so no id can be stranded in-flight.
|
||||
for (const id of ids) {
|
||||
if (!returned.has(id)) {
|
||||
inFlightRef.current.delete(id);
|
||||
resolveWaiters(id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure: errors must never be swallowed silently.
|
||||
console.error("[pageEmbed] template lookup failed", err);
|
||||
for (const id of ids) {
|
||||
inFlightRef.current.delete(id);
|
||||
resolveWaiters(id);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(id: string) => {
|
||||
queueRef.current.add(id);
|
||||
if (tickRef.current === null) {
|
||||
tickRef.current = setTimeout(flush, 10);
|
||||
}
|
||||
},
|
||||
[flush],
|
||||
);
|
||||
|
||||
const subscribe = useCallback<ContextValue["subscribe"]>(
|
||||
({ sourcePageId, setResult }) => {
|
||||
const list = subscribersRef.current.get(sourcePageId) ?? [];
|
||||
list.push(setResult);
|
||||
subscribersRef.current.set(sourcePageId, list);
|
||||
|
||||
const cached = resultCacheRef.current.get(sourcePageId);
|
||||
if (cached) {
|
||||
setResult(cached);
|
||||
} else if (!inFlightRef.current.has(sourcePageId)) {
|
||||
enqueue(sourcePageId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const cur = subscribersRef.current.get(sourcePageId) ?? [];
|
||||
const next = cur.filter((x) => x !== setResult);
|
||||
if (next.length === 0) subscribersRef.current.delete(sourcePageId);
|
||||
else subscribersRef.current.set(sourcePageId, next);
|
||||
};
|
||||
},
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
const refresh = useCallback<ContextValue["refresh"]>(
|
||||
(sourcePageId) =>
|
||||
new Promise<void>((resolve) => {
|
||||
resultCacheRef.current.delete(sourcePageId);
|
||||
inFlightRef.current.delete(sourcePageId);
|
||||
const waiters = pendingRef.current.get(sourcePageId) ?? [];
|
||||
waiters.push(resolve);
|
||||
pendingRef.current.set(sourcePageId, waiters);
|
||||
enqueue(sourcePageId);
|
||||
}),
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<ContextValue>(
|
||||
() => ({ subscribe, refresh }),
|
||||
[subscribe, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageEmbedLookupContext.Provider value={value}>
|
||||
{children}
|
||||
</PageEmbedLookupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageEmbedLookup(sourcePageId: string | null | undefined): {
|
||||
result: PageTemplateLookup | null;
|
||||
refresh: () => Promise<void>;
|
||||
available: boolean;
|
||||
} {
|
||||
const ctx = useContext(PageEmbedLookupContext);
|
||||
const [result, setResult] = useState<PageTemplateLookup | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx || !sourcePageId) return;
|
||||
const unsubscribe = ctx.subscribe({ sourcePageId, setResult });
|
||||
return unsubscribe;
|
||||
}, [ctx, sourcePageId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!ctx || !sourcePageId) return;
|
||||
await ctx.refresh(sourcePageId);
|
||||
}, [ctx, sourcePageId]);
|
||||
|
||||
return { result, refresh, available: Boolean(ctx) };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Modal, ScrollArea, TextInput, Text, UnstyledButton, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IconFileText, IconSearch } from "@tabler/icons-react";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import { buildPickerQuery, excludeHost } from "./page-embed-picker.utils";
|
||||
|
||||
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
|
||||
|
||||
type PickerDetail = {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
/** Host page id, used to forbid self-embed in the picker. */
|
||||
hostPageId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal page picker for inserting a `pageEmbed`. Queries search-suggestions
|
||||
* with `onlyTemplates` so only template-flagged pages are offered. Forbids
|
||||
* selecting the current (host) page (self-embed guard at insertion time).
|
||||
* Mounted once per editor; opened via a CustomEvent dispatched by the slash
|
||||
* command item.
|
||||
*/
|
||||
export default function PageEmbedPicker() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const detailRef = useRef<PickerDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<PickerDetail>).detail;
|
||||
if (!detail?.editor) return;
|
||||
detailRef.current = detail;
|
||||
setQuery("");
|
||||
setOpened(true);
|
||||
};
|
||||
document.addEventListener(PAGE_EMBED_PICKER_EVENT, handler);
|
||||
return () => document.removeEventListener(PAGE_EMBED_PICKER_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ["page-embed-template-picker", query],
|
||||
queryFn: () => searchSuggestions(buildPickerQuery(query)),
|
||||
enabled: opened,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const hostPageId = detailRef.current?.hostPageId;
|
||||
const pages = excludeHost((data?.pages ?? []) as IPage[], hostPageId);
|
||||
|
||||
const handleSelect = (page: IPage) => {
|
||||
const detail = detailRef.current;
|
||||
if (!detail) return;
|
||||
const { editor, range } = detail;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertPageEmbed({ sourcePageId: page.id })
|
||||
.run();
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={t("Embed page")}
|
||||
size="md"
|
||||
>
|
||||
<TextInput
|
||||
placeholder={t("Search templates...")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
autoFocus
|
||||
mb="sm"
|
||||
/>
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
{pages.length === 0 && !isFetching && (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No templates found")}
|
||||
</Text>
|
||||
)}
|
||||
{pages.map((page) => (
|
||||
<UnstyledButton
|
||||
key={page.id}
|
||||
onClick={() => handleSelect(page)}
|
||||
style={{ display: "block", width: "100%", padding: "8px 4px" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{page.icon ? (
|
||||
<span>{page.icon}</span>
|
||||
) : (
|
||||
<IconFileText size={16} />
|
||||
)}
|
||||
<Text size="sm" truncate>
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { excludeHost, buildPickerQuery } from "./page-embed-picker.utils";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
function page(id: string): IPage {
|
||||
return { id, title: id, slugId: `slug-${id}` } as IPage;
|
||||
}
|
||||
|
||||
describe("excludeHost", () => {
|
||||
it("drops the host page from the results (self-embed guard)", () => {
|
||||
const result = excludeHost([page("a"), page("host"), page("b")], "host");
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns all pages when hostPageId is undefined", () => {
|
||||
const result = excludeHost([page("a"), page("b")], undefined);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("drops null/blank entries", () => {
|
||||
const result = excludeHost(
|
||||
[page("a"), null as unknown as IPage, page("b")],
|
||||
"host",
|
||||
);
|
||||
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPickerQuery", () => {
|
||||
it("passes onlyTemplates:true with the query and page inclusion", () => {
|
||||
expect(buildPickerQuery("foo")).toEqual({
|
||||
query: "foo",
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an empty query", () => {
|
||||
expect(buildPickerQuery("").query).toBe("");
|
||||
expect(buildPickerQuery("").onlyTemplates).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
import type { SearchSuggestionParams } from "@/features/search/types/search.types";
|
||||
|
||||
/**
|
||||
* Self-embed guard at insertion time: drop the host page (and any null/blank
|
||||
* entries) from the picker results so the current page can't embed itself.
|
||||
*/
|
||||
export function excludeHost(
|
||||
pages: IPage[],
|
||||
hostPageId: string | undefined,
|
||||
): IPage[] {
|
||||
return pages.filter((p) => p && p.id !== hostPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the search-suggestions query for the template picker. Always restricts
|
||||
* to template-flagged pages (`onlyTemplates`) and includes pages, mirroring the
|
||||
* inline query args in PageEmbedPicker.
|
||||
*/
|
||||
export function buildPickerQuery(query: string): SearchSuggestionParams {
|
||||
return {
|
||||
query,
|
||||
includePages: true,
|
||||
onlyTemplates: true,
|
||||
limit: 20,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDots,
|
||||
IconEyeOff,
|
||||
IconFileText,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconRepeat,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "../transclusion/transclusion.module.css";
|
||||
import { usePageEmbedLookup } from "./page-embed-lookup-context";
|
||||
import {
|
||||
PageEmbedAncestryProvider,
|
||||
usePageEmbedAncestry,
|
||||
} from "./page-embed-ancestry-context";
|
||||
import { decideEmbedState } from "./decide-embed-state";
|
||||
import PageEmbedContent from "./page-embed-content";
|
||||
|
||||
function Placeholder({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={classes.placeholder}>
|
||||
<span className={classes.placeholderIcon}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageEmbedView(props: NodeViewProps) {
|
||||
const isEditable = props.editor.isEditable;
|
||||
const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null;
|
||||
const [openMenus, setOpenMenus] = useState(0);
|
||||
const trackOpen = (open: boolean) =>
|
||||
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={classes.includeWrap}
|
||||
data-editable={isEditable ? "true" : "false"}
|
||||
data-focused={isEditable && props.selected ? "true" : "false"}
|
||||
data-menu-open={openMenus > 0 ? "true" : "false"}
|
||||
contentEditable={false}
|
||||
>
|
||||
<ErrorBoundary
|
||||
resetKeys={[sourcePageId]}
|
||||
onError={(err) =>
|
||||
// Never swallow: log the full error with the offending source id.
|
||||
console.error("[pageEmbed] render error", { sourcePageId, err })
|
||||
}
|
||||
fallback={
|
||||
<Placeholder
|
||||
icon={<IconAlertTriangle size={18} stroke={1.6} />}
|
||||
label="Failed to load this embedded page"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PageEmbedBody {...props} trackOpen={trackOpen} />
|
||||
</ErrorBoundary>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function PageEmbedBody({
|
||||
editor,
|
||||
node,
|
||||
deleteNode,
|
||||
trackOpen,
|
||||
}: NodeViewProps & { trackOpen: (open: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const sourcePageId: string | null = node.attrs.sourcePageId ?? null;
|
||||
const isEditable = editor.isEditable;
|
||||
const ancestry = usePageEmbedAncestry();
|
||||
|
||||
// @ts-ignore - editor.storage.pageId is set by the host editor
|
||||
const hostPageId: string | undefined = editor.storage?.pageId;
|
||||
|
||||
const { result, refresh, available } = usePageEmbedLookup(sourcePageId);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Cycle / depth / availability decision (pure, unit-tested) ------------
|
||||
// Evaluated before any nested editor is rendered.
|
||||
const embedState = decideEmbedState({
|
||||
sourcePageId,
|
||||
chain: ancestry.chain,
|
||||
hostPageId: ancestry.hostPageId,
|
||||
available,
|
||||
result,
|
||||
});
|
||||
|
||||
const sourceTitle =
|
||||
result && !("status" in result) ? result.title : null;
|
||||
const sourceIcon = result && !("status" in result) ? result.icon : null;
|
||||
// The app routes pages by slugId, not the raw UUID. Build the link from the
|
||||
// resolved slugId (the `/p/:pageSlug` route redirects to the full URL).
|
||||
const sourceSlugId =
|
||||
result && !("status" in result) ? result.slugId : null;
|
||||
const sourceHref = sourceSlugId
|
||||
? buildPageUrl(undefined, sourceSlugId, sourceTitle ?? undefined)
|
||||
: null;
|
||||
|
||||
const controls = isEditable ? (
|
||||
<div
|
||||
className={classes.includeControls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Tooltip label={t("Refresh")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
disabled={!sourcePageId}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Remove from page")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const header =
|
||||
// Render the badge whenever the source resolves (sourceHref), not only when
|
||||
// it has a title/icon — the title link is now the single way to open the
|
||||
// source, so it must not disappear when title and icon are both empty.
|
||||
sourceTitle || sourceIcon || sourceHref ? (
|
||||
<div className={classes.transclusionBadge}>
|
||||
{sourceIcon ? `${sourceIcon} ` : <IconFileText size={12} />}
|
||||
{sourceHref ? (
|
||||
<Link
|
||||
to={sourceHref}
|
||||
style={{ borderBottom: "none", textDecoration: "none" }}
|
||||
title={t("Open source page")}
|
||||
aria-label={t("Open source page")}
|
||||
>
|
||||
{sourceTitle || t("Untitled")}
|
||||
</Link>
|
||||
) : (
|
||||
sourceTitle || t("Untitled")
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (embedState === "no_source") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
||||
label={t("No page selected")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "cycle") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Circular embed: this page is already shown above")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "too_deep") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconRepeat size={18} stroke={1.6} />}
|
||||
label={t("Embed nesting limit reached")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "unavailable") {
|
||||
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconEyeOff size={18} stroke={1.6} />}
|
||||
label={t("Embedded page is not available here")}
|
||||
/>
|
||||
);
|
||||
} else if (embedState === "loading") {
|
||||
body = <div style={{ minHeight: 24 }} />;
|
||||
} else if (embedState === "ok" && result && !("status" in result)) {
|
||||
body = (
|
||||
<PageEmbedAncestryProvider
|
||||
sourcePageId={sourcePageId}
|
||||
hostPageId={hostPageId}
|
||||
>
|
||||
{/*
|
||||
Tiptap's EditorProvider consumes `content` only at initial mount, so a
|
||||
changed `content` prop (e.g. after Refresh re-fetches fresh content)
|
||||
would not update the read-only sub-editor. Key on the source's
|
||||
updatedAt to remount PageEmbedContent (and its inner EditorProvider)
|
||||
whenever the source page changes, applying the refreshed content.
|
||||
*/}
|
||||
<PageEmbedContent
|
||||
key={result.sourceUpdatedAt}
|
||||
content={result.content}
|
||||
/>
|
||||
</PageEmbedAncestryProvider>
|
||||
);
|
||||
} else if (embedState === "no_access") {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconEyeOff size={18} stroke={1.6} />}
|
||||
label={t("You don't have access to this page")}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<Placeholder
|
||||
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
||||
label={t("The embedded page no longer exists")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls}
|
||||
{header}
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSuggestionItems,
|
||||
isHtmlEmbedFeatureEnabled,
|
||||
} from "./menu-items";
|
||||
|
||||
// Gating coverage for the workspace-level "HTML embed" slash item. The gate is
|
||||
// read from the persisted `currentUser` localStorage entry (the same payload
|
||||
// `currentUserAtom` writes). It must default to OFF, only show when the toggle
|
||||
// is explicitly true, and never throw on a broken/garbage stored value.
|
||||
|
||||
const KEY = "currentUser";
|
||||
|
||||
function setCurrentUser(value: unknown): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("isHtmlEmbedFeatureEnabled (workspace toggle gate)", () => {
|
||||
it("is OFF when no currentUser is persisted (default)", () => {
|
||||
localStorage.removeItem(KEY);
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("is OFF when the toggle is absent from workspace settings", () => {
|
||||
setCurrentUser({ workspace: { settings: {} } });
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("is OFF when the toggle is explicitly false", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: false } } });
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("is ON only when the toggle is exactly true", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: true } } });
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw and returns false on a broken localStorage value", () => {
|
||||
// Invalid JSON: JSON.parse throws; the gate must swallow it -> false.
|
||||
localStorage.setItem(KEY, "{not valid json");
|
||||
expect(() => isHtmlEmbedFeatureEnabled()).not.toThrow();
|
||||
expect(isHtmlEmbedFeatureEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function hasHtmlEmbedItem(query = "html"): boolean {
|
||||
const groups = getSuggestionItems({ query });
|
||||
return Object.values(groups)
|
||||
.flat()
|
||||
.some((item) => item.title === "HTML embed");
|
||||
}
|
||||
|
||||
describe("getSuggestionItems — HTML embed item gating", () => {
|
||||
it("hides the HTML embed item when the toggle is OFF (default)", () => {
|
||||
localStorage.removeItem(KEY);
|
||||
expect(hasHtmlEmbedItem()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the HTML embed item when the toggle is explicitly false", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: false } } });
|
||||
expect(hasHtmlEmbedItem()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the HTML embed item when the toggle is ON", () => {
|
||||
setCurrentUser({ workspace: { settings: { htmlEmbed: true } } });
|
||||
expect(hasHtmlEmbedItem()).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the item without throwing on a broken localStorage value", () => {
|
||||
localStorage.setItem(KEY, "{not valid json");
|
||||
expect(() => getSuggestionItems({ query: "html" })).not.toThrow();
|
||||
expect(hasHtmlEmbedItem()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
IconTag,
|
||||
IconMoodSmile,
|
||||
IconRotate2,
|
||||
IconSuperscript,
|
||||
IconArrowsMaximize,
|
||||
} from "@tabler/icons-react";
|
||||
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
import {
|
||||
CommandProps,
|
||||
SlashMenuGroupedItemsType,
|
||||
@@ -366,6 +369,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDetails().run(),
|
||||
},
|
||||
{
|
||||
title: "Footnote",
|
||||
description: "Insert a footnote reference.",
|
||||
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
|
||||
icon: IconSuperscript,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setFootnote().run(),
|
||||
},
|
||||
{
|
||||
title: "Callout",
|
||||
description: "Insert callout notice.",
|
||||
@@ -535,6 +546,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Embed page",
|
||||
description: "Insert a live, read-only copy of another page.",
|
||||
searchTerms: [
|
||||
"template",
|
||||
"embed",
|
||||
"embed page",
|
||||
"page",
|
||||
"live",
|
||||
"include",
|
||||
"reuse",
|
||||
],
|
||||
icon: IconArrowsMaximize,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
// @ts-ignore - editor.storage.pageId is set by the host editor
|
||||
const hostPageId: string | undefined = editor.storage?.pageId;
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(PAGE_EMBED_PICKER_EVENT, {
|
||||
detail: { editor, range, hostPageId },
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "2 Columns",
|
||||
description: "Split content into two columns.",
|
||||
@@ -587,6 +621,21 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.insertColumns({ layout: "five_equal" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "HTML embed",
|
||||
description: "Embed raw HTML, CSS and JavaScript (sandboxed).",
|
||||
searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"],
|
||||
icon: IconCode,
|
||||
requiresHtmlEmbedFeature: true,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setHtmlEmbed({ source: "" })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Iframe embed",
|
||||
description: "Embed any Iframe",
|
||||
@@ -744,6 +793,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the workspace-level HTML embed master toggle from the persisted
|
||||
* `currentUser` payload (the same localStorage entry `currentUserAtom` writes,
|
||||
* carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash
|
||||
* `getSuggestionItems` is a plain function (no React/atom context), so we read
|
||||
* the persisted state directly. UI gate only; an anonymous public-share read is
|
||||
* served already-stripped content by the server when the toggle is OFF.
|
||||
*/
|
||||
export function isHtmlEmbedFeatureEnabled(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem("currentUser");
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.workspace?.settings?.htmlEmbed === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const getSuggestionItems = ({
|
||||
query,
|
||||
excludeItems,
|
||||
@@ -753,6 +821,7 @@ export const getSuggestionItems = ({
|
||||
}): SlashMenuGroupedItemsType => {
|
||||
const search = query.toLowerCase();
|
||||
const filteredGroups: SlashMenuGroupedItemsType = {};
|
||||
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
|
||||
|
||||
const fuzzyMatch = (query: string, target: string) => {
|
||||
let queryIndex = 0;
|
||||
@@ -767,6 +836,9 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
// Hide the HTML embed item unless the workspace master toggle is ON.
|
||||
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
|
||||
return false;
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
|
||||
@@ -21,6 +21,10 @@ export type SlashMenuItemType = {
|
||||
searchTerms: string[];
|
||||
command: (props: CommandProps) => void;
|
||||
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
||||
// When true, the item is hidden unless the workspace HTML embed master toggle
|
||||
// is ON. UI gate only — for anonymous public-share reads the server serves
|
||||
// already-stripped content when the toggle is OFF.
|
||||
requiresHtmlEmbedFeature?: boolean;
|
||||
};
|
||||
|
||||
export type SlashMenuGroupedItemsType = {
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
}
|
||||
|
||||
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-pageEmbed.ProseMirror-selectednode) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
HtmlEmbed,
|
||||
TiptapPdf,
|
||||
PageBreak,
|
||||
SearchAndReplace,
|
||||
@@ -60,7 +61,11 @@ import {
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
PageEmbed,
|
||||
TableView,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -87,10 +92,15 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
|
||||
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
|
||||
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
|
||||
import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -230,7 +240,7 @@ export const mainExtensions = [
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
@@ -365,6 +375,13 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
// Raw HTML/CSS/JS node (Variant C). The node is registered for ALL users so
|
||||
// documents authored by admins render correctly for everyone; INSERTION is
|
||||
// gated to admins in the slash menu, and the server strips the node from any
|
||||
// non-admin write so a non-admin cannot persist it.
|
||||
HtmlEmbed.configure({
|
||||
view: HtmlEmbedView,
|
||||
}),
|
||||
TiptapPdf.configure({
|
||||
view: PdfView,
|
||||
}),
|
||||
@@ -381,6 +398,22 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
FootnoteReference.configure({
|
||||
view: FootnoteReferenceView,
|
||||
// Skip orphan-cleanup on remote/collaboration steps so collaborating
|
||||
// clients never fight over footnote integrity (deterministic numbering
|
||||
// decorations handle the rest).
|
||||
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
|
||||
}),
|
||||
FootnotesList.configure({
|
||||
view: FootnotesListView,
|
||||
}),
|
||||
FootnoteDefinition.configure({
|
||||
view: FootnoteDefinitionView,
|
||||
}),
|
||||
PageEmbed.configure({
|
||||
view: PageEmbedView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
@@ -420,7 +453,8 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw (Whiteboard)",
|
||||
"Audio",
|
||||
"Synced block"
|
||||
"Synced block",
|
||||
"Embed page"
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed/page-embed-lookup-context";
|
||||
import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context";
|
||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageEditorProps {
|
||||
@@ -407,6 +410,8 @@ export default function PageEditor({
|
||||
|
||||
return (
|
||||
<TransclusionLookupProvider>
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
{showStatic ? (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
@@ -454,6 +459,7 @@ export default function PageEditor({
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
{editor && editorIsEditable && <PageEmbedPicker />}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
@@ -461,6 +467,8 @@ export default function PageEditor({
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</PageEmbedAncestryProvider>
|
||||
</PageEmbedLookupProvider>
|
||||
</TransclusionLookupProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
const filteredExtensions = mainExtensions
|
||||
.filter((ext) => ext.name !== "uniqueID")
|
||||
// Read-only must only DECORATE footnotes (numbering), never mutate the
|
||||
// doc. Disable the footnote sync/integrity plugin so a programmatic
|
||||
// setContent on a doc the viewer can't edit is never rewritten.
|
||||
.map((ext) =>
|
||||
ext.name === "footnoteReference"
|
||||
? ext.configure({ enableSync: false })
|
||||
: ext,
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
ToggleTemplateResponse,
|
||||
Error,
|
||||
{ pageId: string; isTemplate?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemplate(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message || "Failed to update template",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
sourcePageIds: string[];
|
||||
}): Promise<{ items: PageTemplateLookup[] }> {
|
||||
const r = await api.post("/pages/template/lookup", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function toggleTemplate(params: {
|
||||
pageId: string;
|
||||
isTemplate?: boolean;
|
||||
}): Promise<ToggleTemplateResponse> {
|
||||
const r = await api.post("/pages/toggle-template", params);
|
||||
return r.data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type PageTemplateLookup =
|
||||
| {
|
||||
sourcePageId: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
content: unknown;
|
||||
sourceUpdatedAt: string;
|
||||
}
|
||||
| { sourcePageId: string; status: "not_found" }
|
||||
| { sourcePageId: string; status: "no_access" };
|
||||
|
||||
export type ToggleTemplateResponse = {
|
||||
pageId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
@@ -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 = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user