Compare commits
163 Commits
a5aa1185b1
...
feat/184-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ecddce748 | ||
|
|
9ad3931a1c | ||
|
|
97250ac1d1 | ||
|
|
7b8d9d62f0 | ||
|
|
5ac75a9688 | ||
|
|
362136ead0 | ||
|
|
c0844d5431 | ||
|
|
4c0a4eb9cc | ||
|
|
1abf9356a9 | ||
|
|
6390c45658 | ||
|
|
95781d80e1 | ||
|
|
106df7c907 | ||
|
|
89edddc5a1 | ||
| c5109aa2a3 | |||
|
|
c4ed4a4855 | ||
|
|
9c1f952b2f | ||
| c6ffdb6536 | |||
|
|
3fd66b4245 | ||
|
|
40d1cdfc77 | ||
|
|
a77a0bc92b | ||
|
|
525172104a | ||
|
|
07ebd8c63e | ||
|
|
c9d252cf2a | ||
|
|
fa929c9e86 | ||
|
|
30cb9d293c | ||
|
|
2d36641f28 | ||
|
|
22852be2e2 | ||
|
|
904f7b4303 | ||
|
|
cac84dec9b | ||
|
|
90dd8f1481 | ||
| 39113c9dbf | |||
|
|
1367070468 | ||
|
|
767ac9e7e2 | ||
|
|
2a4ef9267e | ||
|
|
309719abc6 | ||
|
|
3511301331 | ||
|
|
b65ca6d7dd | ||
| 4a3819373d | |||
|
|
e682bbccd1 | ||
|
|
9d2bec8eb8 | ||
| b6630deb32 | |||
|
|
7ef98a663b | ||
| 109ab10fc5 | |||
|
|
2b7c861f78 | ||
|
|
d181b5c4ff | ||
|
|
12ff76fb89 | ||
|
|
26ca19f89e | ||
|
|
50e79275e1 | ||
|
|
8be8279809 | ||
|
|
19f84ca0e7 | ||
|
|
e9409e245b | ||
|
|
fa6a87e22d | ||
|
|
0fc9c4a998 | ||
|
|
40b8f7922a | ||
| 08c70cf550 | |||
|
|
ae6ed76d9a | ||
|
|
276ccc0783 | ||
|
|
406921ac6a | ||
|
|
c64d7f315e | ||
|
|
7a7aa79eab | ||
| 719bccd80d | |||
| 83e64bad1a | |||
| ee78a96803 | |||
| d971d02346 | |||
|
|
53cbec9354 | ||
|
|
686c3f9d14 | ||
|
|
6faf2475e6 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
e99c00a9ee | ||
|
|
1f459d8d26 | ||
|
|
9632146d23 | ||
|
|
0314416bfa | ||
|
|
001ebe2e53 | ||
|
|
eb5b696431 | ||
|
|
422389d84e | ||
|
|
fad1aa0501 | ||
|
|
8bb4224a20 | ||
| 13589b3973 | |||
|
|
69fcccd6e8 | ||
|
|
0db48f1706 | ||
|
|
2e72a24d13 | ||
|
|
aad0a37cfd | ||
|
|
50d3e7b476 | ||
|
|
bd62d906bb | ||
|
|
e4b46ddbfc | ||
|
|
deeec50b5f | ||
|
|
7eefdad512 | ||
|
|
a7f8ee04b3 | ||
|
|
378d8b676b | ||
| 580f7bd5bb | |||
|
|
b538c729c3 | ||
|
|
0643cd1d82 | ||
| e3b23e0d26 | |||
|
|
b392219659 | ||
|
|
ba5cd02439 | ||
|
|
1043fe3b51 | ||
|
|
df50f23d58 | ||
|
|
eb5c8e6611 | ||
|
|
d32ad73158 | ||
|
|
acf2241e23 | ||
|
|
cb61274187 | ||
|
|
fdeede003b | ||
|
|
1d610b3a62 | ||
|
|
6bb9dfdc86 | ||
|
|
770ba70541 | ||
|
|
3d47c306fa | ||
|
|
c919d4f636 | ||
|
|
c4807022f2 | ||
|
|
00ca4ff3d6 | ||
|
|
ef7d04d1e7 | ||
|
|
5b59a70e3f | ||
|
|
eafd15f0ef | ||
|
|
fbdb8aa16c | ||
|
|
9b61024b95 | ||
|
|
63c26042ba | ||
|
|
2644fe6a83 | ||
|
|
993f884e64 | ||
|
|
2f058a6e40 | ||
|
|
3ddc329bba | ||
|
|
ed3b65c36b | ||
|
|
de115ade1e | ||
|
|
364838d0b2 | ||
|
|
aa7a115f66 | ||
|
|
30c358a2f8 | ||
|
|
ea61c96a7c | ||
|
|
f80276d41a | ||
|
|
8218c1a8ef | ||
|
|
d7e7489654 | ||
|
|
8f1af676ba | ||
|
|
34c5b557ef | ||
|
|
59f0c8b22d | ||
|
|
77ccc596ea | ||
|
|
e536c6f9a9 | ||
|
|
fdaf20ca7b | ||
|
|
47a2ae420b | ||
|
|
1cfad1f6fb | ||
|
|
a766672574 | ||
|
|
5e8cb628f0 | ||
|
|
8413185a1d | ||
|
|
8fee6a86c2 | ||
|
|
ae6faf3abc | ||
|
|
e7b719bbb8 | ||
|
|
27c91e4a69 | ||
|
|
c3596dce68 | ||
|
|
b6787cc542 | ||
| 176b0f575f | |||
|
|
df81851eb3 | ||
|
|
4597183a1e | ||
|
|
99d0cb8773 | ||
|
|
5aa199660d | ||
|
|
bf2ebb9d47 | ||
|
|
ad90e2290e | ||
| e262f1695c | |||
|
|
c065e26d14 | ||
|
|
91e7335d54 | ||
|
|
b0faa2fe32 | ||
|
|
d1fbcc1bfa | ||
|
|
6edbbab43b | ||
|
|
59190148db | ||
| 80a4b5a1b0 | |||
|
|
da15b55786 | ||
|
|
a14560c7c9 |
56
.env.example
56
.env.example
@@ -132,10 +132,58 @@ MCP_DOCMOST_PASSWORD=
|
||||
# NEVER set is_agent on a human or shared account — every action by that account
|
||||
# (including normal human edits) would then be mis-attributed as AI.
|
||||
|
||||
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
|
||||
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
|
||||
# baked into the Docker image at build time per branch (see the Dockerfile ARG
|
||||
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
|
||||
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
|
||||
# admin feature is unavailable. Local-filesystem sources are no longer supported.
|
||||
# AI_AGENT_ROLES_CATALOG_URL=
|
||||
|
||||
# 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
|
||||
|
||||
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
|
||||
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
|
||||
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
|
||||
# provider is eventually broken instead of leaking forever. Default 900000 (15 min).
|
||||
# AI_STREAM_TIMEOUT_MS=900000
|
||||
|
||||
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
||||
# A pooled connection idle longer than this is closed instead of reused, so a
|
||||
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
||||
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
|
||||
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
|
||||
# AI_STREAM_KEEPALIVE_MS=10000
|
||||
|
||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
||||
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
||||
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
|
||||
# AI_MCP_STREAM_TIMEOUT_MS=300000
|
||||
|
||||
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
||||
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||
# but never returns a result — which the silence timeout above never breaks.
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
|
||||
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
||||
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
||||
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
||||
# it, and a client reconnects/live-follows the run.
|
||||
#
|
||||
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
||||
# AbortController that backs it are process-local, so a Stop only aborts a run
|
||||
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
||||
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
||||
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
||||
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||
|
||||
# --- 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
|
||||
@@ -161,3 +209,11 @@ MCP_DOCMOST_PASSWORD=
|
||||
# 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
|
||||
#
|
||||
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
|
||||
# (input re-sent per step + output, summed across every accepted turn). The
|
||||
# hourly request cap above bounds how MANY calls run, not how expensive each is,
|
||||
# so this caps the owner's actual provider bill directly. Like the request cap it
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
158
.github/workflows/develop.yml
vendored
158
.github/workflows/develop.yml
vendored
@@ -52,7 +52,165 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
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
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
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
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Build server
|
||||
run: pnpm server:build
|
||||
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Start server (prod)
|
||||
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
|
||||
# migration mismatch) is diagnosable; without this the only signal is
|
||||
# the generic health-loop timeout below, ~120s later.
|
||||
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
|
||||
|
||||
- name: Wait for server health
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3000/api/health > /dev/null; then
|
||||
echo "Server is healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Server did not become healthy in time"
|
||||
exit 1
|
||||
|
||||
- name: Dump server log on failure
|
||||
if: failure()
|
||||
run: cat /tmp/server.log || true
|
||||
|
||||
- name: Seed admin
|
||||
run: |
|
||||
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
|
||||
|
||||
- name: Run mcp e2e
|
||||
env:
|
||||
DOCMOST_API_URL: http://localhost:3000/api
|
||||
DOCMOST_EMAIL: e2e@example.com
|
||||
DOCMOST_PASSWORD: E2ePassword123
|
||||
run: pnpm --filter @docmost/mcp test:e2e
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -17,6 +17,7 @@ permissions:
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
@@ -57,6 +58,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
|
||||
@@ -85,6 +87,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
push: false
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
|
||||
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -15,6 +15,38 @@ permissions:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Real Postgres + Redis so the server integration suite (`*.int-spec.ts`,
|
||||
# behind `pnpm --filter server test:int`) runs in CI (red-team finding #7).
|
||||
# Without it, cost-cap / FK-cascade / jsonb-round-trip / real-apply tests
|
||||
# only ran locally, so regressions in those paths stayed green in CI.
|
||||
# Postgres uses the pgvector image because migrations create vector columns
|
||||
# and global-setup runs `CREATE EXTENSION vector`. Credentials/db match the
|
||||
# defaults in apps/server/test/integration/db.ts + global-setup.ts
|
||||
# (docmost / docmost_dev_pw, maintenance db `docmost`, redis on 6379), so no
|
||||
# TEST_*_URL overrides are needed.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost_dev_pw
|
||||
POSTGRES_DB: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -36,5 +68,12 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run tests
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
# Integration suite against the real Postgres/Redis services above. Runs
|
||||
# the FK-cascade, cost-cap, jsonb-round-trip and real-apply specs that the
|
||||
# unit run (mocks only) cannot cover. global-setup drops/recreates the
|
||||
# isolated `docmost_test` DB and migrates it to latest.
|
||||
- name: Run server integration tests
|
||||
run: pnpm --filter server test:int
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,6 +42,7 @@ lerna-debug.log*
|
||||
.nx/installation
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
.claude/tmp/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
56
AGENTS.md
56
AGENTS.md
@@ -254,11 +254,12 @@ 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. 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.
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (39 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.
|
||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
@@ -283,37 +284,46 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
### Cutting a release
|
||||
|
||||
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
|
||||
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
|
||||
|
||||
1. Make sure `main` is clean and pushed (`git status`, `git push`).
|
||||
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
|
||||
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
|
||||
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).
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
|
||||
4. For a real release (skip for a bare hotfix tag), 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 the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
|
||||
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
|
||||
```bash
|
||||
git push gitea develop && git push gitea vX.Y.Z
|
||||
git push github develop && git push github vX.Y.Z
|
||||
```
|
||||
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
|
||||
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only develop # or a merge commit if fast-forward is not possible
|
||||
git push gitea main && git push github main
|
||||
```
|
||||
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
|
||||
|
||||
#### Why develop keeps showing the *previous* version (and why step 7 matters)
|
||||
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
|
||||
|
||||
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>`.
|
||||
`git describe --tags --always` (see `vite.config.ts`) 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`.
|
||||
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
|
||||
|
||||
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.
|
||||
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
|
||||
|
||||
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
|
||||
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
|
||||
|
||||
`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`.
|
||||
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
|
||||
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just 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 in scope.
|
||||
|
||||
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.)
|
||||
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
|
||||
|
||||
## Planning docs
|
||||
|
||||
|
||||
254
CHANGELOG.md
254
CHANGELOG.md
@@ -12,10 +12,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Quick-create regular and temporary notes from the Home and Space screens.**
|
||||
The Home screen now shows a second action next to "New note" that creates a
|
||||
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
|
||||
resolving the target space the same way the regular button does — created
|
||||
directly when you can write to a single space, or via a space picker when
|
||||
several. Each space overview screen gains two buttons — "New note" and "New
|
||||
temporary note" — that create the page directly in that space and open it,
|
||||
mirroring the existing space-sidebar actions and shown only to members who can
|
||||
manage pages.
|
||||
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
|
||||
message gains a "send now" action that interrupts the streaming turn and
|
||||
immediately sends that message, keeping the agent's partial output. The
|
||||
follow-up turn is tagged as an interrupt so the model is told its previous
|
||||
answer was cut off and builds on it instead of restarting; the rest of the
|
||||
queue still flushes normally afterward. (#198)
|
||||
|
||||
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
|
||||
catalog of agent roles, grouped into bundles and offered in several languages,
|
||||
and import the ones they want into the workspace (with skip-or-rename handling
|
||||
for name collisions); the same role in a different language imports as a
|
||||
separate install. An imported role remembers its catalog origin and offers a
|
||||
one-click update when the catalog ships a newer revision. Backed by four new
|
||||
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
|
||||
`/catalog/bundle` (read one bundle's roles), `/import`, and
|
||||
`/update-from-catalog` — and a new `source` column linking a role to its
|
||||
catalog slug/language/version. The catalog source is configured via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
|
||||
catalog's raw files; the image ships a per-branch default baked in CI, and it
|
||||
can be overridden at runtime via the env var (see `.env.example`). (#222)
|
||||
- **Author footnotes inline from an agent, and deterministic server-side footnote
|
||||
canonicalization on every non-editor write path.** A new MCP `insert_footnote`
|
||||
tool places a footnote at a body anchor by content only — the agent supplies
|
||||
WHERE (anchor text) and WHAT (markdown); the number and the bottom
|
||||
`footnotesList` are derived server-side, so an agent can never assign a number,
|
||||
edit the list, or desync, and a same-content note reuses one definition. Under
|
||||
the hood, the editor's footnote-integrity invariant (one trailing list,
|
||||
numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now
|
||||
enforced as a pure `canonicalizeFootnotes(doc)` on the FULL-document write paths
|
||||
that bypass the editor's plugins: server markdown/HTML import, `PageService`
|
||||
create and full-document (`replace`) updates, the client markdown paste, and the
|
||||
MCP markdown page-import / `update_page` (markdown) / `update_page_json` /
|
||||
`docmost_transform` / `insert_footnote` / `copy_page_content` paths. It is
|
||||
idempotent (a no-op once canonical) and is deliberately NOT applied to
|
||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||
contain a standalone footnote definition, which canonicalization would drop.
|
||||
(#228)
|
||||
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
|
||||
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
|
||||
AI-chat turn becomes a first-class, server-side RUN tracked in a new
|
||||
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
|
||||
losing the connection no longer aborts the turn — it keeps executing and
|
||||
persisting server-side, and only an explicit Stop ends it. A client can
|
||||
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
|
||||
(resolve the latest run + its assistant message for a chat) and
|
||||
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
|
||||
enforces one active run per chat, and a startup sweep settles any run left
|
||||
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||
deployment. (#184)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
||||
a page "Shared to web" now defaults to the page alone; descendant pages become
|
||||
public only when you explicitly turn on the dedicated "Include sub-pages"
|
||||
toggle. Previously the create call defaulted to including sub-pages, silently
|
||||
exposing every child of a freshly shared page. (#216)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||
link whose target page name had no file extension (e.g. a bare title) was
|
||||
collapsed to empty text during export, producing an unclickable, label-less
|
||||
link; the page name is now preserved. (#204)
|
||||
- **Deep pages no longer render a blank breadcrumb while the sidebar tree loads.**
|
||||
The breadcrumb now falls back to the page's own ancestor chain (fetched
|
||||
independently of the lazily-built sidebar tree) so a deep page resolves its
|
||||
trail immediately; navigating away no longer leaves the previously-viewed
|
||||
page's breadcrumb showing until the new one resolves. (#206, #218)
|
||||
- **Pasted GitHub-style callouts (`> [!NOTE]` …) now convert to real callouts.**
|
||||
GitHub admonition blocks pasted as Markdown are recognized and rendered as
|
||||
callout blocks instead of plain block-quotes. (#192)
|
||||
- **The editor stays read-only until collaboration has synced.** While a page is
|
||||
connecting, the body is shown as a non-editable static view with a
|
||||
"Connecting… (read-only)" banner, so edits typed before the document finishes
|
||||
syncing can no longer be silently dropped. (#218)
|
||||
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
|
||||
page's vanity slug previously inserted a second `share_aliases` row instead of
|
||||
renaming the existing one, leaving the old `/l/<old>` link live forever and
|
||||
making the share modal's lookup nondeterministic. Slug edits and confirmed
|
||||
reassigns now rename/retarget the single row, and a new partial unique index on
|
||||
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
|
||||
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
|
||||
orphaned duplicate alias rows the old bug created (keeping the newest per
|
||||
page), so any previously-live duplicate `/l/<old>` link begins returning the
|
||||
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
|
||||
#227)
|
||||
- **Typing a custom address already used by another page no longer looks like a
|
||||
dead end.** The share modal previously flagged such a name with a red "This
|
||||
address is already in use" error, hiding the fact that saving offers to MOVE
|
||||
the address to the current page. The field now shows an informational hint —
|
||||
"This address is in use. Saving will move it to this page." — and keeps Save
|
||||
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` →
|
||||
"Move custom address?") is discoverable instead of reading as terminal. (#227)
|
||||
|
||||
### Security
|
||||
|
||||
- **The anonymous public-share page payload is trimmed to an explicit allowlist.**
|
||||
The `/shares/page-info` route (the only unauthenticated path serializing a
|
||||
page + its share) now returns only the fields the public renderer needs;
|
||||
internal metadata — creator/last-updater/contributor ids, space/workspace ids,
|
||||
AI/source bookkeeping, lock/template flags, parent/position and raw timestamps
|
||||
— is no longer exposed to anonymous viewers. (#218)
|
||||
- **A forged or mismatched share id can no longer render a page off its slug
|
||||
alone.** When the public URL carries a share id/key, the page must be reachable
|
||||
through that exact share (its own share or an ancestor `includeSubPages`
|
||||
share); any other value now returns the generic "not found" instead of
|
||||
serving the page. (#218)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
the database step by step and exported server-side, the desktop app no longer
|
||||
freezes at 100% CPU on long agent runs, and MCP writes are badged with
|
||||
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
|
||||
per-reference back-links), hardens page moves and duplication against cycles
|
||||
and lost edits, and caps the anonymous public-share assistant with a
|
||||
per-workspace rolling-day token budget.
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||
permanent first. The deadline is frozen at creation time, so later changes to
|
||||
the workspace setting never reschedule existing notes; an hourly background
|
||||
sweep trashes notes past their deadline (children ride along). An open
|
||||
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||
Operators configure the lifetime per workspace. (#201)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
finalized once to `completed`/`error`/`aborted`. A process that dies mid-turn
|
||||
keeps every finished step, and a startup sweep flips any dangling `streaming`
|
||||
row (untouched for 10 minutes) to `aborted`. Chat "Copy" now exports
|
||||
server-side from these rows (`POST /ai-chat/export`) rather than from live
|
||||
client state, so the export is identical whether a chat is freshly streaming,
|
||||
just switched to, or reloaded — and is available from the first turn of a new
|
||||
chat. (#183, #174)
|
||||
|
||||
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
|
||||
the MCP endpoint by a dedicated agent account are now badged as "AI", with
|
||||
unspoofable provenance derived from a per-user `is_agent` flag (not from the
|
||||
request body). **Operator setup:** use a *dedicated* service account for the
|
||||
request body). **Operator setup:** use a _dedicated_ service account for the
|
||||
MCP fallback and set the flag with SQL —
|
||||
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
|
||||
human or shared account, or its normal edits get mis-attributed as AI. See the
|
||||
@@ -25,9 +187,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
flagging dangling references, empty or duplicate definitions, and `[^id]`
|
||||
markers inside table rows, so an agent can fix its own markup. The page is
|
||||
still created; the field is omitted when there are no problems. (#166)
|
||||
- **AI chat "Protocol" setting (`chatApiStyle`).** A new admin choice in AI
|
||||
settings for the `openai` driver: `openai-compatible` (default) routes chat
|
||||
through `@ai-sdk/openai-compatible`, which surfaces a provider's streamed
|
||||
reasoning (`reasoning_content` → reasoning parts) for z.ai/GLM, DeepSeek,
|
||||
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
|
||||
reasoning-model request shaping). Chosen explicitly rather than inferred from
|
||||
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
|
||||
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
|
||||
now has an admin-authored `instructions` field ("how/when to use this server's
|
||||
tools") that is injected into the agent's system prompt next to that server's
|
||||
tool descriptions. Trusted text, rendered inside the prompt safety sandwich;
|
||||
shown only for a server that actually connected and contributed ≥1 callable
|
||||
tool. (#180)
|
||||
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
||||
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
||||
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
||||
- **Generate a page title from its content.** A "sparkles" button in the page
|
||||
byline reads the live editor content (including unsaved edits), generates a
|
||||
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
|
||||
applies it through the existing `/pages/update` route — reflecting it in the
|
||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||
flag and throttled per user. (#199)
|
||||
- **AI chat: header button auto-opens the chat bound to the current document.**
|
||||
Clicking the AI-chat button in the header while viewing a page now reopens the
|
||||
latest chat tied to that document instead of whatever chat was last active,
|
||||
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
|
||||
chat you created on the page wins; with no bound chat — or off a page, or if
|
||||
the lookup fails — it falls soft to a fresh chat and keeps the current
|
||||
selection otherwise. (#191)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI chat now feeds the model the full stored transcript.** The per-turn model
|
||||
conversation was rebuilt from a sliding window of the 50 most recent stored
|
||||
rows, which silently dropped the beginning of any longer chat. It is now
|
||||
rebuilt from the complete non-deleted transcript in chronological order, so
|
||||
the model sees every turn (a 5000-row backstop guards process memory — a
|
||||
safety net far above any realistic chat, not a conversational limit). On a
|
||||
very long chat this can eventually reach the model's context window; the
|
||||
client already surfaces that as "start a new chat". (#202)
|
||||
|
||||
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
||||
For the `openai` driver the chat provider defaults to the openai-compatible
|
||||
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
||||
model's reasoning out of the box. An endpoint that is real OpenAI behind a
|
||||
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
|
||||
|
||||
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
|
||||
same id are ONE footnote — one number, one definition, several back-references
|
||||
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
|
||||
@@ -45,6 +251,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
|
||||
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
|
||||
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
|
||||
per-turn work grew quadratically and saturated the main thread. The stream is now
|
||||
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
|
||||
markdown part / reasoning block is memoized, so a long turn no longer re-parses
|
||||
already-finished content. (#182)
|
||||
- **Editor: caret/selection landed on the wrong line when clicking inside code
|
||||
blocks and footnotes.** The affected NodeViews rendered their non-editable
|
||||
chrome (language menu, footnotes heading, footnote number marker) before the
|
||||
@@ -54,6 +267,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
are nudged after a paste to refresh stale hit-testing geometry. The caret
|
||||
symptom is macOS-specific and was confirmed manually on macOS; the automated
|
||||
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
|
||||
- **AI chat: the live token counter now ticks between agent steps.** During a
|
||||
multi-step turn the header token badge (and the "Thinking… · N tokens" line)
|
||||
no longer froze on the previous step's authoritative usage; the current step's
|
||||
estimate is combined per-component with `max`, so the count rises smoothly and
|
||||
never jumps backwards. (#163)
|
||||
- **AI chat: "New chat" during a streaming first turn now resets the whole
|
||||
chat, not just the role badge.** Starting a new chat mid-stream cleared the
|
||||
header but left the in-flight turn's messages behind, so the fresh chat opened
|
||||
pre-populated with the previous conversation; it now fully resets. (#161)
|
||||
- **AI chat: a dropped tool argument now yields an actionable error.** When the
|
||||
model omitted a required parameter (typically `pageId`) in a parallel/batch
|
||||
tool call, the assistant forwarded zod's raw "expected string, received
|
||||
undefined" text; tool inputs now return a message naming each missing/invalid
|
||||
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
|
||||
(#190)
|
||||
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
|
||||
under one of its own descendants is rejected in the same transaction as the
|
||||
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
|
||||
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
|
||||
so a pre-existing cycle can no longer spin a query. (#207)
|
||||
- **Page/editor robustness batch.** Duplicating a page now copies shared
|
||||
attachments for every referencing page (not just the first); colliding block
|
||||
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
|
||||
wrong node; transient collab store failures are retried so autosave edits
|
||||
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
|
||||
(#206)
|
||||
|
||||
### Security
|
||||
|
||||
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
|
||||
share assistant now caps a workspace's actual token spend (input + output,
|
||||
summed across every accepted turn) over a trailing day, on top of the hourly
|
||||
request cap — so a caller who evades the per-IP throttle still cannot run up
|
||||
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
|
||||
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
|
||||
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
|
||||
|
||||
## [0.93.0] - 2026-06-21
|
||||
|
||||
@@ -137,8 +386,7 @@ embeds — plus a large batch of security hardening and test coverage.
|
||||
- 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.
|
||||
- Import: surface the real error cause from `/pages/import` instead of a generic 400.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Agent-roles catalog base URL: per-branch default set at build time (CI);
|
||||
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
|
||||
ARG AI_AGENT_ROLES_CATALOG_URL=""
|
||||
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
|
||||
|
||||
# Copy apps
|
||||
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
||||
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
|
||||
|
||||
@@ -34,7 +34,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
|
||||
| --- | --- |
|
||||
| **EE code removed** | Stripped all client and server Enterprise-Edition code; ships as a clean community/AGPL build with no license checks. |
|
||||
| **Comment resolution** | Re-implemented from scratch as a community feature (resolve / re-open with Open/Resolved tabs). No EE code reused, available to anyone who can comment. |
|
||||
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 38 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
|
||||
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 39 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
|
||||
| **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. |
|
||||
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
|
||||
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
|
||||
@@ -44,7 +44,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
|
||||
### Embedded MCP server
|
||||
|
||||
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **38
|
||||
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39
|
||||
agent-native tools**: surgical per-block edits (patch / insert / delete by id),
|
||||
structure-preserving find/replace, scripted `(doc) => doc` transforms with a dry-run diff,
|
||||
structured table editing, version history with diff / restore, comments, images and share
|
||||
@@ -60,7 +60,7 @@ every little fix. And it needs no enterprise license.
|
||||
| | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
|
||||
| --- | :---: | :---: |
|
||||
| **Enterprise license** | Not required | Required |
|
||||
| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
|
||||
| **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
|
||||
| **Per-block edits / find-replace / scripted transforms** | ✅ | — |
|
||||
| **Structured table editing, version diff / restore** | ✅ | — |
|
||||
| **Comments, images, share links** | ✅ | — |
|
||||
@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **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.
|
||||
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||
|
||||
### In progress
|
||||
|
||||
@@ -114,7 +115,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- 🔭 **Viewer comments** — let read-only viewers leave comments.
|
||||
- 🔭 **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).
|
||||
- 🔭 **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 [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||
- 🔭 **Offline mode** — offline sync & PWA support.
|
||||
- 🔭 **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.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| --- | --- |
|
||||
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
|
||||
| **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. |
|
||||
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 38 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
|
||||
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 39 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
|
||||
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
|
||||
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
||||
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
|
||||
**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||
**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
|
||||
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
|
||||
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
|
||||
@@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
|
||||
| --- | :---: | :---: |
|
||||
| **Enterprise-лицензия** | Не нужна | Нужна |
|
||||
| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||
| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
|
||||
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
|
||||
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
|
||||
@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||
|
||||
### В процессе
|
||||
|
||||
@@ -115,7 +116,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
||||
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
||||
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
||||
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
||||
|
||||
|
||||
193
agent-roles-catalog/README.md
Normal file
193
agent-roles-catalog/README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Agent roles catalog
|
||||
|
||||
This directory is **data, not application code**. It holds the content of an
|
||||
"agent roles catalog": reusable agent role definitions (system prompts plus a
|
||||
little metadata), grouped into bundles and translated into one or more
|
||||
languages. A separate server reads these files and serves them; nothing here is
|
||||
executable application logic except the validation script.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
agent-roles-catalog/
|
||||
index.json # the catalog manifest: bundles, languages, role versions
|
||||
bundles/
|
||||
<bundle-id>/
|
||||
<lang>.json # one file per declared language (e.g. ru.json, en.json)
|
||||
scripts/
|
||||
check.mjs # validates the catalog (no dependencies)
|
||||
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
|
||||
package.json # defines the `check` script
|
||||
README.md
|
||||
```
|
||||
|
||||
Currently shipped bundles:
|
||||
|
||||
- `editorial` — the editorial suite (structural-editor, line-editor,
|
||||
fact-checker, proofreader, narrator), languages `ru`, `en`.
|
||||
- `research` — a single `researcher` role, languages `ru`, `en`.
|
||||
|
||||
## How it's served
|
||||
|
||||
The server does not bundle this data; it reads it at request time from a single
|
||||
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
|
||||
(`EnvironmentService.getAiAgentRolesCatalogSource()`), an `http(s)://` base URL
|
||||
to the catalog's raw files. The server fetches `<base>/index.json` for the
|
||||
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
|
||||
file (REMOTE only).
|
||||
|
||||
That base URL is provided as a per-branch default in the Docker image (set in
|
||||
CI: a `develop` build points at the `develop` raw URL, a release build at the
|
||||
`main` raw URL) and can be overridden at runtime via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
|
||||
supported; if the value is unset the catalog is unavailable.
|
||||
|
||||
The fetched JSON is re-validated server-side (the catalog is treated as
|
||||
untrusted input). See `.env.example` for the variable and the CHANGELOG for the
|
||||
rollout.
|
||||
|
||||
## `index.json` schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundles": [
|
||||
{
|
||||
"id": "editorial", // unique bundle id; matches bundles/<id>/
|
||||
"name": { "ru": "...", "en": "..." }, // localized display name
|
||||
"description": { "ru": "...", "en": "..." },
|
||||
"languages": ["ru", "en"], // which <lang>.json files must exist
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 1 }
|
||||
// ...
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`version` lives **here, in index.json**, per role. Bump it whenever a role's
|
||||
content (instructions, name, description, etc.) changes, so consumers can detect
|
||||
updates.
|
||||
|
||||
## Bundle (`<lang>.json`) schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"language": "ru",
|
||||
"roles": [
|
||||
{
|
||||
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
|
||||
"emoji": "🧱",
|
||||
"name": "...", // REQUIRED, localized
|
||||
"description": "...", // localized
|
||||
"instructions": "...", // REQUIRED, the system prompt, localized
|
||||
"autoStart": true, // whether the role starts working immediately
|
||||
"launchMessage": "..." // first message sent on launch (or null)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `modelConfig` is intentionally absent; the server treats an absent
|
||||
`modelConfig` as `null`.
|
||||
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
|
||||
files of the same bundle. Only `name`, `description`, `instructions`, and
|
||||
`launchMessage` are translated.
|
||||
|
||||
## Slug uniqueness
|
||||
|
||||
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
|
||||
bundle. A slug appears once per language file of its bundle (same slug in
|
||||
`ru.json` and `en.json`), but no two different bundles may share a slug.
|
||||
`scripts/check.mjs` enforces this.
|
||||
|
||||
## How to add things
|
||||
|
||||
### Add a role to an existing bundle
|
||||
|
||||
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
|
||||
`slug` and `version: 1`.
|
||||
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
|
||||
bundle, translating `name`, `description`, `instructions`, and
|
||||
`launchMessage`.
|
||||
3. Run the check (see below).
|
||||
|
||||
### Add a bundle
|
||||
|
||||
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
|
||||
`languages`, `roles`).
|
||||
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
|
||||
object per `roles[]` entry.
|
||||
3. Run the check.
|
||||
|
||||
### Add a language to a bundle
|
||||
|
||||
1. Add the language code to that bundle's `languages[]` in `index.json`.
|
||||
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
|
||||
translated.
|
||||
3. Run the check.
|
||||
|
||||
### Change a role's content
|
||||
|
||||
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
|
||||
`version`** in `index.json`. Then run `node scripts/check.mjs --update-hashes`
|
||||
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
|
||||
now **fails if a role's content changed but its `version` was not bumped**, so
|
||||
this step is mandatory — the lock can only be refreshed after the bump.
|
||||
|
||||
## Validating
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
node scripts/check.mjs # or: npm run check
|
||||
```
|
||||
|
||||
It fails (exit code 1) if any slug is duplicated across the catalog, if a
|
||||
bundle's index `roles[]` don't match the slugs present in each language file, if
|
||||
a declared language file is missing, or if any role is missing a required field
|
||||
(`slug`, `name`, `instructions`). It prints `OK` on success.
|
||||
|
||||
### Content-hash guard
|
||||
|
||||
`check.mjs` also guards against changing a role's content without bumping its
|
||||
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
|
||||
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
|
||||
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
|
||||
`launchMessage`) across all of its language files, in a deterministic canonical
|
||||
form. This lockfile is a **check artifact only** — the server fetches only
|
||||
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
|
||||
effect on the served catalog or its schema.
|
||||
|
||||
On a normal run, for every role the check recomputes the hash and compares it
|
||||
against the lock:
|
||||
|
||||
- content unchanged and versions agree → OK;
|
||||
- content changed but `version` not bumped above the lock → **error** asking you
|
||||
to bump and refresh;
|
||||
- content changed and `version` bumped → **error** asking you to record it by
|
||||
refreshing the lock;
|
||||
- role missing from the lock, or a lock entry for a role that no longer exists →
|
||||
**error** asking you to refresh.
|
||||
|
||||
Refresh the lock with:
|
||||
|
||||
```sh
|
||||
node scripts/check.mjs --update-hashes # alias: --fix
|
||||
```
|
||||
|
||||
This recomputes the lock from the current catalog, prunes entries for removed
|
||||
roles, and prints what changed — but it **refuses to write** (exit 1) if any
|
||||
role's content changed while its `index.json` version was not bumped, so the
|
||||
version bump is always enforced first. The check also requires every
|
||||
`index.json` role to carry a finite numeric `version` (the server requires the
|
||||
same).
|
||||
|
||||
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
|
||||
role and run `--update-hashes`, then re-add it with changed content at the same
|
||||
version) is **not** caught, because a brand-new slug has no lock baseline to
|
||||
enforce a bump against.
|
||||
51
agent-roles-catalog/bundles/editorial/en.json
Normal file
51
agent-roles-catalog/bundles/editorial/en.json
Normal file
File diff suppressed because one or more lines are too long
51
agent-roles-catalog/bundles/editorial/ru.json
Normal file
51
agent-roles-catalog/bundles/editorial/ru.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/en.json
Normal file
15
agent-roles-catalog/bundles/research/en.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/ru.json
Normal file
15
agent-roles-catalog/bundles/research/ru.json
Normal file
File diff suppressed because one or more lines are too long
31
agent-roles-catalog/index.json
Normal file
31
agent-roles-catalog/index.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundles": [
|
||||
{
|
||||
"id": "editorial",
|
||||
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
|
||||
"description": {
|
||||
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
|
||||
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 2 },
|
||||
{ "slug": "line-editor", "version": 2 },
|
||||
{ "slug": "fact-checker", "version": 3 },
|
||||
{ "slug": "proofreader", "version": 3 },
|
||||
{ "slug": "narrator", "version": 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": { "ru": "Исследование", "en": "Research" },
|
||||
"description": {
|
||||
"ru": "Глубокое исследование темы с подготовкой отчёта.",
|
||||
"en": "Deep research on a topic with a prepared report."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [ { "slug": "researcher", "version": 1 } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
8
agent-roles-catalog/package.json
Normal file
8
agent-roles-catalog/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "agent-roles-catalog",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "node scripts/check.mjs"
|
||||
}
|
||||
}
|
||||
353
agent-roles-catalog/scripts/check.mjs
Normal file
353
agent-roles-catalog/scripts/check.mjs
Normal file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env node
|
||||
// Validates the agent roles catalog.
|
||||
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
|
||||
// between a bundle's index roles[] and the slugs present in each language
|
||||
// file, a missing declared language file, or a role missing required fields.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const catalogDir = join(__dirname, "..");
|
||||
|
||||
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
|
||||
// the current catalog instead of just validating against it.
|
||||
const updateHashes =
|
||||
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
|
||||
|
||||
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
|
||||
// the server never fetches it, so it has zero impact on the served schema.
|
||||
const lockPath = join(__dirname, "content-hashes.json");
|
||||
|
||||
const errors = [];
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
errors.push(`Cannot read/parse ${path}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const indexPath = join(catalogDir, "index.json");
|
||||
if (!existsSync(indexPath)) {
|
||||
console.error(`Missing index.json at ${indexPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const index = readJson(indexPath);
|
||||
if (!index) {
|
||||
for (const e of errors) console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
|
||||
if (bundles.length === 0) {
|
||||
errors.push("index.json has no bundles[]");
|
||||
}
|
||||
|
||||
// Track every slug seen across the whole catalog to detect duplicates.
|
||||
const slugSeen = new Map(); // slug -> "bundleId/lang"
|
||||
|
||||
for (const bundle of bundles) {
|
||||
const bundleId = bundle.id;
|
||||
if (!bundleId) {
|
||||
errors.push("A bundle in index.json is missing an id");
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
|
||||
// Duplicate slugs inside the bundle index roles[].
|
||||
const indexSlugSet = new Set(indexSlugs);
|
||||
if (indexSlugSet.size !== indexSlugs.length) {
|
||||
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
|
||||
}
|
||||
|
||||
// Each index role must carry a finite numeric "version". The server requires
|
||||
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
|
||||
// below relies on it for the bump comparison, so enforce it here too.
|
||||
for (const r of bundle.roles || []) {
|
||||
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
if (languages.length === 0) {
|
||||
errors.push(`Bundle "${bundleId}" declares no languages`);
|
||||
}
|
||||
|
||||
for (const lang of languages) {
|
||||
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
||||
if (!existsSync(langPath)) {
|
||||
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langFile = readJson(langPath);
|
||||
if (!langFile) continue;
|
||||
|
||||
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
||||
const fileSlugs = roles.map((r) => r && r.slug);
|
||||
|
||||
// (d) Required fields per role.
|
||||
for (const role of roles) {
|
||||
for (const field of ["slug", "name", "instructions"]) {
|
||||
if (role == null || role[field] == null || role[field] === "") {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) index roles[] must match the slugs present in each language file.
|
||||
const fileSlugSet = new Set(fileSlugs);
|
||||
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
|
||||
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
|
||||
if (missingInFile.length > 0) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
|
||||
);
|
||||
}
|
||||
if (extraInFile.length > 0) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// (a) Duplicate slugs across the whole catalog.
|
||||
for (const slug of fileSlugs) {
|
||||
if (!slug) continue;
|
||||
const where = `${bundleId}/${lang}`;
|
||||
// Only flag duplicates across DIFFERENT bundles or files; the same slug
|
||||
// is expected to appear once per language file of the same bundle.
|
||||
if (slugSeen.has(slug)) {
|
||||
const prev = slugSeen.get(slug);
|
||||
const prevBundle = prev.split("/")[0];
|
||||
if (prevBundle !== bundleId) {
|
||||
errors.push(
|
||||
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
slugSeen.set(slug, where);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-hash guard: detect "content changed without a version bump".
|
||||
//
|
||||
// check.mjs cannot use git history, so we maintain a lockfile
|
||||
// (scripts/content-hashes.json) mapping each role slug to its recorded
|
||||
// { version, hash }. On every run we recompute each role's content hash and
|
||||
// compare it against the lock; a content change is only allowed once the role's
|
||||
// version in index.json has been bumped and the lock refreshed.
|
||||
//
|
||||
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
|
||||
// the role and run --update-hashes, then re-add it with changed content at the
|
||||
// same version) is NOT caught, because a brand-new slug has no lock baseline to
|
||||
// enforce a bump against. We document this rather than building tombstones.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Content fields hashed for each role, in a fixed canonical order. `slug` is
|
||||
// identity (not content) and `version` lives in index.json, so neither is here.
|
||||
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
|
||||
// EXCLUDED: no shipped role uses it today, and being an object it would need a
|
||||
// deterministic deep canonicalization (recursive key sort) before hashing —
|
||||
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
|
||||
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
|
||||
// change to it is still caught by the bump guard.
|
||||
const CONTENT_FIELDS = [
|
||||
"emoji",
|
||||
"autoStart",
|
||||
"name",
|
||||
"description",
|
||||
"instructions",
|
||||
"launchMessage",
|
||||
];
|
||||
|
||||
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
|
||||
// current catalog so we can compute hashes and read index versions.
|
||||
function collectCatalogRoles() {
|
||||
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
|
||||
for (const bundle of bundles) {
|
||||
const bundleId = bundle.id;
|
||||
if (!bundleId) continue;
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
for (const r of bundle.roles || []) {
|
||||
if (!r || !r.slug) continue;
|
||||
if (!out.has(r.slug)) {
|
||||
out.set(r.slug, { version: r.version, langRoles: new Map() });
|
||||
} else {
|
||||
// Same slug declared twice in index.json roles[]; already flagged above.
|
||||
out.get(r.slug).version = r.version;
|
||||
}
|
||||
}
|
||||
for (const lang of languages) {
|
||||
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
||||
if (!existsSync(langPath)) continue;
|
||||
const langFile = readJson(langPath);
|
||||
if (!langFile) continue;
|
||||
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
||||
for (const role of roles) {
|
||||
if (!role || !role.slug) continue;
|
||||
const entry = out.get(role.slug);
|
||||
if (!entry) continue; // role not declared in index.json; flagged above.
|
||||
entry.langRoles.set(lang, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Deterministic content hash for a role: languages sorted ascending, each
|
||||
// language's content fields taken in CONTENT_FIELDS order (null when absent).
|
||||
function contentHash(langRoles) {
|
||||
const langs = [...langRoles.keys()].sort();
|
||||
const canonical = langs.map((lang) => {
|
||||
const role = langRoles.get(lang);
|
||||
const fields = {};
|
||||
for (const field of CONTENT_FIELDS) {
|
||||
fields[field] = role && role[field] != null ? role[field] : null;
|
||||
}
|
||||
return [lang, fields];
|
||||
});
|
||||
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
||||
}
|
||||
|
||||
// Compute current { version, hash } for every catalog role.
|
||||
const catalogRoles = collectCatalogRoles();
|
||||
const current = new Map(); // slug -> { version, hash }
|
||||
for (const [slug, entry] of catalogRoles) {
|
||||
current.set(slug, {
|
||||
version: entry.version,
|
||||
hash: contentHash(entry.langRoles),
|
||||
});
|
||||
}
|
||||
|
||||
// Load the existing lock (may be absent on first run).
|
||||
let lock = {};
|
||||
if (existsSync(lockPath)) {
|
||||
const parsed = readJson(lockPath);
|
||||
if (parsed && typeof parsed === "object") lock = parsed;
|
||||
}
|
||||
|
||||
if (updateHashes) {
|
||||
// Refresh the lock from the current catalog, but refuse to write if any role's
|
||||
// content changed without its version being bumped above the existing lock.
|
||||
const blockers = [];
|
||||
for (const [slug, cur] of current) {
|
||||
const prev = lock[slug];
|
||||
if (!prev) continue; // new role; nothing to enforce a bump against.
|
||||
if (cur.hash === prev.hash) continue; // content unchanged.
|
||||
// Defense-in-depth: a non-numeric version must never pass the bump check via
|
||||
// `undefined <= N` (which is false). The standard checks already flag a
|
||||
// missing numeric version, but guard here too before comparing.
|
||||
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
|
||||
blockers.push(
|
||||
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
|
||||
);
|
||||
} else if (cur.version <= prev.version) {
|
||||
blockers.push(
|
||||
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Still honor the standard checks before allowing a write.
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
console.error("Refusing to update content-hash lock:");
|
||||
for (const b of blockers) console.error(` - ${b}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Compute the change summary relative to the old lock, pruning removed slugs.
|
||||
const newLock = {};
|
||||
const added = [];
|
||||
const changed = [];
|
||||
const removed = [];
|
||||
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
newLock[slug] = { version: cur.version, hash: cur.hash };
|
||||
const prev = lock[slug];
|
||||
if (!prev) added.push(slug);
|
||||
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
|
||||
}
|
||||
for (const slug of Object.keys(lock)) {
|
||||
if (!current.has(slug)) removed.push(slug);
|
||||
}
|
||||
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
|
||||
console.log(`Wrote ${lockPath}`);
|
||||
if (added.length) console.log(` added: ${added.join(", ")}`);
|
||||
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
|
||||
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
|
||||
if (!added.length && !changed.length && !removed.length) {
|
||||
console.log(" (no changes; lock already up to date)");
|
||||
}
|
||||
console.log("OK");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Normal run: validate current content against the lock.
|
||||
for (const [slug, cur] of current) {
|
||||
const prev = lock[slug];
|
||||
if (!prev) {
|
||||
errors.push(
|
||||
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (cur.hash === prev.hash) {
|
||||
// Content unchanged; the lock version must still agree with index.json.
|
||||
if (cur.version !== prev.version) {
|
||||
errors.push(
|
||||
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Content changed.
|
||||
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
|
||||
// comparison, so a missing version can never silently pass the bump check
|
||||
// (and we avoid a misleading "version bumped to undefined" message).
|
||||
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
|
||||
errors.push(
|
||||
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
} else if (cur.version <= prev.version) {
|
||||
errors.push(
|
||||
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
} else {
|
||||
errors.push(
|
||||
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Lock entries for slugs that no longer exist in the catalog.
|
||||
for (const slug of Object.keys(lock)) {
|
||||
if (!current.has(slug)) {
|
||||
errors.push(
|
||||
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
26
agent-roles-catalog/scripts/content-hashes.json
Normal file
26
agent-roles-catalog/scripts/content-hashes.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 3,
|
||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.93.0",
|
||||
"version": "0.94.1",
|
||||
"scripts": {
|
||||
"dev": "node scripts/copy-vad-assets.mjs && vite",
|
||||
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
|
||||
"Toggle AI search": "KI-Suche umschalten",
|
||||
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
|
||||
"Toggle generative AI": "Generative KI umschalten",
|
||||
"Upgrade your plan": "Upgrade Ihres Plans",
|
||||
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
|
||||
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
|
||||
|
||||
@@ -258,6 +258,7 @@
|
||||
"Copy to space": "Copy to space",
|
||||
"Copy chat": "Copy chat",
|
||||
"Copied": "Copied",
|
||||
"Failed to export chat": "Failed to export chat",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
@@ -597,6 +598,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Make temporary": "Make temporary",
|
||||
"Make permanent": "Make permanent",
|
||||
"New temporary note": "New temporary note",
|
||||
"Temporary note": "Temporary note",
|
||||
"Temporary notes": "Temporary notes",
|
||||
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
|
||||
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
|
||||
"Note is now permanent": "Note is now permanent",
|
||||
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Permanently delete": "Permanently delete",
|
||||
@@ -675,9 +687,6 @@
|
||||
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
"Toggle AI search": "Toggle AI search",
|
||||
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
||||
"Toggle generative AI": "Toggle generative AI",
|
||||
"Upgrade your plan": "Upgrade your plan",
|
||||
"Available with a paid license": "Available with a paid license",
|
||||
"Upgrade your license tier.": "Upgrade your license tier.",
|
||||
@@ -710,9 +719,12 @@
|
||||
"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.",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
|
||||
"Test": "Test",
|
||||
"Available tools": "Available tools",
|
||||
"No tools available": "No tools available",
|
||||
"Failed": "Failed",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Created successfully",
|
||||
"Deleted successfully": "Deleted successfully",
|
||||
"Clear": "Clear",
|
||||
@@ -1077,6 +1089,8 @@
|
||||
"Undo": "Undo",
|
||||
"Redo": "Redo",
|
||||
"Backlinks": "Backlinks",
|
||||
"Back to references": "Back to references",
|
||||
"Back to reference {{label}}": "Back to reference {{label}}",
|
||||
"Last updated by": "Last updated by",
|
||||
"Last updated": "Last updated",
|
||||
"Stats": "Stats",
|
||||
@@ -1163,8 +1177,9 @@
|
||||
"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",
|
||||
"Tokens generated this turn": "Tokens generated this turn",
|
||||
"Context size / model limit": "Context size / model limit",
|
||||
"Context window (tokens)": "Context window (tokens)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||
"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…",
|
||||
@@ -1173,6 +1188,8 @@
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Send now": "Send now",
|
||||
"Interrupt and send now": "Interrupt and send now",
|
||||
"Stop": "Stop",
|
||||
"Response stopped.": "Response stopped.",
|
||||
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
|
||||
@@ -1307,5 +1324,46 @@
|
||||
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
|
||||
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
|
||||
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
|
||||
"Showing {{count}} subpages_other": "Showing {{count}} subpages"
|
||||
"Showing {{count}} subpages_other": "Showing {{count}} subpages",
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address",
|
||||
"Generate title with AI": "Generate title with AI",
|
||||
"Title generated": "Title generated",
|
||||
"Failed to generate title": "Failed to generate title",
|
||||
"The note is empty": "The note is empty",
|
||||
"Could not generate a title": "Could not generate a title",
|
||||
"AI title generation is disabled": "AI title generation is disabled",
|
||||
"AI is not configured": "AI is not configured",
|
||||
"Too many requests, please try again later": "Too many requests, please try again later",
|
||||
"Import from catalog": "Import from catalog",
|
||||
"Browse the catalog": "Browse the catalog",
|
||||
"Role catalog": "Role catalog",
|
||||
"On name conflict": "On name conflict",
|
||||
"Skip": "Skip",
|
||||
"Import": "Import",
|
||||
"Installed": "Installed",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
||||
"Connecting… (read-only)": "Connecting… (read-only)"
|
||||
}
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
|
||||
"Toggle AI search": "Alternar búsqueda de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
|
||||
"Toggle generative AI": "Activar IA generativa",
|
||||
"Upgrade your plan": "Mejora tu plan",
|
||||
"Available with a paid license": "Disponible con una licencia de pago",
|
||||
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
|
||||
"Toggle AI search": "Basculer la recherche IA",
|
||||
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
|
||||
"Toggle generative AI": "Activer/désactiver l'IA générative",
|
||||
"Upgrade your plan": "Mettez à niveau votre forfait",
|
||||
"Available with a paid license": "Disponible avec une licence payante",
|
||||
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
|
||||
"Toggle AI search": "Attiva/disattiva ricerca AI",
|
||||
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
|
||||
"Toggle generative AI": "Attiva/Disattiva AI generativa",
|
||||
"Upgrade your plan": "Aggiorna il tuo piano",
|
||||
"Available with a paid license": "Disponibile con una licenza a pagamento",
|
||||
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||
"Toggle AI search": "AI検索を切り替え",
|
||||
"Generative AI (Ask AI)": "生成AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
||||
"Toggle generative AI": "生成AIを切り替える",
|
||||
"Upgrade your plan": "プランをアップグレードする",
|
||||
"Available with a paid license": "有料ライセンスで利用可能",
|
||||
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||
"Toggle AI search": "AI 검색 전환",
|
||||
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
||||
"Toggle generative AI": "생성 AI 토글",
|
||||
"Upgrade your plan": "요금제를 업그레이드하세요",
|
||||
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
|
||||
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
|
||||
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
|
||||
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
|
||||
"Toggle generative AI": "Generatieve AI schakelen",
|
||||
"Upgrade your plan": "Upgrade je abonnement",
|
||||
"Available with a paid license": "Beschikbaar met een betaalde licentie",
|
||||
"Upgrade your license tier.": "Upgrade je licentieniveau.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
|
||||
"Toggle AI search": "Alternar pesquisa de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
|
||||
"Toggle generative AI": "Alternar IA generativa",
|
||||
"Upgrade your plan": "Faça upgrade do seu plano",
|
||||
"Available with a paid license": "Disponível com uma licença paga",
|
||||
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copied": "Скопировано",
|
||||
"Failed to export chat": "Не удалось экспортировать чат",
|
||||
"Duplicate": "Дублировать",
|
||||
"Select a user": "Выберите пользователя",
|
||||
"Select a group": "Выберите группу",
|
||||
@@ -405,6 +406,8 @@
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
@@ -604,6 +607,17 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||
"Move to trash": "Переместить в корзину",
|
||||
"Make temporary": "Сделать временной",
|
||||
"Make permanent": "Сделать постоянной",
|
||||
"New temporary note": "Новая временная заметка",
|
||||
"Temporary note": "Временная заметка",
|
||||
"Temporary notes": "Временные заметки",
|
||||
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
|
||||
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
|
||||
"Note is now permanent": "Заметка теперь постоянная",
|
||||
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
|
||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||
"Restore page": "Восстановить страницу",
|
||||
"Permanently delete": "Удалить навсегда",
|
||||
@@ -701,19 +715,27 @@
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Tokens generated this turn": "Токенов сгенерировано за ход",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Test": "Тест",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
@@ -727,9 +749,6 @@
|
||||
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
|
||||
"Toggle AI search": "Переключить поиск ИИ",
|
||||
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
|
||||
"Toggle generative AI": "Переключить генеративный ИИ",
|
||||
"Upgrade your plan": "Обновите свой тарифный план",
|
||||
"Available with a paid license": "Доступно с платной лицензией",
|
||||
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
|
||||
@@ -749,6 +768,8 @@
|
||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||
"Instructions": "Инструкции",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||
"Sources": "Источники",
|
||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||
"No answer available": "Ответ недоступен",
|
||||
@@ -1160,5 +1181,47 @@
|
||||
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
|
||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц"
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
|
||||
"Generate title with AI": "Сгенерировать название через AI",
|
||||
"Title generated": "Название сгенерировано",
|
||||
"Failed to generate title": "Не удалось сгенерировать название",
|
||||
"The note is empty": "Заметка пустая",
|
||||
"Could not generate a title": "Не удалось придумать название",
|
||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||
"AI is not configured": "AI не настроен",
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
|
||||
"Import from catalog": "Импорт из каталога",
|
||||
"Browse the catalog": "Открыть каталог",
|
||||
"Role catalog": "Каталог ролей",
|
||||
"On name conflict": "При конфликте имён",
|
||||
"Skip": "Пропустить",
|
||||
"Import": "Импортировать",
|
||||
"Installed": "Установлено",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
||||
"Connecting… (read-only)": "Подключение… (только чтение)"
|
||||
}
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
|
||||
"Toggle AI search": "Переключити пошук з ШІ",
|
||||
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
|
||||
"Toggle generative AI": "Переключити генеративний ШІ",
|
||||
"Upgrade your plan": "Оновіть свій тарифний план",
|
||||
"Available with a paid license": "Доступно за платною ліцензією",
|
||||
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||
"Toggle AI search": "切换AI搜索",
|
||||
"Generative AI (Ask AI)": "生成型AI (询问AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
||||
"Toggle generative AI": "切换生成型AI",
|
||||
"Upgrade your plan": "升级您的方案",
|
||||
"Available with a paid license": "需付费许可才可用",
|
||||
"Upgrade your license tier.": "升级您的许可等级。",
|
||||
|
||||
@@ -10,12 +10,12 @@ 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 { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
@@ -38,7 +38,9 @@ export function AppHeader() {
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
// Opening from the header auto-opens the document's bound chat (last chat
|
||||
// created on the current page); off a page it keeps the current selection.
|
||||
const openAiChat = useOpenAiChatForCurrentPage();
|
||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
@@ -105,7 +107,7 @@ export function AppHeader() {
|
||||
color="dark"
|
||||
size="sm"
|
||||
aria-label={t("AI chat")}
|
||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||
onClick={openAiChat}
|
||||
>
|
||||
<IconMessage size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type UIMessage } from "@ai-sdk/react";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -35,17 +34,21 @@ import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatRunQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { shouldObserveRun } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
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 { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
@@ -80,17 +83,31 @@ function computeInitialGeom() {
|
||||
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
|
||||
);
|
||||
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
|
||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN);
|
||||
const maxTop = Math.max(
|
||||
EDGE_MARGIN,
|
||||
window.innerHeight - height - EDGE_MARGIN,
|
||||
);
|
||||
const top = Math.min(60, maxTop);
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
// Clamp a geometry so the window stays within the current viewport.
|
||||
function clampGeom(g: { left: number; top: number; width: number; height: number }) {
|
||||
function clampGeom(g: {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const effWidth = Math.max(g.width, MIN_WIDTH);
|
||||
const effHeight = Math.max(g.height, MIN_HEIGHT);
|
||||
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN);
|
||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN);
|
||||
const maxLeft = Math.max(
|
||||
EDGE_MARGIN,
|
||||
window.innerWidth - effWidth - EDGE_MARGIN,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
EDGE_MARGIN,
|
||||
window.innerHeight - effHeight - EDGE_MARGIN,
|
||||
);
|
||||
return {
|
||||
...g,
|
||||
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
|
||||
@@ -107,7 +124,7 @@ function clampGeom(g: { left: number; top: number; width: number; height: number
|
||||
* ported from the GitmostAgent.jsx design.
|
||||
*/
|
||||
export default function AiChatWindow() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const queryClient = useQueryClient();
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
@@ -148,19 +165,60 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// Live snapshot of the active thread's useChat state, kept up to date by
|
||||
// ChatThread. Lets the export include the in-progress (not-yet-persisted)
|
||||
// streaming turn. A ref avoids re-rendering this window on every token.
|
||||
const liveThreadRef = useRef<{ messages: UIMessage[]; isStreaming: boolean }>({
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
});
|
||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
|
||||
// (it is only owner-gated and returns `{ run: null }` when the chat has no
|
||||
// run); but when the feature is off no runs are ever created, so polling it
|
||||
// would always come back empty — we gate it off here to avoid pointless polls.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const autonomousRunsEnabled =
|
||||
workspace?.settings?.ai?.autonomousRuns === true;
|
||||
|
||||
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
|
||||
// `null` means no turn is in flight -> the badge falls back to the persisted
|
||||
// context size below.
|
||||
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
|
||||
// Whether THIS tab is the one actively streaming the open chat's run locally
|
||||
// (it started the run here and holds the SSE). Reported up from ChatThread. We
|
||||
// are the STREAMER while true and a passive OBSERVER while false — the basis of
|
||||
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
|
||||
// mount effect on every chat switch.
|
||||
const [localStreaming, setLocalStreaming] = useState(false);
|
||||
const onStreamingChange = useCallback((streaming: boolean) => {
|
||||
setLocalStreaming(streaming);
|
||||
}, []);
|
||||
|
||||
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||
// already has the live SSE — polling/merging too would double-render). The
|
||||
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||
const { data: runData } = useAiChatRunQuery(
|
||||
activeChatId ?? undefined,
|
||||
autonomousRunsEnabled && !localStreaming,
|
||||
);
|
||||
const run = runData?.run ?? null;
|
||||
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||
// but only while we are an observer (never when we are the streamer — guards
|
||||
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||
// final persisted output is shown on reopen.
|
||||
const observedRow = shouldObserveRun(run, localStreaming)
|
||||
? (runData?.message ?? null)
|
||||
: null;
|
||||
|
||||
// When the observed run reaches a terminal status, do a final messages refetch
|
||||
// so the persisted final state (token/context badge, export source) is shown,
|
||||
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||
const finalizedRunIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!run || !activeChatId) return;
|
||||
if (run.status === "pending" || run.status === "running") {
|
||||
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||
finalizedRunIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (finalizedRunIdRef.current === run.id) return;
|
||||
finalizedRunIdRef.current = run.id;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||
});
|
||||
}, [run, activeChatId, queryClient]);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
@@ -185,17 +243,23 @@ export default function AiChatWindow() {
|
||||
// The invalidate closures are passed inline: `onTurnFinished` is read live by
|
||||
// useChat's onFinish (never in an effect dep array), so their identity does not
|
||||
// matter — no memoization ceremony needed.
|
||||
const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } =
|
||||
useChatSession({
|
||||
activeChatId,
|
||||
setActiveChatId,
|
||||
chats,
|
||||
messagesLoading,
|
||||
onInvalidateChatList: () =>
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
|
||||
onInvalidateChatMessages: (id) =>
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
|
||||
});
|
||||
const {
|
||||
threadKey,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
} = useChatSession({
|
||||
activeChatId,
|
||||
setActiveChatId,
|
||||
chats,
|
||||
messagesLoading,
|
||||
onInvalidateChatList: () =>
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
|
||||
onInvalidateChatMessages: (id) =>
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
|
||||
});
|
||||
|
||||
// startNewChat/selectChat set the public atom; the hook's render-phase
|
||||
// reconciler handles the remount when activeChatId actually CHANGES. But
|
||||
@@ -205,12 +269,25 @@ export default function AiChatWindow() {
|
||||
// just-failed chat after they chose a fresh one.
|
||||
const startNewChat = useCallback((): void => {
|
||||
cancelPendingAdoption();
|
||||
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
|
||||
// while a brand-new chat's first turn is still streaming leaves activeChatId
|
||||
// null (the real id is adopted only at turn end), so setActiveChatId(null)
|
||||
// alone is a no-op and the reconciler never remounts — the chat/stream/history
|
||||
// would persist and only the role badge would drop. This always remounts the
|
||||
// thread into a clean new chat.
|
||||
startFreshThread();
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
}, [
|
||||
cancelPendingAdoption,
|
||||
startFreshThread,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
@@ -225,19 +302,28 @@ export default function AiChatWindow() {
|
||||
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
|
||||
);
|
||||
|
||||
// The active chat object (for its title) and an export gate: only enable the
|
||||
// export button when an existing chat with loaded persisted rows is active.
|
||||
// The active chat object (for its title) and an export gate. The export is now
|
||||
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
|
||||
// row is persisted upfront + per step, so even a brand-new chat whose first
|
||||
// turn is streaming/interrupted has a server row to render. Enable the button
|
||||
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
|
||||
// chat that id is adopted EARLY — at the stream's `start` chunk via
|
||||
// onServerChatId (#174) — so the Copy button is available during the first
|
||||
// turn's stream, not only after it terminates.
|
||||
const activeChat = useMemo(
|
||||
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
||||
[chats, activeChatId],
|
||||
);
|
||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
||||
const canExport = !!activeChatId;
|
||||
|
||||
// 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>(() => {
|
||||
const currentRole = useMemo<{
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
} | null>(() => {
|
||||
if (activeChat?.roleName) {
|
||||
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
||||
}
|
||||
@@ -245,37 +331,21 @@ export default function AiChatWindow() {
|
||||
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.
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
||||
// While the active thread is streaming, the current user message and the
|
||||
// in-progress assistant reply are NOT yet in messageRows (the persisted
|
||||
// query is only refetched after the turn finishes). Pull the live tail —
|
||||
// messages whose id is not among the persisted rows — and append them,
|
||||
// flagging the streaming assistant message as still generating.
|
||||
const live = liveThreadRef.current;
|
||||
const rowIds = new Set(messageRows.map((r) => r.id));
|
||||
const pending = live.isStreaming
|
||||
? live.messages
|
||||
.filter((m) => !rowIds.has(m.id))
|
||||
.map((m) => ({
|
||||
role: m.role,
|
||||
parts: (m.parts ?? []) as { type: string; text?: string }[],
|
||||
generating: m.role === "assistant",
|
||||
}))
|
||||
: [];
|
||||
const markdown = buildChatMarkdown({
|
||||
title: activeChat?.title ?? null,
|
||||
chatId: activeChatId,
|
||||
rows: messageRows,
|
||||
pending,
|
||||
t,
|
||||
});
|
||||
clipboard.copy(markdown);
|
||||
notifications.show({ message: t("Copied") });
|
||||
}, [activeChatId, messageRows, activeChat, clipboard, t]);
|
||||
// Fetch the server-rendered Markdown export and copy it to the clipboard. The
|
||||
// server is the single source of truth (#183): it renders the transcript from
|
||||
// the persisted rows — including an interrupted turn's in-progress row — so the
|
||||
// export is identical whether the chat is freshly streaming, just switched to,
|
||||
// or reloaded. The `lang` of the active i18n drives the few localized labels.
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!activeChatId) return;
|
||||
try {
|
||||
const markdown = await exportAiChat(activeChatId, i18n.language);
|
||||
clipboard.copy(markdown);
|
||||
notifications.show({ message: t("Copied") });
|
||||
} catch {
|
||||
notifications.show({ message: t("Failed to export chat"), color: "red" });
|
||||
}
|
||||
}, [activeChatId, clipboard, t, i18n.language]);
|
||||
|
||||
// Current context size for the active chat: how much the conversation now
|
||||
// occupies in the model's context window — NOT the cumulative tokens spent.
|
||||
@@ -284,24 +354,19 @@ export default function AiChatWindow() {
|
||||
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
||||
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
||||
// mid-stream — acceptable for v1.
|
||||
const contextTokens = useMemo(() => {
|
||||
if (!activeChatId || !messageRows) return 0;
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
return meta.contextTokens;
|
||||
}
|
||||
const usage = meta.usage;
|
||||
if (usage) {
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) return fallback;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [activeChatId, messageRows]);
|
||||
//
|
||||
// The denominator `maxContextTokens` (the model's configured max window) is
|
||||
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
|
||||
// on a completed turn, but the numerator and denominator are taken from the
|
||||
// most recent row carrying EACH value independently — they may land on
|
||||
// different rows (e.g. a fresh error row can carry contextTokens but not
|
||||
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
|
||||
// no row has it (older rows, or no admin-configured limit) — the badge then
|
||||
// shows just the current size with no denominator.
|
||||
const { contextTokens, maxContextTokens } = useMemo(
|
||||
() => selectContextBadge(activeChatId ? messageRows : undefined),
|
||||
[activeChatId, messageRows],
|
||||
);
|
||||
|
||||
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||
// first-frame jump): compute an initial top-right placement the first time,
|
||||
@@ -351,7 +416,8 @@ export default function AiChatWindow() {
|
||||
const width = el.offsetWidth;
|
||||
const height = el.offsetHeight;
|
||||
setGeom((prev) => {
|
||||
if (!prev || (prev.width === width && prev.height === height)) return prev;
|
||||
if (!prev || (prev.width === width && prev.height === height))
|
||||
return prev;
|
||||
return { ...prev, width, height };
|
||||
});
|
||||
});
|
||||
@@ -491,17 +557,18 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
|
||||
once it finishes, fall back to the persisted context size. Require
|
||||
> 0 so the very first emit (an empty tail message, count 0) does not
|
||||
flash a "0" badge before any token streams in (#151 review). */}
|
||||
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
|
||||
<Tooltip label={t("Tokens generated this turn")} withArrow>
|
||||
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span>
|
||||
</Tooltip>
|
||||
) : contextTokens > 0 ? (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
|
||||
{/* Always show the persisted "current / max" context. The denominator
|
||||
(the admin-configured model limit) is appended only when known;
|
||||
not clamped when current > max (shown as-is, e.g. "210k / 200k").
|
||||
Hidden entirely until a turn has recorded a context figure. */}
|
||||
{contextTokens > 0 ? (
|
||||
<Tooltip label={t("Context size / model limit")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(contextTokens)}
|
||||
{maxContextTokens > 0
|
||||
? ` / ${formatTokens(maxContextTokens)}`
|
||||
: ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -515,7 +582,11 @@ export default function AiChatWindow() {
|
||||
aria-label={t("Copy chat")}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
{clipboard.copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -610,6 +681,7 @@ export default function AiChatWindow() {
|
||||
) : (
|
||||
<ChatThread
|
||||
key={threadKey}
|
||||
threadKey={threadKey}
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
@@ -621,8 +693,13 @@ export default function AiChatWindow() {
|
||||
onRolePicked={(role) => setSelectedRoleId(role.id)}
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
liveStateRef={liveThreadRef}
|
||||
onLiveTurnTokens={setLiveTurnTokens}
|
||||
onServerChatId={onServerChatId}
|
||||
// #184: live-follow a still-running run when we reopened the chat as
|
||||
// a passive observer; null when there is nothing to observe or this
|
||||
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||
// while we are the streamer.
|
||||
observedRow={observedRow}
|
||||
onStreamingChange={onStreamingChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,45 @@
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
|
||||
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
||||
wide LLM table must scroll horizontally instead of collapsing its columns:
|
||||
`.markdown` sets `word-break: break-word`, which (with the default table
|
||||
layout) shrinks columns to a single glyph and wraps headers mid-word
|
||||
("Секция" -> "Секци / я"). Make the table a horizontally scrollable block,
|
||||
give cells a readable minimum width, and restore word-boundary wrapping. */
|
||||
.markdown table {
|
||||
display: block;
|
||||
/* lets the table scroll horizontally on its own */
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin-block-end: 0.5em;
|
||||
}
|
||||
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding: 3px 8px;
|
||||
/* readable floor; the block scrolls when the row exceeds the panel */
|
||||
min-width: 6em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
/* cancel the inherited break-word so words don't split mid-glyph */
|
||||
word-break: normal;
|
||||
/* still wrap genuinely long words / URLs at the cell edge */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown th {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* GFM wraps cell text in <p>; drop its default block margin inside cells. */
|
||||
.markdown table p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Animated three-dot "typing" indicator shown while the agent is thinking but
|
||||
has not yet produced any visible text/tool parts. */
|
||||
.typingDots {
|
||||
@@ -122,7 +161,11 @@
|
||||
margin-top: 4px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
white-space: pre-wrap;
|
||||
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||
rendered markdown <div> it would turn the newlines between block tags
|
||||
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
||||
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
||||
inline itself (see reasoning-block.tsx). */
|
||||
}
|
||||
|
||||
.reasoningText p {
|
||||
|
||||
198
apps/client/src/features/ai-chat/components/chat-thread.test.tsx
Normal file
198
apps/client/src/features/ai-chat/components/chat-thread.test.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
// above the imports) can expose the captured useChat callbacks / transport and
|
||||
// the spies back to the test body.
|
||||
const h = vi.hoisted(() => ({
|
||||
state: {
|
||||
status: "streaming" as string,
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||
vi.mock("@ai-sdk/react", () => ({
|
||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||
h.state.onFinish = opts.onFinish ?? null;
|
||||
return {
|
||||
messages: [],
|
||||
sendMessage: h.state.sendMessage,
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||
setMessages: h.state.setMessages,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||
vi.mock("ai", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
generateId: () => `gid-${counter++}`,
|
||||
DefaultChatTransport: class {
|
||||
constructor(opts: {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
}) {
|
||||
h.state.transport = opts;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||
// interaction this test needs to populate the queue while "streaming".
|
||||
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
||||
default: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import ChatThread from "./chat-thread";
|
||||
|
||||
function renderThread() {
|
||||
const onTurnFinished = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
describe("ChatThread — send now (#198)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
|
||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||
renderThread();
|
||||
|
||||
// Queue a message while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
const sendNowBtn = screen.getByLabelText("Send now");
|
||||
expect(sendNowBtn).toBeTruthy();
|
||||
|
||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||
// the resend happens once the abort lands in onFinish.
|
||||
fireEvent.click(sendNowBtn);
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: true,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("tags exactly the next send as interrupted (one-shot flag)", () => {
|
||||
renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
// The send right after "send now" carries interrupted: true...
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||
// ...and only that one (the flag is read-and-cleared).
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
|
||||
it("sends immediately without an interrupt when not streaming", () => {
|
||||
h.state.status = "ready";
|
||||
renderThread();
|
||||
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||
expect(h.state.stop).not.toHaveBeenCalled();
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// #184 passive-observer merge: when reconnecting to a still-running run, the
|
||||
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
|
||||
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
|
||||
// SSE owns the view, so a stale observedRow must never overwrite it).
|
||||
describe("ChatThread — observer run merge (#184)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.onFinish = null;
|
||||
h.state.setMessages.mockReset();
|
||||
});
|
||||
|
||||
const observedRow = {
|
||||
id: "a-run",
|
||||
role: "assistant",
|
||||
content: "step 1\nstep 2",
|
||||
metadata: {
|
||||
parts: [{ type: "text", text: "step 1\nstep 2" }],
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} as const;
|
||||
|
||||
function renderObserver(status: string) {
|
||||
h.state.status = status;
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={[]}
|
||||
onTurnFinished={vi.fn()}
|
||||
observedRow={observedRow as never}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("merges the polled run message when this tab is a passive observer", () => {
|
||||
renderObserver("ready");
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
// The updater replaces/append the observed assistant row by id.
|
||||
const updater = h.state.setMessages.mock.calls[0][0] as (
|
||||
prev: { id: string; parts: { text: string }[] }[],
|
||||
) => { id: string; parts: { text: string }[] }[];
|
||||
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[1].id).toBe("a-run");
|
||||
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
|
||||
});
|
||||
|
||||
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
|
||||
renderObserver("streaming");
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconClockHour4, IconX } from "@tabler/icons-react";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconClockHour4,
|
||||
IconPlayerPlayFilled,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@@ -27,15 +24,24 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
// Throttle how often the streamed `messages` state triggers a re-render. Without
|
||||
// it, useChat updates state on EVERY token, so the whole transcript's markdown
|
||||
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
|
||||
// into a quadratic CPU storm that pins the main thread and freezes the UI.
|
||||
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
|
||||
// from the token rate.
|
||||
const STREAM_THROTTLE_MS = 50;
|
||||
|
||||
/** The page the user is currently viewing, sent as chat context. */
|
||||
export interface OpenPageContext {
|
||||
id: string;
|
||||
@@ -45,6 +51,11 @@ export interface OpenPageContext {
|
||||
interface ChatThreadProps {
|
||||
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
||||
chatId: string | null;
|
||||
/** This thread's mount key (the same value the parent uses as React `key`).
|
||||
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
|
||||
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
|
||||
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
|
||||
threadKey?: string;
|
||||
/** Persisted rows to seed initial messages (existing chats only). */
|
||||
initialRows?: IAiChatMessageRow[];
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
@@ -66,20 +77,29 @@ interface ChatThreadProps {
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for a
|
||||
* new chat, adopts the freshly created chat id. `serverChatId` is the
|
||||
* authoritative id the server streamed on the assistant message metadata, or
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
|
||||
onTurnFinished: (serverChatId?: string) => void;
|
||||
/** Parent-owned ref that this thread keeps updated with its live useChat
|
||||
* snapshot (full message list + streaming flag), so the header's
|
||||
* "Copy chat" export can include the in-progress, not-yet-persisted
|
||||
* assistant message. A ref (not state) avoids re-rendering the parent on
|
||||
* every streamed delta. */
|
||||
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>;
|
||||
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||
* every streamed delta. Called with `null` when no turn is in flight (the
|
||||
* parent then reverts the badge to the persisted context size). */
|
||||
onLiveTurnTokens?: (tokens: number | null) => void;
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
|
||||
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
|
||||
* finishing on a thread already abandoned by New chat mid-stream (#161). */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
|
||||
* chat id streamed on the assistant message metadata, so a brand-new chat
|
||||
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
|
||||
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
|
||||
* the parent polls the reconnect endpoint and feeds the run's incrementally-
|
||||
* persisted assistant message here; we merge it into the live list so new
|
||||
* steps/tool-calls appear as they are persisted. Null when there is nothing to
|
||||
* observe (no run, feature off, or this tab IS the streamer). The merge is
|
||||
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
|
||||
* fight the local stream when we are the streamer. */
|
||||
observedRow?: IAiChatMessageRow | null;
|
||||
/** Report this tab's live streaming status up to the parent, so it can stop
|
||||
* polling the run while WE are the active streamer (the SSE owns the view) and
|
||||
* resume once we go idle. Called from an effect on every transition. */
|
||||
onStreamingChange?: (streaming: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +136,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
||||
*/
|
||||
export default function ChatThread({
|
||||
chatId,
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
@@ -123,8 +144,9 @@ export default function ChatThread({
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
liveStateRef,
|
||||
onLiveTurnTokens,
|
||||
onServerChatId,
|
||||
observedRow,
|
||||
onStreamingChange,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -200,12 +222,25 @@ export default function ChatThread({
|
||||
// helper can call the current instance from the stable `onFinish` callback.
|
||||
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||
|
||||
// "Send now" single-flight flags. Kept in refs (not state) so they are read
|
||||
// inside the stable `onFinish` callback and the transport closure WITHOUT a
|
||||
// re-render or a stale closure. Both are one-shot (read-and-clear).
|
||||
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
|
||||
// though an aborted turn normally keeps the queue intact.
|
||||
// - interruptNextSendRef: tag the next send as a user interrupt so the server
|
||||
// injects the "your previous answer was interrupted" note for that turn only.
|
||||
const flushOnAbortRef = useRef(false);
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
// Returns whether a message was actually sent, so callers can tell an empty
|
||||
// dequeue (nothing to flush) from a real send.
|
||||
const flushNext = useCallback(() => {
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return;
|
||||
if (!head) return false;
|
||||
setQueue(rest);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
return true;
|
||||
}, [setQueue]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
@@ -231,28 +266,39 @@ export default function ChatThread({
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
// are read live from refs so changing chats/pages does NOT recreate the
|
||||
// transport. `openPage` is null on a non-page route.
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
// Read-and-clear the interrupt flag so the "you were interrupted" note
|
||||
// is carried by ONLY this request (the one resending the promoted
|
||||
// message right after we aborted the previous turn). The server still
|
||||
// confirms it against history before acting on it.
|
||||
const interrupted = interruptNextSendRef.current;
|
||||
interruptNextSendRef.current = false; // one-shot
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
interrupted,
|
||||
messages,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error } = useChat({
|
||||
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||
// every render mid-stream (see `chatStoreId` above).
|
||||
id: chatStoreId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
|
||||
experimental_throttle: STREAM_THROTTLE_MS,
|
||||
// `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
|
||||
@@ -264,14 +310,31 @@ export default function ChatThread({
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design.
|
||||
onTurnFinished(extractServerChatId(message));
|
||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||
// session ignore this finish if it belongs to a thread abandoned by New chat
|
||||
// mid-stream (#161).
|
||||
onTurnFinished(extractServerChatId(message), threadKey);
|
||||
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||
if (isError) setStopNotice(null);
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||
// immediately send the promoted head. Flush it even though the turn was
|
||||
// aborted (the normal abort path below keeps the queue intact). The
|
||||
// interrupt note travels with this send via interruptNextSendRef.
|
||||
if (flushOnAbortRef.current) {
|
||||
flushOnAbortRef.current = false;
|
||||
// Suppress the "Response stopped." flash for an intentional interrupt.
|
||||
setStopNotice(null);
|
||||
// If the promoted head vanished (e.g. the user removed it before the
|
||||
// abort landed) flushNext sends nothing — clear the one-shot interrupt
|
||||
// tag so it can't leak onto the next unrelated send. On a real send the
|
||||
// tag is consumed by prepareSendMessagesRequest and stays untouched.
|
||||
if (!flushNext()) interruptNextSendRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
},
|
||||
@@ -286,13 +349,40 @@ export default function ChatThread({
|
||||
// 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();
|
||||
onTurnFinished(undefined, threadKey);
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
|
||||
// CURRENT status rather than a value captured in a stale render closure — a turn
|
||||
// can finish between render and click, and arming the interrupt refs against a
|
||||
// no-op stop() would leave them set to leak into a later, unrelated Stop.
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
|
||||
// on the assistant message metadata at the `start` chunk (message.metadata.
|
||||
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
|
||||
// AS SOON AS it appears (mid-stream), so a brand-new chat adopts its real id
|
||||
// WHILE the first turn is still streaming and activeChatId-gated affordances
|
||||
// (the Copy/export button) light up immediately, instead of only at onFinish.
|
||||
// Keyed by the last-seen id so we forward each distinct id exactly once. The
|
||||
// parent's onServerChatId is idempotent and a no-op once the chat has an id.
|
||||
const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!onServerChatId) return;
|
||||
const tail = messages[messages.length - 1];
|
||||
if (tail?.role !== "assistant") return;
|
||||
const serverChatId = extractServerChatId(tail);
|
||||
if (!serverChatId || serverChatId === lastForwardedChatIdRef.current)
|
||||
return;
|
||||
lastForwardedChatIdRef.current = serverChatId;
|
||||
onServerChatId(serverChatId);
|
||||
}, [messages, onServerChatId]);
|
||||
|
||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
|
||||
@@ -304,75 +394,76 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||
// #184: report our live streaming status up so the parent stops polling the run
|
||||
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
|
||||
// Effect (not render) so it never updates parent state during our own render;
|
||||
// fires on mount with `false`, which also re-syncs the parent after a chat
|
||||
// switch remounts this thread (a fresh mount is idle until the user sends).
|
||||
useEffect(() => {
|
||||
if (isStreaming) setStopNotice(null);
|
||||
}, [isStreaming]);
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
|
||||
// Mirror the live useChat snapshot into the parent-owned ref so the export
|
||||
// (handled in AiChatWindow) can include the in-progress streaming turn. The
|
||||
// cleanup clears the ref on unmount so a thread torn down by `key` on chat
|
||||
// switch can't leak its (possibly still-streaming) tail into the next chat's
|
||||
// export before the new thread's effect repopulates the ref.
|
||||
// #184 passive-observer merge: when the parent feeds a polled run message (we
|
||||
// reopened a chat whose run is still going and did NOT start it here), merge it
|
||||
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
|
||||
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
|
||||
// owns the view and a stale observedRow must never overwrite it. `observedRow`
|
||||
// is a stable per-poll object, so this runs once per poll, not per render.
|
||||
useEffect(() => {
|
||||
if (!liveStateRef) return;
|
||||
liveStateRef.current = { messages, isStreaming };
|
||||
return () => {
|
||||
liveStateRef.current = { messages: [], isStreaming: false };
|
||||
};
|
||||
}, [liveStateRef, messages, isStreaming]);
|
||||
if (isStreaming || !observedRow) return;
|
||||
const observed = rowToUiMessage(observedRow);
|
||||
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||
}, [observedRow, isStreaming, setMessages]);
|
||||
|
||||
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||
// streamed delta. The tail assistant message's reasoning+output (estimate while
|
||||
// streaming, authoritative once a step reports usage) is the live figure. When
|
||||
// the turn ends we emit a final exact value, then `null` so the parent reverts
|
||||
// the badge to the persisted context size.
|
||||
const lastEmitRef = useRef(0);
|
||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!onLiveTurnTokens) return;
|
||||
if (!isStreaming) {
|
||||
// Turn ended (or never started): clear any pending throttle and revert.
|
||||
if (emitTimerRef.current) {
|
||||
clearTimeout(emitTimerRef.current);
|
||||
emitTimerRef.current = null;
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
// queue/flush machinery: promote the target to the head, then abort — the
|
||||
// onFinish flush-on-abort branch sends exactly that head, tagged as an
|
||||
// interrupt so the server notes the previous answer was cut off.
|
||||
const sendNow = useCallback(
|
||||
(id: string) => {
|
||||
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
|
||||
// the turn may have finished between this render and the click, in which case
|
||||
// stop() is a no-op and arming the interrupt refs would strand them for a
|
||||
// later, unrelated Stop. Reading the ref always sees the current status.
|
||||
const liveStreaming =
|
||||
statusRef.current === "submitted" || statusRef.current === "streaming";
|
||||
if (liveStreaming) {
|
||||
// Promote to head so the onFinish -> flushNext path sends exactly it.
|
||||
setQueue(promoteToHead(queuedRef.current, id));
|
||||
flushOnAbortRef.current = true;
|
||||
interruptNextSendRef.current = true;
|
||||
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
|
||||
} else {
|
||||
// Nothing to interrupt: just send it now (no interrupt note).
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
lastEmitRef.current = 0;
|
||||
onLiveTurnTokens(null);
|
||||
return;
|
||||
}
|
||||
const tail = messages[messages.length - 1];
|
||||
const live =
|
||||
tail?.role === "assistant" ? liveTurnTokens(tail) : null;
|
||||
const total = live ? live.reasoning + live.output : 0;
|
||||
const now = Date.now();
|
||||
const MIN_INTERVAL = 120; // ms (~8 Hz)
|
||||
const elapsed = now - lastEmitRef.current;
|
||||
if (elapsed >= MIN_INTERVAL) {
|
||||
lastEmitRef.current = now;
|
||||
onLiveTurnTokens(total);
|
||||
} else if (!emitTimerRef.current) {
|
||||
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
|
||||
emitTimerRef.current = setTimeout(() => {
|
||||
emitTimerRef.current = null;
|
||||
lastEmitRef.current = Date.now();
|
||||
onLiveTurnTokens(total);
|
||||
}, MIN_INTERVAL - elapsed);
|
||||
}
|
||||
}, [messages, isStreaming, onLiveTurnTokens]);
|
||||
},
|
||||
[setQueue, stop],
|
||||
);
|
||||
|
||||
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
|
||||
// trailing emit can't fire into a torn-down thread's parent.
|
||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
|
||||
// the race where a flag was armed but the expected abort never fired (the turn
|
||||
// finished in the same tick as the click), so it cannot leak into a later turn.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
if (isStreaming) {
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// 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".
|
||||
// of a generic "Something went wrong". Computed here (not only in the JSX) so
|
||||
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
@@ -458,6 +549,17 @@ export default function ChatThread({
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
|
||||
// reasoning-block.test.tsx.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
|
||||
// OTHER named export of markdown.ts intact via `importActual`, and override only
|
||||
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
|
||||
// still renders. This is the seam that proves the MarkdownPart memo works: a
|
||||
// finalized text part must NOT be re-parsed on a later streamed delta.
|
||||
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
|
||||
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
||||
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/features/ai-chat/utils/markdown.ts")
|
||||
>("@/features/ai-chat/utils/markdown.ts");
|
||||
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
||||
});
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
|
||||
// as the memo key. The signature must NOT be recomputed inside the memo from the
|
||||
// live (mutable) message — see message-item.tsx.
|
||||
const renderRow = (message: UIMessage) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
|
||||
const callsFor = (text: string) =>
|
||||
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
|
||||
|
||||
describe("MessageItem markdown memoization", () => {
|
||||
it("does not re-parse finalized text parts when only a tail part grows", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
|
||||
// Two finalized text parts.
|
||||
const first = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
]);
|
||||
const { rerender } = renderRow(first);
|
||||
|
||||
// Both finalized parts parsed exactly once on the initial render.
|
||||
expect(callsFor("alpha")).toBe(1);
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
|
||||
// A streamed delta: a NEW message object where only a third tail part grows;
|
||||
// the first two parts' text is byte-identical.
|
||||
const next = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
{ type: "text", text: "gamm" },
|
||||
]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={next} signature={messageSignature(next)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The finalized parts hit the MarkdownPart memo: still parsed at most once
|
||||
// each across BOTH renders (the resilient invariant). The only new parse is
|
||||
// for the changed/added tail part.
|
||||
expect(callsFor("alpha")).toBe(1);
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
expect(callsFor("gamm")).toBe(1);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
|
||||
// `parts` IN PLACE and reusing the message object. A row that mounted empty
|
||||
// (reasoning-first providers render nothing at first) must still stream its text
|
||||
// in once the parent hands down a fresh signature snapshot. Before the fix the
|
||||
// memo recomputed the signature from the (mutated) message — identical on both
|
||||
// sides — and froze the row at its empty render, so the answer never appeared.
|
||||
it("streams text in after the row mounted empty and parts mutated in place", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does).
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Empty text part: nothing visible rendered yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders (the memo did NOT freeze the empty mount).
|
||||
expect(callsFor("streamed answer")).toBe(1);
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
112
apps/client/src/features/ai-chat/components/message-item.test.ts
Normal file
112
apps/client/src/features/ai-chat/components/message-item.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next: importing the component module pulls in `useTranslation`,
|
||||
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
|
||||
// minimal `t` that echoes the key is enough. Mirrors the stub in
|
||||
// reasoning-block.test.tsx.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { arePropsEqual } from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
/**
|
||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||
* return false on any visible prop/content change (so the row re-renders) and
|
||||
* true when nothing visible changed (so a finalized row is skipped). The memo key
|
||||
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
|
||||
* per render via `messageSignature(message)`. A FIXED message id is used so a
|
||||
* content-identical clone yields an equal signature.
|
||||
*/
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Build the props the parent would pass, INCLUDING the snapshot signature it
|
||||
// computes during its own render (the load-bearing part — see message-item.tsx:
|
||||
// the signature must never be recomputed inside arePropsEqual).
|
||||
const props = (
|
||||
message: UIMessage,
|
||||
over: Record<string, unknown> = {},
|
||||
) => ({
|
||||
message,
|
||||
signature: messageSignature(message),
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("arePropsEqual", () => {
|
||||
it("returns false when showCitations differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { showCitations: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when neutralizeInternalLinks differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when assistantName differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for the same content in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer" }]);
|
||||
expect(a).not.toBe(b);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when content changed in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
|
||||
// `parts` in place and handing back a message wrapper that SHARES them. So the
|
||||
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
|
||||
// recomputing `messageSignature(message)` inside the comparator would read
|
||||
// identical (latest) content on BOTH sides → always "equal" → the memo skips
|
||||
// every streamed update and the assistant row freezes at its initial empty
|
||||
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
|
||||
// the parent captured at each render. This fails against the old implementation
|
||||
// (a `prev.message === next.message` fast path + a signature recomputed from the
|
||||
// live objects).
|
||||
it("re-renders when parts were mutated in place but the snapshot changed", () => {
|
||||
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
|
||||
const prevSig = messageSignature(message); // snapshot BEFORE the delta
|
||||
// SDK streams a delta by mutating the shared part IN PLACE:
|
||||
(message.parts[0] as { text: string }).text = "hello world";
|
||||
const nextSig = messageSignature(message); // snapshot AFTER the delta
|
||||
expect(prevSig).not.toBe(nextSig);
|
||||
// Same object reference on both sides (the SDK reuses it), differing snapshots.
|
||||
const base = {
|
||||
message,
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
};
|
||||
expect(
|
||||
arePropsEqual(
|
||||
{ ...base, signature: prevSig },
|
||||
{ ...base, signature: nextSig },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
@@ -15,6 +16,25 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
/**
|
||||
* Immutable content signature for `message`, computed by the PARENT
|
||||
* (MessageList) during its render via `messageSignature(message)`. This is the
|
||||
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
|
||||
* NOT recomputed from `message` inside `arePropsEqual`.
|
||||
*
|
||||
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
|
||||
* array/objects in place and handing back a message wrapper that SHARES those
|
||||
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
|
||||
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
|
||||
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
|
||||
* post-mount render, and the assistant row freezes at its initial empty (null)
|
||||
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
|
||||
* providers start empty, so NOTHING shows). Snapshotting the signature into this
|
||||
* immutable string prop in the parent fixes that: `prev.signature` holds the
|
||||
* value from the previous render (old content) and `next.signature` the new
|
||||
* content, so they differ as the turn streams in and the row re-renders.
|
||||
*/
|
||||
signature: string;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
@@ -34,6 +54,39 @@ interface MessageItemProps {
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||
* `text` string is compared by value).
|
||||
*/
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
}) {
|
||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Fallback when markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Render a single UIMessage by iterating its `parts`:
|
||||
* - `text` parts -> sanitized markdown.
|
||||
@@ -41,17 +94,20 @@ interface MessageItemProps {
|
||||
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
||||
* User messages render their text as a right-aligned plain bubble.
|
||||
*
|
||||
* This component is intentionally NOT memoized: `useChat` replaces the streaming
|
||||
* assistant message with a freshly cloned object on every streamed delta, so the
|
||||
* `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.
|
||||
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
|
||||
* per-message content signature: the streaming TAIL message's signature changes
|
||||
* on each delta so it still re-renders and streams in, while finalized rows are
|
||||
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
|
||||
* long turn no longer re-parses the whole transcript on every token.
|
||||
*/
|
||||
export default function MessageItem({
|
||||
function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
// `signature` is intentionally not read in the body — it exists solely as the
|
||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -109,24 +165,12 @@ export default function MessageItem({
|
||||
// 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, {
|
||||
neutralizeInternalLinks,
|
||||
});
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Fallback when markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{part.text}
|
||||
</Text>
|
||||
<MarkdownPart
|
||||
key={index}
|
||||
text={part.text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,3 +221,32 @@ export default function MessageItem({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
|
||||
* parent), so it still re-renders and streams in; every FINALIZED message keeps
|
||||
* the same signature and is skipped, turning a per-token whole-transcript
|
||||
* re-render into a tail-only one.
|
||||
*
|
||||
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
|
||||
* at its own render), NEVER `messageSignature(prev.message)` vs
|
||||
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
|
||||
* place, so both `prev.message` and `next.message` reflect the latest content
|
||||
* here — recomputing the signature from them yields equal strings every time and
|
||||
* freezes the row at its initial empty render (the bug this guards against). See
|
||||
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
|
||||
* fast path: same-reference-but-mutated must still re-render when the snapshot
|
||||
* signature changed. */
|
||||
export function arePropsEqual(
|
||||
prev: MessageItemProps,
|
||||
next: MessageItemProps,
|
||||
): boolean {
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageItem, arePropsEqual);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
|
||||
// Mirrors the t-mock pattern used by the other component tests in this folder
|
||||
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
|
||||
// every OTHER named export of markdown.ts intact via `importActual`, and override
|
||||
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
|
||||
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
|
||||
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
|
||||
// factory runs.
|
||||
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
||||
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/features/ai-chat/utils/markdown.ts")
|
||||
>("@/features/ai-chat/utils/markdown.ts");
|
||||
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
||||
});
|
||||
|
||||
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
|
||||
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
|
||||
// whole point of this file (it closes the parent-side coverage gap left by the
|
||||
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
|
||||
// harness). Use the relative import for the component under test, mirroring how
|
||||
// message-list.tsx itself imports `MessageItem from "./message-item"`.
|
||||
import MessageList from "./message-list";
|
||||
|
||||
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
|
||||
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
|
||||
//
|
||||
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
|
||||
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
|
||||
// One assistant message wrapping the given `parts`. Reused across renders in the
|
||||
// regression test to model how the AI SDK hands back the SAME message object.
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
describe("MessageList", () => {
|
||||
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
const { queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList
|
||||
messages={[msg([{ type: "text", text: "hello world" }])]}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
// The assistant text renders, which proves MessageList mounted the real
|
||||
// MessageItem and handed it a valid `signature` prop (computed from the real
|
||||
// `messageSignature`) — the full parent -> child -> markdown path is live.
|
||||
expect(queryByText("hello world")).not.toBeNull();
|
||||
});
|
||||
|
||||
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
|
||||
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
|
||||
// delta that REUSES the same message object. The fix moved the content signature
|
||||
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
|
||||
// every render and forward it as the immutable `signature` prop, so MessageItem's
|
||||
// memo (which compares that prop snapshot) sees it change and re-renders the row.
|
||||
//
|
||||
// This test exercises the PARENT half that the memo tests only simulate: if
|
||||
// MessageList ever cached/memoized the signature keyed on the message object's
|
||||
// identity (which stays stable across deltas while its `parts` mutate in place),
|
||||
// the snapshot would never change, MessageItem's memo would skip every delta, and
|
||||
// the row would freeze at its empty mount — exactly the regression class. That
|
||||
// would make this test fail. See message-item.tsx (`signature` prop +
|
||||
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
|
||||
// snapshot at render time).
|
||||
it("reflects in-place part mutation of a reused message object across renders", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does). The empty text
|
||||
// part means MessageItem renders nothing visible initially.
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Nothing streamed yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place on the SAME message object...
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
// ...then re-render with a NEW array literal that still holds the SAME mutated
|
||||
// message object (this mirrors useChat handing back a fresh array of reused
|
||||
// message objects on each delta).
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders: MessageList re-snapshotted the signature, so the
|
||||
// row re-rendered instead of freezing at its empty mount.
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
expect(
|
||||
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ 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 { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -51,7 +51,9 @@ const BOTTOM_THRESHOLD = 40;
|
||||
* 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 assistant row has no parts yet, or
|
||||
* - its last part is an empty/whitespace text part, or
|
||||
* - its last part is an empty/whitespace text part, or a finished ("done")
|
||||
* text part while the turn continues (the model paused after some narration
|
||||
* and is thinking about its next step), 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
|
||||
@@ -65,7 +67,19 @@ export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean)
|
||||
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;
|
||||
// Only while it is STILL streaming, though: once a non-empty text part is
|
||||
// finalized ("done") but the turn is still in flight, the model has paused
|
||||
// after some narration and is working on its next step (e.g. about to call a
|
||||
// tool) — nothing is visibly progressing, so the dots must show. A text part
|
||||
// without a `state` is treated as still-rendering (kept suppressed); this
|
||||
// branch only runs while streaming, where live parts always carry a state.
|
||||
if (
|
||||
lastPart.type === "text" &&
|
||||
lastPart.text.trim().length > 0 &&
|
||||
(lastPart as { state?: "streaming" | "done" }).state !== "done"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
|
||||
if (
|
||||
isToolPart(lastPart.type) &&
|
||||
@@ -95,19 +109,6 @@ export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
|
||||
return !assistantMessageHasVisibleContent(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live thinking-token count to show on the standalone typing indicator. It
|
||||
* is the reasoning split of the tail assistant message (estimate while streaming,
|
||||
* authoritative once the server attaches usage at a step/turn boundary). Returns
|
||||
* 0 when the turn has produced no reasoning yet — the indicator then shows the
|
||||
* plain "Thinking…" line.
|
||||
*/
|
||||
export function tailThinkingTokens(messages: UIMessage[]): number {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || last.role !== "assistant") return 0;
|
||||
return liveTurnTokens(last).reasoning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
|
||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||
@@ -196,9 +197,16 @@ export default function MessageList({
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||
// string and handed to MessageItem as its memo key. It must NOT be
|
||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||
// shared `parts` in place, so prev/next message objects both read the
|
||||
// latest content there and the memo would skip every streamed update
|
||||
// (freezing the row at its empty render). See message-item.tsx.
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
@@ -208,7 +216,6 @@ export default function MessageList({
|
||||
<TypingIndicator
|
||||
assistantName={assistantName}
|
||||
showName={typingIndicatorShowsName(messages)}
|
||||
thinkingTokens={tailThinkingTokens(messages)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
@@ -26,14 +27,23 @@ interface ReasoningBlockProps {
|
||||
* Providers that don't stream reasoning TEXT still render this block from the
|
||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||
*/
|
||||
export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
const html = trimmed ? renderChatMarkdown(trimmed, {}) : "";
|
||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
||||
// paragraphs) — ONLY here, not in the normal answer.
|
||||
const html = useMemo(
|
||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
||||
[trimmed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={classes.reasoningBlock} mb={6}>
|
||||
@@ -81,3 +91,8 @@ export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
||||
export default memo(ReasoningBlock);
|
||||
|
||||
@@ -82,4 +82,14 @@ describe("showTypingIndicator", () => {
|
||||
showTypingIndicator([msg("assistant", [doneTool, text])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming after a text part is finalized (paused before the next step)", () => {
|
||||
const doneText = { type: "text", text: "Now creating the page in", state: "done" } as unknown as UIMessage["parts"][number];
|
||||
expect(showTypingIndicator([msg("assistant", [doneText])], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides while a text part is actively streaming (state: streaming)", () => {
|
||||
const streamingText = { type: "text", text: "Now writ", state: "streaming" } as unknown as UIMessage["parts"][number];
|
||||
expect(showTypingIndicator([msg("assistant", [streamingText])], true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { tailThinkingTokens } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for `tailThinkingTokens`: the live thinking-token count the
|
||||
* standalone typing indicator shows. It is the reasoning split of the tail
|
||||
* assistant message (estimate while streaming, authoritative once usage arrives).
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: unknown[],
|
||||
metadata?: unknown,
|
||||
): UIMessage =>
|
||||
({ id: Math.random().toString(), role, parts, metadata }) as UIMessage;
|
||||
|
||||
describe("tailThinkingTokens", () => {
|
||||
it("is 0 when there are no messages", () => {
|
||||
expect(tailThinkingTokens([])).toBe(0);
|
||||
});
|
||||
|
||||
it("is 0 when the tail message is the user's", () => {
|
||||
expect(tailThinkingTokens([msg("user", [{ type: "text", text: "q" }])])).toBe(0);
|
||||
});
|
||||
|
||||
it("is 0 when the assistant has produced no reasoning yet", () => {
|
||||
expect(
|
||||
tailThinkingTokens([msg("assistant", [{ type: "text", text: "answer" }])]),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("estimates reasoning tokens from streamed reasoning text", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
expect(
|
||||
tailThinkingTokens([
|
||||
msg("assistant", [{ type: "reasoning", text: "12345678" }]),
|
||||
]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("uses authoritative usage.reasoningTokens once the server attaches it", () => {
|
||||
expect(
|
||||
tailThinkingTokens([
|
||||
msg("assistant", [{ type: "reasoning", text: "x" }], {
|
||||
usage: { outputTokens: 100, reasoningTokens: 42 },
|
||||
}),
|
||||
]),
|
||||
).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,6 @@ interface TypingIndicatorProps {
|
||||
* assistant row above already shows the same name, to avoid a duplicate label.
|
||||
*/
|
||||
showName?: boolean;
|
||||
/**
|
||||
* Live thinking/reasoning token count for the in-flight turn. When > 0 the
|
||||
* typing line becomes `Thinking… · {count} tokens` (like Claude Code). Omitted
|
||||
* / 0 keeps the plain `Thinking…` line.
|
||||
*/
|
||||
thinkingTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,23 +26,20 @@ interface TypingIndicatorProps {
|
||||
*
|
||||
* 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).
|
||||
* identity name when provided (otherwise the generic "AI agent"); below it the
|
||||
* animated dots stand in for the nascent bubble until content arrives.
|
||||
*/
|
||||
export default function TypingIndicator({ assistantName, showName = true, thinkingTokens }: TypingIndicatorProps) {
|
||||
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = resolveAssistantName(assistantName);
|
||||
// Show the running thinking-token count only once there is something to count.
|
||||
const thinkingLine =
|
||||
thinkingTokens && thinkingTokens > 0
|
||||
? t("Thinking… · {{count}} tokens", { count: thinkingTokens })
|
||||
: t("Thinking…");
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
{showName !== false && (
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
// Extra bottom gap (vs MessageItem's mb={4}) gives the small bouncing
|
||||
// dots room below the name label; without it they crowd the label. Only
|
||||
// applies when the name is shown — the nameless case spaces fine on its own.
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
{name ?? t("AI agent")}
|
||||
</Text>
|
||||
)}
|
||||
@@ -58,9 +49,6 @@ export default function TypingIndicator({ assistantName, showName = true, thinki
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{thinkingLine}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useChatSession } from "./use-chat-session";
|
||||
import type { UseChatSessionOptions } from "./use-chat-session";
|
||||
|
||||
@@ -64,7 +64,10 @@ describe("useChatSession", () => {
|
||||
result.current.onTurnFinished(undefined);
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
// The refetch lands with the new row => adopt it.
|
||||
rerender({ activeChatId: null, chats: { items: [{ id: "x" }, { id: "new" }] } });
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }, { id: "new" }] },
|
||||
});
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
||||
});
|
||||
|
||||
@@ -88,7 +91,10 @@ describe("useChatSession", () => {
|
||||
});
|
||||
result.current.onTurnFinished(undefined);
|
||||
// a was deleted, new was added — same length, but membership changed.
|
||||
rerender({ activeChatId: null, chats: { items: [{ id: "b" }, { id: "new" }] } });
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "b" }, { id: "new" }] },
|
||||
});
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
||||
});
|
||||
|
||||
@@ -171,6 +177,40 @@ describe("useChatSession", () => {
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
|
||||
});
|
||||
|
||||
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
|
||||
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
|
||||
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
|
||||
// fires at the terminal outcome). The hook must adopt it immediately so the
|
||||
// window's activeChatId-gated Copy/export button lights up during the stream.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
result.current.onServerChatId("A");
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
|
||||
const chats = { items: [] };
|
||||
const { result, rerender } = setup({ activeChatId: null, chats });
|
||||
const keyBefore = result.current.threadKey;
|
||||
result.current.onServerChatId("A");
|
||||
// Parent reflects the adopted id back in; the SAME mount key is kept so the
|
||||
// in-flight useChat store (the streaming turn) is preserved.
|
||||
rerender({ activeChatId: "A", chats });
|
||||
expect(result.current.threadKey).toBe(keyBefore);
|
||||
});
|
||||
|
||||
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: "chat-1",
|
||||
chats: { items: [{ id: "chat-1" }] },
|
||||
});
|
||||
result.current.onServerChatId("chat-1"); // already has an id
|
||||
result.current.onServerChatId(undefined); // no streamed id
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
|
||||
const chats = { items: [{ id: "B" }] };
|
||||
const { result, rerender } = setup({ activeChatId: null, chats });
|
||||
@@ -187,6 +227,50 @@ describe("useChatSession", () => {
|
||||
expect(result.current.threadKey).toBe("C");
|
||||
});
|
||||
|
||||
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
|
||||
// Brand-new chat whose first turn is still streaming: the id is adopted only
|
||||
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
|
||||
// chat" must still remount to a clean thread even though the atom is unchanged
|
||||
// — the render-phase reconciler (null === null) would otherwise do nothing,
|
||||
// leaving the old chat/stream/history in place (the bug: only the role badge
|
||||
// dropped).
|
||||
const { result } = setup({ activeChatId: null, chats: { items: [] } });
|
||||
const keyBefore = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(keyBefore);
|
||||
});
|
||||
|
||||
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
|
||||
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
|
||||
// abort the abandoned stream on unmount: its onFinish still fires later with
|
||||
// the real server id, tagged with the OLD (abandoned) mount key. That must not
|
||||
// adopt — it would yank the user back into the chat they just left.
|
||||
const { result, setActiveChatId, onInvalidateChatList } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const abandonedKey = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(abandonedKey);
|
||||
// The abandoned turn finishes in the background, streaming its real id "A".
|
||||
result.current.onTurnFinished("A", abandonedKey);
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
|
||||
// It still refreshes the chat list so the left-behind chat shows in history.
|
||||
expect(onInvalidateChatList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
|
||||
// The happy path must keep working: onTurnFinished tagged with the mounted
|
||||
// thread's own key adopts in place as before.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const currentKey = result.current.threadKey;
|
||||
result.current.onTurnFinished("A", currentKey);
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
|
||||
// Open an existing chat whose history is still loading => loader on.
|
||||
const { result, rerender } = setup({
|
||||
|
||||
@@ -31,9 +31,26 @@ export interface UseChatSessionResult {
|
||||
threadKey: string;
|
||||
/** Show the history loader instead of the live thread. */
|
||||
waitingForHistory: boolean;
|
||||
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
|
||||
* even when `activeChatId` is unchanged. The window calls this from
|
||||
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
|
||||
* still streaming (activeChatId still null, nothing to diverge) actually
|
||||
* resets the chat instead of only dropping the role badge (#161). */
|
||||
startFreshThread: () => void;
|
||||
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
|
||||
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string) => void;
|
||||
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
|
||||
* thread that produced the turn (omit => "current thread", back-compatible):
|
||||
* a turn ABANDONED by New chat mid-stream still fires this after its thread
|
||||
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
|
||||
* new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
|
||||
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
|
||||
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
|
||||
* button, #174) available immediately. In-place adoption only (same mount key,
|
||||
* no list/messages invalidation — that is left to onTurnFinished at the end).
|
||||
* Idempotent and a no-op once the chat already has an id. */
|
||||
onServerChatId: (serverChatId?: string) => void;
|
||||
/** Disarm any pending error-path new-chat fallback. The window calls this from
|
||||
* startNewChat/selectChat so a late refetch can't yank the user back into a
|
||||
* just-failed chat after they explicitly moved on. */
|
||||
@@ -85,15 +102,21 @@ export function useChatSession(
|
||||
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
|
||||
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
|
||||
// session key with no chat id yet.
|
||||
const [thread, dispatch] = useReducer(
|
||||
threadSessionReducer,
|
||||
undefined,
|
||||
() =>
|
||||
activeChatId === null
|
||||
? newThread(`new-${generateId()}`)
|
||||
: switchThread(activeChatId),
|
||||
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
|
||||
activeChatId === null
|
||||
? newThread(`new-${generateId()}`)
|
||||
: switchThread(activeChatId),
|
||||
);
|
||||
|
||||
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
|
||||
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
|
||||
// does not abort a stream on unmount and proxies callbacks through a ref, so an
|
||||
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
|
||||
// matching its key against this ref keeps that late finish from adopting the
|
||||
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
|
||||
const threadKeyRef = useRef(thread.key);
|
||||
threadKeyRef.current = thread.key;
|
||||
|
||||
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
|
||||
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
|
||||
// reaches the client, so the primary metadata adoption cannot run. We then ARM
|
||||
@@ -111,7 +134,23 @@ export function useChatSession(
|
||||
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
|
||||
// list, which races a second tab — #137; see adopt-chat-id.ts).
|
||||
const onTurnFinished = useCallback(
|
||||
(serverChatId?: string) => {
|
||||
(serverChatId?: string, finishingThreadKey?: string) => {
|
||||
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
|
||||
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
|
||||
// not abort on unmount and proxies callbacks through a ref). If that late
|
||||
// finish ran the adoption path it would set activeChatId to the abandoned
|
||||
// chat's real id and yank the user out of the fresh chat they just opened.
|
||||
// So adopt / arm the fallback ONLY for the still-mounted thread; an
|
||||
// abandoned one merely refreshes the chat list (so the left-behind chat
|
||||
// surfaces in history) and does nothing else. A missing key (undefined)
|
||||
// means "current thread" — keeps old call sites/tests working.
|
||||
if (
|
||||
finishingThreadKey !== undefined &&
|
||||
finishingThreadKey !== threadKeyRef.current
|
||||
) {
|
||||
onInvalidateChatList();
|
||||
return;
|
||||
}
|
||||
// Read the live id from the ref, not the closure: on a failed turn this can
|
||||
// run twice in one turn (onFinish + onError) before any re-render, and the
|
||||
// primary branch below updates the ref so the second call sees the adopted id.
|
||||
@@ -150,6 +189,31 @@ export function useChatSession(
|
||||
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
|
||||
);
|
||||
|
||||
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
|
||||
// the server emits it on the `start` chunk, so a brand-new chat gets its real
|
||||
// `activeChatId` WHILE its first turn streams — not only at terminal
|
||||
// onTurnFinished. This makes the activeChatId-gated Copy/export button
|
||||
// available during the first turn. Pure in-place adoption (same mount key, like
|
||||
// the primary path) with NO invalidation: the list/messages refresh stays on
|
||||
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
|
||||
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
|
||||
// still-new chat).
|
||||
const onServerChatId = useCallback(
|
||||
(serverChatId?: string) => {
|
||||
const adopted = resolveAdoptedChatId(
|
||||
activeChatIdRef.current,
|
||||
serverChatId,
|
||||
);
|
||||
if (!adopted) return;
|
||||
activeChatIdRef.current = adopted;
|
||||
setActiveChatId(adopted);
|
||||
dispatch({ type: "adopt", chatId: adopted });
|
||||
// Early adoption beat the error-path fallback to it — disarm.
|
||||
pendingNewChatRef.current = null;
|
||||
},
|
||||
[setActiveChatId],
|
||||
);
|
||||
|
||||
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
|
||||
// turn errored before the `start` chunk (no authoritative id streamed). Once
|
||||
// the per-user list refetch lands with the just-created row, adopt the SINGLE
|
||||
@@ -229,10 +293,30 @@ export function useChatSession(
|
||||
pendingNewChatRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
|
||||
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
|
||||
// so "New chat" pressed while a brand-new chat's first turn is still streaming
|
||||
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
|
||||
// end of the turn) is a no-op for it and the abandoned thread/stream/history
|
||||
// would persist. Dispatching reconcile with a fresh key and chatId:null here
|
||||
// always produces a new mount key, so React remounts ChatThread (a clean useChat
|
||||
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
|
||||
// keeps the reconciler from interfering. Also disarms any pending fallback.
|
||||
const startFreshThread = useCallback(() => {
|
||||
pendingNewChatRef.current = null;
|
||||
dispatch({
|
||||
type: "reconcile",
|
||||
chatId: null,
|
||||
newKey: `new-${generateId()}`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
threadKey: thread.key,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
};
|
||||
}
|
||||
|
||||
135
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.test.tsx
Normal file
135
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.test.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// useMatch is the only react-router-dom export the hook uses; drive its return
|
||||
// per test to simulate "on a page" vs "off a page".
|
||||
const useMatchMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useMatch: () => useMatchMock(),
|
||||
}));
|
||||
|
||||
// The bound-chat resolver is the network boundary; stub it per test.
|
||||
const getBoundChatMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
|
||||
}));
|
||||
|
||||
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
|
||||
// tests override useMatch to go off-page.
|
||||
function onPage(pageSlug = "doc-p1") {
|
||||
useMatchMock.mockReturnValue({ params: { pageSlug } });
|
||||
}
|
||||
function offPage() {
|
||||
useMatchMock.mockReturnValue(null);
|
||||
}
|
||||
|
||||
// Render the hook inside an explicit jotai store so atom side effects are
|
||||
// assertable; the store is returned for setup + assertions.
|
||||
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
|
||||
const store = createStore();
|
||||
seed?.(store);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
);
|
||||
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
|
||||
return { store, open: () => act(() => result.current()) };
|
||||
}
|
||||
|
||||
describe("useOpenAiChatForCurrentPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onPage();
|
||||
});
|
||||
|
||||
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
|
||||
getBoundChatMock.mockResolvedValue("bound-chat-1");
|
||||
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
|
||||
});
|
||||
|
||||
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
|
||||
getBoundChatMock.mockResolvedValue(null);
|
||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("off a page: keeps the current selection and does NOT resolve", async () => {
|
||||
offPage();
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(activeAiChatIdAtom, "keep-me");
|
||||
s.set(aiChatDraftAtom, "untouched");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
|
||||
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
|
||||
getBoundChatMock.mockResolvedValue("would-switch");
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(aiChatWindowOpenAtom, true);
|
||||
s.set(activeAiChatIdAtom, "current");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(getBoundChatMock).not.toHaveBeenCalled();
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("current");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
|
||||
getBoundChatMock.mockResolvedValue("same");
|
||||
const { store, open } = setup((s) => {
|
||||
s.set(activeAiChatIdAtom, "same");
|
||||
s.set(aiChatDraftAtom, "in-progress");
|
||||
});
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
|
||||
getBoundChatMock.mockRejectedValue(new Error("network"));
|
||||
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the picked role on a real switch", async () => {
|
||||
getBoundChatMock.mockResolvedValue("bound");
|
||||
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
|
||||
|
||||
await open();
|
||||
|
||||
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
|
||||
});
|
||||
});
|
||||
67
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts
Normal file
67
apps/client/src/features/ai-chat/hooks/use-open-ai-chat.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import {
|
||||
aiChatWindowOpenAtom,
|
||||
activeAiChatIdAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
/**
|
||||
* The generic "open the AI chat" action, WITH document binding: when invoked
|
||||
* while viewing a page, it resolves that page's bound chat and selects it before
|
||||
* opening — so the last chat for this document re-opens by itself. With no bound
|
||||
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
|
||||
* by the app-header entry point; NOT by the provenance badge (which deep-links).
|
||||
*/
|
||||
export function useOpenAiChatForCurrentPage() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// Same route-match trick the window uses: read :pageSlug from the pathname.
|
||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
||||
// see :pageSlug — match the full path against the authenticated page route.
|
||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
||||
// re-resolve and yank the user to another chat: resolve only on a genuine
|
||||
// closed -> open transition. (`windowOpen` is already true here, so there
|
||||
// is nothing to set — just bail.)
|
||||
if (windowOpen) return;
|
||||
// Open the window FIRST so the control feels instant: the bound-chat
|
||||
// round-trip below must never gate the window appearing, or on a slow
|
||||
// connection the first click reads as a hung control until the POST returns.
|
||||
setWindowOpen(true);
|
||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||
if (pageId) {
|
||||
try {
|
||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
||||
} catch {
|
||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||
}
|
||||
}
|
||||
// Clear the composer draft / picked role ONLY on an actual switch, so
|
||||
// reopening the same chat does not wipe an in-progress draft. Applied after
|
||||
// the resolve so the window is already visible while the switch settles.
|
||||
if (resolved !== activeChatId) {
|
||||
setActiveChatId(resolved);
|
||||
setDraft("");
|
||||
setSelectedRoleId(null);
|
||||
}
|
||||
}, [
|
||||
windowOpen,
|
||||
activeChatId,
|
||||
pageId,
|
||||
setWindowOpen,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
}
|
||||
@@ -12,36 +12,60 @@ import {
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChatRun,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
getAiRoles,
|
||||
importAiRolesFromCatalog,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
updateAiRoleFromCatalog,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
// Catalog reads resolve bundle names per language, so the language is part of
|
||||
// the cache key (a language switch refetches rather than reusing stale names).
|
||||
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
|
||||
"ai-role-catalog",
|
||||
language,
|
||||
];
|
||||
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
|
||||
bundleId: string,
|
||||
language: string,
|
||||
) => ["ai-role-catalog-bundle", bundleId, language];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
];
|
||||
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||
|
||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||
export function useAiChatsQuery() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -71,7 +95,9 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
@@ -112,6 +138,34 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||
*
|
||||
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||
* failed fetch can never spin a tight loop.
|
||||
*/
|
||||
export function useAiChatRunQuery(
|
||||
chatId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiChatRunResponse, Error>({
|
||||
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||
queryFn: () => getAiChatRun(chatId as string),
|
||||
enabled: !!chatId && enabled,
|
||||
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameAiChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -223,3 +277,112 @@ export function useDeleteAiRoleMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
|
||||
* fetch runs only when the catalog modal is open. The catalog can 502 when the
|
||||
* curated source is unreachable; callers handle the error state in the UI.
|
||||
*/
|
||||
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
|
||||
return useQuery<IAiRoleCatalog, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
|
||||
queryFn: () => getAiRoleCatalog(language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
|
||||
* fetch only runs when a bundle is actually expanded.
|
||||
*/
|
||||
export function useAiRoleCatalogBundleQuery(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiRoleCatalogBundle, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportAiRolesFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
{
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Failed to import {{count}} role(s)", {
|
||||
count: result.errors.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// Imported roles can appear in the chat picker / badges.
|
||||
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 useUpdateAiRoleFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
|
||||
mutationFn: (id) => updateAiRoleFromCatalog(id),
|
||||
onSuccess: (result) => {
|
||||
// The server returns updated:false with a reason for a no-op (already
|
||||
// up to date / removed from catalog / language no longer offered). Map
|
||||
// each reason to a specific message instead of a generic "up to date".
|
||||
// Narrow the discriminated union via `"reason" in result` (the `updated`
|
||||
// boolean discriminant does not narrow under this project's
|
||||
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
|
||||
// union, so the comparisons below are compiler-checked.
|
||||
let message: string;
|
||||
if (!("reason" in result)) {
|
||||
message = t("Updated to the latest version");
|
||||
} else if (result.reason === "not-in-catalog") {
|
||||
message = t("This role is no longer in the catalog");
|
||||
} else if (result.reason === "language-unavailable") {
|
||||
message = t("This language is no longer available in the catalog");
|
||||
} else {
|
||||
// "up-to-date" (the only remaining reason).
|
||||
message = t("Already up to date");
|
||||
}
|
||||
notifications.show({ message });
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||
// use it); stub it so the module imports cleanly in this hook test.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getAiChatRun: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const runningResponse: IAiChatRunResponse = {
|
||||
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||
message: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "working...",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("useAiChatRunQuery — enable gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||
expect(result.current.data?.run?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
// Give any errant fetch a chance to fire, then assert none did.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT fetch when there is no chat id", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
|
||||
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
|
||||
// the result carries partial errors. These tests pin both branches via
|
||||
// renderHook with a mocked service (twin precedent:
|
||||
// update-from-catalog-message.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key with interpolated values so we assert against the exact
|
||||
// English message strings (mirrors react-i18next's default interpolation).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars
|
||||
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleImportResult) {
|
||||
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useImportAiRolesFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate({
|
||||
bundleId: "general",
|
||||
language: "en",
|
||||
conflict: "rename",
|
||||
});
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Imported 3, renamed 1, skipped 2",
|
||||
});
|
||||
});
|
||||
|
||||
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
|
||||
await runMutation({
|
||||
created: 1,
|
||||
renamed: 0,
|
||||
skipped: 0,
|
||||
errors: [
|
||||
{ slug: "a", message: "name taken" },
|
||||
{ slug: "b", message: "name taken" },
|
||||
],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||
message: "Imported 1, renamed 0, skipped 0",
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
|
||||
color: "red",
|
||||
message: "Failed to import 2 role(s)",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
|
||||
// a user-facing notification message. These tests pin each of the four branches
|
||||
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
|
||||
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key so we assert against the exact English message strings.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
}));
|
||||
|
||||
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
|
||||
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useUpdateAiRoleFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate("role-1");
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("updated:true -> 'Updated to the latest version'", async () => {
|
||||
await runMutation({
|
||||
updated: true,
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
role: { id: "role-1" } as never,
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Updated to the latest version",
|
||||
});
|
||||
});
|
||||
|
||||
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "not-in-catalog" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This role is no longer in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "language-unavailable" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This language is no longer available in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("up-to-date -> 'Already up to date'", async () => {
|
||||
await runMutation({ updated: false, reason: "up-to-date" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Already up to date",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,15 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -37,6 +43,34 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||
* persisted lifecycle state and the assistant message it materializes (the
|
||||
* partial output while the run is in-flight, the final output once it finished).
|
||||
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||
* runs, so the endpoint returns `{ run: null }`.
|
||||
*/
|
||||
export async function getAiChatRun(
|
||||
chatId: string,
|
||||
): Promise<IAiChatRunResponse> {
|
||||
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
*/
|
||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||
pageId,
|
||||
});
|
||||
return req.data.chatId;
|
||||
}
|
||||
|
||||
/** Rename a chat. */
|
||||
export async function renameAiChat(data: {
|
||||
chatId: string;
|
||||
@@ -50,6 +84,37 @@ export async function deleteAiChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai-chat/delete", { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chat to Markdown (#183). The server renders the transcript from the
|
||||
* persisted rows (the DB is the single source of truth — including an
|
||||
* interrupted turn's in-progress row, persisted upfront + per step), so the
|
||||
* client just copies the returned string. `lang` localizes the few fixed
|
||||
* role/tool labels; defaults to English server-side when omitted.
|
||||
*/
|
||||
export async function exportAiChat(
|
||||
chatId: string,
|
||||
lang?: string,
|
||||
): Promise<string> {
|
||||
const req = await api.post<{ markdown: string }>("/ai-chat/export", {
|
||||
chatId,
|
||||
lang,
|
||||
});
|
||||
return req.data.markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from note content (markdown). One-shot, non-streaming
|
||||
* (#199): the server only summarizes the supplied text and returns a suggestion;
|
||||
* it never writes the page. The caller applies the title via /pages/update.
|
||||
*/
|
||||
export async function generatePageTitle(content: string): Promise<string> {
|
||||
const req = await api.post<{ title: string }>(
|
||||
"/ai-chat/generate-page-title",
|
||||
{ content },
|
||||
);
|
||||
return req.data.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
@@ -76,6 +141,59 @@ export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||
|
||||
/** 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 });
|
||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
|
||||
id,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
|
||||
* Browse a curated catalog, import roles/bundles into the workspace, and update
|
||||
* an imported role when the catalog ships a newer version. Same `{ data }`
|
||||
* unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** Browse the catalog, optionally localized to `language`. */
|
||||
export async function getAiRoleCatalog(
|
||||
language?: string,
|
||||
): Promise<IAiRoleCatalog> {
|
||||
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
|
||||
language,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Open one catalog bundle in a language (role content + versions). */
|
||||
export async function getAiRoleCatalogBundle(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<IAiRoleCatalogBundle> {
|
||||
const req = await api.post<IAiRoleCatalogBundle>(
|
||||
"/ai-chat/roles/catalog/bundle",
|
||||
{ bundleId, language },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Import roles from a catalog bundle into the workspace (admin). */
|
||||
export async function importAiRolesFromCatalog(
|
||||
payload: IAiRoleImportPayload,
|
||||
): Promise<IAiRoleImportResult> {
|
||||
const req = await api.post<IAiRoleImportResult>(
|
||||
"/ai-chat/roles/import",
|
||||
payload,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update an already-imported role from its catalog source (admin). */
|
||||
export async function updateAiRoleFromCatalog(
|
||||
id: string,
|
||||
): Promise<IAiRoleUpdateFromCatalogResult> {
|
||||
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
|
||||
"/ai-chat/roles/update-from-catalog",
|
||||
{ id },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,79 @@ export interface IAiRole {
|
||||
autoStart: boolean;
|
||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||
launchMessage: string | null;
|
||||
// Catalog origin of an imported role, or null for a manually-created one.
|
||||
// Admin-only (present only in the admin list view); the picker view omits it.
|
||||
// The admin UI compares `version` against the catalog to offer an update.
|
||||
source?: { slug: string; language: string; version: number } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
|
||||
export interface IAiRoleCatalogBundleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}
|
||||
|
||||
/** The browsable catalog index (mirrors `getCatalog()`). */
|
||||
export interface IAiRoleCatalog {
|
||||
languages: string[];
|
||||
bundles: IAiRoleCatalogBundleSummary[];
|
||||
}
|
||||
|
||||
/** A single role inside an opened catalog bundle (localized content + version). */
|
||||
export interface IAiRoleCatalogRole {
|
||||
slug: string;
|
||||
emoji: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
|
||||
export interface IAiRoleCatalogBundle {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
roles: IAiRoleCatalogRole[];
|
||||
}
|
||||
|
||||
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
|
||||
export interface IAiRoleImportPayload {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
// Omitted => import the whole bundle; otherwise only these slugs.
|
||||
slugs?: string[];
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
|
||||
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
|
||||
* to a specific message; a successful update carries the version bump + new role.
|
||||
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
|
||||
* comparisons be compiler-checked.
|
||||
*/
|
||||
export type IAiRoleUpdateFromCatalogResult =
|
||||
| {
|
||||
updated: false;
|
||||
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
|
||||
}
|
||||
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
@@ -116,6 +185,9 @@ export interface IAiChatMessageRow {
|
||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||
// floating window's header badge.
|
||||
contextTokens?: number;
|
||||
// The model's max context window (denominator for the header badge); set
|
||||
// alongside contextTokens on a completed turn; absent on older rows.
|
||||
maxContextTokens?: number;
|
||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||
error?: string;
|
||||
@@ -128,6 +200,38 @@ export interface IAiChatMessageRow {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
|
||||
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
|
||||
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
|
||||
* for display/diagnostics. The DB is the source of truth, so this resolves for an
|
||||
* in-flight run (the browser dropped, the run kept going) and a finished one.
|
||||
*/
|
||||
export interface IAiChatRun {
|
||||
id: string;
|
||||
chatId: string;
|
||||
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
|
||||
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
|
||||
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
|
||||
error?: string | null;
|
||||
stepCount?: number;
|
||||
assistantMessageId?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
|
||||
* assistant message it materializes (the partial/final output, projected from the
|
||||
* persisted rows). Both are `null` when the chat has never had a run.
|
||||
*/
|
||||
export interface IAiChatRunResponse {
|
||||
run: IAiChatRun | null;
|
||||
message: IAiChatMessageRow | null;
|
||||
}
|
||||
|
||||
export interface IAiChatListParams extends QueryParams {}
|
||||
|
||||
export interface IAiChatMessagesParams {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
|
||||
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Build a workspace role with a catalog source. Fields irrelevant to the
|
||||
// install-state decision are filled with harmless defaults.
|
||||
function installedRole(
|
||||
source: { slug: string; language: string; version: number },
|
||||
overrides: Partial<IAiRole> = {},
|
||||
): IAiRole {
|
||||
return {
|
||||
id: `role-${source.slug}-${source.language}`,
|
||||
name: source.slug,
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const catalogRole = { slug: "writer", version: 3 };
|
||||
|
||||
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
|
||||
// pure function so the import/installed/update decision is testable directly.
|
||||
describe("catalogRoleInstallState", () => {
|
||||
it("no matching installed role -> import", () => {
|
||||
const result = catalogRoleInstallState(catalogRole, [], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version > catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version == catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 3,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version < catalog -> update (from/to)", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 1,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("same slug but DIFFERENT language -> import (a separate install)", () => {
|
||||
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
|
||||
// fresh import, not treat the ru copy as already installed.
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "ru",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("matches the right language when the same slug is installed in several", () => {
|
||||
const ru = installedRole(
|
||||
{ slug: "writer", language: "ru", version: 5 },
|
||||
{ id: "ru-role" },
|
||||
);
|
||||
const en = installedRole(
|
||||
{ slug: "writer", language: "en", version: 1 },
|
||||
{ id: "en-role" },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed: en,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores manually-created roles (no source) sharing the name", () => {
|
||||
const manual = installedRole(
|
||||
{ slug: "writer", language: "en", version: 9 },
|
||||
{ source: null },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [manual], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* The install state of a single catalog role relative to the workspace's
|
||||
* existing roles. Extracted as a pure function so the catalog modal's role-state
|
||||
* computation is unit-testable without mounting the component (mirrors the
|
||||
* `roleLaunchMessage` precedent in role-launch.ts).
|
||||
*
|
||||
* A catalog role is matched to an installed role by BOTH `source.slug` and
|
||||
* `source.language`: the same slug in a different language is a separate install
|
||||
* (so it shows as "import", not "installed"). When matched, the installed source
|
||||
* version decides the state:
|
||||
* - no match -> "import"
|
||||
* - matched & installed version >= catalog version -> "installed"
|
||||
* - matched & installed version < catalog version -> "update" (from -> to)
|
||||
*/
|
||||
export type CatalogRoleInstallState =
|
||||
| { state: "import" }
|
||||
| { state: "installed"; installed: IAiRole }
|
||||
| {
|
||||
state: "update";
|
||||
installed: IAiRole;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
};
|
||||
|
||||
export function catalogRoleInstallState(
|
||||
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogRoleInstallState {
|
||||
const installed = workspaceRoles.find(
|
||||
(r) => r.source?.slug === role.slug && r.source?.language === language,
|
||||
);
|
||||
if (!installed) return { state: "import" };
|
||||
const fromVersion = installed.source?.version ?? 0;
|
||||
if (fromVersion >= role.version) {
|
||||
return { state: "installed", installed };
|
||||
}
|
||||
return {
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion,
|
||||
toVersion: role.version,
|
||||
};
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
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");
|
||||
});
|
||||
|
||||
it("appends the reasoning figure to the row footer when reasoningTokens > 0", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 8, reasoningTokens: 3 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("_Tokens — in: 10, out: 8, reasoning: 3, total: 18_");
|
||||
});
|
||||
|
||||
it("omits the reasoning figure when reasoningTokens is 0 / absent", () => {
|
||||
const zero = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(zero).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
expect(zero).not.toContain("reasoning:");
|
||||
|
||||
const absent = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(absent).not.toContain("reasoning:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — pending / in-progress messages", () => {
|
||||
it("continues the heading numbering after the persisted rows", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "persisted" })],
|
||||
pending: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "live question" }],
|
||||
generating: false,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "live answer" }],
|
||||
generating: true,
|
||||
},
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("## 2. You");
|
||||
expect(md).toContain("## 3. AI agent");
|
||||
expect(md).toContain("live question");
|
||||
expect(md).toContain("live answer");
|
||||
});
|
||||
|
||||
it("flags a generating assistant pending message as still being generated", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "persisted" })],
|
||||
pending: [
|
||||
{
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "partial reply" }],
|
||||
generating: true,
|
||||
},
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("partial reply");
|
||||
expect(md).toContain("still being generated");
|
||||
});
|
||||
|
||||
it("renders a non-generating user pending message without the note", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "persisted" })],
|
||||
pending: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "my live message" }],
|
||||
generating: false,
|
||||
},
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("my live message");
|
||||
expect(md).not.toContain("still being generated");
|
||||
});
|
||||
|
||||
it("includes the pending messages in the metadata message count", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "a" }),
|
||||
row({ role: "assistant", content: "b" }),
|
||||
],
|
||||
pending: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "c" }],
|
||||
generating: false,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "d" }],
|
||||
generating: true,
|
||||
},
|
||||
],
|
||||
t,
|
||||
});
|
||||
// 2 persisted rows + 2 pending = 4.
|
||||
expect(md).toContain("- Messages: 4");
|
||||
});
|
||||
|
||||
it("emits the heading and note for a generating assistant with empty parts", () => {
|
||||
expect(() =>
|
||||
buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "persisted" })],
|
||||
pending: [
|
||||
{
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
generating: true,
|
||||
},
|
||||
],
|
||||
t,
|
||||
}),
|
||||
).not.toThrow();
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "persisted" })],
|
||||
pending: [
|
||||
{
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
generating: true,
|
||||
},
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("still being generated");
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Client-only Markdown builder for an AI agent chat. Serializes the already
|
||||
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
|
||||
* Markdown string suitable for copying to the clipboard. NO network call is
|
||||
* made and NO server/DB code is touched — this reuses the rich "request
|
||||
* internals" (tool calls with input/output, per-message token usage,
|
||||
* finish/error info) that the chat already holds client-side.
|
||||
*
|
||||
* Only role labels and tool action labels are localized via the passed-in `t`
|
||||
* translator; the structural document words (Input/Output/Error/Tokens/...) are
|
||||
* plain English constants because the output is a technical artifact.
|
||||
*/
|
||||
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
ToolUiPart,
|
||||
getToolName,
|
||||
toolRunState,
|
||||
toolLabelKey,
|
||||
} from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
|
||||
// Minimal translator signature compatible with react-i18next's `t`.
|
||||
type Translate = (key: string, values?: Record<string, unknown>) => string;
|
||||
|
||||
interface BuildChatMarkdownArgs {
|
||||
title: string | null;
|
||||
chatId: string;
|
||||
rows: IAiChatMessageRow[];
|
||||
/** In-progress, not-yet-persisted live messages (the current streaming
|
||||
* turn) to append after the persisted rows. `generating: true` adds a
|
||||
* note that the message is still being produced. */
|
||||
pending?: PendingMessage[];
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
/** A single AI SDK UIMessage part (text part or other). */
|
||||
interface TextLikePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** A live, not-yet-persisted message (current streaming turn) to append. */
|
||||
interface PendingMessage {
|
||||
role: "user" | "assistant" | string;
|
||||
parts: TextLikePart[];
|
||||
generating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||
* `String(value)` if serialization throws (e.g. a circular structure).
|
||||
*/
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
|
||||
* the longest backtick run inside the content, so embedded backticks (or even
|
||||
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
|
||||
*/
|
||||
function fence(code: string, lang = ""): string {
|
||||
const runs: string[] = code.match(/`+/g) ?? [];
|
||||
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const delim = "`".repeat(Math.max(3, longest + 1));
|
||||
return `${delim}${lang}\n${code}\n${delim}`;
|
||||
}
|
||||
|
||||
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
|
||||
function rowTokens(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Render one message's UIMessage parts into an array of Markdown blocks
|
||||
* (text blocks + tool blocks). Mirrors MessageItem's part handling. */
|
||||
function renderMessageParts(parts: TextLikePart[], t: Translate): string[] {
|
||||
const out: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
const text = (part.text ?? "").trim();
|
||||
// Skip empty/whitespace-only text parts (matches MessageItem).
|
||||
if (text.length > 0) out.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isToolPart =
|
||||
part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
if (!isToolPart) continue;
|
||||
|
||||
const tp = part as unknown as ToolUiPart;
|
||||
const name = getToolName(tp);
|
||||
const { key, values } = toolLabelKey(name);
|
||||
const label = t(key, values);
|
||||
const state = toolRunState(tp.state);
|
||||
|
||||
const toolLines: string[] = [
|
||||
`**Tool: ${label}** (\`${name}\`) — ${state}`,
|
||||
];
|
||||
if (tp.input !== undefined) {
|
||||
toolLines.push("Input:");
|
||||
toolLines.push(fence(stringify(tp.input), "json"));
|
||||
}
|
||||
if (tp.output !== undefined) {
|
||||
toolLines.push("Output:");
|
||||
toolLines.push(fence(stringify(tp.output), "json"));
|
||||
}
|
||||
if (tp.errorText) {
|
||||
toolLines.push(`**Error:** ${tp.errorText}`);
|
||||
}
|
||||
out.push(toolLines.join("\n\n"));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
||||
* export timestamp), so it is straightforward to unit-test.
|
||||
*/
|
||||
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
||||
const { title, chatId, rows, pending, t } = args;
|
||||
const blocks: string[] = [];
|
||||
|
||||
const heading = (title ?? "").trim() || t("Untitled chat");
|
||||
blocks.push(`# ${heading}`);
|
||||
|
||||
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
||||
const totalTokens = rows.reduce((sum, row) => {
|
||||
const usage = row.metadata?.usage;
|
||||
return usage ? sum + rowTokens(usage) : sum;
|
||||
}, 0);
|
||||
const meta = [
|
||||
`- Chat ID: \`${chatId}\``,
|
||||
`- Exported: ${new Date().toISOString()}`,
|
||||
`- Messages: ${rows.length + (pending?.length ?? 0)}`,
|
||||
];
|
||||
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
||||
blocks.push(meta.join("\n"));
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
blocks.push("---");
|
||||
|
||||
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You");
|
||||
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
||||
|
||||
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
||||
blocks.push(`<!-- ${row.createdAt} -->`);
|
||||
|
||||
// Resolve parts: prefer the rich persisted parts, else a single text part
|
||||
// built from the plain-text content (mirrors `rowToUiMessage`).
|
||||
const parts: TextLikePart[] =
|
||||
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
||||
? (row.metadata.parts as TextLikePart[])
|
||||
: [{ type: "text", text: row.content ?? "" }];
|
||||
|
||||
blocks.push(...renderMessageParts(parts, t));
|
||||
|
||||
if (row.metadata?.error) {
|
||||
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
|
||||
}
|
||||
|
||||
const usage = row.metadata?.usage;
|
||||
if (usage) {
|
||||
const total = usage.totalTokens ?? rowTokens(usage);
|
||||
// Reasoning (thinking) tokens are shown only when the provider reported a
|
||||
// positive count; old rows / non-reasoning providers omit it.
|
||||
const reasoning =
|
||||
usage.reasoningTokens && usage.reasoningTokens > 0
|
||||
? `, reasoning: ${usage.reasoningTokens}`
|
||||
: "";
|
||||
blocks.push(
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Append the in-progress, not-yet-persisted live messages (the current
|
||||
// streaming turn) after the persisted rows. Heading numbering CONTINUES from
|
||||
// the persisted rows. A `generating` assistant gets a note that the captured
|
||||
// response is partial; pending messages carry no usage/token footer yet.
|
||||
(pending ?? []).forEach((message, p) => {
|
||||
blocks.push("---");
|
||||
|
||||
const num = rows.length + p + 1;
|
||||
const roleLabel = message.role === "assistant" ? t("AI agent") : t("You");
|
||||
blocks.push(`## ${num}. ${roleLabel}`);
|
||||
|
||||
blocks.push(...renderMessageParts(message.parts, t));
|
||||
|
||||
// A generating assistant may have empty/no parts yet — still emit the
|
||||
// heading (above) and this note so the export shows the in-progress turn.
|
||||
if (message.generating === true) {
|
||||
blocks.push(
|
||||
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Blank line between blocks so the Markdown renders cleanly.
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
describe("collapseBlankLines", () => {
|
||||
it("collapses a run of 2+ newlines to a single newline", () => {
|
||||
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
|
||||
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("keeps single newlines untouched", () => {
|
||||
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
it("preserves blank lines INSIDE a fenced code block", () => {
|
||||
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
|
||||
// Prose blanks collapse; the blank lines between the ``` fences survive.
|
||||
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
|
||||
});
|
||||
|
||||
it("handles a tilde fence and preserves its interior blanks", () => {
|
||||
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
|
||||
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
|
||||
});
|
||||
|
||||
it("leaves an unclosed fence's remaining lines verbatim", () => {
|
||||
const src = "intro\n\n```\nstill\n\nopen";
|
||||
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
|
||||
});
|
||||
|
||||
it("is a no-op for text with no blank lines", () => {
|
||||
expect(collapseBlankLines("just one line")).toBe("just one line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
||||
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
||||
const loose =
|
||||
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
||||
expect(html).toContain("<li>item one</li>");
|
||||
expect(html).not.toContain("<li><p>");
|
||||
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
||||
expect(html).toContain("<ul>");
|
||||
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||
});
|
||||
|
||||
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
||||
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||
expect(html).toContain("<ol>");
|
||||
expect(html).toContain("<li>first</li>");
|
||||
expect(html).not.toContain("<li><p>");
|
||||
});
|
||||
|
||||
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
||||
const loose = "- a\n\n- b";
|
||||
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
|
||||
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
|
||||
|
||||
/**
|
||||
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
|
||||
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
|
||||
*
|
||||
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
|
||||
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
|
||||
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
|
||||
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
|
||||
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
|
||||
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
|
||||
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
|
||||
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
|
||||
* normal assistant answer (where paragraph spacing is intentional).
|
||||
*
|
||||
* Fenced code is preserved verbatim: a fence opens on a line whose first
|
||||
* non-space characters are ``` or ~~~ and closes on the next line that starts
|
||||
* with the same fence character. Blank lines between fences (significant for
|
||||
* code formatting) are never collapsed.
|
||||
*/
|
||||
export function collapseBlankLines(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
const out: string[] = [];
|
||||
let inFence = false;
|
||||
let fenceChar = "";
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const ch = fenceMatch[1][0];
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceChar = ch;
|
||||
} else if (ch === fenceChar) {
|
||||
inFence = false;
|
||||
}
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside a fenced block every line (including blanks) is significant.
|
||||
if (inFence) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
|
||||
// `\n` between the surrounding content lines.
|
||||
if (line.trim() === "") continue;
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the header context badge selection. Covers the two
|
||||
* non-obvious rules: numerator and denominator are each taken from the most
|
||||
* recent row carrying THAT value (they may live on different rows), and a fresh
|
||||
* row with a zero/absent value must NOT shadow an older positive one.
|
||||
*/
|
||||
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
content: null,
|
||||
metadata,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
describe("selectContextBadge", () => {
|
||||
it("returns zeros for empty / nullish input", () => {
|
||||
expect(selectContextBadge(undefined)).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect(selectContextBadge(null)).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect(selectContextBadge([])).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads both figures from the most recent row that carries them", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1500, maxContextTokens: 200000 }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("falls back to legacy usage total for older rows without contextTokens", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
|
||||
|
||||
expect(
|
||||
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
|
||||
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
|
||||
});
|
||||
|
||||
it("takes numerator and denominator from different rows", () => {
|
||||
// Freshest row (an error turn) carries contextTokens but no max; the older
|
||||
// completed turn carries the max. Each is picked from its own latest row.
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 800, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1200, error: "402: nope" }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("does not let a fresh zero/absent max shadow an older positive max", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1200, maxContextTokens: 0 }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("skips rows with null metadata", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 500, maxContextTokens: 200000 }),
|
||||
row(null),
|
||||
]),
|
||||
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("reports current > max as-is (no clamp)", () => {
|
||||
expect(
|
||||
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
|
||||
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
|
||||
});
|
||||
});
|
||||
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Derive the header context badge figures from the persisted message rows.
|
||||
*
|
||||
* - `contextTokens` (numerator): how much the conversation now occupies in the
|
||||
* model's context window. Read from the most recent row carrying a context
|
||||
* figure — `contextTokens` (final-step input+output) on rows recorded after
|
||||
* this shipped, else that turn's legacy `usage` total for older rows.
|
||||
* - `maxContextTokens` (denominator): the model's configured max window, stamped
|
||||
* alongside `contextTokens` on a completed turn.
|
||||
*
|
||||
* Each value is taken from the most recent row carrying THAT value
|
||||
* independently — they may land on different rows (e.g. a fresh error row can
|
||||
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
|
||||
* whichever is still unset. `0` means "no row has it" (older rows, or no
|
||||
* admin-configured limit); the badge then omits the value.
|
||||
*/
|
||||
export function selectContextBadge(
|
||||
messageRows: readonly IAiChatMessageRow[] | undefined | null,
|
||||
): { contextTokens: number; maxContextTokens: number } {
|
||||
let contextTokens = 0;
|
||||
let maxContextTokens = 0;
|
||||
if (!messageRows) return { contextTokens, maxContextTokens };
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (contextTokens === 0) {
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
contextTokens = meta.contextTokens;
|
||||
} else if (meta.usage) {
|
||||
const usage = meta.usage;
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) contextTokens = fallback;
|
||||
}
|
||||
}
|
||||
if (
|
||||
maxContextTokens === 0 &&
|
||||
typeof meta.maxContextTokens === "number" &&
|
||||
meta.maxContextTokens > 0
|
||||
) {
|
||||
maxContextTokens = meta.maxContextTokens;
|
||||
}
|
||||
if (contextTokens !== 0 && maxContextTokens !== 0) break;
|
||||
}
|
||||
return { contextTokens, maxContextTokens };
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import {
|
||||
estimateTokens,
|
||||
liveTurnTokens,
|
||||
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("returns 0 for the empty string", () => {
|
||||
@@ -25,95 +13,3 @@ describe("estimateTokens", () => {
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — estimate path", () => {
|
||||
it("is all zeros for an undefined message", () => {
|
||||
expect(liveTurnTokens(undefined)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is all zeros for a parts-less message", () => {
|
||||
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates output from text parts", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
|
||||
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
|
||||
});
|
||||
|
||||
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "12345678" },
|
||||
{ type: "text", text: "abcd" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
|
||||
});
|
||||
|
||||
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcd" }, // 1
|
||||
{ type: "tool-getPage", state: "output-available" }, // ignored
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcdefgh" }, // 2
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
|
||||
});
|
||||
|
||||
it("ignores non text/reasoning parts (tools, step-start)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "step-start" },
|
||||
{ type: "tool-getPage", state: "input-available" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — authoritative path", () => {
|
||||
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
|
||||
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "estimate would be tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
|
||||
it("treats missing reasoningTokens as 0 and keeps full output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "x" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 42 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
|
||||
});
|
||||
|
||||
it("never returns a negative output when reasoning exceeds reported output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
|
||||
});
|
||||
|
||||
it("falls back to the estimate when metadata has no usage object", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/**
|
||||
* Live token counting for a streaming AI-chat turn — split into REASONING
|
||||
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
|
||||
* `Thinking… · 60 tokens` next to its thinking indicator.
|
||||
* Rough client-side token estimation for AI-chat UI affordances.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so the live number is a
|
||||
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
|
||||
* once the server attaches it on a step/turn boundary (see the server's
|
||||
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
|
||||
* authoritative usage is present we return it verbatim (the number "jumps to
|
||||
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
|
||||
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
||||
* bundle, and be wrong for Gemini/Ollama anyway).
|
||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||
* ("Thinking · N tokens").
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -24,71 +17,3 @@ export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/** Authoritative per-step/turn usage the server attaches to message metadata. */
|
||||
export interface AuthoritativeUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** Live token split for a turn's tail (streaming) assistant message. */
|
||||
export interface LiveTurnTokens {
|
||||
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
|
||||
reasoning: number;
|
||||
/** Answer/output tokens (estimate, or authoritative when available). */
|
||||
output: number;
|
||||
/** True when the numbers come from authoritative server usage, not estimate. */
|
||||
authoritative: boolean;
|
||||
}
|
||||
|
||||
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
|
||||
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
|
||||
const meta = message?.metadata as
|
||||
| { usage?: AuthoritativeUsage }
|
||||
| undefined;
|
||||
const usage = meta?.usage;
|
||||
if (!usage || typeof usage !== "object") return undefined;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token split for the given (streaming) assistant message.
|
||||
*
|
||||
* Prefers AUTHORITATIVE `metadata.usage` when the server has attached it (at a
|
||||
* step/turn boundary, incl. `reasoningTokens`) — so the live counter snaps to the
|
||||
* provider's exact figures. Until then it returns a running ESTIMATE summed over
|
||||
* the message parts: `reasoning` parts feed the reasoning estimate, `text` parts
|
||||
* feed the output estimate. Multi-part / multi-step turns accumulate naturally
|
||||
* because every part of the turn is summed.
|
||||
*
|
||||
* Providers that don't stream reasoning text still surface a reasoning count once
|
||||
* the authoritative usage arrives (`usage.reasoningTokens`); on the pure estimate
|
||||
* path such a turn simply shows `reasoning: 0` until then.
|
||||
*/
|
||||
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
|
||||
if (!message) return { reasoning: 0, output: 0, authoritative: false };
|
||||
|
||||
const usage = metadataUsage(message);
|
||||
if (usage) {
|
||||
// Authoritative branch: outputTokens already INCLUDES reasoning tokens in the
|
||||
// AI SDK usage shape, so subtract reasoning out for the "answer" figure (never
|
||||
// go negative if a provider reports them inconsistently).
|
||||
const reasoning = usage.reasoningTokens ?? 0;
|
||||
const totalOutput = usage.outputTokens ?? 0;
|
||||
const output = Math.max(0, totalOutput - reasoning);
|
||||
return { reasoning, output, authoritative: true };
|
||||
}
|
||||
|
||||
let reasoning = 0;
|
||||
let output = 0;
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "reasoning") {
|
||||
reasoning += estimateTokens((part as { text?: string }).text ?? "");
|
||||
} else if (part.type === "text") {
|
||||
output += estimateTokens((part as { text?: string }).text ?? "");
|
||||
}
|
||||
}
|
||||
return { reasoning, output, authoritative: false };
|
||||
}
|
||||
|
||||
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for `messageSignature`, the cheap per-message content
|
||||
* signature that drives MessageItem's memo (a streaming row's signature must
|
||||
* change on every delta so it re-renders, while a finalized row's stays stable
|
||||
* so it is skipped). Each test exercises ONE change signal and asserts it flips
|
||||
* the signature; a content-identical clone must keep an EQUAL signature.
|
||||
*
|
||||
* The signature embeds `message.id` and `message.role`, so the `msg` factory
|
||||
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
|
||||
* identical content would get different signatures and the negative case would
|
||||
* be impossible to express.
|
||||
*/
|
||||
const msg = (
|
||||
parts: UIMessage["parts"],
|
||||
metadata?: unknown,
|
||||
): UIMessage =>
|
||||
({
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
|
||||
describe("messageSignature", () => {
|
||||
it("changes when a text part grows", () => {
|
||||
const before = msg([{ type: "text", text: "alpha" }]);
|
||||
const after = msg([{ type: "text", text: "alpha beta" }]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a new part is appended", () => {
|
||||
const before = msg([{ type: "text", text: "alpha" }]);
|
||||
const after = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a part's state flips", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "input-streaming" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a tool part gains an output", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
output: { ok: true },
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when a part gains an errorText", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-error" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-error",
|
||||
errorText: "boom",
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
|
||||
// The specifically-commented edge case: the authoritative turn total lands on
|
||||
// the final finish-step AFTER the reasoning text length and state are frozen.
|
||||
// Only the token count appears between these two snapshots, so the signature
|
||||
// MUST still flip — otherwise the "Thinking · N tokens" header would never
|
||||
// snap from the live estimate to the exact figure.
|
||||
const before = msg([
|
||||
{ type: "reasoning", text: "thinking", state: "done" } as never,
|
||||
]);
|
||||
const after = msg(
|
||||
[{ type: "reasoning", text: "thinking", state: "done" } as never],
|
||||
{ usage: { reasoningTokens: 42 } },
|
||||
);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when metadata.error appears", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }]);
|
||||
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "stop",
|
||||
});
|
||||
const after = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "aborted",
|
||||
});
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
|
||||
// A finalized row that is re-created as a fresh object (different parts array
|
||||
// by reference, same parts by value) must keep an EQUAL signature, so the
|
||||
// memo skips re-rendering it.
|
||||
const a = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||
]);
|
||||
const b = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||
]);
|
||||
expect(a).not.toBe(b);
|
||||
expect(messageSignature(a)).toBe(messageSignature(b));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-part-kind coupling guard for the load-bearing invariant documented at the
|
||||
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
|
||||
* MessageItem render body draws, or the memo freezes a stale row. This is an
|
||||
* executable lock for the part kinds rendered TODAY — read alongside
|
||||
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
|
||||
* helper (message-content.ts), which "mirrors MessageItem's render decisions
|
||||
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
|
||||
* signature. If a new visible field is rendered without being added here AND to
|
||||
* the signature, the corresponding assertion below should fail — that is the
|
||||
* guard. (This intentionally stops short of the render-descriptor refactor:
|
||||
* adding a part kind or a visible field still requires a human to extend both
|
||||
* the signature and this block.)
|
||||
*/
|
||||
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
|
||||
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
|
||||
it("flips when the visible text changes", () => {
|
||||
// Streaming is append-only, so the visible text only grows; the signature
|
||||
// samples its length, so the growth is the change signal.
|
||||
const before = msg([{ type: "text", text: "answer" }]);
|
||||
const after = msg([{ type: "text", text: "answer extended" }]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
|
||||
it("flips when the visible reasoning text changes", () => {
|
||||
const before = msg([
|
||||
{ type: "reasoning", text: "think", state: "streaming" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
|
||||
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
|
||||
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
|
||||
// finish-step after text length and state are frozen.
|
||||
const before = msg([
|
||||
{ type: "reasoning", text: "think", state: "done" } as never,
|
||||
]);
|
||||
const after = msg(
|
||||
[{ type: "reasoning", text: "think", state: "done" } as never],
|
||||
{ usage: { reasoningTokens: 99 } },
|
||||
);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
|
||||
it("flips when the run state changes (running ↔ done icon + label)", () => {
|
||||
// toolRunState(part.state) selects the spinner/check/error icon.
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "input-available" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when output arrives (drives the rendered citation links)", () => {
|
||||
// toolCitations reads part.output to render the "/p/{id}" anchors.
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-available" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
output: { id: "page-1", title: "Doc" },
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when errorText appears (the visible red error detail line)", () => {
|
||||
const before = msg([
|
||||
{ type: "tool-getPage", state: "output-error" } as never,
|
||||
]);
|
||||
const after = msg([
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-error",
|
||||
errorText: "permission denied",
|
||||
} as never,
|
||||
]);
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata banners — render draws error / aborted notices", () => {
|
||||
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }]);
|
||||
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
|
||||
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
|
||||
const before = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "stop",
|
||||
});
|
||||
const after = msg([{ type: "text", text: "answer" }], {
|
||||
finishReason: "aborted",
|
||||
});
|
||||
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/** Cheap content signature for one message: changes iff something VISIBLE in the
|
||||
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
|
||||
* appended, a tool/text part flips state once), so a per-part [type, text
|
||||
* length, state, error/output presence] tuple + the persisted metadata
|
||||
* (error/finishReason) is a sufficient change signal without comparing full
|
||||
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
|
||||
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
|
||||
* text length, state, error/output presence] (e.g. a tool that streams
|
||||
* `preliminary` output, or a client-side regenerate that edits a finalized
|
||||
* row in place), extend this signature or the memo will freeze a stale row. */
|
||||
export function messageSignature(message: UIMessage): string {
|
||||
const parts = message.parts
|
||||
.map((p) => {
|
||||
const any = p as {
|
||||
type: string;
|
||||
text?: string;
|
||||
state?: string;
|
||||
errorText?: string;
|
||||
output?: unknown;
|
||||
};
|
||||
return [
|
||||
any.type,
|
||||
any.text?.length ?? 0,
|
||||
any.state ?? "",
|
||||
any.errorText ? 1 : 0,
|
||||
any.output !== undefined ? 1 : 0,
|
||||
].join(":");
|
||||
})
|
||||
.join("|");
|
||||
const meta = message.metadata as
|
||||
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
|
||||
| undefined;
|
||||
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
|
||||
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
|
||||
// state are already frozen. Without it in the signature the row's signature would be
|
||||
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
|
||||
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
|
||||
// to the exact figure.
|
||||
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
|
||||
meta?.finishReason ?? ""
|
||||
}#${meta?.usage?.reasoningTokens ?? ""}`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enqueueMessage,
|
||||
dequeue,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "./queue-helpers";
|
||||
@@ -89,6 +90,52 @@ describe("removeQueuedById", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("promoteToHead", () => {
|
||||
it("moves the matching id to the front, preserving the rest's order", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
{ id: "c", text: "third" },
|
||||
];
|
||||
expect(promoteToHead(queue, "c")).toEqual([
|
||||
{ id: "c", text: "third" },
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op order-wise when the id is already the head", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "a")).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an equivalent list when the id is not present", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
expect(promoteToHead(queue, "missing")).toEqual(queue);
|
||||
});
|
||||
|
||||
it("does not mutate the input queue", () => {
|
||||
const queue: QueuedMessage[] = [
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
];
|
||||
promoteToHead(queue, "b");
|
||||
expect(queue).toEqual([
|
||||
{ id: "a", text: "first" },
|
||||
{ id: "b", text: "second" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIFO order", () => {
|
||||
it("preserves order across enqueue -> dequeue", () => {
|
||||
let queue: QueuedMessage[] = [];
|
||||
|
||||
@@ -32,3 +32,16 @@ export function removeQueuedById(
|
||||
): QueuedMessage[] {
|
||||
return queue.filter((m) => m.id !== id);
|
||||
}
|
||||
|
||||
/** Move the queued message with the given id to the FRONT (returns a new array).
|
||||
* No-op (returns an equivalent array) when the id is absent. Pure — backs the
|
||||
* "send now" action: promoting a message to the head lets the existing
|
||||
* onFinish -> flushNext path send exactly that message on the abort we trigger. */
|
||||
export function promoteToHead(
|
||||
queue: QueuedMessage[],
|
||||
id: string,
|
||||
): QueuedMessage[] {
|
||||
const target = queue.find((m) => m.id === id);
|
||||
if (!target) return queue;
|
||||
return [target, ...queue.filter((m) => m.id !== id)];
|
||||
}
|
||||
|
||||
104
apps/client/src/features/ai-chat/utils/run-polling.test.ts
Normal file
104
apps/client/src/features/ai-chat/utils/run-polling.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
RUN_POLL_INTERVAL_MS,
|
||||
isRunActive,
|
||||
runPollInterval,
|
||||
shouldObserveRun,
|
||||
mergeObservedMessage,
|
||||
} from "./run-polling.ts";
|
||||
|
||||
function makeRun(status: string): IAiChatRun {
|
||||
return { id: "run-1", chatId: "c1", status };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isRunActive", () => {
|
||||
it("treats pending and running as active", () => {
|
||||
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats terminal / unknown / nullish as not active", () => {
|
||||
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||
expect(isRunActive(null)).toBe(false);
|
||||
expect(isRunActive(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||
it("returns 2000ms while the run is pending/running", () => {
|
||||
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||
});
|
||||
|
||||
it("returns false (stop polling) once the run is terminal", () => {
|
||||
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false (no polling) when there is no run", () => {
|
||||
expect(runPollInterval(null)).toBe(false);
|
||||
expect(runPollInterval(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT observe when there is no run", () => {
|
||||
expect(shouldObserveRun(null, false)).toBe(false);
|
||||
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeObservedMessage", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the observed message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const observed = makeMsg("a1", "first token");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
71
apps/client/src/features/ai-chat/utils/run-polling.ts
Normal file
71
apps/client/src/features/ai-chat/utils/run-polling.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||
* assistant message into the rendered thread. These are the small pure decisions
|
||||
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||
*/
|
||||
|
||||
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||
// so a stale/odd value never polls forever).
|
||||
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||
|
||||
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||
* reaching a terminal status — no separate timeout cap is needed.
|
||||
*/
|
||||
export function runPollInterval(
|
||||
run: IAiChatRun | null | undefined,
|
||||
): number | false {
|
||||
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||
* stopped separately by {@link runPollInterval}.
|
||||
*/
|
||||
export function shouldObserveRun(
|
||||
run: IAiChatRun | null | undefined,
|
||||
localStreaming: boolean,
|
||||
): boolean {
|
||||
return !!run && !localStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an observed assistant message into the rendered list: replace the message
|
||||
* with the same id in place (the in-progress assistant row is already seeded from
|
||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||
* new array; the input is never mutated.
|
||||
*/
|
||||
export function mergeObservedMessage(
|
||||
messages: UIMessage[],
|
||||
observed: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!observed) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||
if (idx === -1) return [...messages, observed];
|
||||
const next = messages.slice();
|
||||
next[idx] = observed;
|
||||
return next;
|
||||
}
|
||||
@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import { ColorSelector } from "./color-selector";
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { templateMode = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const user = useAtomValue(userAtom);
|
||||
const editorToolbarEnabled =
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
||||
const showLinkMenuRef = useRef(showLinkMenu);
|
||||
|
||||
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
}, [showCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
showAiMenuRef.current = showAiMenu;
|
||||
}, [showAiMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
showLinkMenuRef.current = showLinkMenu;
|
||||
}, [showLinkMenu]);
|
||||
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
empty ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
showAiMenuRef.current ||
|
||||
showLinkMenuRef.current ||
|
||||
showCommentPopupRef?.current
|
||||
) {
|
||||
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
// Hide the bubble menu immediately when AI menu is shown
|
||||
if (showAiMenu || showLinkMenu) return;
|
||||
// Hide the bubble menu immediately when the link menu is shown
|
||||
if (showLinkMenu) return;
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
style={{ zIndex: 199, position: "relative" }}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
{isGenerativeAiEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className={clsx(classes.buttonRoot)}
|
||||
radius="0"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => {
|
||||
setShowAiMenu(true);
|
||||
}}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)}
|
||||
{!editorToolbarEnabled && (
|
||||
<>
|
||||
<NodeSelector
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
sortFrequentlyUsedEmoji,
|
||||
getFrequentlyUsedEmoji,
|
||||
LOCAL_STORAGE_FREQUENT_KEY,
|
||||
} from "./utils";
|
||||
|
||||
describe("sortFrequentlyUsedEmoji", () => {
|
||||
it("orders known emoji by descending usage count", async () => {
|
||||
const result = await sortFrequentlyUsedEmoji({
|
||||
rocket: 1,
|
||||
joy: 9,
|
||||
heart_eyes: 5,
|
||||
});
|
||||
expect(result.map((e) => e.id)).toEqual(["joy", "heart_eyes", "rocket"]);
|
||||
});
|
||||
|
||||
it("caps the result at the top 5 most frequent", async () => {
|
||||
const result = await sortFrequentlyUsedEmoji({
|
||||
rocket: 1,
|
||||
joy: 2,
|
||||
heart_eyes: 3,
|
||||
grinning: 4,
|
||||
laughing: 5,
|
||||
scream: 6,
|
||||
sweat_smile: 7,
|
||||
});
|
||||
expect(result).toHaveLength(5);
|
||||
// Highest counts retained, lowest (rocket:1, joy:2) dropped.
|
||||
expect(result.map((e) => e.id)).toEqual([
|
||||
"sweat_smile",
|
||||
"scream",
|
||||
"laughing",
|
||||
"grinning",
|
||||
"heart_eyes",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops ids that have no matching emoji in the index", async () => {
|
||||
const result = await sortFrequentlyUsedEmoji({
|
||||
__definitely_not_a_real_emoji_id__: 100,
|
||||
rocket: 1,
|
||||
});
|
||||
expect(result.map((e) => e.id)).toEqual(["rocket"]);
|
||||
});
|
||||
|
||||
it("maps each entry to its native glyph and a command", async () => {
|
||||
const [entry] = await sortFrequentlyUsedEmoji({ rocket: 5 });
|
||||
expect(entry.id).toBe("rocket");
|
||||
expect(typeof entry.emoji).toBe("string");
|
||||
expect(entry.emoji.length).toBeGreaterThan(0);
|
||||
expect(typeof entry.command).toBe("function");
|
||||
});
|
||||
|
||||
it("returns an empty list for empty input", async () => {
|
||||
expect(await sortFrequentlyUsedEmoji({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFrequentlyUsedEmoji", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("falls back to the default map when nothing is stored", () => {
|
||||
const result = getFrequentlyUsedEmoji();
|
||||
expect(result["+1"]).toBe(10);
|
||||
expect(result["rocket"]).toBe(1);
|
||||
});
|
||||
|
||||
it("parses a valid stored JSON map", () => {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_FREQUENT_KEY,
|
||||
JSON.stringify({ rocket: 42 }),
|
||||
);
|
||||
expect(getFrequentlyUsedEmoji()).toEqual({ rocket: 42 });
|
||||
});
|
||||
|
||||
// BUG (issue #204, Phase 2): getFrequentlyUsedEmoji() does an unprotected
|
||||
// JSON.parse() of the raw localStorage value. A corrupt value (e.g. truncated
|
||||
// by a crash, or written by another tab/extension) makes the emoji menu throw
|
||||
// on open instead of degrading gracefully to the default set.
|
||||
//
|
||||
// Documented with it.fails: this asserts the DESIRED behavior (return a sane
|
||||
// default, never throw). It currently FAILS because the function throws —
|
||||
// flip to `it()` once utils.ts guards the JSON.parse.
|
||||
it.fails(
|
||||
"should degrade to a sane default on corrupt localStorage (currently throws)",
|
||||
() => {
|
||||
localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, "{not valid json");
|
||||
let result: Record<string, number> | undefined;
|
||||
expect(() => {
|
||||
result = getFrequentlyUsedEmoji();
|
||||
}).not.toThrow();
|
||||
// Should hand back a usable, non-empty map rather than nothing.
|
||||
expect(result).toBeTruthy();
|
||||
expect(Object.keys(result ?? {}).length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
|
||||
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 { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
type FixedToolbarProps = {
|
||||
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
||||
const editor = editorProp ?? editorFromAtom;
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
|
||||
if (!editor || !state) return null;
|
||||
|
||||
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={classes.inner}>
|
||||
{/* {isGenerativeAiEnabled && (
|
||||
<>
|
||||
<AskAiGroup />
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)} */}
|
||||
<BlockTypeGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<InlineMarksGroup editor={editor} state={state} />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export const AskAiGroup: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const setShowAiMenu = useSetAtom(showAiMenuAtom);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="xs"
|
||||
leftSection={<IconSparkles size={14} />}
|
||||
onClick={() => setShowAiMenu(true)}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FC } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
color?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI "generate title" button (#199). Reads the live editor content and applies a
|
||||
* model-suggested title immediately. Rendered in the page byline, only in edit
|
||||
* mode and when the workspace's AI chat flag is on.
|
||||
*/
|
||||
export const GenerateTitleGroup: FC<Props> = ({
|
||||
pageId,
|
||||
color = "gray",
|
||||
iconSize = 20,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const gen = useGeneratePageTitle(pageId);
|
||||
|
||||
return (
|
||||
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={color}
|
||||
aria-label={t("Generate title with AI")}
|
||||
loading={gen.isPending}
|
||||
onClick={() => gen.mutate()}
|
||||
>
|
||||
<IconSparkles size={iconSize} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,45 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFootnoteNumber } from "@docmost/editor-ext";
|
||||
import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
|
||||
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
|
||||
*/
|
||||
export function backlinkLabel(index: number): string {
|
||||
let out = "";
|
||||
let x = index;
|
||||
while (x >= 0) {
|
||||
out = String.fromCharCode(97 + (x % 26)) + out;
|
||||
x = Math.floor(x / 26) - 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* After #166 a footnote can be referenced more than once (one number, one
|
||||
* definition, N forward links). When it is, the back-link becomes a row of
|
||||
* per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
|
||||
* a single-reference footnote keeps the plain ↩.
|
||||
*/
|
||||
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.
|
||||
// Read the cached number/ref-count 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 refCount = getFootnoteRefCount(editor.state, id);
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
const jumpTo = (e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
editor.commands.scrollToReference(id);
|
||||
editor.commands.scrollToReference(id, index);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -42,16 +62,47 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
>
|
||||
{number}.
|
||||
</span>
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
onClick={handleBack}
|
||||
role="button"
|
||||
aria-label={t("Back to reference")}
|
||||
title={t("Back to reference")}
|
||||
>
|
||||
↩
|
||||
</span>
|
||||
{refCount > 1 ? (
|
||||
// Multiple references -> ↩ followed by one lettered link per occurrence.
|
||||
<span
|
||||
className={classes.backLinks}
|
||||
contentEditable={false}
|
||||
role="group"
|
||||
aria-label={t("Back to references")}
|
||||
>
|
||||
<span className={classes.backLinkArrow} aria-hidden="true">
|
||||
↩
|
||||
</span>
|
||||
{Array.from({ length: refCount }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={classes.backLink}
|
||||
onClick={(e) => jumpTo(e, i)}
|
||||
role="button"
|
||||
aria-label={t("Back to reference {{label}}", {
|
||||
label: backlinkLabel(i),
|
||||
})}
|
||||
title={t("Back to reference {{label}}", {
|
||||
label: backlinkLabel(i),
|
||||
})}
|
||||
>
|
||||
{backlinkLabel(i)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
// Single reference -> the plain ↩ (unchanged behavior).
|
||||
<span
|
||||
className={classes.backLink}
|
||||
contentEditable={false}
|
||||
onClick={(e) => jumpTo(e, 0)}
|
||||
role="button"
|
||||
aria-label={t("Back to reference")}
|
||||
title={t("Back to reference")}
|
||||
>
|
||||
↩
|
||||
</span>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
|
||||
/**
|
||||
* Structural regression guard for #146 (PR #147).
|
||||
@@ -36,10 +36,14 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// footnote-definition-view reads a cached number from the numbering plugin;
|
||||
// stub it so we don't need a live ProseMirror state.
|
||||
// footnote-definition-view reads a cached number + reference count from the
|
||||
// numbering plugin; stub them so we don't need a live ProseMirror state. The
|
||||
// ref-count is a hoisted mutable so a test can drive the single-vs-multi
|
||||
// backlink branch (#168). Default 1 = single reference (the #146 cases).
|
||||
const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
|
||||
vi.mock("@docmost/editor-ext", () => ({
|
||||
getFootnoteNumber: () => 1,
|
||||
getFootnoteRefCount: () => mockRefCount.value,
|
||||
}));
|
||||
|
||||
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
|
||||
@@ -59,7 +63,8 @@ vi.mock("@mantine/core", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/common/copy-button", () => ({
|
||||
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
|
||||
CopyButton: ({ children }: any) =>
|
||||
children({ copied: false, copy: () => {} }),
|
||||
}));
|
||||
vi.mock("@tabler/icons-react", () => ({
|
||||
IconCheck: () => null,
|
||||
@@ -70,7 +75,9 @@ vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
|
||||
}));
|
||||
|
||||
import FootnotesListView from "./footnotes-list-view";
|
||||
import FootnoteDefinitionView from "./footnote-definition-view";
|
||||
import FootnoteDefinitionView, {
|
||||
backlinkLabel,
|
||||
} from "./footnote-definition-view";
|
||||
import CodeBlockView from "../code-block/code-block-view";
|
||||
|
||||
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
|
||||
@@ -141,3 +148,84 @@ describe("#146 editable NodeView contentDOM-first invariant", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// #168: a footnote referenced more than once shows one lettered backlink per
|
||||
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
|
||||
// footnote keeps the plain ↩.
|
||||
describe("#168 footnote definition multi-backlinks", () => {
|
||||
afterEach(() => {
|
||||
// Reset the shared ref-count mock so other tests see a single reference.
|
||||
mockRefCount.value = 1;
|
||||
});
|
||||
|
||||
const makeProps = () =>
|
||||
({
|
||||
node: { attrs: { id: "fn-1" }, textContent: "" },
|
||||
editor: {
|
||||
state: {},
|
||||
isEditable: true,
|
||||
commands: { scrollToReference: vi.fn() },
|
||||
},
|
||||
getPos: () => 0,
|
||||
updateAttributes: () => {},
|
||||
deleteNode: () => {},
|
||||
}) as any;
|
||||
|
||||
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
|
||||
mockRefCount.value = 3;
|
||||
const { getByTestId } = render(<FootnoteDefinitionView {...makeProps()} />);
|
||||
const wrapper = getByTestId("nvw");
|
||||
|
||||
const links = wrapper.querySelectorAll('[role="button"]');
|
||||
expect(Array.from(links).map((l) => l.textContent)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
// The ↩ arrow is present (as decorative chrome, not a button).
|
||||
expect(wrapper.textContent).toContain("↩");
|
||||
});
|
||||
|
||||
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
|
||||
mockRefCount.value = 3;
|
||||
const props = makeProps();
|
||||
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
|
||||
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
|
||||
|
||||
fireEvent.click(links[1]); // "b"
|
||||
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
|
||||
"fn-1",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("a single-reference footnote renders just one ↩ (no letters)", () => {
|
||||
mockRefCount.value = 1;
|
||||
const props = makeProps();
|
||||
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
|
||||
const wrapper = getByTestId("nvw");
|
||||
|
||||
const links = wrapper.querySelectorAll('[role="button"]');
|
||||
expect(links.length).toBe(1);
|
||||
expect(links[0].textContent).toBe("↩");
|
||||
|
||||
fireEvent.click(links[0]);
|
||||
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
|
||||
"fn-1",
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// #185 re-review pt 7: backlinkLabel is base-26 (a..z, then aa…). The component
|
||||
// tests only cover a,b,c (index 0-2); pin the >= 26 carry boundary.
|
||||
describe("backlinkLabel base-26 boundary (#168)", () => {
|
||||
it("maps 0->a, 25->z, 26->aa, 27->ab, 51->az, 52->ba", () => {
|
||||
expect(backlinkLabel(0)).toBe("a");
|
||||
expect(backlinkLabel(25)).toBe("z");
|
||||
expect(backlinkLabel(26)).toBe("aa");
|
||||
expect(backlinkLabel(27)).toBe("ab");
|
||||
expect(backlinkLabel(51)).toBe("az");
|
||||
expect(backlinkLabel(52)).toBe("ba");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +104,19 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
|
||||
which pushes the first text line ~0.5em below the "N." marker (aligned to
|
||||
flex-start), making the number float above the text. Drop the outer margins
|
||||
so the marker and the first line share the same top edge — same approach
|
||||
used for callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.definitionContent > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
@@ -115,3 +128,18 @@
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
|
||||
occurrence. Sits on the right, after the content, like the single ↩. */
|
||||
.backLinks {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.backLinkArrow {
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
isHeaderCell,
|
||||
sortItems,
|
||||
weaveItems,
|
||||
type SortableItem,
|
||||
} from "./sort-cells";
|
||||
|
||||
// isHeaderCell only reads node.type.name and node.attrs?.header, so a minimal
|
||||
// duck-typed node is sufficient (no real ProseMirror schema needed).
|
||||
function fakeNode(typeName: string, attrs: Record<string, unknown> = {}) {
|
||||
return { type: { name: typeName }, attrs } as unknown as ProseMirrorNode;
|
||||
}
|
||||
|
||||
function item<T>(
|
||||
payload: T,
|
||||
text: string,
|
||||
originalOrder: number,
|
||||
opts: { isHeader?: boolean; isEmpty?: boolean } = {},
|
||||
): SortableItem<T> {
|
||||
return {
|
||||
payload,
|
||||
text,
|
||||
originalOrder,
|
||||
isHeader: opts.isHeader ?? false,
|
||||
isEmpty: opts.isEmpty ?? text.trim() === "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("isHeaderCell", () => {
|
||||
it("recognizes the tableHeader node type", () => {
|
||||
expect(isHeaderCell(fakeNode("tableHeader"))).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes the snake_case table_header node type", () => {
|
||||
expect(isHeaderCell(fakeNode("table_header"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a plain cell with header:true attr as a header", () => {
|
||||
expect(isHeaderCell(fakeNode("tableCell", { header: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a regular body cell", () => {
|
||||
expect(isHeaderCell(fakeNode("tableCell", { header: false }))).toBe(false);
|
||||
expect(isHeaderCell(fakeNode("tableCell"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortItems", () => {
|
||||
it("sorts non-empty rows ascending using a base/numeric collator", () => {
|
||||
const data = [
|
||||
item("c", "cherry", 0),
|
||||
item("a", "Apple", 1),
|
||||
item("b", "banana", 2),
|
||||
];
|
||||
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts descending when direction is desc", () => {
|
||||
const data = [
|
||||
item("a", "apple", 0),
|
||||
item("b", "banana", 1),
|
||||
item("c", "cherry", 2),
|
||||
];
|
||||
expect(sortItems(data, "desc").map((i) => i.payload)).toEqual([
|
||||
"c",
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders numerically, not lexically (numeric collator)", () => {
|
||||
const data = [
|
||||
item("ten", "10", 0),
|
||||
item("two", "2", 1),
|
||||
item("one", "1", 2),
|
||||
];
|
||||
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"ten",
|
||||
]);
|
||||
});
|
||||
|
||||
it("always pushes empty cells to the bottom regardless of direction", () => {
|
||||
const data = [
|
||||
item("empty", "", 0, { isEmpty: true }),
|
||||
item("b", "banana", 1),
|
||||
item("a", "apple", 2),
|
||||
];
|
||||
const asc = sortItems(data, "asc");
|
||||
expect(asc.map((i) => i.payload)).toEqual(["a", "b", "empty"]);
|
||||
const desc = sortItems(data, "desc");
|
||||
// Empty stays last even when the rest is reversed.
|
||||
expect(desc[desc.length - 1].payload).toBe("empty");
|
||||
});
|
||||
|
||||
it("keeps empty cells in their original relative order (stable)", () => {
|
||||
const data = [
|
||||
item("e1", "", 5, { isEmpty: true }),
|
||||
item("e2", "", 2, { isEmpty: true }),
|
||||
item("a", "apple", 9),
|
||||
];
|
||||
const sorted = sortItems(data, "asc");
|
||||
// e2 (originalOrder 2) before e1 (originalOrder 5).
|
||||
expect(sorted.map((i) => i.payload)).toEqual(["a", "e2", "e1"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const data = [item("b", "banana", 0), item("a", "apple", 1)];
|
||||
const snapshot = data.map((i) => i.payload);
|
||||
sortItems(data, "asc");
|
||||
expect(data.map((i) => i.payload)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weaveItems", () => {
|
||||
it("keeps header rows pinned in place and fills body slots from sorted data", () => {
|
||||
const header = item("H", "Name", 0, { isHeader: true });
|
||||
const all = [
|
||||
header,
|
||||
item("orig-b", "b", 1),
|
||||
item("orig-a", "a", 2),
|
||||
];
|
||||
const sortedBody = [item("orig-a", "a", 2), item("orig-b", "b", 1)];
|
||||
|
||||
const woven = weaveItems(all, sortedBody);
|
||||
// Header never moves out of row 0...
|
||||
expect(woven[0]).toBe(header);
|
||||
// ...and the body positions are filled in sorted order.
|
||||
expect(woven.slice(1).map((i) => i.payload)).toEqual(["orig-a", "orig-b"]);
|
||||
});
|
||||
|
||||
it("does not consume body data for header positions (header stays at top)", () => {
|
||||
const header = item("H", "head", 0, { isHeader: true });
|
||||
const all = [header, item("x", "x", 1), item("y", "y", 2)];
|
||||
const sortedBody = [item("y", "y", 2), item("x", "x", 1)];
|
||||
const woven = weaveItems(all, sortedBody);
|
||||
expect(woven[0].isHeader).toBe(true);
|
||||
expect(woven.filter((i) => !i.isHeader).map((i) => i.payload)).toEqual([
|
||||
"y",
|
||||
"x",
|
||||
]);
|
||||
});
|
||||
|
||||
it("interleaves correctly when a header sits between body rows", () => {
|
||||
const header = item("H", "head", 1, { isHeader: true });
|
||||
const all = [
|
||||
item("b1", "b1", 0),
|
||||
header,
|
||||
item("b2", "b2", 2),
|
||||
];
|
||||
const sortedBody = [item("b2", "b2", 2), item("b1", "b1", 0)];
|
||||
const woven = weaveItems(all, sortedBody);
|
||||
expect(woven.map((i) => i.payload)).toEqual(["b2", "H", "b1"]);
|
||||
expect(woven[1]).toBe(header);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/features/editor/editor-sync-state.test.ts
Normal file
32
apps/client/src/features/editor/editor-sync-state.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
||||
|
||||
describe("isCollabSynced", () => {
|
||||
it("is true only when Connected and synced", () => {
|
||||
expect(isCollabSynced(WebSocketStatus.Connected, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false while connecting or not yet synced", () => {
|
||||
expect(isCollabSynced(WebSocketStatus.Connecting, true)).toBe(false);
|
||||
expect(isCollabSynced(WebSocketStatus.Connected, false)).toBe(false);
|
||||
expect(isCollabSynced(WebSocketStatus.Disconnected, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
|
||||
const base = { editable: true, inEditMode: true, showStatic: false };
|
||||
|
||||
it("allows editing only after the static (pre-sync) phase ends", () => {
|
||||
expect(isBodyEditable(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("never editable while the static read-only editor is shown", () => {
|
||||
expect(isBodyEditable({ ...base, showStatic: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("honors read-only and view mode", () => {
|
||||
expect(isBodyEditable({ ...base, editable: false })).toBe(false);
|
||||
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/features/editor/editor-sync-state.ts
Normal file
32
apps/client/src/features/editor/editor-sync-state.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
|
||||
/**
|
||||
* The collab document is usable only once the provider is Connected AND has
|
||||
* synced (both the local IndexedDB replica and the remote room). Until then the
|
||||
* in-browser Y.Doc is empty/stale, so edits would either be dropped or clobber
|
||||
* the server's authoritative doc when it finally arrives.
|
||||
*/
|
||||
export function isCollabSynced(
|
||||
status: WebSocketStatus | string,
|
||||
isSynced: boolean,
|
||||
): boolean {
|
||||
return status === WebSocketStatus.Connected && isSynced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page BODY editor may accept edits.
|
||||
*
|
||||
* `showStatic` is true during the pre-sync window (a read-only static editor is
|
||||
* shown). Gating editability on `!showStatic` guarantees the body never becomes
|
||||
* editable before the collab doc is synced, so early keystrokes on a freshly
|
||||
* created page can't land only in local ProseMirror and then be lost when the
|
||||
* server's initial empty doc syncs in (#218). Read-only and view modes are
|
||||
* still honored via `editable`/`inEditMode`.
|
||||
*/
|
||||
export function isBodyEditable(opts: {
|
||||
editable: boolean;
|
||||
inEditMode: boolean;
|
||||
showStatic: boolean;
|
||||
}): boolean {
|
||||
return opts.editable && opts.inEditMode && !opts.showStatic;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import {
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
import { canonicalizePastedFootnotes } from "./markdown-clipboard";
|
||||
|
||||
/**
|
||||
* A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is
|
||||
* applied with a manual transaction (handlePaste returns true), so it bypasses
|
||||
* the editor's footnoteSyncPlugin — which never reorders an existing list. These
|
||||
* tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted
|
||||
* out-of-order markdown footnote block come out canonical (issue #228).
|
||||
*/
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
];
|
||||
|
||||
function makeSchema() {
|
||||
const editor = new Editor({ extensions, content: { type: "doc", content: [] } });
|
||||
const { schema } = editor;
|
||||
return { editor, schema };
|
||||
}
|
||||
|
||||
/** List footnote def ids of the (single) footnotesList in a slice, in order. */
|
||||
function listIds(slice: Slice): string[] {
|
||||
const out: string[] = [];
|
||||
slice.content.forEach((node: PMNode) => {
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
||||
node.content.forEach((def: PMNode) => {
|
||||
if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasList(slice: Slice): boolean {
|
||||
let found = false;
|
||||
slice.content.forEach((n: PMNode) => {
|
||||
if (n.type.name === FOOTNOTES_LIST_NAME) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("canonicalizePastedFootnotes", () => {
|
||||
it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => {
|
||||
const { editor, schema } = makeSchema();
|
||||
// Body references c, a, b (and again a => reuse); definitions a, b, c, z
|
||||
// (z is an orphan) — the exact shape a markdown paste produces.
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.text("body "),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
]),
|
||||
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note A")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note C")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("orphan")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
// Reference order, orphan z dropped, reused a appears once.
|
||||
expect(listIds(out)).toEqual(["c", "a", "b"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => {
|
||||
// A paste that reuses an id defined in the TARGET doc must NOT gain a
|
||||
// synthesized empty definition here — it carries no footnotesList of its own.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.from(
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.text("see "),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
]),
|
||||
),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(hasList(out)).toBe(false);
|
||||
expect(out).toBe(slice); // returned unchanged (same reference)
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a definitions-ONLY paste untouched (no references -> no empty paste)", () => {
|
||||
// A whole-block paste of ONLY definitions (a footnotesList with no matching
|
||||
// footnoteReference anywhere in the selection). Canonicalizing it would strip
|
||||
// the reference-less list -> an EMPTY paste, losing the pasted text. The hook
|
||||
// must leave such a block untouched.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note A")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(out).toBe(slice); // returned unchanged (same reference, content kept)
|
||||
expect(listIds(out)).toEqual(["a", "b"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves an open (partial) slice untouched even if it carries a list", () => {
|
||||
// An open slice (openStart/openEnd > 0) is a partial selection, not a
|
||||
// standalone block, so it is returned as-is BEFORE any footnote handling.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
]),
|
||||
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("A")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(out).toBe(slice);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||
|
||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||
function root(html: string): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div;
|
||||
}
|
||||
|
||||
function firstRowColWidths(container: HTMLElement): (string | null)[] {
|
||||
const row = container.querySelector("tr");
|
||||
return Array.from(row?.children ?? []).map((c) =>
|
||||
c.getAttribute("colwidth"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("normalizeTableColumnWidths", () => {
|
||||
// The core "squash столбцов вставленной таблицы" concern: markdown has no
|
||||
// widths, so every pasted table would otherwise render at table-layout:fixed
|
||||
// / 100% and squash columns. This stamps an explicit per-column px width.
|
||||
it("stamps the default px width on every column when no widths are present", () => {
|
||||
const container = root(
|
||||
"<table><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["150", "150", "150"]);
|
||||
});
|
||||
|
||||
it("derives column widths from a colgroup", () => {
|
||||
const container = root(
|
||||
"<table>" +
|
||||
'<colgroup><col style="width:200px"><col style="width:80px"></colgroup>' +
|
||||
"<tbody><tr><td>a</td><td>b</td></tr></tbody>" +
|
||||
"</table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["200", "80"]);
|
||||
});
|
||||
|
||||
it("derives column widths from per-cell width attributes", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td width="120">a</td><td width="90">b</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["120", "90"]);
|
||||
});
|
||||
|
||||
it("derives column widths from a cell style:width:px", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td style="width:140px">a</td><td>b</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
// First cell width parsed; a fully-unmeasured column is left untouched
|
||||
// (the 100 fallback only fills in NULL gaps inside an otherwise-measured
|
||||
// multi-column slice, e.g. a colspan).
|
||||
expect(firstRowColWidths(container)).toEqual(["140", null]);
|
||||
});
|
||||
|
||||
it("fills a null gap inside a measured colspanned slice with 100", () => {
|
||||
// colgroup gives [200, null]; the single colspan=2 cell spans both, so its
|
||||
// slice is [200, null] -> the null is backfilled to 100 => "200,100".
|
||||
const container = root(
|
||||
"<table>" +
|
||||
'<colgroup><col style="width:200px"><col></colgroup>' +
|
||||
'<tbody><tr><td colspan="2">merged</td></tr></tbody>' +
|
||||
"</table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["200,100"]);
|
||||
});
|
||||
|
||||
it("splits a measured width across a colspanned cell", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td colspan="2" width="300">merged</td><td width="100">x</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
// 300 / colspan(2) = 150 per underlying column => "150,150" on the merged cell.
|
||||
expect(firstRowColWidths(container)).toEqual(["150,150", "100"]);
|
||||
});
|
||||
|
||||
it("falls back to the default width per spanned column when nothing is measurable", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td colspan="2">merged</td><td>x</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["150,150", "150"]);
|
||||
});
|
||||
|
||||
it("leaves cells that already have a colwidth untouched", () => {
|
||||
const container = root(
|
||||
'<table><tbody><tr><td colwidth="42">a</td><td>b</td></tr></tbody></table>',
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
expect(firstRowColWidths(container)).toEqual(["42", "150"]);
|
||||
});
|
||||
|
||||
it("normalizes every table in the subtree", () => {
|
||||
const container = root(
|
||||
"<table><tbody><tr><td>a</td></tr></tbody></table>" +
|
||||
"<table><tbody><tr><td>b</td><td>c</td></tr></tbody></table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
const tables = container.querySelectorAll("table");
|
||||
const widths = Array.from(tables).map((t) =>
|
||||
Array.from(t.querySelector("tr")!.children).map((c) =>
|
||||
c.getAttribute("colwidth"),
|
||||
),
|
||||
);
|
||||
expect(widths).toEqual([["150"], ["150", "150"]]);
|
||||
});
|
||||
|
||||
it("only annotates the first row (column widths are defined once)", () => {
|
||||
const container = root(
|
||||
"<table><tbody>" +
|
||||
"<tr><td>a</td><td>b</td></tr>" +
|
||||
"<tr><td>c</td><td>d</td></tr>" +
|
||||
"</tbody></table>",
|
||||
);
|
||||
normalizeTableColumnWidths(container);
|
||||
const rows = container.querySelectorAll("tr");
|
||||
expect(
|
||||
Array.from(rows[1].children).map((c) => c.getAttribute("colwidth")),
|
||||
).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,14 @@ import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
markdownToHtml,
|
||||
htmlToMarkdown,
|
||||
canonicalizeFootnotes,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
import type { Schema } from "@tiptap/pm/model";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
@@ -83,12 +90,25 @@ export const MarkdownClipboard = Extension.create({
|
||||
const body = elementFromString(parsed);
|
||||
normalizeTableColumnWidths(body);
|
||||
|
||||
const contentNodes = DOMParser.fromSchema(
|
||||
const parsedSlice = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(body, {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
|
||||
// A markdown paste builds its ProseMirror fragment directly (DOM ->
|
||||
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
|
||||
// reorders an existing list. So a pasted markdown block whose footnote
|
||||
// definitions are out of order (or contains orphan defs) would be
|
||||
// stored out of order. Canonicalize the self-contained pasted block so
|
||||
// its footnotes come out reference-ordered, deduped and orphan-free
|
||||
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
|
||||
// to whole-block pastes that carry their own footnotesList.
|
||||
const contentNodes = canonicalizePastedFootnotes(
|
||||
parsedSlice,
|
||||
this.editor.schema,
|
||||
);
|
||||
|
||||
tr.replaceRange(from, to, contentNodes);
|
||||
const insertEnd = tr.mapping.map(from, 1);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
||||
@@ -133,6 +153,54 @@ export const MarkdownClipboard = Extension.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||
* list, so an out-of-order pasted block would otherwise persist out of order).
|
||||
*
|
||||
* Scoped deliberately to whole-block pastes (openStart/openEnd === 0) that carry
|
||||
* their OWN footnotesList: canonicalizeFootnotes would synthesize empty
|
||||
* definitions for any reference lacking a definition, which is correct for a
|
||||
* standalone block but would be wrong for a reference-only paste that REUSES a
|
||||
* footnote already defined in the target document — so those are left untouched
|
||||
* for the paste/sync plugins to merge. Residual: when the pasted block is merged
|
||||
* into a doc that already has footnotes, ordering RELATIVE to the pre-existing
|
||||
* footnotes is still governed by the sync plugin (which does not reorder).
|
||||
*
|
||||
* Also requires at least one footnoteReference in the selection: a definitions-ONLY
|
||||
* paste (`[^a]: …` with no `[^a]` reference in the same block) has no references,
|
||||
* so canonicalizeFootnotes would drop the whole list and the paste would come out
|
||||
* EMPTY — losing the pasted text. Such a block is left as-is for the sync plugin.
|
||||
*/
|
||||
export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice {
|
||||
if (slice.openStart !== 0 || slice.openEnd !== 0) return slice;
|
||||
|
||||
let hasFootnotesList = false;
|
||||
let hasReference = false;
|
||||
slice.content.forEach((node) => {
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true;
|
||||
// footnoteReference is an inline atom, never a top-level slice child here
|
||||
// (this function early-returns for open slices, so children are whole
|
||||
// blocks), so it is only reachable by descending.
|
||||
node.descendants((child) => {
|
||||
if (child.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true;
|
||||
});
|
||||
});
|
||||
if (!hasFootnotesList) return slice;
|
||||
// No reference anywhere -> a definitions-only paste; canonicalizing would strip
|
||||
// the reference-less list (empty paste). Leave it untouched.
|
||||
if (!hasReference) return slice;
|
||||
|
||||
const content = slice.content.toJSON();
|
||||
if (!Array.isArray(content)) return slice;
|
||||
|
||||
const canonical = canonicalizeFootnotes({ type: "doc", content }) as {
|
||||
content?: unknown[];
|
||||
};
|
||||
const fragment = Fragment.fromJSON(schema, canonical.content ?? []);
|
||||
return new Slice(fragment, 0, 0);
|
||||
}
|
||||
|
||||
function elementFromString(value) {
|
||||
// add a wrapper to preserve leading and trailing whitespace
|
||||
const wrappedValue = `<body>${value}</body>`;
|
||||
|
||||
@@ -26,17 +26,20 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
|
||||
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 { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
pageEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
|
||||
|
||||
type PageUser = {
|
||||
id: string;
|
||||
@@ -74,6 +77,9 @@ export function FullEditor({
|
||||
const [user] = useAtom(userAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
@@ -103,6 +109,7 @@ export function FullEditor({
|
||||
<MemoizedFixedToolbar />
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
@@ -111,11 +118,13 @@ export function FullEditor({
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
@@ -128,19 +137,23 @@ export function FullEditor({
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
pageId: string;
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
editable?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDictationEnabled?: boolean;
|
||||
isTitleGenEnabled?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
pageId,
|
||||
creator,
|
||||
contributors,
|
||||
editable,
|
||||
isEditMode,
|
||||
isDictationEnabled,
|
||||
isTitleGenEnabled,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||
@@ -148,6 +161,9 @@ function PageByline({
|
||||
const showDictation = Boolean(
|
||||
isDictationEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
const showTitleGen = Boolean(
|
||||
isTitleGenEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -238,6 +254,11 @@ function PageByline({
|
||||
{showDictation && editor && (
|
||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
||||
)}
|
||||
{/* Shown only in edit mode when the workspace's AI chat flag is on,
|
||||
so AI title generation stays reachable from the byline (#199). */}
|
||||
{showTitleGen && (
|
||||
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
// --- Mocks for the hook's collaborators ---------------------------------------
|
||||
|
||||
const generatePageTitleMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
generatePageTitle: (content: string) => generatePageTitleMock(content),
|
||||
}));
|
||||
|
||||
const updateTitleMock = vi.fn();
|
||||
const updatePageDataMock = vi.fn();
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
|
||||
updatePageData: (page: unknown) => updatePageDataMock(page),
|
||||
}));
|
||||
|
||||
const emitMock = vi.fn();
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => emitMock,
|
||||
}));
|
||||
|
||||
const localEmitMock = vi.fn();
|
||||
vi.mock("@/lib/local-emitter.ts", () => ({
|
||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||
}));
|
||||
|
||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
||||
// purely via the fake page editor's getHTML().
|
||||
vi.mock("@docmost/editor-ext", () => ({
|
||||
htmlToMarkdown: (html: string) => html,
|
||||
}));
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Import after mocks are registered.
|
||||
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
||||
|
||||
// --- Test helpers -------------------------------------------------------------
|
||||
|
||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
getHTML: () => html,
|
||||
storage: { pageId },
|
||||
} as unknown as Editor;
|
||||
}
|
||||
|
||||
function makeTitleEditor(): Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
isFocused: false,
|
||||
commands: { setContent: vi.fn() },
|
||||
} as unknown as Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
}
|
||||
|
||||
function setup(pageId: string, store = createStore()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
|
||||
wrapper,
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
const PAGE_A = {
|
||||
id: "pageA",
|
||||
title: "Generated Title",
|
||||
spaceId: "space1",
|
||||
slugId: "slugA",
|
||||
parentPageId: null,
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useGeneratePageTitle", () => {
|
||||
it("shows a notice and bails when the editor content is empty", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
|
||||
);
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the title untouched when the model returns nothing usable", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockResolvedValue(" ");
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Could not generate a title",
|
||||
color: "yellow",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
generatePageTitleMock.mockResolvedValue("Generated Title");
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Title generated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Control when generation resolves so we can navigate mid-flight.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// User navigates to page B: the live page editor now belongs to pageB.
|
||||
act(() => {
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageB"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// DB write is still correct (keyed by the captured pageId)...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Resolve generation under our control so we can mark the live title editor
|
||||
// as focused before the post-generation write runs.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// The user clicked into the title field while the model ran — overwriting it
|
||||
// now would clobber what they are actively typing.
|
||||
act(() => {
|
||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// The DB write still persists the value...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// ...but the visible field is left alone while it is focused.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bails before calling the model when the page editor is destroyed", async () => {
|
||||
const store = createStore();
|
||||
const pageEditor = makePageEditor("pageA");
|
||||
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
|
||||
store.set(pageEditorAtom as never, pageEditor);
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[403, "AI title generation is disabled"],
|
||||
[503, "AI is not configured"],
|
||||
[429, "Too many requests, please try again later"],
|
||||
[500, "Failed to generate title"],
|
||||
])("maps HTTP %s onError to a friendly message", async (status, message) => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockRejectedValue({ response: { status } });
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message, color: "red" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
// Maximum length we send to the model. The server truncates again; this is a
|
||||
// cheap client-side bound so we never ship a huge body over the wire.
|
||||
const MAX_CONTENT_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* Generate a title for the given page from the LIVE editor content (#199),
|
||||
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
|
||||
* server endpoint only summarizes the supplied markdown — it never writes the
|
||||
* page; the actual title write goes through the existing /pages/update mutation
|
||||
* (which enforces edit permission), and is mirrored to the title field + other
|
||||
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
|
||||
* button can show a loading state via `isPending`.
|
||||
*/
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
|
||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
||||
if (!markdown) {
|
||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (
|
||||
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
|
||||
).trim();
|
||||
if (!title) {
|
||||
// The model returned nothing usable — keep the existing title untouched.
|
||||
notifications.show({
|
||||
message: t("Could not generate a title"),
|
||||
color: "yellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
|
||||
notifications.show({ message: t("Title generated") });
|
||||
},
|
||||
onError: (err) => {
|
||||
// Map known HTTP statuses to friendly messages, falling back to generic.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
const message =
|
||||
status === 403
|
||||
? t("AI title generation is disabled")
|
||||
: status === 503
|
||||
? t("AI is not configured")
|
||||
: status === 429
|
||||
? t("Too many requests, please try again later")
|
||||
: t("Failed to generate title");
|
||||
notifications.show({ message, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -84,6 +84,10 @@ import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed
|
||||
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";
|
||||
import {
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -440,6 +444,9 @@ export default function PageEditor({
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
@@ -451,17 +458,21 @@ export default function PageEditor({
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||
}, [currentPageEditMode, editor, editable]);
|
||||
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
// Keep the body read-only until the collab doc has synced (showStatic), so
|
||||
// early keystrokes on a freshly created page can't be lost (#218).
|
||||
editor.setEditable(
|
||||
isBodyEditable({
|
||||
editable,
|
||||
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||
showStatic,
|
||||
}),
|
||||
);
|
||||
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
||||
isSynced
|
||||
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||
) {
|
||||
hasConnectedOnceRef.current = true;
|
||||
setShowStatic(false);
|
||||
@@ -473,17 +484,43 @@ export default function PageEditor({
|
||||
<PageEmbedLookupProvider>
|
||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||
{showStatic ? (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Surface the pre-sync read-only window so edits typed before the
|
||||
collab provider connects aren't silently swallowed (#218). Shown
|
||||
only when the user is otherwise allowed to edit. */}
|
||||
{editable && currentPageEditMode === PageEditMode.Edit && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
padding: "2px 8px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
background: "var(--mantine-color-gray-light)",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{t("Connecting… (read-only)")}
|
||||
</div>
|
||||
)}
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
|
||||
@@ -10,9 +10,15 @@ ul[data-type="taskList"] {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
padding-top: 0.2rem;
|
||||
/* Box exactly one text-line tall and center the checkbox in it, so the
|
||||
checkbox lines up with the first line of the item's text. This tracks
|
||||
the editor line-height (--mantine-line-height-xl) instead of a magic
|
||||
padding-top that drifts from the real line box. */
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
height: calc(var(--mantine-line-height-xl, 1.65) * 1em);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Menu, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Button, Menu, Stack, Text } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -10,24 +11,38 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { canCreatePage } from "./can-create-page.ts";
|
||||
|
||||
// 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() {
|
||||
// A single create-note action, parametrized by `temporary`. Self-contained: it
|
||||
// owns its own create mutation so the regular and temporary buttons show
|
||||
// independent loading state, while the list of writable spaces is resolved once
|
||||
// by the parent and passed in. With exactly one writable space it creates
|
||||
// directly; with several it shows a target-space picker.
|
||||
function CreateNoteButton({
|
||||
writableSpaces,
|
||||
temporary,
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
writableSpaces: ISpace[];
|
||||
temporary: boolean;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
// Mantine color token; lets the temporary action tint toward the warm
|
||||
// orange/amber used by the clock marker + banner while "New note" stays neutral.
|
||||
color: string;
|
||||
}) {
|
||||
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.
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
@@ -35,24 +50,21 @@ export default function NewNoteButton() {
|
||||
}
|
||||
};
|
||||
|
||||
// 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} />}
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
onClick={() => createNote(writableSpaces[0])}
|
||||
>
|
||||
{t("New note")}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
|
||||
<Menu shadow="md" width="target" position="bottom-start">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("New note")}
|
||||
{label}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// Prominent home-screen actions 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. Renders two full-width, vertically stacked buttons: a
|
||||
// neutral regular note and an orange-tinted temporary note (which auto-moves to
|
||||
// Trash after the workspace lifetime). Stacking full-width keeps the longer
|
||||
// "New temporary note" label from clipping on narrow mobile widths.
|
||||
export default function NewNoteButton() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
|
||||
|
||||
// No writable space → nothing to create in; render nothing.
|
||||
if (writableSpaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={false}
|
||||
label={t("New note")}
|
||||
icon={<IconPlus size={18} />}
|
||||
color="gray"
|
||||
/>
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={true}
|
||||
label={t("New temporary note")}
|
||||
icon={<IconHourglass size={18} />}
|
||||
color="orange"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getDefaultStore } from "jotai";
|
||||
|
||||
// Mock the app entry so importing the query module doesn't boot the whole app
|
||||
// (it only needs queryClient's cache methods, which we stub here). The spies are
|
||||
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
|
||||
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(() => undefined as unknown),
|
||||
invalidateQueries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData, getQueryData, invalidateQueries },
|
||||
}));
|
||||
|
||||
import { syncTemporaryExpiresInCache } from "./page-embed-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
|
||||
({
|
||||
id,
|
||||
slugId,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
}) as unknown as SpaceTreeNode;
|
||||
|
||||
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getQueryData.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
|
||||
const store = getDefaultStore();
|
||||
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
|
||||
store.set(treeDataAtom, tree);
|
||||
|
||||
const deadline = "2026-07-01T00:00:00.000Z";
|
||||
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
|
||||
|
||||
const next = store.get(treeDataAtom);
|
||||
// A new atom value was written...
|
||||
expect(next).not.toBe(tree);
|
||||
// ...the matching node gained the deadline...
|
||||
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
|
||||
// ...and the untouched sibling is unchanged.
|
||||
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
|
||||
const store = getDefaultStore();
|
||||
const tree = [mkNode("p1", "slug-1")];
|
||||
store.set(treeDataAtom, tree);
|
||||
|
||||
syncTemporaryExpiresInCache(
|
||||
{ id: "not-in-tree", slugId: "missing" },
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
);
|
||||
|
||||
// treeModel.update is a no-op (same reference) for an unknown id, so the
|
||||
// guard skips the store write entirely — same reference back.
|
||||
expect(store.get(treeDataAtom)).toBe(tree);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,57 @@
|
||||
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";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import {
|
||||
toggleTemplate,
|
||||
toggleTemporary,
|
||||
} from "@/features/page-embed/services/page-embed-api";
|
||||
import type {
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "@/features/page-embed/types/page-embed.types";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
/**
|
||||
* After toggling a note's temporary state, mirror the new deadline into the
|
||||
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
|
||||
* menu label, the in-page banner, and the tree icon all reflect the change.
|
||||
* Centralised here so the header menu and the banner can't drift apart on the
|
||||
* cache-key plumbing.
|
||||
*/
|
||||
export function syncTemporaryExpiresInCache(
|
||||
page: { id: string; slugId: string },
|
||||
temporaryExpiresAt: string | null,
|
||||
) {
|
||||
for (const key of [page.slugId, page.id]) {
|
||||
const cached = queryClient.getQueryData<any>(["pages", key]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData(["pages", key], {
|
||||
...cached,
|
||||
temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Patch the in-memory sidebar tree node so its temporary clock marker
|
||||
// appears/disappears immediately — WITHOUT a reload. The page cache update
|
||||
// above only drives the in-page banner/menu; the sidebar reads
|
||||
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
|
||||
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
|
||||
// store the sidebar's hooks read from. `treeModel.update` returns the same
|
||||
// reference (a no-op) when the page isn't in the currently loaded tree.
|
||||
const store = getDefaultStore();
|
||||
const prevTree = store.get(treeDataAtom);
|
||||
const nextTree = treeModel.update(prevTree, page.id, {
|
||||
temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
@@ -18,3 +68,20 @@ export function useToggleTemplateMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemporaryMutation() {
|
||||
return useMutation<
|
||||
ToggleTemporaryResponse,
|
||||
Error,
|
||||
{ pageId: string; temporary?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemporary(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message:
|
||||
err?.response?.data?.message || "Failed to update temporary note",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
||||
import type {
|
||||
PageTemplateLookup,
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "../types/page-embed.types";
|
||||
|
||||
export async function lookupTemplate(params: {
|
||||
@@ -18,3 +19,11 @@ export async function toggleTemplate(params: {
|
||||
const r = await api.post("/pages/toggle-template", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function toggleTemporary(params: {
|
||||
pageId: string;
|
||||
temporary?: boolean;
|
||||
}): Promise<ToggleTemporaryResponse> {
|
||||
const r = await api.post("/pages/toggle-temporary", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,9 @@ export type ToggleTemplateResponse = {
|
||||
pageId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ToggleTemporaryResponse = {
|
||||
pageId: string;
|
||||
// null => the note was made permanent; ISO string => armed deadline.
|
||||
temporaryExpiresAt: string | null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@@ -15,8 +15,12 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
usePageQuery,
|
||||
usePageBreadcrumbsQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -38,14 +42,29 @@ export default function Breadcrumb() {
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
// The page's own ancestor chain, fetched independently of the lazily-built
|
||||
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
|
||||
// while the tree backfills (#218).
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData?.length > 0 && currentPage) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
||||
setBreadcrumbNodes(breadcrumb || null);
|
||||
}
|
||||
}, [currentPage?.id, treeData]);
|
||||
if (!currentPage) return;
|
||||
|
||||
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
|
||||
// (#218). It resolves the correct chain when possible and, on a transient
|
||||
// miss, clears a chain left over from a previously-viewed page instead of
|
||||
// showing the wrong trail — while keeping a chain already resolved for THIS
|
||||
// page to avoid a blank flash.
|
||||
setBreadcrumbNodes((previous) =>
|
||||
computeBreadcrumbState(
|
||||
treeData,
|
||||
ancestors as IPage[] | undefined,
|
||||
currentPage.id,
|
||||
previous,
|
||||
),
|
||||
);
|
||||
}, [currentPage?.id, treeData, ancestors]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -1).map((node) => (
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeBreadcrumbState,
|
||||
resolveBreadcrumbNodes,
|
||||
} from "./breadcrumb.utils";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
// Pure selection/mapping behind the breadcrumb (#218): tree-hit prefers the live
|
||||
// sidebar tree, tree-miss maps the page's own ancestors, and "no data" returns
|
||||
// null so the component keeps its prior state.
|
||||
|
||||
function treeNode(id: string, over?: Partial<SpaceTreeNode>): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: `node-${id}`,
|
||||
icon: null,
|
||||
position: "a",
|
||||
hasChildren: false,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
children: [],
|
||||
...over,
|
||||
} as SpaceTreeNode;
|
||||
}
|
||||
|
||||
function ancestorPage(id: string, over?: Partial<IPage>): IPage {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `title-${id}`,
|
||||
icon: "📄",
|
||||
position: "m",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
hasChildren: true,
|
||||
...over,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe("resolveBreadcrumbNodes", () => {
|
||||
it("tree-hit: returns the path found in the live sidebar tree", () => {
|
||||
const child = treeNode("child");
|
||||
const root = treeNode("root", { hasChildren: true, children: [child] });
|
||||
// findBreadcrumbPath walks the tree; the chain ends at the target page.
|
||||
const result = resolveBreadcrumbNodes([root], [ancestorPage("child")], "child");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.map((n) => n.id)).toEqual(["root", "child"]);
|
||||
// Came from the tree, NOT the ancestor mapping (icon stays the tree's null).
|
||||
expect(result![result!.length - 1].icon).toBeNull();
|
||||
});
|
||||
|
||||
it("tree-miss: maps the page's own ancestors (title->name, hasChildren default)", () => {
|
||||
// Tree has no node for the target page -> findBreadcrumbPath misses.
|
||||
const unrelated = treeNode("unrelated");
|
||||
const ancestors = [
|
||||
ancestorPage("a", { hasChildren: true }),
|
||||
ancestorPage("b", { hasChildren: undefined as any }),
|
||||
];
|
||||
|
||||
const result = resolveBreadcrumbNodes([unrelated], ancestors, "missing-page");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
// Non-trivial field transform: title -> name.
|
||||
expect(result![0].name).toBe("title-a");
|
||||
// hasChildren defaults to false when the ancestor row omits it.
|
||||
expect(result![1].hasChildren).toBe(false);
|
||||
expect(result![0].hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to ancestors when the tree is empty", () => {
|
||||
const result = resolveBreadcrumbNodes([], [ancestorPage("a")], "a");
|
||||
expect(result!.map((n) => n.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns null when there is no tree hit and no ancestor data", () => {
|
||||
expect(resolveBreadcrumbNodes([], [], "x")).toBeNull();
|
||||
expect(resolveBreadcrumbNodes(undefined, undefined, "x")).toBeNull();
|
||||
expect(resolveBreadcrumbNodes(null, null, "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBreadcrumbState (stale-chain clearing on navigation)", () => {
|
||||
it("uses a freshly resolved chain when available", () => {
|
||||
const child = treeNode("B");
|
||||
const root = treeNode("root", { hasChildren: true, children: [child] });
|
||||
const next = computeBreadcrumbState([root], null, "B", null);
|
||||
expect(next!.map((n) => n.id)).toEqual(["root", "B"]);
|
||||
});
|
||||
|
||||
it("navigating A->B to a page absent from treeData clears the previous A chain (no stale trail)", () => {
|
||||
// Previous chain ends at page A; we are now on page B, which is not yet in
|
||||
// the lazily-built tree and whose ancestors have not loaded.
|
||||
const previous = [treeNode("rootA"), treeNode("A")];
|
||||
const next = computeBreadcrumbState([treeNode("unrelated")], undefined, "B", previous);
|
||||
// Must NOT keep showing A's (clickable) chain.
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a chain that already ends at the current page through a transient miss", () => {
|
||||
// We already resolved B once (chain ends at B); a transient miss must not
|
||||
// blank it.
|
||||
const previous = [treeNode("rootB"), treeNode("B")];
|
||||
const next = computeBreadcrumbState([], undefined, "B", previous);
|
||||
expect(next).toBe(previous);
|
||||
});
|
||||
|
||||
it("returns null when nothing resolves and there is no previous chain", () => {
|
||||
expect(computeBreadcrumbState([], undefined, "B", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { findBreadcrumbPath, pageToTreeNode } from "@/features/page/tree/utils";
|
||||
|
||||
/**
|
||||
* Pure selection/mapping for the breadcrumb nodes (#218). Three branches:
|
||||
* 1. tree-hit — the lazily-built sidebar tree already contains this page's
|
||||
* ancestor chain, so prefer it (stays live with sidebar renames/moves).
|
||||
* 2. tree-miss — fall back to the page's own ancestor data so a deep page
|
||||
* resolves immediately instead of rendering a blank breadcrumb for seconds
|
||||
* while the tree backfills. Mapped through the canonical `pageToTreeNode`
|
||||
* (title -> name, hasChildren defaulted to false).
|
||||
* 3. neither — no data yet, return null (the caller decides whether to keep
|
||||
* a prior chain via computeBreadcrumbState).
|
||||
*/
|
||||
export function resolveBreadcrumbNodes(
|
||||
treeData: SpaceTreeNode[] | null | undefined,
|
||||
ancestors: IPage[] | null | undefined,
|
||||
pageId: string,
|
||||
): SpaceTreeNode[] | null {
|
||||
if (treeData && treeData.length > 0) {
|
||||
const breadcrumb = findBreadcrumbPath(treeData, pageId);
|
||||
if (breadcrumb) {
|
||||
return breadcrumb;
|
||||
}
|
||||
}
|
||||
|
||||
if (ancestors && ancestors.length > 0) {
|
||||
return ancestors.map((page) =>
|
||||
pageToTreeNode(page, { hasChildren: page.hasChildren ?? false }),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the next breadcrumb state, given the previous one. When a chain
|
||||
* resolves (#218) it always wins. When nothing resolves yet, a stale chain from
|
||||
* a previously-viewed page must be CLEARED rather than left showing the wrong,
|
||||
* clickable trail (the reverse regression of the original blank-breadcrumb fix
|
||||
* when navigating A -> B to a deep page not yet in the lazily-built tree). The
|
||||
* one chain we keep through a transient miss is one that already ends at the
|
||||
* current page — that means we already resolved THIS page, so keeping it avoids
|
||||
* a needless blank flash without ever showing the previous page's chain.
|
||||
*/
|
||||
export function computeBreadcrumbState(
|
||||
treeData: SpaceTreeNode[] | null | undefined,
|
||||
ancestors: IPage[] | null | undefined,
|
||||
pageId: string,
|
||||
previous: SpaceTreeNode[] | null,
|
||||
): SpaceTreeNode[] | null {
|
||||
const resolved = resolveBreadcrumbNodes(treeData, ancestors, pageId);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const previousEndsAtCurrentPage =
|
||||
previous != null && previous[previous.length - 1]?.id === pageId;
|
||||
return previousEndsAtCurrentPage ? previous : null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user