Compare commits
58 Commits
fix/ai-cha
...
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 | |||
|
|
276ccc0783 | ||
|
|
c64d7f315e | ||
|
|
7a7aa79eab |
22
.env.example
22
.env.example
@@ -132,6 +132,14 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# NEVER set is_agent on a human or shared account — every action by that account
|
# 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.
|
# (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.
|
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||||
@@ -162,6 +170,20 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# 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 ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# 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
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
|
|||||||
1
.github/workflows/develop.yml
vendored
1
.github/workflows/develop.yml
vendored
@@ -52,6 +52,7 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_VERSION=${{ steps.version.outputs.value }}
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
cache-from: type=gha,scope=develop-amd64
|
||||||
|
|||||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -17,6 +17,7 @@ permissions:
|
|||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.version || github.ref_name }}
|
VERSION: ${{ inputs.version || github.ref_name }}
|
||||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Run the reusable test suite first so a failing test blocks the image build.
|
# Run the reusable test suite first so a failing test blocks the image build.
|
||||||
@@ -57,6 +58,7 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_VERSION=${{ env.VERSION }}
|
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
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
cache-from: type=gha,scope=${{ matrix.suffix }}
|
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||||
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
|
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
|
||||||
@@ -85,6 +87,7 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
build-args: |
|
build-args: |
|
||||||
APP_VERSION=${{ env.VERSION }}
|
APP_VERSION=${{ env.VERSION }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||||
push: false
|
push: false
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE }}:latest
|
${{ env.IMAGE }}:latest
|
||||||
|
|||||||
@@ -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.
|
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
|
||||||
|
|
||||||
### The two AI subsystems (the main fork additions)
|
### 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:
|
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/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/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/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
|
### 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:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
|
|||||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **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
|
message gains a "send now" action that interrupts the streaming turn and
|
||||||
immediately sends that message, keeping the agent's partial output. The
|
immediately sends that message, keeping the agent's partial output. The
|
||||||
@@ -19,6 +28,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
answer was cut off and builds on it instead of restarting; the rest of the
|
answer was cut off and builds on it instead of restarting; the rest of the
|
||||||
queue still flushes normally afterward. (#198)
|
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
|
## [0.94.0] - 2026-06-26
|
||||||
|
|
||||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||||
@@ -97,6 +209,13 @@ per-workspace rolling-day token budget.
|
|||||||
applies it through the existing `/pages/update` route — reflecting it in the
|
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`
|
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||||
flag and throttled per user. (#199)
|
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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ RUN apt-get update \
|
|||||||
|
|
||||||
WORKDIR /app
|
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 apps
|
||||||
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
||||||
COPY --from=builder /app/apps/client/dist /app/apps/client/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. |
|
| **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. |
|
| **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. |
|
| **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*. |
|
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
|
||||||
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
|
| **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
|
### Embedded MCP server
|
||||||
|
|
||||||
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
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),
|
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,
|
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
|
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 |
|
| | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
|
||||||
| --- | :---: | :---: |
|
| --- | :---: | :---: |
|
||||||
| **Enterprise license** | Not required | Required |
|
| **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** | ✅ | — |
|
| **Per-block edits / find-replace / scripted transforms** | ✅ | — |
|
||||||
| **Structured table editing, version diff / restore** | ✅ | — |
|
| **Structured table editing, version diff / restore** | ✅ | — |
|
||||||
| **Comments, images, share links** | ✅ | — |
|
| **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).
|
- ✅ **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.
|
- ✅ **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.
|
- ✅ **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
|
### In progress
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
|
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
|
||||||
| **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. |
|
| **Резолв комментариев** | Переписан с нуля как 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-серверы. |
|
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
|
||||||
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
||||||
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||||
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
|
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
|
||||||
**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||||
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
|
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
|
||||||
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
|
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
|
||||||
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
|
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
|
||||||
@@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
|
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
|
||||||
| --- | :---: | :---: |
|
| --- | :---: | :---: |
|
||||||
| **Enterprise-лицензия** | Не нужна | Нужна |
|
| **Enterprise-лицензия** | Не нужна | Нужна |
|
||||||
| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||||
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
|
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
|
||||||
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
|
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
|
||||||
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
|
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
|
||||||
@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||||
|
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||||
|
|
||||||
### В процессе
|
### В процессе
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -665,9 +665,6 @@
|
|||||||
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
|
"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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Upgrade Ihres Plans",
|
||||||
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
|
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
|
||||||
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
|
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
|
||||||
|
|||||||
@@ -687,9 +687,6 @@
|
|||||||
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
|
"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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Upgrade your plan",
|
||||||
"Available with a paid license": "Available with a paid license",
|
"Available with a paid license": "Available with a paid license",
|
||||||
"Upgrade your license tier.": "Upgrade your license tier.",
|
"Upgrade your license tier.": "Upgrade your license tier.",
|
||||||
@@ -1336,6 +1333,7 @@
|
|||||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
"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",
|
"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 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 custom address?": "Move custom address?",
|
||||||
"Move here": "Move here",
|
"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}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||||
@@ -1349,5 +1347,23 @@
|
|||||||
"Could not generate a title": "Could not generate a title",
|
"Could not generate a title": "Could not generate a title",
|
||||||
"AI title generation is disabled": "AI title generation is disabled",
|
"AI title generation is disabled": "AI title generation is disabled",
|
||||||
"AI is not configured": "AI is not configured",
|
"AI is not configured": "AI is not configured",
|
||||||
"Too many requests, please try again later": "Too many requests, please try again later"
|
"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-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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Mejora tu plan",
|
||||||
"Available with a paid license": "Disponible con una licencia de pago",
|
"Available with a paid license": "Disponible con una licencia de pago",
|
||||||
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
|
"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-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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Mettez à niveau votre forfait",
|
||||||
"Available with a paid license": "Disponible avec une licence payante",
|
"Available with a paid license": "Disponible avec une licence payante",
|
||||||
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
|
"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-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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Aggiorna il tuo piano",
|
||||||
"Available with a paid license": "Disponibile con una licenza a pagamento",
|
"Available with a paid license": "Disponibile con una licenza a pagamento",
|
||||||
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
|
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
|
||||||
|
|||||||
@@ -665,9 +665,6 @@
|
|||||||
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||||
"Toggle AI search": "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": "プランをアップグレードする",
|
"Upgrade your plan": "プランをアップグレードする",
|
||||||
"Available with a paid license": "有料ライセンスで利用可能",
|
"Available with a paid license": "有料ライセンスで利用可能",
|
||||||
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
|
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
|
||||||
|
|||||||
@@ -665,9 +665,6 @@
|
|||||||
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||||
"Toggle AI search": "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": "요금제를 업그레이드하세요",
|
"Upgrade your plan": "요금제를 업그레이드하세요",
|
||||||
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
|
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
|
||||||
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
|
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
|
||||||
|
|||||||
@@ -665,9 +665,6 @@
|
|||||||
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
|
"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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Upgrade je abonnement",
|
||||||
"Available with a paid license": "Beschikbaar met een betaalde licentie",
|
"Available with a paid license": "Beschikbaar met een betaalde licentie",
|
||||||
"Upgrade your license tier.": "Upgrade je licentieniveau.",
|
"Upgrade your license tier.": "Upgrade je licentieniveau.",
|
||||||
|
|||||||
@@ -665,9 +665,6 @@
|
|||||||
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
|
"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.",
|
"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",
|
"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",
|
"Upgrade your plan": "Faça upgrade do seu plano",
|
||||||
"Available with a paid license": "Disponível com uma licença paga",
|
"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.",
|
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
|
||||||
|
|||||||
@@ -749,9 +749,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)": "Генеративный ИИ (Спросить ИИ)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
|
|
||||||
"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.": "Обновите уровень вашей лицензии.",
|
||||||
@@ -1193,6 +1190,7 @@
|
|||||||
"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": "Используйте 2–60 строчных букв, цифр и дефисов",
|
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||||
"This address is already in use": "Этот адрес уже занят",
|
"This address is already in use": "Этот адрес уже занят",
|
||||||
|
"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?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||||
@@ -1206,5 +1204,24 @@
|
|||||||
"Could not generate a title": "Не удалось придумать название",
|
"Could not generate a title": "Не удалось придумать название",
|
||||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||||
"AI is not configured": "AI не настроен",
|
"AI is not configured": "AI не настроен",
|
||||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
|
"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-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)": "Генеративний ШІ (Запитати ШІ)",
|
|
||||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
|
|
||||||
"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.": "Оновіть рівень своєї ліцензії.",
|
||||||
|
|||||||
@@ -665,9 +665,6 @@
|
|||||||
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
||||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||||
"Toggle AI search": "切换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": "升级您的方案",
|
"Upgrade your plan": "升级您的方案",
|
||||||
"Available with a paid license": "需付费许可才可用",
|
"Available with a paid license": "需付费许可才可用",
|
||||||
"Upgrade your license tier.": "升级您的许可等级。",
|
"Upgrade your license tier.": "升级您的许可等级。",
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import classes from "./app-header.module.css";
|
|||||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} 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 { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
@@ -38,7 +38,9 @@ export function AppHeader() {
|
|||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
|
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
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).
|
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ export function AppHeader() {
|
|||||||
color="dark"
|
color="dark"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={t("AI chat")}
|
aria-label={t("AI chat")}
|
||||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
onClick={openAiChat}
|
||||||
>
|
>
|
||||||
<IconMessage size={20} />
|
<IconMessage size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useMatch } from "react-router-dom";
|
import { useMatch } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -34,9 +34,12 @@ import {
|
|||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
AI_CHAT_MESSAGES_RQ_KEY,
|
AI_CHAT_MESSAGES_RQ_KEY,
|
||||||
useAiChatMessagesQuery,
|
useAiChatMessagesQuery,
|
||||||
|
useAiChatRunQuery,
|
||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
useAiRolesQuery,
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} 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 ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
@@ -162,6 +165,61 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||||
|
|
||||||
|
// #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;
|
||||||
|
|
||||||
|
// 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
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
// pathname against the authenticated page route instead so "the current page"
|
// pathname against the authenticated page route instead so "the current page"
|
||||||
@@ -636,6 +694,12 @@ export default function AiChatWindow() {
|
|||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
onServerChatId={onServerChatId}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const h = vi.hoisted(() => ({
|
|||||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
|
setMessages: vi.fn(),
|
||||||
transport: null as null | {
|
transport: null as null | {
|
||||||
prepareSendMessagesRequest: (arg: {
|
prepareSendMessagesRequest: (arg: {
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
@@ -30,6 +31,8 @@ vi.mock("@ai-sdk/react", () => ({
|
|||||||
status: h.state.status,
|
status: h.state.status,
|
||||||
stop: h.state.stop,
|
stop: h.state.stop,
|
||||||
error: null,
|
error: null,
|
||||||
|
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||||
|
setMessages: h.state.setMessages,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -140,3 +143,56 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
import {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -86,6 +87,19 @@ interface ChatThreadProps {
|
|||||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
* which fires only at the terminal outcome. */
|
* which fires only at the terminal outcome. */
|
||||||
onServerChatId?: (serverChatId?: string) => void;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,6 +145,8 @@ export default function ChatThread({
|
|||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
onServerChatId,
|
onServerChatId,
|
||||||
|
observedRow,
|
||||||
|
onStreamingChange,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -274,7 +290,7 @@ export default function ChatThread({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
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
|
// 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
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||||
// every render mid-stream (see `chatStoreId` above).
|
// every render mid-stream (see `chatStoreId` above).
|
||||||
@@ -378,6 +394,27 @@ export default function ChatThread({
|
|||||||
|
|
||||||
const isStreaming = status === "submitted" || status === "streaming";
|
const isStreaming = status === "submitted" || status === "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(() => {
|
||||||
|
onStreamingChange?.(isStreaming);
|
||||||
|
}, [isStreaming, onStreamingChange]);
|
||||||
|
|
||||||
|
// #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 (isStreaming || !observedRow) return;
|
||||||
|
const observed = rowToUiMessage(observedRow);
|
||||||
|
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||||
|
}, [observedRow, isStreaming, setMessages]);
|
||||||
|
|
||||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||||
// stay queued and flush normally after the new turn. Reuses the existing
|
// stay queued and flush normally after the new turn. Reuses the existing
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
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,
|
deleteAiChat,
|
||||||
deleteAiRole,
|
deleteAiRole,
|
||||||
getAiChatMessages,
|
getAiChatMessages,
|
||||||
|
getAiChatRun,
|
||||||
getAiChats,
|
getAiChats,
|
||||||
|
getAiRoleCatalog,
|
||||||
|
getAiRoleCatalogBundle,
|
||||||
getAiRoles,
|
getAiRoles,
|
||||||
|
importAiRolesFromCatalog,
|
||||||
renameAiChat,
|
renameAiChat,
|
||||||
updateAiRole,
|
updateAiRole,
|
||||||
|
updateAiRoleFromCatalog,
|
||||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import {
|
import {
|
||||||
IAiChat,
|
IAiChat,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
|
IAiRoleCatalog,
|
||||||
|
IAiRoleCatalogBundle,
|
||||||
IAiRoleCreate,
|
IAiRoleCreate,
|
||||||
|
IAiRoleImportPayload,
|
||||||
|
IAiRoleImportResult,
|
||||||
IAiRoleUpdate,
|
IAiRoleUpdate,
|
||||||
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { IPagination } from "@/lib/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_CHATS_RQ_KEY = ["ai-chats"];
|
||||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
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) => [
|
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||||
"ai-chat-messages",
|
"ai-chat-messages",
|
||||||
chatId,
|
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). */
|
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||||
export function useAiChatsQuery() {
|
export function useAiChatsQuery() {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHATS_RQ_KEY,
|
queryKey: AI_CHATS_RQ_KEY,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||||
@@ -71,7 +95,9 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
|||||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
enabled: !!chatId,
|
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() {
|
export function useRenameAiChatMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
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,
|
IAiChatListParams,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiChatMessagesParams,
|
IAiChatMessagesParams,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
|
IAiRoleCatalog,
|
||||||
|
IAiRoleCatalogBundle,
|
||||||
IAiRoleCreate,
|
IAiRoleCreate,
|
||||||
|
IAiRoleImportPayload,
|
||||||
|
IAiRoleImportResult,
|
||||||
IAiRoleUpdate,
|
IAiRoleUpdate,
|
||||||
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +43,34 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
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. */
|
/** Rename a chat. */
|
||||||
export async function renameAiChat(data: {
|
export async function renameAiChat(data: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
@@ -112,3 +146,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
|||||||
});
|
});
|
||||||
return req.data;
|
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;
|
autoStart: boolean;
|
||||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||||
launchMessage: string | null;
|
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;
|
createdAt?: string;
|
||||||
updatedAt?: 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. */
|
/** Admin create payload for a role. */
|
||||||
export interface IAiRoleCreate {
|
export interface IAiRoleCreate {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -131,6 +200,38 @@ export interface IAiChatMessageRow {
|
|||||||
createdAt: string;
|
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 IAiChatListParams extends QueryParams {}
|
||||||
|
|
||||||
export interface IAiChatMessagesParams {
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
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 yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
export const showAiMenuAtom = atom(false);
|
|
||||||
|
|
||||||
export const showLinkMenuAtom = atom(false);
|
export const showLinkMenuAtom = atom(false);
|
||||||
|
|
||||||
// Current page's edit mode — initialized from the user's saved preference on
|
// Current page's edit mode — initialized from the user's saved preference on
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import {
|
|||||||
IconStrikethrough,
|
IconStrikethrough,
|
||||||
IconUnderline,
|
IconUnderline,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconSparkles,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./bubble-menu.module.css";
|
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 { ColorSelector } from "./color-selector";
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { TextAlignmentSelector } from "./text-alignment-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 { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
const { templateMode = false } = props;
|
const { templateMode = false } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user?.settings?.preferences?.editorToolbar ?? false;
|
user?.settings?.preferences?.editorToolbar ?? false;
|
||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
const showAiMenuRef = useRef(showAiMenu);
|
|
||||||
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
||||||
const showLinkMenuRef = useRef(showLinkMenu);
|
const showLinkMenuRef = useRef(showLinkMenu);
|
||||||
|
|
||||||
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
}, [showCommentPopup]);
|
}, [showCommentPopup]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showAiMenuRef.current = showAiMenu;
|
|
||||||
}, [showAiMenu]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showLinkMenuRef.current = showLinkMenu;
|
showLinkMenuRef.current = showLinkMenu;
|
||||||
}, [showLinkMenu]);
|
}, [showLinkMenu]);
|
||||||
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
empty ||
|
empty ||
|
||||||
isNodeSelection(selection) ||
|
isNodeSelection(selection) ||
|
||||||
isCellSelection(selection) ||
|
isCellSelection(selection) ||
|
||||||
showAiMenuRef.current ||
|
|
||||||
showLinkMenuRef.current ||
|
showLinkMenuRef.current ||
|
||||||
showCommentPopupRef?.current
|
showCommentPopupRef?.current
|
||||||
) {
|
) {
|
||||||
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
// Hide the bubble menu immediately when AI menu is shown
|
// Hide the bubble menu immediately when the link menu is shown
|
||||||
if (showAiMenu || showLinkMenu) return;
|
if (showLinkMenu) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
style={{ zIndex: 199, position: "relative" }}
|
style={{ zIndex: 199, position: "relative" }}
|
||||||
>
|
>
|
||||||
<div className={classes.bubbleMenu}>
|
<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 && (
|
{!editorToolbarEnabled && (
|
||||||
<>
|
<>
|
||||||
<NodeSelector
|
<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 { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||||
import { MoreInsertsGroup } from "./groups/more-inserts-group";
|
import { MoreInsertsGroup } from "./groups/more-inserts-group";
|
||||||
import { HistoryGroup } from "./groups/history-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";
|
import classes from "./fixed-toolbar.module.css";
|
||||||
|
|
||||||
type FixedToolbarProps = {
|
type FixedToolbarProps = {
|
||||||
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
|||||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
const editorFromAtom = useAtomValue(pageEditorAtom);
|
||||||
const editor = editorProp ?? editorFromAtom;
|
const editor = editorProp ?? editorFromAtom;
|
||||||
const state = useToolbarState(editor);
|
const state = useToolbarState(editor);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
|
||||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
|
||||||
|
|
||||||
if (!editor || !state) return null;
|
if (!editor || !state) return null;
|
||||||
|
|
||||||
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className={classes.inner}>
|
<div className={classes.inner}>
|
||||||
{/* {isGenerativeAiEnabled && (
|
|
||||||
<>
|
|
||||||
<AskAiGroup />
|
|
||||||
<div className={classes.divider} />
|
|
||||||
</>
|
|
||||||
)} */}
|
|
||||||
<BlockTypeGroup editor={editor} />
|
<BlockTypeGroup editor={editor} />
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
<InlineMarksGroup editor={editor} state={state} />
|
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,7 +13,7 @@ interface Props {
|
|||||||
/**
|
/**
|
||||||
* AI "generate title" button (#199). Reads the live editor content and applies a
|
* 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
|
* model-suggested title immediately. Rendered in the page byline, only in edit
|
||||||
* mode and when the workspace's generative AI flag is on.
|
* mode and when the workspace's AI chat flag is on.
|
||||||
*/
|
*/
|
||||||
export const GenerateTitleGroup: FC<Props> = ({
|
export const GenerateTitleGroup: FC<Props> = ({
|
||||||
pageId,
|
pageId,
|
||||||
|
|||||||
@@ -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 { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||||
import { find } from "linkifyjs";
|
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({
|
export const MarkdownClipboard = Extension.create({
|
||||||
name: "markdownClipboard",
|
name: "markdownClipboard",
|
||||||
@@ -83,12 +90,25 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
const body = elementFromString(parsed);
|
const body = elementFromString(parsed);
|
||||||
normalizeTableColumnWidths(body);
|
normalizeTableColumnWidths(body);
|
||||||
|
|
||||||
const contentNodes = DOMParser.fromSchema(
|
const parsedSlice = DOMParser.fromSchema(
|
||||||
this.editor.schema,
|
this.editor.schema,
|
||||||
).parseSlice(body, {
|
).parseSlice(body, {
|
||||||
preserveWhitespace: true,
|
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);
|
tr.replaceRange(from, to, contentNodes);
|
||||||
const insertEnd = tr.mapping.map(from, 1);
|
const insertEnd = tr.mapping.map(from, 1);
|
||||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -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) {
|
function elementFromString(value) {
|
||||||
// add a wrapper to preserve leading and trailing whitespace
|
// add a wrapper to preserve leading and trailing whitespace
|
||||||
const wrappedValue = `<body>${value}</body>`;
|
const wrappedValue = `<body>${value}</body>`;
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ export function FullEditor({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||||
// AI title generation reuses the generative AI flag (same gate as the on-page
|
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||||
// generative menu); the server enforces it too (#199).
|
// that enables the chat agent); the server enforces it too (#199).
|
||||||
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true;
|
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user.settings?.preferences?.editorToolbar ?? false;
|
user.settings?.preferences?.editorToolbar ?? false;
|
||||||
@@ -254,7 +254,7 @@ function PageByline({
|
|||||||
{showDictation && editor && (
|
{showDictation && editor && (
|
||||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
||||||
)}
|
)}
|
||||||
{/* Shown only in edit mode when the workspace's generative AI flag is on,
|
{/* Shown only in edit mode when the workspace's AI chat flag is on,
|
||||||
so AI title generation stays reachable from the byline (#199). */}
|
so AI title generation stays reachable from the byline (#199). */}
|
||||||
{showTitleGen && (
|
{showTitleGen && (
|
||||||
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
|
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
|
||||||
|
|||||||
@@ -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 { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context";
|
||||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
isBodyEditable,
|
||||||
|
isCollabSynced,
|
||||||
|
} from "@/features/editor/editor-sync-state";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -440,6 +444,9 @@ export default function PageEditor({
|
|||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
|
const hasConnectedOnceRef = useRef(false);
|
||||||
|
const [showStatic, setShowStatic] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||||
@@ -451,17 +458,21 @@ export default function PageEditor({
|
|||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
// Keep the body read-only until the collab doc has synced (showStatic), so
|
||||||
}, [currentPageEditMode, editor, editable]);
|
// early keystrokes on a freshly created page can't be lost (#218).
|
||||||
|
editor.setEditable(
|
||||||
const hasConnectedOnceRef = useRef(false);
|
isBodyEditable({
|
||||||
const [showStatic, setShowStatic] = useState(true);
|
editable,
|
||||||
|
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||||
|
showStatic,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hasConnectedOnceRef.current &&
|
!hasConnectedOnceRef.current &&
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected &&
|
isCollabSynced(yjsConnectionStatus, isSynced)
|
||||||
isSynced
|
|
||||||
) {
|
) {
|
||||||
hasConnectedOnceRef.current = true;
|
hasConnectedOnceRef.current = true;
|
||||||
setShowStatic(false);
|
setShowStatic(false);
|
||||||
@@ -473,17 +484,43 @@ export default function PageEditor({
|
|||||||
<PageEmbedLookupProvider>
|
<PageEmbedLookupProvider>
|
||||||
<PageEmbedAncestryProvider hostPageId={pageId}>
|
<PageEmbedAncestryProvider hostPageId={pageId}>
|
||||||
{showStatic ? (
|
{showStatic ? (
|
||||||
<EditorProvider
|
<div style={{ position: "relative" }}>
|
||||||
editable={false}
|
{/* Surface the pre-sync read-only window so edits typed before the
|
||||||
immediatelyRender={true}
|
collab provider connects aren't silently swallowed (#218). Shown
|
||||||
extensions={mainExtensions}
|
only when the user is otherwise allowed to edit. */}
|
||||||
content={content}
|
{editable && currentPageEditMode === PageEditMode.Edit && (
|
||||||
editorProps={{
|
<div
|
||||||
attributes: {
|
role="status"
|
||||||
"aria-label": t("Page content"),
|
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 className="editor-container" style={{ position: "relative" }}>
|
||||||
<div ref={menuContainerRef}>
|
<div ref={menuContainerRef}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Menu, Text } from "@mantine/core";
|
import { Button, Menu, Stack, Text } from "@mantine/core";
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
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 { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { canCreatePage } from "./can-create-page.ts";
|
import { canCreatePage } from "./can-create-page.ts";
|
||||||
|
|
||||||
// Prominent home-screen action to create a new note (page). Because the home
|
// A single create-note action, parametrized by `temporary`. Self-contained: it
|
||||||
// screen has no active space, the target space is resolved from the user's
|
// owns its own create mutation so the regular and temporary buttons show
|
||||||
// writable spaces: created directly when there is one, picked from a dropdown
|
// independent loading state, while the list of writable spaces is resolved once
|
||||||
// when there are several.
|
// by the parent and passed in. With exactly one writable space it creates
|
||||||
export default function NewNoteButton() {
|
// 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 { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
|
||||||
|
|
||||||
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
|
|
||||||
|
|
||||||
const createNote = async (space: ISpace) => {
|
const createNote = async (space: ISpace) => {
|
||||||
try {
|
try {
|
||||||
// `spaceId` is accepted by the create-page endpoint but is not part of
|
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||||
// the shared `IPageInput` type; cast to satisfy the mutation signature.
|
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||||
|
// signature.
|
||||||
const createdPage = await createPageMutation.mutateAsync({
|
const createdPage = await createPageMutation.mutateAsync({
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
|
...(temporary ? { temporary: true } : {}),
|
||||||
} as any);
|
} as any);
|
||||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||||
} catch {
|
} 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;
|
const isPending = createPageMutation.isPending;
|
||||||
|
|
||||||
// Exactly one writable space → create directly, no picker needed.
|
// Exactly one writable space → create directly, no picker needed.
|
||||||
if (writableSpaces.length === 1) {
|
if (writableSpaces.length === 1) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
|
||||||
size="md"
|
size="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color={color}
|
||||||
leftSection={<IconPlus size={18} />}
|
fullWidth
|
||||||
|
leftSection={icon}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => createNote(writableSpaces[0])}
|
onClick={() => createNote(writableSpaces[0])}
|
||||||
>
|
>
|
||||||
{t("New note")}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
|
|||||||
<Menu shadow="md" width="target" position="bottom-start">
|
<Menu shadow="md" width="target" position="bottom-start">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
|
||||||
size="md"
|
size="md"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color={color}
|
||||||
leftSection={<IconPlus size={18} />}
|
fullWidth
|
||||||
|
leftSection={icon}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
>
|
>
|
||||||
{t("New note")}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
|
|||||||
</Menu>
|
</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,5 +1,6 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { getDefaultStore } from "jotai";
|
||||||
import {
|
import {
|
||||||
toggleTemplate,
|
toggleTemplate,
|
||||||
toggleTemporary,
|
toggleTemporary,
|
||||||
@@ -9,6 +10,9 @@ import type {
|
|||||||
ToggleTemporaryResponse,
|
ToggleTemporaryResponse,
|
||||||
} from "@/features/page-embed/types/page-embed.types";
|
} from "@/features/page-embed/types/page-embed.types";
|
||||||
import { queryClient } from "@/main.tsx";
|
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
|
* After toggling a note's temporary state, mirror the new deadline into the
|
||||||
@@ -30,6 +34,19 @@ export function syncTemporaryExpiresInCache(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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({
|
queryClient.invalidateQueries({
|
||||||
predicate: (item) =>
|
predicate: (item) =>
|
||||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Anchor,
|
Anchor,
|
||||||
@@ -15,8 +15,12 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import classes from "./breadcrumb.module.css";
|
import classes from "./breadcrumb.module.css";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
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 { 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 { extractPageSlugId } from "@/lib";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -38,14 +42,29 @@ export default function Breadcrumb() {
|
|||||||
const { data: currentPage } = usePageQuery({
|
const { data: currentPage } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
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)");
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (treeData?.length > 0 && currentPage) {
|
if (!currentPage) return;
|
||||||
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
|
|
||||||
setBreadcrumbNodes(breadcrumb || null);
|
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
|
||||||
}
|
// (#218). It resolves the correct chain when possible and, on a transient
|
||||||
}, [currentPage?.id, treeData]);
|
// 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 = () =>
|
const HiddenNodesTooltipContent = () =>
|
||||||
breadcrumbNodes?.slice(1, -1).map((node) => (
|
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;
|
||||||
|
}
|
||||||
@@ -176,8 +176,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
temporary: next,
|
temporary: next,
|
||||||
});
|
});
|
||||||
// Reflect the new deadline in the page cache so the menu label flips and
|
// Reflect the new deadline in the page cache (menu label + banner) AND in
|
||||||
// any banner updates. The sidebar icon refreshes via its own query.
|
// the sidebar tree node so its clock marker updates immediately, no reload.
|
||||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: next
|
message: next
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
|
|||||||
|
|
||||||
// Check if the page already exists in the tree (it shouldn't)
|
// Check if the page already exists in the tree (it shouldn't)
|
||||||
if (!treeModel.find(currentTree, restoredPage.id)) {
|
if (!treeModel.find(currentTree, restoredPage.id)) {
|
||||||
// Create the tree node data with hasChildren from backend
|
// Create the tree node data with hasChildren from backend. Routed
|
||||||
const nodeData: SpaceTreeNode = {
|
// through the canonical mapper so the field copy stays in lockstep with
|
||||||
id: restoredPage.id,
|
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
|
||||||
slugId: restoredPage.slugId,
|
// page is made permanent), so the mapper carries that null through and
|
||||||
|
// the node correctly shows no clock marker.
|
||||||
|
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
|
||||||
name: restoredPage.title || "Untitled",
|
name: restoredPage.title || "Untitled",
|
||||||
icon: restoredPage.icon,
|
|
||||||
position: restoredPage.position,
|
|
||||||
spaceId: restoredPage.spaceId,
|
|
||||||
parentPageId: restoredPage.parentPageId,
|
|
||||||
hasChildren: restoredPage.hasChildren || false,
|
hasChildren: restoredPage.hasChildren || false,
|
||||||
children: [],
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// Determine the parent and index
|
// Determine the parent and index
|
||||||
const parentId = restoredPage.parentPageId || null;
|
const parentId = restoredPage.parentPageId || null;
|
||||||
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
slugId: data.slugId,
|
slugId: data.slugId,
|
||||||
spaceId: data.spaceId,
|
spaceId: data.spaceId,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
|
// Carry the death-timer deadline so a note created as temporary keeps its
|
||||||
|
// sidebar clock marker when the tree is rebuilt from this cached entry
|
||||||
|
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
|
||||||
|
// node's marker with `undefined`, hiding it until a reload.
|
||||||
|
temporaryExpiresAt: data.temporaryExpiresAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "@/features/page-embed/queries/page-embed-query";
|
} from "@/features/page-embed/queries/page-embed-query";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||||
@@ -130,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
const currentIndex = siblings?.index ?? 0;
|
const currentIndex = siblings?.index ?? 0;
|
||||||
const newIndex = currentIndex + 1;
|
const newIndex = currentIndex + 1;
|
||||||
|
|
||||||
const treeNodeData: SpaceTreeNode = {
|
// Routed through the canonical mapper so the field copy stays in lockstep
|
||||||
id: duplicatedPage.id,
|
// with buildTree. The server does NOT arm a death timer on duplicate (the
|
||||||
slugId: duplicatedPage.slugId,
|
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
|
||||||
name: duplicatedPage.title,
|
// carries that null through and the duplicated node correctly shows no
|
||||||
position: duplicatedPage.position,
|
// clock marker — matching the server without a reload.
|
||||||
spaceId: duplicatedPage.spaceId,
|
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
|
||||||
parentPageId: duplicatedPage.parentPageId,
|
|
||||||
icon: duplicatedPage.icon,
|
|
||||||
hasChildren: duplicatedPage.hasChildren,
|
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
children: [],
|
});
|
||||||
};
|
|
||||||
|
|
||||||
setData((prev) =>
|
setData((prev) =>
|
||||||
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
|||||||
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
||||||
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import {
|
import {
|
||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
@@ -139,18 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
throw new Error("Failed to create page");
|
throw new Error("Failed to create page");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNode: SpaceTreeNode = {
|
// Route through the canonical mapper so the field copy (esp.
|
||||||
id: createdPage.id,
|
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
|
||||||
slugId: createdPage.slugId,
|
// optimistic insert) can't drift from buildTree. `name: ""` because a
|
||||||
|
// freshly created page is untitled; `hasChildren: false` because it has no
|
||||||
|
// children yet.
|
||||||
|
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
|
||||||
name: "",
|
name: "",
|
||||||
position: createdPage.position,
|
|
||||||
spaceId: createdPage.spaceId,
|
|
||||||
parentPageId: createdPage.parentPageId,
|
|
||||||
hasChildren: false,
|
hasChildren: false,
|
||||||
// Show the temporary-note icon immediately on optimistic insert.
|
});
|
||||||
temporaryExpiresAt: createdPage.temporaryExpiresAt,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read latest tree at call time. Without this, callers that mutate the
|
// Read latest tree at call time. Without this, callers that mutate the
|
||||||
// tree (e.g. lazy-load children on expand) immediately before calling
|
// tree (e.g. lazy-load children on expand) immediately before calling
|
||||||
@@ -173,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||||
// the ids match exactly regardless of which path runs first.
|
// the ids match exactly regardless of which path runs first.
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (treeModel.find(prev, newNode.id)) return prev;
|
const existing = treeModel.find(prev, newNode.id);
|
||||||
|
if (existing) {
|
||||||
|
// The server `addTreeNode` broadcast won the race and already inserted
|
||||||
|
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
|
||||||
|
// a temporary note WITHOUT its clock marker until reload; patch it on
|
||||||
|
// from the authoritative create response so the marker shows now.
|
||||||
|
if (
|
||||||
|
newNode.temporaryExpiresAt &&
|
||||||
|
!(existing as SpaceTreeNode).temporaryExpiresAt
|
||||||
|
) {
|
||||||
|
return treeModel.update(prev, newNode.id, {
|
||||||
|
temporaryExpiresAt: newNode.temporaryExpiresAt,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// handleCreate race-guard temporaryExpiresAt patch: when the server's
|
||||||
|
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
|
||||||
|
// updater runs, the updater must not re-insert. Two sub-branches:
|
||||||
|
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
|
||||||
|
// omitted it) while the authoritative create response DOES → patch the
|
||||||
|
// deadline on so the clock marker shows now, without a reload.
|
||||||
|
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
|
||||||
|
// `prev` by reference (a no-op write).
|
||||||
|
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
|
||||||
|
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
|
||||||
|
|
||||||
|
// Mirrors the setData updater in use-tree-mutation handleCreate.
|
||||||
|
const applyOptimisticInsert = (
|
||||||
|
tree: TN[],
|
||||||
|
parentId: string | null,
|
||||||
|
node: TN,
|
||||||
|
index: number,
|
||||||
|
): TN[] => {
|
||||||
|
const existing = treeModel.find(tree, node.id) as TN | null;
|
||||||
|
if (existing) {
|
||||||
|
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
|
||||||
|
return treeModel.update(tree, node.id, {
|
||||||
|
temporaryExpiresAt: node.temporaryExpiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
return treeModel.insert(tree, parentId, node, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixtureTN: TN[] = [
|
||||||
|
{ id: "a", name: "A" },
|
||||||
|
{ id: "b", name: "B" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const deadline = "2026-07-01T00:00:00.000Z";
|
||||||
|
|
||||||
|
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
|
||||||
|
// Server broadcast won the race and inserted the node WITHOUT a deadline.
|
||||||
|
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||||
|
id: "new",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The authoritative create response carries the deadline.
|
||||||
|
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
|
||||||
|
const patched = applyOptimisticInsert(
|
||||||
|
afterServer,
|
||||||
|
null,
|
||||||
|
created,
|
||||||
|
afterServer.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new reference (the patch wrote) and the node now has the deadline...
|
||||||
|
expect(patched).not.toBe(afterServer);
|
||||||
|
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
|
||||||
|
deadline,
|
||||||
|
);
|
||||||
|
// ...and still exactly one node (no duplicate re-insert).
|
||||||
|
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
|
||||||
|
const existingDeadline = deadline;
|
||||||
|
// The node already exists WITH a deadline (the broadcast carried it).
|
||||||
|
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||||
|
id: "new",
|
||||||
|
name: "",
|
||||||
|
temporaryExpiresAt: existingDeadline,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The create response carries a DIFFERENT deadline; the guard must ignore it.
|
||||||
|
const created: TN = {
|
||||||
|
id: "new",
|
||||||
|
name: "",
|
||||||
|
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const after = applyOptimisticInsert(
|
||||||
|
afterServer,
|
||||||
|
null,
|
||||||
|
created,
|
||||||
|
afterServer.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// prev returned by reference (no write) and the original deadline is kept.
|
||||||
|
expect(after).toBe(afterServer);
|
||||||
|
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
|
||||||
|
existingDeadline,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
||||||
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
||||||
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
||||||
|
|||||||
@@ -9,26 +9,45 @@ export function sortPositionKeys(keys: any[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
|
||||||
|
* materialises a tree node from a page (buildTree, the optimistic insert in
|
||||||
|
* handleCreate, restore, duplicate) routes through here so the field copy —
|
||||||
|
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
|
||||||
|
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
|
||||||
|
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
|
||||||
|
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
|
||||||
|
* so restore (which the server nulls) stays permanent and a temporary create
|
||||||
|
* keeps its clock marker without a reload.
|
||||||
|
*/
|
||||||
|
export function pageToTreeNode(
|
||||||
|
page: IPage,
|
||||||
|
overrides?: Partial<SpaceTreeNode>,
|
||||||
|
): SpaceTreeNode {
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
slugId: page.slugId,
|
||||||
|
name: page.title,
|
||||||
|
icon: page.icon,
|
||||||
|
position: page.position,
|
||||||
|
hasChildren: page.hasChildren,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||||
|
isTemplate: page.isTemplate,
|
||||||
|
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||||
|
children: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||||
const pageMap: Record<string, SpaceTreeNode> = {};
|
const pageMap: Record<string, SpaceTreeNode> = {};
|
||||||
|
|
||||||
const tree: SpaceTreeNode[] = [];
|
const tree: SpaceTreeNode[] = [];
|
||||||
|
|
||||||
pages.forEach((page) => {
|
pages.forEach((page) => {
|
||||||
pageMap[page.id] = {
|
pageMap[page.id] = pageToTreeNode(page);
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
name: page.title,
|
|
||||||
icon: page.icon,
|
|
||||||
position: page.position,
|
|
||||||
hasChildren: page.hasChildren,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
|
||||||
isTemplate: page.isTemplate,
|
|
||||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import type { IShareAlias } from "@/features/share/types/share.types";
|
||||||
|
|
||||||
|
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The mutation + query hooks reach react-query/network; the availability probe
|
||||||
|
// hits the API. Stub them so the section renders in isolation and we can drive
|
||||||
|
// the exact branches (taken name -> hint, 409 -> reassign modal).
|
||||||
|
const setMutateAsync = vi.fn();
|
||||||
|
let currentAlias: IShareAlias | null = null;
|
||||||
|
let availabilityResult: {
|
||||||
|
valid: boolean;
|
||||||
|
available: boolean;
|
||||||
|
currentPageId: string | null;
|
||||||
|
} = { valid: true, available: true, currentPageId: null };
|
||||||
|
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||||
|
useSetShareAliasMutation: () => ({
|
||||||
|
mutateAsync: setMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useRemoveShareAliasMutation: () => ({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/share/services/share-service.ts", () => ({
|
||||||
|
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ShareAliasSection from "./share-alias-section";
|
||||||
|
|
||||||
|
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
|
||||||
|
id: `alias-${alias}`,
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
alias,
|
||||||
|
pageId,
|
||||||
|
creatorId: "user-1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSection(pageId = "page-Y") {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<ShareAliasSection pageId={pageId} readOnly={false} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMutateAsync.mockReset();
|
||||||
|
currentAlias = null;
|
||||||
|
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||||
|
// Page Y already owns "bee"; the user retypes a name owned by page X.
|
||||||
|
currentAlias = aliasRow("bee", "page-Y");
|
||||||
|
availabilityResult = {
|
||||||
|
valid: true,
|
||||||
|
available: false,
|
||||||
|
currentPageId: "page-X",
|
||||||
|
};
|
||||||
|
|
||||||
|
renderSection("page-Y");
|
||||||
|
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "test2" } });
|
||||||
|
|
||||||
|
// The reassign hint replaces the old dead-end red error.
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"This address is in use. Saving will move it to this page.",
|
||||||
|
),
|
||||||
|
).toBeDefined(),
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
// The old terminal "already in use" error must NOT be shown.
|
||||||
|
expect(screen.queryByText("This address is already in use")).toBeNull();
|
||||||
|
|
||||||
|
// Save stays enabled so the confirm-reassign flow can run.
|
||||||
|
const saveBtn = screen.getByRole("button", {
|
||||||
|
name: "Save",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(saveBtn.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
|
||||||
|
currentAlias = aliasRow("bee", "page-Y");
|
||||||
|
availabilityResult = {
|
||||||
|
valid: true,
|
||||||
|
available: false,
|
||||||
|
currentPageId: "page-X",
|
||||||
|
};
|
||||||
|
// The server rejects the un-confirmed save asking the client to confirm.
|
||||||
|
setMutateAsync.mockRejectedValueOnce({
|
||||||
|
status: 409,
|
||||||
|
response: {
|
||||||
|
status: 409,
|
||||||
|
data: {
|
||||||
|
code: "ALIAS_REASSIGN_REQUIRED",
|
||||||
|
currentPageId: "page-X",
|
||||||
|
currentPageTitle: "Alias Test Page X",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSection("page-Y");
|
||||||
|
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "test2" } });
|
||||||
|
|
||||||
|
const saveBtn = screen.getByRole("button", {
|
||||||
|
name: "Save",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
fireEvent.click(saveBtn);
|
||||||
|
|
||||||
|
// First save sent WITHOUT confirmReassign.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ alias: "test2", confirmReassign: false }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The "Move custom address?" confirm modal must appear (the path forward).
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("Move custom address?")).toBeDefined(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
|
||||||
|
|
||||||
|
// Confirming retries WITH confirmReassign: true.
|
||||||
|
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ alias: "test2", confirmReassign: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,8 +120,13 @@ export default function ShareAliasSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showInvalid = normalized.length > 0 && !isValid;
|
const showInvalid = normalized.length > 0 && !isValid;
|
||||||
const showTaken =
|
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
|
||||||
isValid && !unchanged && availability && !availability.available;
|
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
|
||||||
|
// the "Move custom address?" confirm modal that retargets the address here.
|
||||||
|
// So surface it as an informational hint (not a terminal red error) and keep
|
||||||
|
// Save enabled, instead of looking like the address is unusable.
|
||||||
|
const reassignable =
|
||||||
|
isValid && !unchanged && !!availability && !availability.available;
|
||||||
|
|
||||||
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
||||||
const prefixLabel = aliasPrefixLabel();
|
const prefixLabel = aliasPrefixLabel();
|
||||||
@@ -185,7 +190,6 @@ export default function ShareAliasSection({
|
|||||||
fontSize: "var(--mantine-font-size-xs)",
|
fontSize: "var(--mantine-font-size-xs)",
|
||||||
color: "var(--mantine-color-dimmed)",
|
color: "var(--mantine-color-dimmed)",
|
||||||
backgroundColor: "var(--mantine-color-default-hover)",
|
backgroundColor: "var(--mantine-color-default-hover)",
|
||||||
borderRight: "1px solid var(--mantine-color-default-border)",
|
|
||||||
borderTopLeftRadius: "var(--input-radius)",
|
borderTopLeftRadius: "var(--input-radius)",
|
||||||
borderBottomLeftRadius: "var(--input-radius)",
|
borderBottomLeftRadius: "var(--input-radius)",
|
||||||
}}
|
}}
|
||||||
@@ -199,9 +203,12 @@ export default function ShareAliasSection({
|
|||||||
error={
|
error={
|
||||||
showInvalid
|
showInvalid
|
||||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||||
: showTaken
|
: undefined
|
||||||
? t("This address is already in use")
|
}
|
||||||
: undefined
|
description={
|
||||||
|
reassignable
|
||||||
|
? t("This address is in use. Saving will move it to this page.")
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// Enabling a public share must NOT silently expose the whole sub-tree (#216):
|
||||||
|
// the create call defaults includeSubPages to false. This was a one-literal,
|
||||||
|
// security-relevant default with no test — lock it.
|
||||||
|
|
||||||
|
const createMutateAsync = vi.fn(async () => ({}));
|
||||||
|
const deleteMutateAsync = vi.fn(async () => ({}));
|
||||||
|
|
||||||
|
// No existing share for this page (toggle starts OFF).
|
||||||
|
let shareData: any = undefined;
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useCreateShareMutation: () => ({ mutateAsync: createMutateAsync }),
|
||||||
|
useDeleteShareMutation: () => ({ mutateAsync: deleteMutateAsync }),
|
||||||
|
useUpdateShareMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useShareForPageQuery: () => ({ data: shareData }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useSpaceQuery: () => ({ data: { settings: {} } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ShareModal from "./share-modal";
|
||||||
|
|
||||||
|
function renderModal() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MantineProvider>
|
||||||
|
<ShareModal readOnly={false} />
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ShareModal — enabling a share defaults includeSubPages to false (#216)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createMutateAsync.mockClear();
|
||||||
|
deleteMutateAsync.mockClear();
|
||||||
|
shareData = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the share with includeSubPages: false when the user turns it on", async () => {
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
// Open the share popover.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Share" }));
|
||||||
|
|
||||||
|
// The "Share to web" toggle is the only switch in the not-yet-shared state.
|
||||||
|
const toggle = await screen.findByRole("switch");
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
await waitFor(() => expect(createMutateAsync).toHaveBeenCalledTimes(1));
|
||||||
|
expect(createMutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
pageId: "page-1",
|
||||||
|
includeSubPages: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,7 +73,10 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
if (value) {
|
if (value) {
|
||||||
await createShareMutation.mutateAsync({
|
await createShareMutation.mutateAsync({
|
||||||
pageId: pageId,
|
pageId: pageId,
|
||||||
includeSubPages: true,
|
// Opt-in: enabling a share must NOT silently expose the whole
|
||||||
|
// sub-tree (#216). Sub-pages are shared only when the user turns on
|
||||||
|
// the dedicated "Include sub-pages" toggle.
|
||||||
|
includeSubPages: false,
|
||||||
searchIndexing: false,
|
searchIndexing: false,
|
||||||
});
|
});
|
||||||
} else if (share && share.id) {
|
} else if (share && share.id) {
|
||||||
|
|||||||
@@ -35,9 +35,17 @@ export interface ISharedItem extends IShare {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISharedPage extends IShare {
|
// The `/shares/page-info` (anonymous) response. Mirrors the server-side
|
||||||
page: IPage;
|
// PublicSharePayload allowlist (#218): the server trims `page`/`share` to these
|
||||||
share: IShare & {
|
// fields exactly, so the client type must not over-declare internal metadata it
|
||||||
|
// will never receive. Keep this in sync with share-public-payload.ts.
|
||||||
|
export interface ISharedPage {
|
||||||
|
page: Pick<IPage, "id" | "slugId" | "title" | "icon" | "content">;
|
||||||
|
share: {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
includeSubPages: boolean;
|
||||||
|
searchIndexing: boolean;
|
||||||
level: number;
|
level: number;
|
||||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||||
};
|
};
|
||||||
@@ -73,6 +81,10 @@ export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
|
|||||||
|
|
||||||
export interface IShareInfoInput {
|
export interface IShareInfoInput {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
// The share id/key from the `/share/:shareId/p/:slug` URL. When present the
|
||||||
|
// server binds content access to this exact share (#218): a forged/mismatched
|
||||||
|
// shareId 404s instead of rendering the page off its slug alone.
|
||||||
|
shareId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vanity /l/:alias pointer.
|
// Vanity /l/:alias pointer.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Stack } from "@mantine/core";
|
||||||
|
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
|
// Space-overview quick actions: create a regular note or a temporary note
|
||||||
|
// (which auto-moves to Trash after the workspace lifetime) directly in the
|
||||||
|
// current space and open it. Mirrors the sidebar's create buttons but lives on
|
||||||
|
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
|
||||||
|
// page is optimistically inserted into the sidebar tree and navigated to.
|
||||||
|
export default function SpaceCreateNoteButtons() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { spaceSlug } = useParams();
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||||
|
// `handleCreate` is read unconditionally to keep hook order stable; it is
|
||||||
|
// only invoked after the permission guard below confirms a loaded space.
|
||||||
|
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||||
|
// Which create action is in flight: drives the per-button spinner and the
|
||||||
|
// shared disabled state so a slow create round-trip cannot be double-fired.
|
||||||
|
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
|
||||||
|
|
||||||
|
// Render nothing until the space loads, or when the user cannot manage pages.
|
||||||
|
if (!space) return null;
|
||||||
|
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNote = (temporary: boolean) => {
|
||||||
|
if (pending) return;
|
||||||
|
setPending(temporary ? "temporary" : "regular");
|
||||||
|
// handleCreate creates the page then navigates away (unmounting this
|
||||||
|
// component); the create mutation already shows a red notification on
|
||||||
|
// failure, so swallow the rejection and just clear the pending flag.
|
||||||
|
handleCreate(null, temporary ? { temporary: true } : undefined)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setPending(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two full-width, vertically stacked buttons: a neutral regular note and an
|
||||||
|
// orange-tinted temporary note. Stacking full-width keeps the longer "New
|
||||||
|
// temporary note" label from clipping on narrow mobile widths.
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
fullWidth
|
||||||
|
leftSection={<IconPlus size={18} />}
|
||||||
|
loading={pending === "regular"}
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => createNote(false)}
|
||||||
|
>
|
||||||
|
{t("New note")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
fullWidth
|
||||||
|
leftSection={<IconHourglass size={18} />}
|
||||||
|
loading={pending === "temporary"}
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => createNote(true)}
|
||||||
|
>
|
||||||
|
{t("New temporary note")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => {
|
|||||||
"child",
|
"child",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
|
||||||
|
// A note created as temporary broadcasts addTreeNode with the death-timer
|
||||||
|
// deadline in its payload; the receiver's inserted node must keep it so
|
||||||
|
// space-tree-row renders the orange clock marker immediately.
|
||||||
|
const tree = roots();
|
||||||
|
const expiresAt = "2026-06-27T21:00:00.000Z";
|
||||||
|
const next = applyAddTreeNode(tree, {
|
||||||
|
parentId: null as unknown as string,
|
||||||
|
index: 0,
|
||||||
|
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
|
||||||
|
});
|
||||||
|
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
useAiRoleCatalogBundleQuery,
|
||||||
|
useAiRoleCatalogQuery,
|
||||||
|
useImportAiRolesFromCatalogMutation,
|
||||||
|
useUpdateAiRoleFromCatalogMutation,
|
||||||
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
import {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogBundleSummary,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||||
|
|
||||||
|
interface AiAgentRolesCatalogModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
// The current admin role list (full view, including `source`). Used to compute
|
||||||
|
// each catalog role's install state (import / installed / update available).
|
||||||
|
roles: IAiRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a name collision with an existing role is handled on import. */
|
||||||
|
type Conflict = "skip" | "rename";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin modal: browse the curated role catalog, import roles, and update an
|
||||||
|
* imported role when the catalog ships a newer version.
|
||||||
|
*
|
||||||
|
* Import is per-bundle (the endpoint takes a single bundleId). Each bundle's
|
||||||
|
* Accordion panel has its own "Import" button that imports only that bundle's
|
||||||
|
* checked roles — the simplest mapping to the one-bundle-per-call API and the
|
||||||
|
* clearest UX. Selection state is tracked per bundle.
|
||||||
|
*/
|
||||||
|
export default function AiAgentRolesCatalogModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
roles,
|
||||||
|
}: AiAgentRolesCatalogModalProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
// The user's i18n base subtag (e.g. "ru-RU" => "ru"); the preferred catalog
|
||||||
|
// language both when seeding and when reconciling against offered languages.
|
||||||
|
const baseLang = (i18n.language || "en").split("-")[0].toLowerCase();
|
||||||
|
|
||||||
|
// Fetch the catalog only while the modal is open. `language` drives both the
|
||||||
|
// catalog query (bundle names) and bundle reads (role content). Seed it
|
||||||
|
// synchronously from the base subtag so the first fetch already uses the
|
||||||
|
// user's language; the effect below still reconciles against the catalog's
|
||||||
|
// offered languages once they load.
|
||||||
|
const [language, setLanguage] = useState<string>(() => baseLang);
|
||||||
|
const catalogQuery = useAiRoleCatalogQuery(language || "en", opened);
|
||||||
|
|
||||||
|
// On name conflict: Skip (default) or Rename to a free " (N)" name.
|
||||||
|
const [conflict, setConflict] = useState<Conflict>("skip");
|
||||||
|
|
||||||
|
// The currently expanded bundle id (Accordion is single-open: one bundle's
|
||||||
|
// roles are fetched at a time).
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Per-bundle selected slugs (import-state roles checked for import).
|
||||||
|
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
|
const languages = catalogQuery.data?.languages;
|
||||||
|
|
||||||
|
// Pick a sensible default language from the catalog once it loads: the i18n
|
||||||
|
// base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!languages || languages.length === 0) return;
|
||||||
|
if (language && languages.includes(language)) return;
|
||||||
|
const preferred = languages.includes(baseLang)
|
||||||
|
? baseLang
|
||||||
|
: languages.includes("en")
|
||||||
|
? "en"
|
||||||
|
: languages[0];
|
||||||
|
setLanguage(preferred);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
// Reset per-language UI state when the language changes (the bundle content,
|
||||||
|
// hence the install computations, are language-specific).
|
||||||
|
useEffect(() => {
|
||||||
|
setExpanded(null);
|
||||||
|
setSelected({});
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Role catalog")}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Select
|
||||||
|
label={t("Language")}
|
||||||
|
data={languages ?? []}
|
||||||
|
value={language || null}
|
||||||
|
onChange={(value) => value && setLanguage(value)}
|
||||||
|
allowDeselect={false}
|
||||||
|
disabled={!languages || languages.length === 0}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
label={t("On name conflict")}
|
||||||
|
value={conflict}
|
||||||
|
onChange={(value) => setConflict(value as Conflict)}
|
||||||
|
>
|
||||||
|
<Group mt="xs">
|
||||||
|
<Radio value="skip" label={t("Skip")} />
|
||||||
|
<Radio value="rename" label={t("Rename")} />
|
||||||
|
</Group>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{catalogQuery.isLoading && (
|
||||||
|
<Center py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalogQuery.isError && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
title={t("The role catalog is unavailable")}
|
||||||
|
>
|
||||||
|
{t("Please try again later.")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalogQuery.data && catalogQuery.data.bundles.length === 0 && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("No bundles available")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalogQuery.data && catalogQuery.data.bundles.length > 0 && (
|
||||||
|
<Accordion
|
||||||
|
variant="separated"
|
||||||
|
value={expanded}
|
||||||
|
onChange={setExpanded}
|
||||||
|
>
|
||||||
|
{catalogQuery.data.bundles.map((bundle) => (
|
||||||
|
<BundlePanel
|
||||||
|
key={bundle.id}
|
||||||
|
bundle={bundle}
|
||||||
|
language={language}
|
||||||
|
expanded={expanded === bundle.id}
|
||||||
|
roles={roles}
|
||||||
|
conflict={conflict}
|
||||||
|
selected={selected[bundle.id]}
|
||||||
|
onToggleSlug={(slug, checked) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev[bundle.id] ?? []);
|
||||||
|
if (checked) next.add(slug);
|
||||||
|
else next.delete(slug);
|
||||||
|
return { ...prev, [bundle.id]: next };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSetSelected={(slugs) =>
|
||||||
|
setSelected((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[bundle.id]: new Set(slugs),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="sm">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Close")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BundlePanelProps {
|
||||||
|
bundle: IAiRoleCatalogBundleSummary;
|
||||||
|
language: string;
|
||||||
|
expanded: boolean;
|
||||||
|
roles: IAiRole[];
|
||||||
|
conflict: Conflict;
|
||||||
|
selected: Set<string> | undefined;
|
||||||
|
onToggleSlug: (slug: string, checked: boolean) => void;
|
||||||
|
onSetSelected: (slugs: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One catalog bundle: its roles (fetched when expanded) + a per-bundle import. */
|
||||||
|
function BundlePanel({
|
||||||
|
bundle,
|
||||||
|
language,
|
||||||
|
expanded,
|
||||||
|
roles,
|
||||||
|
conflict,
|
||||||
|
selected,
|
||||||
|
onToggleSlug,
|
||||||
|
onSetSelected,
|
||||||
|
}: BundlePanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Only fetch this bundle's roles once it is actually expanded.
|
||||||
|
const bundleQuery = useAiRoleCatalogBundleQuery(
|
||||||
|
bundle.id,
|
||||||
|
language,
|
||||||
|
expanded && !!language,
|
||||||
|
);
|
||||||
|
|
||||||
|
const importMutation = useImportAiRolesFromCatalogMutation();
|
||||||
|
const updateMutation = useUpdateAiRoleFromCatalogMutation();
|
||||||
|
|
||||||
|
// Compute each catalog role's install state against the current workspace
|
||||||
|
// roles (matched by source.slug + source.language). The decision lives in the
|
||||||
|
// pure `catalogRoleInstallState` helper so it is unit-tested directly.
|
||||||
|
const computed = useMemo(() => {
|
||||||
|
const list = bundleQuery.data?.roles ?? [];
|
||||||
|
return list.map((role) => ({
|
||||||
|
role,
|
||||||
|
...catalogRoleInstallState(role, roles, language),
|
||||||
|
}));
|
||||||
|
}, [bundleQuery.data, roles, language]);
|
||||||
|
|
||||||
|
// Default-check every importable role once the bundle content arrives (unless
|
||||||
|
// the user already touched the selection for this bundle).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bundleQuery.data || selected !== undefined) return;
|
||||||
|
onSetSelected(
|
||||||
|
computed.filter((c) => c.state === "import").map((c) => c.role.slug),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bundleQuery.data]);
|
||||||
|
|
||||||
|
const importableSlugs = computed
|
||||||
|
.filter((c) => c.state === "import")
|
||||||
|
.map((c) => c.role.slug);
|
||||||
|
const checkedSlugs = importableSlugs.filter((slug) => selected?.has(slug));
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
importMutation.mutate({
|
||||||
|
bundleId: bundle.id,
|
||||||
|
language,
|
||||||
|
slugs: checkedSlugs,
|
||||||
|
conflict,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={bundle.id}>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={500}>{bundle.name}</Text>
|
||||||
|
{bundle.description && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{bundle.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
{bundleQuery.isLoading && (
|
||||||
|
<Center py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundleQuery.isError && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
title={t("The role catalog is unavailable")}
|
||||||
|
>
|
||||||
|
{t("Please try again later.")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bundleQuery.data && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{computed.map((entry) => (
|
||||||
|
<CatalogRoleRow
|
||||||
|
key={entry.role.slug}
|
||||||
|
role={entry.role}
|
||||||
|
state={entry.state}
|
||||||
|
checked={
|
||||||
|
entry.state === "import"
|
||||||
|
? !!selected?.has(entry.role.slug)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onToggle={(checked) => onToggleSlug(entry.role.slug, checked)}
|
||||||
|
fromVersion={
|
||||||
|
entry.state === "update" ? entry.fromVersion : undefined
|
||||||
|
}
|
||||||
|
onUpdate={
|
||||||
|
entry.state === "update"
|
||||||
|
? () => updateMutation.mutate(entry.installed.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
updating={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
onClick={handleImport}
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
disabled={checkedSlugs.length === 0}
|
||||||
|
>
|
||||||
|
{t("Import")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogRoleRowProps {
|
||||||
|
role: IAiRoleCatalogRole;
|
||||||
|
state: "import" | "installed" | "update";
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: (checked: boolean) => void;
|
||||||
|
// The installed role's current source version (only set in the "update" state).
|
||||||
|
fromVersion?: number;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
updating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single catalog role row with its install-state affordance. */
|
||||||
|
function CatalogRoleRow({
|
||||||
|
role,
|
||||||
|
state,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
fromVersion,
|
||||||
|
onUpdate,
|
||||||
|
updating,
|
||||||
|
}: CatalogRoleRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||||
|
<Group gap="xs" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||||
|
{state === "import" && (
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onToggle(event.currentTarget.checked)}
|
||||||
|
aria-label={role.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{role.emoji ? `${role.emoji} ` : ""}
|
||||||
|
{role.name}
|
||||||
|
</Text>
|
||||||
|
{role.description && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{role.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
|
||||||
|
{state === "installed" && (
|
||||||
|
<Badge size="sm" variant="light" color="gray">
|
||||||
|
{t("Installed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{state === "update" && (
|
||||||
|
<>
|
||||||
|
<Badge size="sm" variant="light" color="blue">
|
||||||
|
{t("v{{from}} → v{{to}}", {
|
||||||
|
from: fromVersion ?? 0,
|
||||||
|
to: role.version,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
<Button size="xs" variant="light" onClick={onUpdate} loading={updating}>
|
||||||
|
{t("Update")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
import {
|
||||||
|
IconPackageImport,
|
||||||
|
IconPencil,
|
||||||
|
IconPlus,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +28,7 @@ import {
|
|||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
||||||
|
import AiAgentRolesCatalogModal from "./ai-agent-roles-catalog-modal.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin section: list / add / edit / delete reusable agent roles. A role
|
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||||
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
|
|||||||
const deleteMutation = useDeleteAiRoleMutation();
|
const deleteMutation = useDeleteAiRoleMutation();
|
||||||
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
// Separate disclosure for the catalog (import/update) modal.
|
||||||
|
const [catalogOpened, { open: openCatalog, close: closeCatalog }] =
|
||||||
|
useDisclosure(false);
|
||||||
// The role being edited; undefined => the modal is in "create" mode.
|
// The role being edited; undefined => the modal is in "create" mode.
|
||||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||||
|
|
||||||
@@ -86,14 +95,24 @@ export default function AiAgentRoles() {
|
|||||||
/>
|
/>
|
||||||
<Text fw={600}>{t("Agent roles")}</Text>
|
<Text fw={600}>{t("Agent roles")}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Group gap="xs" wrap="nowrap">
|
||||||
leftSection={<IconPlus size={16} />}
|
<Button
|
||||||
variant="default"
|
leftSection={<IconPackageImport size={16} />}
|
||||||
size="xs"
|
variant="default"
|
||||||
onClick={openCreate}
|
size="xs"
|
||||||
>
|
onClick={openCatalog}
|
||||||
{t("Add role")}
|
>
|
||||||
</Button>
|
{t("Import from catalog")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
onClick={openCreate}
|
||||||
|
>
|
||||||
|
{t("Add role")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
{t(
|
{t(
|
||||||
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!isLoading && (!roles || roles.length === 0) && (
|
{!isLoading && (!roles || roles.length === 0) && (
|
||||||
<Text size="sm" c="dimmed" mt="sm">
|
<Group gap="sm" mt="sm" align="center">
|
||||||
{t("No roles configured")}
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
{t("No roles configured")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPackageImport size={16} />}
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={openCatalog}
|
||||||
|
>
|
||||||
|
{t("Browse the catalog")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap="xs" mt="sm">
|
<Stack gap="xs" mt="sm">
|
||||||
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
|
|||||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<AiAgentRolesCatalogModal
|
||||||
|
opened={catalogOpened}
|
||||||
|
onClose={closeCatalog}
|
||||||
|
roles={roles ?? []}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface IWorkspace {
|
|||||||
plan?: string;
|
plan?: string;
|
||||||
enforceMfa?: boolean;
|
enforceMfa?: boolean;
|
||||||
aiSearch?: boolean;
|
aiSearch?: boolean;
|
||||||
generativeAi?: boolean;
|
|
||||||
disablePublicSharing?: boolean;
|
disablePublicSharing?: boolean;
|
||||||
mcpEnabled?: boolean;
|
mcpEnabled?: boolean;
|
||||||
aiChat?: boolean;
|
aiChat?: boolean;
|
||||||
@@ -61,12 +60,14 @@ export interface IWorkspaceApiSettings {
|
|||||||
|
|
||||||
export interface IWorkspaceAiSettings {
|
export interface IWorkspaceAiSettings {
|
||||||
search?: boolean;
|
search?: boolean;
|
||||||
generative?: boolean;
|
|
||||||
mcp?: boolean;
|
mcp?: boolean;
|
||||||
chat?: boolean;
|
chat?: boolean;
|
||||||
dictation?: boolean;
|
dictation?: boolean;
|
||||||
dictationStreaming?: boolean;
|
dictationStreaming?: boolean;
|
||||||
publicShareAssistant?: boolean;
|
publicShareAssistant?: boolean;
|
||||||
|
// #184: detached agent runs (a run survives a browser disconnect and can be
|
||||||
|
// reconnected to / live-followed on reopen). Gates the run-reconnect polling.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSharingSettings {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default function SharedPage() {
|
|||||||
|
|
||||||
const { data, isLoading, isError, error } = useSharePageQuery({
|
const { data, isLoading, isError, error } = useSharePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
|
// Forward the URL's shareId so the server binds content to this share
|
||||||
|
// (#218): a forged shareId 404s instead of rendering the page off its slug.
|
||||||
|
shareId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
|
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Container} from "@mantine/core";
|
import {Container, Space} from "@mantine/core";
|
||||||
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
|
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
|
||||||
|
import SpaceCreateNoteButtons from "@/features/space/components/space-create-note-buttons.tsx";
|
||||||
import {useParams} from "react-router-dom";
|
import {useParams} from "react-router-dom";
|
||||||
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
|
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
|
||||||
import {getAppName} from "@/lib/config.ts";
|
import {getAppName} from "@/lib/config.ts";
|
||||||
@@ -15,7 +16,13 @@ export default function SpaceHome() {
|
|||||||
<title>{space?.name || 'Overview'} - {getAppName()}</title>
|
<title>{space?.name || 'Overview'} - {getAppName()}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Container size={"900"} pt="xl">
|
<Container size={"900"} pt="xl">
|
||||||
{space && <SpaceHomeTabs/>}
|
{space && (
|
||||||
|
<>
|
||||||
|
<SpaceCreateNoteButtons/>
|
||||||
|
<Space h="md"/>
|
||||||
|
<SpaceHomeTabs/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -205,6 +205,32 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
expect(historyQueue.add).toHaveBeenCalledTimes(1);
|
expect(historyQueue.add).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #206 persist-6 — RED (it.failing): a momentarily-empty live Y.Doc must not
|
||||||
|
// overwrite non-empty persisted content. `onStoreDocument` empty-guards the
|
||||||
|
// LOAD path but not the STORE path, so today an empty doc (a client/agent
|
||||||
|
// glitch, a bad merge, an emptying transclusion) is written straight over the
|
||||||
|
// page and the content is wiped silently. A store-side empty-guard is a real
|
||||||
|
// behaviour change (a deliberate "select-all + delete" is also empty), so it
|
||||||
|
// is left UNFIXED pending a product decision; this documents the data-loss
|
||||||
|
// path and flips to a normal passing test the moment the guard lands.
|
||||||
|
it.failing(
|
||||||
|
'does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)',
|
||||||
|
async () => {
|
||||||
|
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||||
|
const document = ydocFor(emptyDoc);
|
||||||
|
pageRepo.findById.mockResolvedValue({
|
||||||
|
...persistedHumanPage('IGNORED'),
|
||||||
|
content: doc('IMPORTANT RICH CONTENT'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||||
|
|
||||||
|
// Desired contract: the empty incoming doc is rejected and the rich page
|
||||||
|
// survives. Today updatePage is called with the empty content (data loss).
|
||||||
|
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// persist-1 — when every attempt fails the hook must NOT report a phantom
|
// persist-1 — when every attempt fails the hook must NOT report a phantom
|
||||||
// success: no "page.updated" badge broadcast and no history snapshot for
|
// success: no "page.updated" badge broadcast and no history snapshot for
|
||||||
// content that was never written.
|
// content that was never written.
|
||||||
|
|||||||
492
apps/server/src/core/ai-chat/ai-chat-run.service.spec.ts
Normal file
492
apps/server/src/core/ai-chat/ai-chat-run.service.spec.ts
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AiChatRunService,
|
||||||
|
RunAlreadyActiveError,
|
||||||
|
ONE_ACTIVE_RUN_PER_CHAT_INDEX,
|
||||||
|
mapTurnStatusToRun,
|
||||||
|
} from './ai-chat-run.service';
|
||||||
|
|
||||||
|
/** Shape a Postgres unique-violation the way the postgres.js driver surfaces it:
|
||||||
|
* SQLSTATE 23505 + the offending index in `constraint_name`. */
|
||||||
|
function uniqueViolation(constraintName: string): Error & {
|
||||||
|
code: string;
|
||||||
|
constraint_name: string;
|
||||||
|
} {
|
||||||
|
return Object.assign(
|
||||||
|
new Error('duplicate key value violates unique constraint'),
|
||||||
|
{
|
||||||
|
code: '23505',
|
||||||
|
constraint_name: constraintName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the #184 phase-1 run lifecycle (AiChatRunService) with a
|
||||||
|
* hand-rolled mock repo — no Nest graph, no DB. The invariant under test is the
|
||||||
|
* one that makes a run "autonomous": a run keeps going when its SUBSCRIBER (the
|
||||||
|
* browser) detaches, and ONLY an explicit stop aborts it. We assert that at the
|
||||||
|
* abort-signal level (the signal the agent loop actually consumes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Minimal EnvironmentService stub. Single-instance (CLOUD unset) by default. */
|
||||||
|
function makeEnv(isCloud = false) {
|
||||||
|
return { isCloud: () => isCloud };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
||||||
|
return {
|
||||||
|
insert: jest.fn(async (v: any) => ({
|
||||||
|
id: 'run-1',
|
||||||
|
status: v.status ?? 'running',
|
||||||
|
chatId: v.chatId,
|
||||||
|
workspaceId: v.workspaceId,
|
||||||
|
})),
|
||||||
|
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
|
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
|
findActiveByChat: jest.fn(async () => undefined),
|
||||||
|
findLatestByChat: jest.fn(async () => undefined),
|
||||||
|
findById: jest.fn(async () => undefined),
|
||||||
|
sweepRunning: jest.fn(async () => 0),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mapTurnStatusToRun', () => {
|
||||||
|
it('maps the turn terminal status to the run terminal status', () => {
|
||||||
|
expect(mapTurnStatusToRun('completed')).toBe('succeeded');
|
||||||
|
expect(mapTurnStatusToRun('error')).toBe('failed');
|
||||||
|
expect(mapTurnStatusToRun('aborted')).toBe('aborted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AiChatRunService.onModuleInit (startup sweep)', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('calls sweepRunning and resolves; logs when > 0', async () => {
|
||||||
|
const repo = makeRepo({ sweepRunning: jest.fn(async () => 2) });
|
||||||
|
const logSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'log')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||||
|
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(logSpy.mock.calls[0][0])).toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a sweep failure is swallowed (never blocks startup)', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
sweepRunning: jest.fn(async () => {
|
||||||
|
throw new Error('db down');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||||
|
// The first warn is the sweep failure (the multi-instance warn never fires
|
||||||
|
// single-instance), so the message is the db error.
|
||||||
|
expect(String(warnSpy.mock.calls[0][0])).toContain('db down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F1 (DECISION C): the boot sweep is UNCONDITIONAL — sweepRunning is called with NO staleness window, so a fresh running run (updatedAt = now) is settled, not skipped', async () => {
|
||||||
|
// The bug: a fast restart (deploy/OOM within minutes of the last step) left a
|
||||||
|
// run stuck 'running' under the old 10-min window, 409ing every later turn in
|
||||||
|
// the chat. The fix settles ALL pending|running on boot. We assert the service
|
||||||
|
// invokes sweepRunning with no `staleMs` (the unconditional path); the repo's
|
||||||
|
// own spec proves no-window => no updatedAt filter.
|
||||||
|
const repo = makeRepo({ sweepRunning: jest.fn(async () => 1) });
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.onModuleInit();
|
||||||
|
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = repo.sweepRunning.mock.calls[0] as unknown[];
|
||||||
|
const firstArg = callArgs[0] as { staleMs?: number } | undefined;
|
||||||
|
// Either no opts at all, or opts without a staleMs window => unconditional.
|
||||||
|
expect(firstArg?.staleMs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F2 (DECISION A): warns at startup that autonomousRuns is single-instance-only when a horizontally-scaled deployment (CLOUD) is detected', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv(true) as never);
|
||||||
|
await svc.onModuleInit();
|
||||||
|
const warned = warnSpy.mock.calls.some((c) =>
|
||||||
|
/single-instance-only/i.test(String(c[0])),
|
||||||
|
);
|
||||||
|
expect(warned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F2: does NOT warn about multi-instance on a single-instance (CLOUD unset) deployment', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv(false) as never);
|
||||||
|
await svc.onModuleInit();
|
||||||
|
const warned = warnSpy.mock.calls.some((c) =>
|
||||||
|
/single-instance-only/i.test(String(c[0])),
|
||||||
|
);
|
||||||
|
expect(warned).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AiChatRunService run lifecycle', () => {
|
||||||
|
it('beginRun inserts a running row and registers a live abort controller', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(repo.insert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
createdBy: 'user-1',
|
||||||
|
status: 'running',
|
||||||
|
trigger: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(handle.runId).toBe('run-1');
|
||||||
|
expect(handle.signal.aborted).toBe(false);
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginRun REJECTS the racer: a 23505 on the one-active-per-chat index throws RunAlreadyActiveError (not swallowed) and registers no controller', async () => {
|
||||||
|
// The race: the controller's cheap pre-check passed for BOTH concurrent
|
||||||
|
// turns, so the loser's INSERT hits the partial unique index. That rejection
|
||||||
|
// is the authoritative gate — it must surface, not be swallowed into an
|
||||||
|
// untracked turn.
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async () => {
|
||||||
|
throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
).rejects.toBeInstanceOf(RunAlreadyActiveError);
|
||||||
|
// No controller leaked for a rejected start.
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginRun does NOT mask an unrelated unique violation as already-active', async () => {
|
||||||
|
// A 23505 on some OTHER constraint is a real bug, not the race — it must
|
||||||
|
// propagate unchanged so it is never silently treated as "already active".
|
||||||
|
const other = uniqueViolation('ai_chat_runs_pkey');
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async () => {
|
||||||
|
throw other;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
).rejects.toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginRun propagates a non-unique insert failure unchanged', async () => {
|
||||||
|
const boom = new Error('connection reset');
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async () => {
|
||||||
|
throw boom;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
).rejects.toBe(boom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two concurrent begins on one chat: exactly one wins, the other is rejected as already-active', async () => {
|
||||||
|
// Integration-style: model the DB partial unique index with a one-shot slot.
|
||||||
|
// The first insert claims it; the second hits a 23505 on the active index.
|
||||||
|
let slotTaken = false;
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async (v: any) => {
|
||||||
|
if (slotTaken) throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||||
|
slotTaken = true;
|
||||||
|
return { id: 'run-win', status: v.status, chatId: v.chatId };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||||
|
]);
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||||
|
const rejected = results.filter((r) => r.status === 'rejected');
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||||
|
RunAlreadyActiveError,
|
||||||
|
);
|
||||||
|
// Exactly the winner is locally active.
|
||||||
|
expect(svc.isLocallyActive('run-win')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a SUBSCRIBER detaching does NOT abort the run (only an explicit stop does)', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
// Model a browser disconnect: nothing in the run service is told to stop.
|
||||||
|
// The signal the agent loop consumes must stay un-aborted and the run stays
|
||||||
|
// locally active — i.e. it keeps running server-side.
|
||||||
|
expect(handle.signal.aborted).toBe(false);
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
// markStopRequested was never called by a mere detach.
|
||||||
|
expect(repo.markStopRequested).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop aborts the live controller, marks the row, and reports true', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
const aborted = jest.fn();
|
||||||
|
handle.signal.addEventListener('abort', aborted);
|
||||||
|
|
||||||
|
const result = await svc.requestStop('run-1', 'ws-1');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(handle.signal.aborted).toBe(true);
|
||||||
|
expect(aborted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.markStopRequested).toHaveBeenCalledWith('run-1', 'ws-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop on a run this replica does NOT hold still marks the row (true)', async () => {
|
||||||
|
// e.g. after a restart, or a sibling replica owns the controller. The row is
|
||||||
|
// marked so the owning replica/sweep settles it; we report a stop took effect.
|
||||||
|
const repo = makeRepo({
|
||||||
|
markStopRequested: jest.fn(async () => ({ id: 'run-9' })),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const result = await svc.requestStop('run-9', 'ws-1');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(svc.isLocallyActive('run-9')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requestStop on an already-settled run (nothing active) reports false', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
markStopRequested: jest.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const result = await svc.requestStop('run-done', 'ws-1');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizeRun settles the row to the mapped status with finishedAt and drops the in-memory entry', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||||
|
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'failed',
|
||||||
|
error: 'provider blew up',
|
||||||
|
finishedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizeRun is IDEMPOTENT: a second settle no-ops (single terminal write)', async () => {
|
||||||
|
// The #184 review fix: AiChatService.stream wraps the turn in a safety-net
|
||||||
|
// catch that settles a failed turn AND streamText's terminal callback may
|
||||||
|
// also settle — both routes call finalizeRun. Only the FIRST may write the
|
||||||
|
// terminal row; the second must no-op so a late settle can never clobber the
|
||||||
|
// real terminal status or double-write the row.
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'first');
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CONCURRENCY: two simultaneous finalizeRun on the same run write the terminal row EXACTLY ONCE (the 2nd caller exits synchronously at the atomic claim)', async () => {
|
||||||
|
// The CRITICAL race: AiChatService.stream's safety-net catch settles the turn
|
||||||
|
// to 'error' while a streamText terminal callback also settles it — both call
|
||||||
|
// finalizeRun for the SAME runId. The once-gate must close ATOMICALLY: a
|
||||||
|
// `settled.has` check alone is read BEFORE the awaited UPDATE, so both callers
|
||||||
|
// would pass it and BOTH write the row (last-write-wins clobber + double
|
||||||
|
// write). The fix claims the run with a SYNCHRONOUS `active.delete` before any
|
||||||
|
// await, so the second caller returns in the same tick, before the UPDATE.
|
||||||
|
//
|
||||||
|
// We force the two calls to overlap by making `update` return a promise we
|
||||||
|
// resolve only AFTER both finalizeRun calls have run their synchronous bodies.
|
||||||
|
let resolveUpdate!: (v: unknown) => void;
|
||||||
|
const updateGate = new Promise((res) => {
|
||||||
|
resolveUpdate = res;
|
||||||
|
});
|
||||||
|
const update = jest.fn(() => updateGate);
|
||||||
|
const repo = makeRepo({ update });
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire both before the (pending) update resolves. The first synchronously
|
||||||
|
// claims the entry (active.delete) and awaits update; the second, started in
|
||||||
|
// the same macrotask, finds the entry already gone and returns at the claim
|
||||||
|
// WITHOUT ever calling update.
|
||||||
|
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||||
|
|
||||||
|
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Let the single in-flight update land; both calls resolve cleanly.
|
||||||
|
resolveUpdate({ id: 'run-1' });
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||||
|
// 'error' settle never wrote, so it could not clobber the real status.
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
|
);
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F6: a TRANSIENT terminal-write failure is ridden out by the bounded retry — the run is settled, not stranded', async () => {
|
||||||
|
// The bug: finalizeRun used to DROP the in-memory entry BEFORE the terminal
|
||||||
|
// UPDATE, then only warn-log a failure. A single transient blip (pool
|
||||||
|
// exhaustion / deadlock / connection hiccup) on that PK UPDATE left the row
|
||||||
|
// 'running' with nothing left to recover it -> every later turn in that chat
|
||||||
|
// 409s until a restart. The fix updates FIRST and retries.
|
||||||
|
let calls = 0;
|
||||||
|
const repo = makeRepo({
|
||||||
|
update: jest.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error('deadlock detected');
|
||||||
|
return { id: 'run-1' };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
|
||||||
|
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||||
|
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
||||||
|
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||||
|
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||||
|
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||||
|
let healthy = false;
|
||||||
|
const repo = makeRepo({
|
||||||
|
update: jest.fn(async () => {
|
||||||
|
if (!healthy) throw new Error('pool exhausted');
|
||||||
|
return { id: 'run-1' };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const errorSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({
|
||||||
|
chatId: 'chat-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||||
|
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||||
|
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||||
|
// blip — distinct from the per-attempt warns.
|
||||||
|
const gaveUp = errorSpy.mock.calls.some(
|
||||||
|
(c) =>
|
||||||
|
/NON-TERMINAL/.test(String(c[0])) &&
|
||||||
|
/run-1/.test(String(c[0])) &&
|
||||||
|
/chat-1/.test(String(c[0])),
|
||||||
|
);
|
||||||
|
expect(gaveUp).toBe(true);
|
||||||
|
|
||||||
|
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||||
|
healthy = true;
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
|
expect(repo.update).toHaveBeenLastCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws-1',
|
||||||
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||||
|
// written), so a double-settle can never clobber the real status.
|
||||||
|
const callsBefore = repo.update.mock.calls.length;
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||||
|
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
update: jest.fn(async () => {
|
||||||
|
throw new Error('transient');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await expect(svc.recordStep('run-1', 'ws-1', 3)).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
svc.linkAssistantMessage('run-1', 'ws-1', 'msg-1'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
426
apps/server/src/core/ai-chat/ai-chat-run.service.ts
Normal file
426
apps/server/src/core/ai-chat/ai-chat-run.service.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||||
|
import { AiChatRun } from '@docmost/db/types/entity.types';
|
||||||
|
import { isUniqueViolation, violatedConstraint } from '@docmost/db/utils';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
|
|
||||||
|
/** Name of the partial unique index enforcing "one active run per chat" (see the
|
||||||
|
* ai_chat_runs migration). A 23505 on THIS constraint is the race-safe signal
|
||||||
|
* that a concurrent turn already owns the chat — distinct from any other unique
|
||||||
|
* collision, which must NOT be silently treated as "already active". */
|
||||||
|
export const ONE_ACTIVE_RUN_PER_CHAT_INDEX = 'ai_chat_runs_one_active_per_chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by {@link AiChatRunService.beginRun} when the run-row INSERT loses the
|
||||||
|
* race for a chat's single active slot (the partial unique index rejects it with
|
||||||
|
* a 23505). This is the AUTHORITATIVE concurrency gate: the controller's cheap
|
||||||
|
* pre-check is only a fast-path, and a request that slips past it must NOT run
|
||||||
|
* untracked. The caller (AiChatService.stream) translates this into a 409 and
|
||||||
|
* aborts the turn BEFORE any AI/provider call.
|
||||||
|
*/
|
||||||
|
export class RunAlreadyActiveError extends Error {
|
||||||
|
constructor(public readonly chatId: string) {
|
||||||
|
super(`An agent run is already in progress for chat ${chatId}`);
|
||||||
|
this.name = 'RunAlreadyActiveError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal status of a TURN (the #183 assistant-row lifecycle) maps onto the
|
||||||
|
* terminal status of a RUN (#184). A turn that completed -> the run succeeded; a
|
||||||
|
* turn that errored -> the run failed; a turn aborted (explicit user stop) -> the
|
||||||
|
* run aborted. Pure + unit-testable.
|
||||||
|
*/
|
||||||
|
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||||
|
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||||
|
|
||||||
|
export function mapTurnStatusToRun(
|
||||||
|
status: TurnTerminalStatus,
|
||||||
|
): RunTerminalStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'succeeded';
|
||||||
|
case 'error':
|
||||||
|
return 'failed';
|
||||||
|
case 'aborted':
|
||||||
|
return 'aborted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An in-flight run held in process memory: its AbortController is the ONLY thing
|
||||||
|
* that can stop the turn (an explicit user stop), independent of the browser
|
||||||
|
* socket. A mere disconnect never touches it, so the run keeps going. */
|
||||||
|
interface ActiveRun {
|
||||||
|
controller: AbortController;
|
||||||
|
chatId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live handle the streaming path drives a run through (returned by
|
||||||
|
* {@link AiChatRunService.beginRun}). The `signal` governs the agent loop's
|
||||||
|
* abort — wired to the run, NOT to the HTTP socket. */
|
||||||
|
export interface RunHandle {
|
||||||
|
runId: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AiChatRunService (#184 phase 1) — owns the agent RUN as a first-class,
|
||||||
|
* server-side lifecycle object detached from the HTTP request / browser window.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - create a run row when a turn starts (pending -> running) and register an
|
||||||
|
* in-memory AbortController for it (the explicit-stop lever);
|
||||||
|
* - finalize the run row (succeeded / failed / aborted) and unregister it;
|
||||||
|
* - service an EXPLICIT user stop (`requestStop`) — the ONLY thing that aborts a
|
||||||
|
* run; a browser disconnect deliberately does NOT;
|
||||||
|
* - crash-recovery sweep of dangling runs on startup.
|
||||||
|
*
|
||||||
|
* The agent loop itself still runs in AiChatService.stream (reusing #183's
|
||||||
|
* step-granular durable write path, `consumeStream` already drains it independent
|
||||||
|
* of the socket); this service only wraps it in a durable lifecycle and an
|
||||||
|
* abort handle that outlives the subscriber.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AiChatRunService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AiChatRunService.name);
|
||||||
|
|
||||||
|
// runId -> ActiveRun. Process-local on purpose (phase 1 is single-process /
|
||||||
|
// in-memory transport; a cross-process BullMQ runner + Redis stop-signal is
|
||||||
|
// deferred to phase 2). A stop for a runId not in this map (e.g. after a
|
||||||
|
// restart) still records `stop_requested_at` on the row.
|
||||||
|
private readonly active = new Map<string, ActiveRun>();
|
||||||
|
|
||||||
|
// runIds whose TERMINAL row write has SUCCEEDED — the idempotency once-gate
|
||||||
|
// (F6). A finalize must short-circuit only AFTER the terminal write has landed,
|
||||||
|
// NOT merely after the in-memory entry was dropped: a transient UPDATE failure
|
||||||
|
// has to stay retryable, so "already settled" means "row already terminal", not
|
||||||
|
// "entry already gone". Grows by one short UUID per finished run over process
|
||||||
|
// uptime — negligible in phase 1's single process.
|
||||||
|
private readonly settled = new Set<string>();
|
||||||
|
|
||||||
|
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||||
|
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||||
|
// brief connection blip). Riding out that blip in-place matters because the
|
||||||
|
// dominant success path (streamText onFinish) settles exactly ONCE — if that
|
||||||
|
// write is dropped and never retried, the row is stranded 'running' and the
|
||||||
|
// one-active-run gate 409s every future turn in the chat until a restart (no
|
||||||
|
// periodic sweep in phase 1).
|
||||||
|
private static readonly FINALIZE_MAX_ATTEMPTS = 3;
|
||||||
|
private static readonly FINALIZE_RETRY_BASE_MS = 50;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly runRepo: AiChatRunRepo,
|
||||||
|
private readonly environment: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crash-recovery sweep on server start: settle EVERY run still left
|
||||||
|
* pending/running to 'aborted' (F1 / DECISION C). The boot sweep is
|
||||||
|
* UNCONDITIONAL — no staleness window — because phase 1 is single-process: on a
|
||||||
|
* fresh boot any pending|running run is definitionally hung (no live runner owns
|
||||||
|
* it), so even a fast restart (deploy/OOM within minutes of the last step) can
|
||||||
|
* no longer leave a run stuck 'running' forever (which would make the
|
||||||
|
* one-active-run gate 409 every future turn in that chat). The staleness window
|
||||||
|
* is reintroduced only for the phase-2 multi-instance timer sweep, where a
|
||||||
|
* booting replica must not abort a run another replica is actively executing.
|
||||||
|
* Best-effort — a sweep failure is logged but MUST NOT block startup (mirrors
|
||||||
|
* AiChatService.onModuleInit for #183).
|
||||||
|
*/
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
this.warnIfMultiInstance();
|
||||||
|
try {
|
||||||
|
// No `staleMs`: unconditional boot sweep (F1). See AiChatRunRepo.sweepRunning.
|
||||||
|
const swept = await this.runRepo.sweepRunning();
|
||||||
|
if (swept > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Startup sweep: marked ${swept} dangling agent run(s) as 'aborted'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Startup sweep of dangling runs failed: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F2 (DECISION A): autonomous runs are SINGLE-INSTANCE-ONLY in phase 1. An
|
||||||
|
* explicit Stop, and the in-memory AbortController that backs it, are
|
||||||
|
* process-local: a Stop only aborts the live turn if it lands on the SAME
|
||||||
|
* replica that owns the run (it still stamps `stop_requested_at` cross-instance,
|
||||||
|
* but nothing reads that flag during an active run yet). Cross-instance pub/sub
|
||||||
|
* stop is phase 2. So if the deployment is horizontally scaled, warn loudly at
|
||||||
|
* startup that a Stop may not reach a run executing on another replica.
|
||||||
|
*
|
||||||
|
* DETECTION: this codebase always wires the socket.io Redis adapter (REDIS_URL
|
||||||
|
* is mandatory), so the adapter alone is NOT a horizontal-scaling signal. The
|
||||||
|
* authoritative signal the codebase has is `CLOUD=true` (EnvironmentService
|
||||||
|
* .isCloud()), the Docmost-cloud multi-replica deployment. We warn whenever that
|
||||||
|
* is set, because any workspace could enable settings.ai.autonomousRuns. A
|
||||||
|
* self-hosted operator running multiple replicas behind a load balancer is also
|
||||||
|
* multi-instance; the deploy docs (.env.example / AGENTS.md) spell out the
|
||||||
|
* single-instance constraint for that case.
|
||||||
|
*/
|
||||||
|
private warnIfMultiInstance(): void {
|
||||||
|
if (this.environment.isCloud()) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Autonomous agent runs (settings.ai.autonomousRuns) are SINGLE-INSTANCE-ONLY ' +
|
||||||
|
'in phase 1: a horizontally-scaled deployment was detected (CLOUD=true). ' +
|
||||||
|
'An explicit Stop only aborts a run executing on the same replica that owns ' +
|
||||||
|
'it (cross-instance Stop is not yet reliable — phase 2). Run a single ' +
|
||||||
|
'instance if you enable autonomousRuns, or keep the flag off.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a run for a turn: insert the run row (status 'running', startedAt now),
|
||||||
|
* register a fresh AbortController for it, and return a {@link RunHandle} whose
|
||||||
|
* `signal` the agent loop uses. The DB partial unique index guarantees at most
|
||||||
|
* one active run per chat — a second concurrent start on the same chat REJECTS
|
||||||
|
* at the insert (a 23505 on {@link ONE_ACTIVE_RUN_PER_CHAT_INDEX}). That
|
||||||
|
* rejection is the AUTHORITATIVE race gate: it is surfaced as a distinct
|
||||||
|
* {@link RunAlreadyActiveError} (NOT swallowed), so the caller turns it into a
|
||||||
|
* 409 and never streams an untracked turn. The controller is registered AFTER a
|
||||||
|
* successful insert so a rejected start leaks nothing.
|
||||||
|
*/
|
||||||
|
async beginRun(args: {
|
||||||
|
chatId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
trigger?: string;
|
||||||
|
}): Promise<RunHandle> {
|
||||||
|
let run: AiChatRun;
|
||||||
|
try {
|
||||||
|
run = await this.runRepo.insert({
|
||||||
|
chatId: args.chatId,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
createdBy: args.userId,
|
||||||
|
trigger: args.trigger ?? 'user',
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The race backstop: a concurrent turn already holds this chat's single
|
||||||
|
// active slot, so the partial unique index rejected our insert. Surface a
|
||||||
|
// distinct signal — the caller MUST reject this turn (409), not run it
|
||||||
|
// untracked. Any OTHER error propagates unchanged.
|
||||||
|
if (
|
||||||
|
isUniqueViolation(err) &&
|
||||||
|
violatedConstraint(err) === ONE_ACTIVE_RUN_PER_CHAT_INDEX
|
||||||
|
) {
|
||||||
|
throw new RunAlreadyActiveError(args.chatId);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.active.set(run.id, {
|
||||||
|
controller,
|
||||||
|
chatId: args.chatId,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
});
|
||||||
|
return { runId: run.id, signal: controller.signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link the assistant message (the #183 projection) to its run. Best-effort. */
|
||||||
|
async linkAssistantMessage(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
assistantMessageId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.runRepo.update(runId, workspaceId, { assistantMessageId });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to link assistant message to run ${runId}: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist progress: bump the run's finished-step count. Best-effort (never
|
||||||
|
* blocks or breaks the stream). */
|
||||||
|
async recordStep(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
stepCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.runRepo.update(runId, workspaceId, { stepCount });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to record step for run ${runId}: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||||
|
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||||
|
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||||
|
*
|
||||||
|
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||||
|
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||||
|
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||||
|
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||||
|
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||||
|
* so two callers can both see `false` and both write the row (last-write-wins
|
||||||
|
* clobbers the real terminal status, and the bounded retry only widens that
|
||||||
|
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
||||||
|
* check-and-clear with NO await between the gate and the entry removal: the
|
||||||
|
* second concurrent caller finds the entry already gone and returns in the same
|
||||||
|
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
||||||
|
* finalizing" is thus a single atomic step.
|
||||||
|
*
|
||||||
|
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||||
|
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||||
|
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||||
|
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||||
|
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||||
|
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
||||||
|
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
||||||
|
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
||||||
|
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
||||||
|
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
||||||
|
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
||||||
|
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
||||||
|
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
||||||
|
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
||||||
|
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
||||||
|
* and harmless meanwhile.
|
||||||
|
*
|
||||||
|
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||||
|
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||||
|
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||||
|
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||||
|
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||||
|
* double-settle collapses to a single write and a late settle can never clobber
|
||||||
|
* the real terminal status or double-write the row.
|
||||||
|
*/
|
||||||
|
async finalizeRun(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
turnStatus: TurnTerminalStatus,
|
||||||
|
error?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||||
|
// Already terminally written -> idempotent no-op.
|
||||||
|
if (this.settled.has(runId)) return;
|
||||||
|
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||||
|
const entry = this.active.get(runId);
|
||||||
|
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||||
|
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||||
|
// the same tick, before any await — so it can never reach the UPDATE.
|
||||||
|
if (!this.active.delete(runId)) return;
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
attempt <= AiChatRunService.FINALIZE_MAX_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.runRepo.update(runId, workspaceId, {
|
||||||
|
status: mapTurnStatusToRun(turnStatus),
|
||||||
|
finishedAt: new Date(),
|
||||||
|
error: error ?? null,
|
||||||
|
});
|
||||||
|
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||||
|
// (claimed above); we do NOT restore it. The slot is now free.
|
||||||
|
this.settled.add(runId);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||||
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
|
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||||
|
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||||
|
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||||
|
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||||
|
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||||
|
this.logger.error(
|
||||||
|
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||||
|
`('running'): terminal write failed after ${
|
||||||
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
|
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||||
|
`boot sweep`,
|
||||||
|
lastError,
|
||||||
|
);
|
||||||
|
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||||
|
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||||
|
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||||
|
// window above is consumed at the `active.delete` claim, and the no-streamText
|
||||||
|
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
||||||
|
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||||
|
// cleared on restart.
|
||||||
|
if (entry) this.active.set(runId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||||
|
* trivial to stub/fake-time in tests. */
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request an EXPLICIT stop of a run (the user pressed Stop). This is the ONLY
|
||||||
|
* thing that aborts a run — distinct from a browser disconnect, which leaves
|
||||||
|
* the run going. Records `stop_requested_at` on the row (only while active) and
|
||||||
|
* aborts the in-process controller if this replica owns the run. Returns true
|
||||||
|
* when a stop took effect (row marked and/or controller aborted), false when
|
||||||
|
* there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
async requestStop(runId: string, workspaceId: string): Promise<boolean> {
|
||||||
|
const marked = await this.runRepo.markStopRequested(runId, workspaceId);
|
||||||
|
const entry = this.active.get(runId);
|
||||||
|
if (entry) {
|
||||||
|
// Abort the live turn -> streamText onAbort fires -> the partial is
|
||||||
|
// persisted (#183) and finalizeRun settles the row as 'aborted'.
|
||||||
|
entry.controller.abort();
|
||||||
|
}
|
||||||
|
return Boolean(marked) || Boolean(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest persisted run for a chat — the reconnect target (an in-flight or
|
||||||
|
* finished run). Pure read-through to the repo. */
|
||||||
|
getLatestForChat(
|
||||||
|
chatId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
return this.runRepo.findLatestByChat(chatId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a run by id (workspace-scoped). Used to resolve + ownership-check an
|
||||||
|
* explicit stop targeting a runId. */
|
||||||
|
getRun(runId: string, workspaceId: string): Promise<AiChatRun | undefined> {
|
||||||
|
return this.runRepo.findById(runId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active run on a chat, if any (used to reject a concurrent start with a
|
||||||
|
* clean 409 before committing to the stream). */
|
||||||
|
getActiveForChat(
|
||||||
|
chatId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<AiChatRun | undefined> {
|
||||||
|
return this.runRepo.findActiveByChat(chatId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/diagnostic seam: whether this replica is holding a live controller for
|
||||||
|
* the run. */
|
||||||
|
isLocallyActive(runId: string): boolean {
|
||||||
|
return this.active.has(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward
|
||||||
|
* the requesting user + workspace + pageId to findLatestByPage and return the
|
||||||
|
* matched chat's id, or `{ chatId: null }` when there is none. The repo already
|
||||||
|
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match
|
||||||
|
* (null) — no extra page-access check is needed. Exercised with hand-rolled
|
||||||
|
* mocks, no Nest graph and no DB.
|
||||||
|
*/
|
||||||
|
describe('AiChatController.boundChat', () => {
|
||||||
|
const user = { id: 'u1' } as User;
|
||||||
|
const workspace = { id: 'ws1' } as Workspace;
|
||||||
|
|
||||||
|
function makeController(chat: unknown) {
|
||||||
|
const aiChatRepo = {
|
||||||
|
findLatestByPage: jest.fn().mockResolvedValue(chat),
|
||||||
|
};
|
||||||
|
const controller = new AiChatController(
|
||||||
|
{} as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
|
aiChatRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { controller, aiChatRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => {
|
||||||
|
const { controller, aiChatRepo } = makeController({
|
||||||
|
id: 'c1',
|
||||||
|
creatorId: 'u1',
|
||||||
|
});
|
||||||
|
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace);
|
||||||
|
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1');
|
||||||
|
expect(res).toEqual({ chatId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => {
|
||||||
|
const { controller } = makeController(undefined);
|
||||||
|
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace);
|
||||||
|
expect(res).toEqual({ chatId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,7 @@ describe('AiChatController.export', () => {
|
|||||||
};
|
};
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
aiChatMessageRepo as never,
|
aiChatMessageRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
163
apps/server/src/core/ai-chat/ai-chat.controller.run.spec.ts
Normal file
163
apps/server/src/core/ai-chat/ai-chat.controller.run.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiring spec for the #184 run-reconnect / run-stop endpoints
|
||||||
|
* (`POST /ai-chat/run` and `POST /ai-chat/stop`). Both are OWNER-gated via
|
||||||
|
* assertOwnedChat (the requesting user must own the chat) and NOT flag-gated.
|
||||||
|
* Exercised with hand-rolled mocks — no Nest graph, no DB. The controller's
|
||||||
|
* constructor order is (aiChatService, aiChatRunService, aiChatRepo,
|
||||||
|
* aiChatMessageRepo, aiTranscription).
|
||||||
|
*/
|
||||||
|
describe('AiChatController run endpoints (#184)', () => {
|
||||||
|
const user = { id: 'u1' } as User;
|
||||||
|
const workspace = { id: 'ws1' } as Workspace;
|
||||||
|
|
||||||
|
function makeController(opts: {
|
||||||
|
chat?: unknown; // what aiChatRepo.findById returns (owner-gate)
|
||||||
|
run?: unknown; // getLatestForChat / getRun result
|
||||||
|
activeRun?: unknown; // getActiveForChat result
|
||||||
|
message?: unknown; // aiChatMessageRepo.findById result
|
||||||
|
stopped?: boolean; // requestStop result
|
||||||
|
}) {
|
||||||
|
const aiChatRunService = {
|
||||||
|
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
|
||||||
|
getRun: jest.fn().mockResolvedValue(opts.run),
|
||||||
|
getActiveForChat: jest.fn().mockResolvedValue(opts.activeRun),
|
||||||
|
requestStop: jest.fn().mockResolvedValue(opts.stopped ?? false),
|
||||||
|
};
|
||||||
|
const aiChatRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(opts.chat),
|
||||||
|
};
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
findById: jest.fn().mockResolvedValue(opts.message),
|
||||||
|
};
|
||||||
|
const controller = new AiChatController(
|
||||||
|
{} as never, // aiChatService
|
||||||
|
aiChatRunService as never,
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiTranscription
|
||||||
|
);
|
||||||
|
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /ai-chat/run (getRun)', () => {
|
||||||
|
it('owner-gates: a chat the user does not own throws ForbiddenException', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
controller.getRun({ chatId: 'c1' }, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
// It must NOT reach the run lookup once the owner-gate fails.
|
||||||
|
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { run: null, message: null } when the chat has never had a run', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
run: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ run: null, message: null });
|
||||||
|
expect(aiChatRunService.getLatestForChat).toHaveBeenCalledWith(
|
||||||
|
'c1',
|
||||||
|
'ws1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the run and its projected assistant message', async () => {
|
||||||
|
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: 'm1' };
|
||||||
|
const message = { id: 'm1', role: 'assistant' };
|
||||||
|
const { controller, aiChatMessageRepo } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
run,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ run, message });
|
||||||
|
expect(aiChatMessageRepo.findById).toHaveBeenCalledWith('m1', 'ws1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns message: null when the run has no linked assistant message', async () => {
|
||||||
|
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: null };
|
||||||
|
const { controller, aiChatMessageRepo } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ run, message: null });
|
||||||
|
expect(aiChatMessageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /ai-chat/stop (stopRun)', () => {
|
||||||
|
it('throws BadRequestException when neither runId nor chatId is given', async () => {
|
||||||
|
const { controller } = makeController({});
|
||||||
|
await expect(
|
||||||
|
controller.stopRun({}, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by runId: owner-gates via the run’s chat, then requests the stop', async () => {
|
||||||
|
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||||
|
run: { id: 'run-1', chatId: 'c1' },
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
stopped: true,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ runId: 'run-1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: true });
|
||||||
|
expect(aiChatRunService.getRun).toHaveBeenCalledWith('run-1', 'ws1');
|
||||||
|
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||||
|
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-1', 'ws1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by runId: a foreign run’s chat throws ForbiddenException (no stop)', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
run: { id: 'run-1', chatId: 'c1' },
|
||||||
|
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
controller.stopRun({ runId: 'run-1' }, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by runId: an unknown run reports { stopped: false }', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
run: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ runId: 'gone' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: false });
|
||||||
|
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by chatId: owner-gates, resolves the active run, requests the stop', async () => {
|
||||||
|
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
activeRun: { id: 'run-9' },
|
||||||
|
stopped: true,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: true });
|
||||||
|
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||||
|
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||||
|
'c1',
|
||||||
|
'ws1',
|
||||||
|
);
|
||||||
|
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-9', 'ws1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops by chatId: reports { stopped: false } when no run is active', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
chat: { id: 'c1', creatorId: 'u1' },
|
||||||
|
activeRun: undefined,
|
||||||
|
});
|
||||||
|
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||||
|
expect(res).toEqual({ stopped: false });
|
||||||
|
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
@@ -20,21 +21,35 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||||
import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
import {
|
||||||
|
AiChat,
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatRun,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
import {
|
||||||
|
AiChatRunHooks,
|
||||||
|
AiChatService,
|
||||||
|
AiChatStreamBody,
|
||||||
|
} from './ai-chat.service';
|
||||||
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import {
|
import {
|
||||||
|
BoundChatDto,
|
||||||
ChatIdDto,
|
ChatIdDto,
|
||||||
ExportChatDto,
|
ExportChatDto,
|
||||||
GeneratePageTitleDto,
|
GeneratePageTitleDto,
|
||||||
GetChatMessagesDto,
|
GetChatMessagesDto,
|
||||||
|
GetRunDto,
|
||||||
RenameChatDto,
|
RenameChatDto,
|
||||||
|
StopRunDto,
|
||||||
} from './dto/ai-chat.dto';
|
} from './dto/ai-chat.dto';
|
||||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||||
import { buildChatMarkdown } from './chat-markdown.util';
|
import { buildChatMarkdown } from './chat-markdown.util';
|
||||||
@@ -51,6 +66,7 @@ export class AiChatController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly aiChatService: AiChatService,
|
private readonly aiChatService: AiChatService,
|
||||||
|
private readonly aiChatRunService: AiChatRunService,
|
||||||
private readonly aiChatRepo: AiChatRepo,
|
private readonly aiChatRepo: AiChatRepo,
|
||||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||||
private readonly aiTranscription: AiTranscriptionService,
|
private readonly aiTranscription: AiTranscriptionService,
|
||||||
@@ -67,6 +83,28 @@ export class AiChatController {
|
|||||||
return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination);
|
return this.aiChatRepo.findByCreator(user.id, workspace.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the chat bound to a document for the requesting user: the most-recent
|
||||||
|
* non-deleted chat created on that page (ai_chats.page_id). Returns
|
||||||
|
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page
|
||||||
|
* access check needed: only the caller's OWN chats are matched, so a foreign
|
||||||
|
* pageId reveals nothing.
|
||||||
|
*/
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('bound-chat')
|
||||||
|
async boundChat(
|
||||||
|
@Body() dto: BoundChatDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<{ chatId: string | null }> {
|
||||||
|
const chat = await this.aiChatRepo.findLatestByPage(
|
||||||
|
user.id,
|
||||||
|
workspace.id,
|
||||||
|
dto.pageId,
|
||||||
|
);
|
||||||
|
return { chatId: chat?.id ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch the messages of a chat (oldest first, paginated). */
|
/** Fetch the messages of a chat (oldest first, paginated). */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('messages')
|
@Post('messages')
|
||||||
@@ -114,6 +152,75 @@ export class AiChatController {
|
|||||||
return { markdown };
|
return { markdown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest run of a chat (#184 phase 1). Returns the run's
|
||||||
|
* persisted lifecycle state ({ status, error, stepCount, timings, ... }) plus
|
||||||
|
* the assistant message it projects (the partial/final output) — 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. Owner-gated via assertOwnedChat.
|
||||||
|
* `{ run: null }` when the chat has never had a run.
|
||||||
|
*/
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('run')
|
||||||
|
async getRun(
|
||||||
|
@Body() dto: GetRunDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<{ run: AiChatRun | null; message: AiChatMessage | null }> {
|
||||||
|
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||||
|
const run = await this.aiChatRunService.getLatestForChat(
|
||||||
|
dto.chatId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (!run) return { run: null, message: null };
|
||||||
|
const message = run.assistantMessageId
|
||||||
|
? await this.aiChatMessageRepo.findById(
|
||||||
|
run.assistantMessageId,
|
||||||
|
workspace.id,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return { run, message: message ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP an agent run (#184 phase 1) — the user pressed Stop. This is
|
||||||
|
* the ONLY thing that ends a detached run; a browser disconnect deliberately
|
||||||
|
* does not. Target by `runId` (from the streamed start metadata) or by `chatId`
|
||||||
|
* (stop whatever run is active on it). Owner-gated. Returns
|
||||||
|
* `{ stopped }` — false when there was nothing active to stop.
|
||||||
|
*/
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('stop')
|
||||||
|
async stopRun(
|
||||||
|
@Body() dto: StopRunDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<{ stopped: boolean }> {
|
||||||
|
let runId = dto.runId;
|
||||||
|
if (!runId && !dto.chatId) {
|
||||||
|
throw new BadRequestException('runId or chatId is required');
|
||||||
|
}
|
||||||
|
if (runId) {
|
||||||
|
// Resolve the run to its chat and owner-gate via that chat.
|
||||||
|
const run = await this.aiChatRunService.getRun(runId, workspace.id);
|
||||||
|
if (!run) return { stopped: false };
|
||||||
|
await this.assertOwnedChat(run.chatId, user, workspace);
|
||||||
|
} else {
|
||||||
|
await this.assertOwnedChat(dto.chatId!, user, workspace);
|
||||||
|
const active = await this.aiChatRunService.getActiveForChat(
|
||||||
|
dto.chatId!,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (!active) return { stopped: false };
|
||||||
|
runId = active.id;
|
||||||
|
}
|
||||||
|
const stopped = await this.aiChatRunService.requestStop(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
return { stopped };
|
||||||
|
}
|
||||||
|
|
||||||
/** Rename a chat. */
|
/** Rename a chat. */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('rename')
|
@Post('rename')
|
||||||
@@ -165,11 +272,20 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// A7 gate: the workspace must have AI chat explicitly enabled.
|
// A7 gate: the workspace must have AI chat explicitly enabled.
|
||||||
const settings = (workspace.settings ?? {}) as { ai?: { chat?: boolean } };
|
const settings = (workspace.settings ?? {}) as {
|
||||||
|
ai?: { chat?: boolean; autonomousRuns?: boolean };
|
||||||
|
};
|
||||||
if (settings.ai?.chat !== true) {
|
if (settings.ai?.chat !== true) {
|
||||||
throw new ForbiddenException('AI chat is disabled');
|
throw new ForbiddenException('AI chat is disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #184 phase 1 flag: when ON, the turn becomes a detached, durable RUN — its
|
||||||
|
// lifecycle is tracked in ai_chat_runs, a browser disconnect no longer aborts
|
||||||
|
// it, and only an explicit /ai-chat/stop ends it. When OFF (the default) the
|
||||||
|
// turn is socket-bound exactly as before, so existing deployments are
|
||||||
|
// unaffected.
|
||||||
|
const autonomousRuns = settings.ai?.autonomousRuns === true;
|
||||||
|
|
||||||
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
// The chat requires an interactive session to mint loopback tokens
|
// The chat requires an interactive session to mint loopback tokens
|
||||||
@@ -193,6 +309,58 @@ export class AiChatController {
|
|||||||
// HttpException) instead of breaking mid-stream.
|
// HttpException) instead of breaking mid-stream.
|
||||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
||||||
|
|
||||||
|
// #184: one active run per chat. For an EXISTING chat reject a concurrent
|
||||||
|
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
|
||||||
|
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
|
||||||
|
// (no chatId) cannot have a prior run, and the DB partial unique index is the
|
||||||
|
// backstop against any race that slips past this check.
|
||||||
|
if (autonomousRuns && body.chatId) {
|
||||||
|
const active = await this.aiChatRunService.getActiveForChat(
|
||||||
|
body.chatId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
if (active) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: 'An agent run is already in progress for this chat',
|
||||||
|
code: 'A_RUN_ALREADY_ACTIVE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
|
||||||
|
// a durable run whose abort is governed by the run (explicit stop), persist
|
||||||
|
// its progress, and settle its terminal status — see AiChatRunService.
|
||||||
|
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
||||||
|
? {
|
||||||
|
begin: (chatId) =>
|
||||||
|
this.aiChatRunService.beginRun({
|
||||||
|
chatId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
trigger: 'user',
|
||||||
|
}),
|
||||||
|
onAssistantSeeded: (runId, messageId) =>
|
||||||
|
this.aiChatRunService.linkAssistantMessage(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
messageId,
|
||||||
|
),
|
||||||
|
onStep: (runId, stepCount) =>
|
||||||
|
void this.aiChatRunService.recordStep(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
stepCount,
|
||||||
|
),
|
||||||
|
onSettled: (runId, status, error) =>
|
||||||
|
this.aiChatRunService.finalizeRun(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||||
// normal completion, so only abort when the response has not finished
|
// normal completion, so only abort when the response has not finished
|
||||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
||||||
@@ -207,18 +375,44 @@ export class AiChatController {
|
|||||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||||
// so log it here before aborting the agent loop.
|
// so log it here.
|
||||||
if (!res.raw.writableEnded) {
|
if (!res.raw.writableEnded) {
|
||||||
this.logger.warn(
|
if (autonomousRuns) {
|
||||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
||||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
// the run keeps executing and persisting server-side; the client
|
||||||
);
|
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||||
controller.abort();
|
this.logger.log(
|
||||||
|
`AI chat stream: client disconnected; run continues server-side ` +
|
||||||
|
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||||
|
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||||
|
);
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
req.raw.once('close', onClose);
|
req.raw.once('close', onClose);
|
||||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||||
|
|
||||||
|
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
|
||||||
|
// pipe keeps writing to a socket the client may have dropped — for the rest of
|
||||||
|
// the (continuing) run. A write to the dead socket can emit an 'error' on the
|
||||||
|
// raw response; without a listener that surfaces as an unhandled error event.
|
||||||
|
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
|
||||||
|
// disconnect, so it does not need this and keeps its exact prior behavior.
|
||||||
|
if (autonomousRuns) {
|
||||||
|
res.raw.on('error', (err) => {
|
||||||
|
this.logger.debug(
|
||||||
|
`AI chat detached stream: post-disconnect socket error swallowed: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Commit to streaming: hijack so Fastify stops managing the response and
|
// Commit to streaming: hijack so Fastify stops managing the response and
|
||||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||||
res.hijack();
|
res.hijack();
|
||||||
@@ -233,15 +427,32 @@ export class AiChatController {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
model,
|
model,
|
||||||
role,
|
role,
|
||||||
|
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||||
|
runHooks,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Any failure AFTER hijack can no longer send a clean JSON error, so emit
|
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||||
// a minimal error on the raw socket if nothing has been written yet.
|
// filter, so emit the error on the raw socket if nothing has been written
|
||||||
this.logger.error('AI chat stream failed', err as Error);
|
// yet. The lost-the-race 409 (RunAlreadyActiveError -> ConflictException)
|
||||||
|
// is raised by stream() BEFORE it writes a byte, so headers are still
|
||||||
|
// unsent here: honor the HttpException's real status + body (a clean 409),
|
||||||
|
// not a blanket 500. Everything else stays a 500.
|
||||||
|
const isHttp = err instanceof HttpException;
|
||||||
|
if (!isHttp) {
|
||||||
|
this.logger.error('AI chat stream failed', err as Error);
|
||||||
|
}
|
||||||
if (!res.raw.headersSent) {
|
if (!res.raw.headersSent) {
|
||||||
res.raw.statusCode = 500;
|
const status = isHttp ? err.getStatus() : 500;
|
||||||
|
const payload = isHttp
|
||||||
|
? err.getResponse()
|
||||||
|
: { error: 'Internal server error' };
|
||||||
|
res.raw.statusCode = status;
|
||||||
res.raw.setHeader('Content-Type', 'application/json');
|
res.raw.setHeader('Content-Type', 'application/json');
|
||||||
res.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
res.raw.end(
|
||||||
|
JSON.stringify(
|
||||||
|
typeof payload === 'string' ? { message: payload } : payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (!res.raw.writableEnded) {
|
} else if (!res.raw.writableEnded) {
|
||||||
res.raw.end();
|
res.raw.end();
|
||||||
}
|
}
|
||||||
@@ -319,8 +530,8 @@ export class AiChatController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a page title from supplied note content (#199). One-shot,
|
* Generate a page title from supplied note content (#199). One-shot,
|
||||||
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative,
|
* non-streaming. Gated by the AI chat flag (settings.ai.chat, the same toggle
|
||||||
* the same flag that gates the on-page generative AI menu); returns { title }.
|
* that enables the chat agent); returns { title }.
|
||||||
* The endpoint NEVER writes the page — the client applies the title via the
|
* The endpoint NEVER writes the page — the client applies the title via the
|
||||||
* existing /pages/update route (which enforces edit permission), so access
|
* existing /pages/update route (which enforces edit permission), so access
|
||||||
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
|
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
|
||||||
@@ -334,9 +545,9 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<{ title: string }> {
|
): Promise<{ title: string }> {
|
||||||
const settings = (workspace.settings ?? {}) as {
|
const settings = (workspace.settings ?? {}) as {
|
||||||
ai?: { generative?: boolean };
|
ai?: { chat?: boolean };
|
||||||
};
|
};
|
||||||
if (settings.ai?.generative !== true) {
|
if (settings.ai?.chat !== true) {
|
||||||
throw new ForbiddenException('AI title generation is disabled');
|
throw new ForbiddenException('AI title generation is disabled');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('cleanGeneratedTitle', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
|
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
|
||||||
* gate on settings.ai.generative (403 when off), delegate to the service when on,
|
* gate on settings.ai.chat (403 when off), delegate to the service when on,
|
||||||
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
|
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
|
||||||
* any other provider/transport fault to a 503. Exercised by instantiating the
|
* any other provider/transport fault to a 503. Exercised by instantiating the
|
||||||
* controller with hand-rolled mocks — no Nest graph, no DB.
|
* controller with hand-rolled mocks — no Nest graph, no DB.
|
||||||
@@ -50,13 +50,14 @@ describe('cleanGeneratedTitle', () => {
|
|||||||
describe('AiChatController.generatePageTitle', () => {
|
describe('AiChatController.generatePageTitle', () => {
|
||||||
const enabledWorkspace = {
|
const enabledWorkspace = {
|
||||||
id: 'ws1',
|
id: 'ws1',
|
||||||
settings: { ai: { generative: true } },
|
settings: { ai: { chat: true } },
|
||||||
} as unknown as Workspace;
|
} as unknown as Workspace;
|
||||||
|
|
||||||
function makeController(generate: jest.Mock) {
|
function makeController(generate: jest.Mock) {
|
||||||
const aiChatService = { generatePageTitle: generate };
|
const aiChatService = { generatePageTitle: generate };
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
aiChatService as never,
|
aiChatService as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -64,7 +65,7 @@ describe('AiChatController.generatePageTitle', () => {
|
|||||||
return { controller, aiChatService };
|
return { controller, aiChatService };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('forbids when the generative AI flag is off', async () => {
|
it('forbids when the AI chat flag is off', async () => {
|
||||||
const generate = jest.fn();
|
const generate = jest.fn();
|
||||||
const { controller } = makeController(generate);
|
const { controller } = makeController(generate);
|
||||||
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
|
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
|
||||||
@@ -74,12 +75,12 @@ describe('AiChatController.generatePageTitle', () => {
|
|||||||
expect(generate).not.toHaveBeenCalled();
|
expect(generate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forbids when settings.ai.generative is anything but exactly true', async () => {
|
it('forbids when settings.ai.chat is anything but exactly true', async () => {
|
||||||
const generate = jest.fn();
|
const generate = jest.fn();
|
||||||
const { controller } = makeController(generate);
|
const { controller } = makeController(generate);
|
||||||
const ws = {
|
const ws = {
|
||||||
id: 'ws1',
|
id: 'ws1',
|
||||||
settings: { ai: { generative: 'yes' } },
|
settings: { ai: { chat: 'yes' } },
|
||||||
} as unknown as Workspace;
|
} as unknown as Workspace;
|
||||||
await expect(
|
await expect(
|
||||||
controller.generatePageTitle({ content: 'body' }, ws),
|
controller.generatePageTitle({ content: 'body' }, ws),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AiModule } from '../../integrations/ai/ai.module';
|
|||||||
import { TokenModule } from '../auth/token.module';
|
import { TokenModule } from '../auth/token.module';
|
||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
import { AiChatService } from './ai-chat.service';
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { EmbeddingModule } from './embedding/embedding.module';
|
import { EmbeddingModule } from './embedding/embedding.module';
|
||||||
@@ -42,6 +43,7 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
|||||||
controllers: [AiChatController, PublicShareChatController],
|
controllers: [AiChatController, PublicShareChatController],
|
||||||
providers: [
|
providers: [
|
||||||
AiChatService,
|
AiChatService,
|
||||||
|
AiChatRunService,
|
||||||
AiTranscriptionService,
|
AiTranscriptionService,
|
||||||
AiChatToolsService,
|
AiChatToolsService,
|
||||||
PublicShareChatService,
|
PublicShareChatService,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { AiChatService } from './ai-chat.service';
|
import { AiChatService, AiChatRunHooks } from './ai-chat.service';
|
||||||
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
||||||
@@ -59,3 +61,97 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #184 CRITICAL run-lifecycle safety net (review fix). A transient failure
|
||||||
|
* AFTER a successful beginRun but BEFORE streamText's terminal callbacks own the
|
||||||
|
* lifecycle must STILL settle the run — otherwise the run row is stuck 'running'
|
||||||
|
* forever (sweepRunning only runs at startup) and the partial unique index + the
|
||||||
|
* controller pre-check 409 every future turn in that chat until a restart. Here
|
||||||
|
* we model the very first bare await after beginRun (the user-message insert)
|
||||||
|
* throwing, wiring the run hooks to a REAL AiChatRunService (mock repo) exactly
|
||||||
|
* as the controller does, and assert the run is settled to 'error' and its
|
||||||
|
* in-memory entry dropped (so a follow-up turn would NOT be 409'd).
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
||||||
|
const user = { id: 'u1' } as User;
|
||||||
|
const workspace = { id: 'ws1' } as Workspace;
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('an exception after beginRun settles the run to error and drops the in-memory entry', async () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
// Real run service over a mock repo, so finalizeRun's in-memory bookkeeping
|
||||||
|
// (active.delete) is exercised for real.
|
||||||
|
const runRepo = {
|
||||||
|
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||||
|
};
|
||||||
|
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
||||||
|
|
||||||
|
// The user-message insert (the first bare await after beginRun) throws.
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
insert: jest.fn().mockRejectedValue(new Error('insert boom')),
|
||||||
|
};
|
||||||
|
const aiChatRepo = {
|
||||||
|
// Existing chat -> chatId stays, no new-chat insert path.
|
||||||
|
findById: jest.fn().mockResolvedValue({ id: 'chat-1', creatorId: 'u1' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiSettings
|
||||||
|
{} as never, // tools
|
||||||
|
{} as never, // mcpClients
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
const runHooks: AiChatRunHooks = {
|
||||||
|
begin: (chatId) =>
|
||||||
|
runService.beginRun({
|
||||||
|
chatId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: user.id,
|
||||||
|
trigger: 'user',
|
||||||
|
}),
|
||||||
|
onSettled: (runId, status, error) =>
|
||||||
|
runService.finalizeRun(runId, workspace.id, status, error),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.stream({
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
sessionId: 'sess',
|
||||||
|
body: {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
res: {} as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('insert boom');
|
||||||
|
|
||||||
|
// The run was begun...
|
||||||
|
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
||||||
|
// ...then settled to a terminal FAILED status by the safety net...
|
||||||
|
expect(runRepo.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runRepo.update).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'ws1',
|
||||||
|
expect.objectContaining({ status: 'failed' }),
|
||||||
|
);
|
||||||
|
// ...and the in-memory entry is gone, so a follow-up turn is NOT 409'd.
|
||||||
|
expect(runService.isLocallyActive('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
337
apps/server/src/core/ai-chat/ai-chat.service.run-race.spec.ts
Normal file
337
apps/server/src/core/ai-chat/ai-chat.service.run-race.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { ConflictException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
||||||
|
// about to reject. The race rejection happens at runHooks.begin(), long before
|
||||||
|
// any streamText/generateText, so these never resolve a real model.
|
||||||
|
jest.mock('ai', () => ({
|
||||||
|
streamText: jest.fn(),
|
||||||
|
generateText: jest.fn(),
|
||||||
|
convertToModelMessages: jest.fn(() => []),
|
||||||
|
stepCountIs: jest.fn(() => () => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { streamText, generateText } from 'ai';
|
||||||
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Race-closure coverage for the "one active run per chat" guard (#184).
|
||||||
|
*
|
||||||
|
* THE BUG: two simultaneous POST /ai-chat/stream on the same chat both pass the
|
||||||
|
* controller's cheap pre-check (TOCTOU), so the loser's run-row INSERT hits the
|
||||||
|
* partial unique index. Previously that 23505 was SWALLOWED and the second turn
|
||||||
|
* streamed UNTRACKED (no runId, not stoppable). THE FIX: beginRun surfaces a
|
||||||
|
* RunAlreadyActiveError and stream() turns it into a 409 BEFORE any AI call —
|
||||||
|
* the second turn never runs.
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream — concurrent-run race rejection (#184)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
const generateTextMock = generateText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
generateTextMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal service whose only reachable deps before begin() are aiChatRepo
|
||||||
|
// (resolve the existing chat) — everything past begin must remain untouched.
|
||||||
|
function makeService(beginImpl: () => Promise<unknown>) {
|
||||||
|
const aiChatMessageRepo = { insert: jest.fn() };
|
||||||
|
const aiChatRepo = {
|
||||||
|
// An existing chat: stream keeps the supplied chatId and skips creation.
|
||||||
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
|
insert: jest.fn(),
|
||||||
|
};
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiSettings
|
||||||
|
{} as never, // tools
|
||||||
|
{} as never, // mcpClients
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
);
|
||||||
|
const begin = jest.fn(beginImpl);
|
||||||
|
return { svc, begin, aiChatRepo, aiChatMessageRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseArgs = (begin: jest.Mock) => ({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: { chatId: 'chat-1', messages: [] } as never,
|
||||||
|
res: { raw: {} } as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: {
|
||||||
|
begin,
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the racer with a 409 ConflictException BEFORE any AI call, and never persists an untracked turn', async () => {
|
||||||
|
// begin loses the unique-index race -> RunAlreadyActiveError.
|
||||||
|
const { svc, begin, aiChatMessageRepo } = makeService(() => {
|
||||||
|
throw new RunAlreadyActiveError('chat-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = svc.stream(baseArgs(begin));
|
||||||
|
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await promise.catch((err: ConflictException) => {
|
||||||
|
expect(err.getStatus()).toBe(409);
|
||||||
|
expect((err.getResponse() as { code?: string }).code).toBe(
|
||||||
|
'A_RUN_ALREADY_ACTIVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The decisive assertions: the rejected racer spent NO tokens and left NO
|
||||||
|
// untracked turn behind.
|
||||||
|
expect(begin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock).not.toHaveBeenCalled();
|
||||||
|
expect(generateTextMock).not.toHaveBeenCalled();
|
||||||
|
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3 — the LOAD-BEARING run-detach wiring: `effectiveSignal = handle.signal`
|
||||||
|
* after runHooks.begin, then `abortSignal: effectiveSignal` passed to streamText.
|
||||||
|
* That single line is what makes a run survive a browser disconnect (the agent
|
||||||
|
* loop's abort is governed by the RUN's signal, not the socket): a regression to
|
||||||
|
* the socket-bound signal would still pass every other test green while silently
|
||||||
|
* breaking Stop + durability. These two tests pin the exact signal streamText
|
||||||
|
* consumes on both paths.
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
// A streamText result stub: the post-call drain + pipe are no-ops here; we only
|
||||||
|
// care WHICH abortSignal streamText was handed.
|
||||||
|
function makeStreamResult() {
|
||||||
|
return {
|
||||||
|
consumeStream: jest.fn(),
|
||||||
|
pipeUIMessageStreamToResponse: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw-response stub sufficient for the post-streamText wiring
|
||||||
|
// (stripStreamingHopByHopHeaders binds writeHead; startSseHeartbeat registers
|
||||||
|
// close/finish listeners; flushHeaders is belt-and-braces).
|
||||||
|
function makeRes() {
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
writeHead: jest.fn(),
|
||||||
|
write: jest.fn(),
|
||||||
|
once: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
flushHeaders: jest.fn(),
|
||||||
|
writableEnded: false,
|
||||||
|
destroyed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire only the deps reached on the way to streamText: resolve the existing
|
||||||
|
// chat, persist the user + seed the assistant row, load (empty) history, the
|
||||||
|
// admin settings, an empty external toolset + Docmost toolset.
|
||||||
|
function makeService() {
|
||||||
|
const aiChatRepo = {
|
||||||
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
|
insert: jest.fn(),
|
||||||
|
};
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findAllByChat: jest.fn(async () => []),
|
||||||
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
};
|
||||||
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
|
const mcpClients = {
|
||||||
|
toolsFor: jest.fn(async () => ({
|
||||||
|
tools: {},
|
||||||
|
clients: [],
|
||||||
|
outcomes: [],
|
||||||
|
instructions: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
aiSettings as never,
|
||||||
|
tools as never,
|
||||||
|
mcpClients as never,
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo (openPage undefined -> never touched)
|
||||||
|
{} as never, // pageAccess
|
||||||
|
);
|
||||||
|
return { svc };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
streamTextMock.mockImplementation(() => makeStreamResult());
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'log')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('happy path (run-wrapped): streamText is driven with abortSignal === handle.signal (the RUN signal, NOT the socket)', async () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const runController = new AbortController();
|
||||||
|
const runSignal = runController.signal;
|
||||||
|
const socketSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: socketSignal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: {
|
||||||
|
begin,
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(begin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
// THE assertion: the agent loop's abort is wired to the RUN, so a browser
|
||||||
|
// disconnect (which aborts only `socketSignal`) cannot end the turn.
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(runSignal);
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).not.toBe(socketSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy path (no runHooks): streamText is driven with the SOCKET signal', async () => {
|
||||||
|
const { svc } = makeService();
|
||||||
|
const socketSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: socketSignal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
// No runHooks -> the turn stays socket-bound (flag off / default).
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F9 — streamText's TERMINAL callbacks carry the #184 run lifecycle:
|
||||||
|
* onStepFinish -> runHooks.onStep(runId, stepCount)
|
||||||
|
* onFinish -> runHooks.onSettled(runId, 'completed') (dominant path)
|
||||||
|
* onAbort -> runHooks.onSettled(runId, 'aborted')
|
||||||
|
* onError -> runHooks.onSettled(runId, 'error', cause)
|
||||||
|
* makeStreamResult() ignores the streamText options, so these callbacks never
|
||||||
|
* fire on their own — a regression in this wiring (esp. the success path) would
|
||||||
|
* strand the run with NO test catching it. Here we CAPTURE the options streamText
|
||||||
|
* was handed and invoke each callback with the real wiring, asserting the run
|
||||||
|
* hooks fire with the right args.
|
||||||
|
*/
|
||||||
|
// Drive stream() to the point streamText is called, capturing the options object
|
||||||
|
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
||||||
|
async function captureStreamCallbacks() {
|
||||||
|
const { svc } = makeService();
|
||||||
|
let capturedOpts: any;
|
||||||
|
streamTextMock.mockImplementation((opts: any) => {
|
||||||
|
capturedOpts = opts;
|
||||||
|
return makeStreamResult();
|
||||||
|
});
|
||||||
|
const runHooks = {
|
||||||
|
begin: jest.fn(async () => ({
|
||||||
|
runId: 'run-1',
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
})),
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled: jest.fn(),
|
||||||
|
};
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: runHooks as never,
|
||||||
|
});
|
||||||
|
expect(capturedOpts).toBeDefined();
|
||||||
|
return { capturedOpts, runHooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
||||||
|
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
// A finished step -> onStep(runId, finishedStepCount).
|
||||||
|
capturedOpts.onStepFinish({ text: 'step one', toolCalls: [], content: [] });
|
||||||
|
expect(runHooks.onStep).toHaveBeenCalledWith('run-1', 1);
|
||||||
|
capturedOpts.onStepFinish({ text: 'step two', toolCalls: [], content: [] });
|
||||||
|
expect(runHooks.onStep).toHaveBeenLastCalledWith('run-1', 2);
|
||||||
|
|
||||||
|
// The success terminal callback settles the run.
|
||||||
|
await capturedOpts.onFinish({
|
||||||
|
text: 'done',
|
||||||
|
finishReason: 'stop',
|
||||||
|
totalUsage: {},
|
||||||
|
usage: {},
|
||||||
|
steps: [],
|
||||||
|
});
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F9: onAbort settles the run "aborted"', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
await capturedOpts.onAbort({ steps: [] });
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('F9: onError settles the run "error" carrying the provider cause', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
await capturedOpts.onError({ error: new Error('provider exploded') });
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'error',
|
||||||
|
expect.stringContaining('provider exploded'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -371,6 +371,12 @@ describe('chatStreamMetadata', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('attaches the runId on the start part when a run wraps the turn (#184)', () => {
|
||||||
|
expect(
|
||||||
|
chatStreamMetadata({ type: 'start' }, 'chat-1', undefined, 'run-1'),
|
||||||
|
).toEqual({ chatId: 'chat-1', runId: 'run-1' });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
||||||
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||||
// running sum, which this just wraps.
|
// running sum, which this just wraps.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,36 @@ export class GetChatMessagesDto {
|
|||||||
cursor?: string;
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the chat bound to a document (the page's most-recent owned chat). */
|
||||||
|
export class BoundChatDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest run of a chat (#184): fetch its persisted lifecycle
|
||||||
|
* state (and the assistant message it projects) for an in-flight or finished run.
|
||||||
|
*/
|
||||||
|
export class GetRunDto {
|
||||||
|
@IsString()
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly STOP an agent run (#184): the user pressed Stop — distinct from a
|
||||||
|
* browser disconnect, which never stops a run. Either the run id (preferred, from
|
||||||
|
* the streamed start metadata) or the chat id (stop whatever run is active on it).
|
||||||
|
*/
|
||||||
|
export class StopRunDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
runId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
chatId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||||
* role/tool-action labels; defaults to English server-side. */
|
* role/tool-action labels; defaults to English server-side. */
|
||||||
export class ExportChatDto {
|
export class ExportChatDto {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { McpClientsService } from './mcp-clients.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #204 (Phase 1, highest-value MCP gap) — external MCP client lease / refcount /
|
||||||
|
* eviction lifecycle.
|
||||||
|
*
|
||||||
|
* `toolsFor` hands the streaming turn a release handle; the real transports must
|
||||||
|
* be closed EXACTLY once and only when (a) the cache entry has been evicted AND
|
||||||
|
* (b) no turn still leases it. The bugs this guards against:
|
||||||
|
* - leak: an evicted entry whose clients are never closed (refCount stuck > 0);
|
||||||
|
* - premature close: a TTL/CRUD eviction closing a client a turn is still
|
||||||
|
* executing tool calls against;
|
||||||
|
* - double close: a release handle closing the same client more than once.
|
||||||
|
*
|
||||||
|
* The private `buildEntry` is stubbed so no real network/MCP connection happens;
|
||||||
|
* we drive only the lease bookkeeping in `toolsFor` / `release` / `evict` /
|
||||||
|
* `invalidate`, which is the untested surface.
|
||||||
|
*/
|
||||||
|
describe('McpClientsService lease/refcount/eviction', () => {
|
||||||
|
type FakeClient = { tools: () => Promise<any>; close: jest.Mock };
|
||||||
|
|
||||||
|
function fakeClient(): FakeClient {
|
||||||
|
return {
|
||||||
|
tools: async () => ({}),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal CacheEntry the service's lease logic operates on.
|
||||||
|
function makeEntry(clients: FakeClient[]) {
|
||||||
|
const timer = setTimeout(() => {}, 60_000);
|
||||||
|
timer.unref?.();
|
||||||
|
return {
|
||||||
|
tools: {},
|
||||||
|
clients,
|
||||||
|
outcomes: [],
|
||||||
|
instructions: [],
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
refCount: 0,
|
||||||
|
evicted: false,
|
||||||
|
closed: false,
|
||||||
|
timer,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
let service: McpClientsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new McpClientsService({} as any, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubBuild(entry: any) {
|
||||||
|
jest.spyOn(service as any, 'buildEntry').mockResolvedValue(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('leases on toolsFor and keeps the client warm (no close) on release', async () => {
|
||||||
|
const client = fakeClient();
|
||||||
|
const entry = makeEntry([client]);
|
||||||
|
stubBuild(entry);
|
||||||
|
|
||||||
|
const lease = await service.toolsFor('ws-1');
|
||||||
|
expect(entry.refCount).toBe(1);
|
||||||
|
|
||||||
|
await lease.clients[0].close();
|
||||||
|
// Released but NOT evicted: the cached entry stays warm for reuse, so the
|
||||||
|
// transport must NOT be closed yet.
|
||||||
|
expect(entry.refCount).toBe(0);
|
||||||
|
expect(client.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers close when an entry is evicted while still leased, then closes once on release', async () => {
|
||||||
|
const client = fakeClient();
|
||||||
|
const entry = makeEntry([client]);
|
||||||
|
stubBuild(entry);
|
||||||
|
|
||||||
|
const lease = await service.toolsFor('ws-2');
|
||||||
|
(service as any).evict(entry);
|
||||||
|
|
||||||
|
// Evicted under an active lease: close is deferred to the last release.
|
||||||
|
expect(entry.evicted).toBe(true);
|
||||||
|
expect(client.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await lease.clients[0].close();
|
||||||
|
expect(client.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(entry.closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one entry across concurrent leases; closes only after the LAST release', async () => {
|
||||||
|
const client = fakeClient();
|
||||||
|
const entry = makeEntry([client]);
|
||||||
|
stubBuild(entry);
|
||||||
|
|
||||||
|
const lease1 = await service.toolsFor('ws-3');
|
||||||
|
const lease2 = await service.toolsFor('ws-3');
|
||||||
|
expect(entry.refCount).toBe(2);
|
||||||
|
|
||||||
|
(service as any).evict(entry);
|
||||||
|
|
||||||
|
await lease1.clients[0].close();
|
||||||
|
// One lease remains: a stream could still be running — must stay open.
|
||||||
|
expect(entry.refCount).toBe(1);
|
||||||
|
expect(client.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await lease2.clients[0].close();
|
||||||
|
expect(entry.refCount).toBe(0);
|
||||||
|
expect(client.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release is idempotent: closing the same handle twice decrements once and closes once', async () => {
|
||||||
|
const client = fakeClient();
|
||||||
|
const entry = makeEntry([client]);
|
||||||
|
stubBuild(entry);
|
||||||
|
|
||||||
|
const lease = await service.toolsFor('ws-4');
|
||||||
|
(service as any).evict(entry);
|
||||||
|
|
||||||
|
await lease.clients[0].close();
|
||||||
|
await lease.clients[0].close();
|
||||||
|
|
||||||
|
expect(entry.refCount).toBe(0); // not -1
|
||||||
|
expect(client.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicting an unleased entry closes its clients immediately', async () => {
|
||||||
|
const client = fakeClient();
|
||||||
|
const entry = makeEntry([client]);
|
||||||
|
stubBuild(entry);
|
||||||
|
|
||||||
|
const built = await (service as any).getOrBuildEntry('ws-5');
|
||||||
|
expect(built.refCount).toBe(0);
|
||||||
|
|
||||||
|
(service as any).evict(entry);
|
||||||
|
expect(client.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(entry.closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidate (TTL/CRUD) does NOT close a client that a turn still leases', async () => {
|
||||||
|
const client = fakeClient();
|
||||||
|
const entry = makeEntry([client]);
|
||||||
|
stubBuild(entry);
|
||||||
|
|
||||||
|
const lease = await service.toolsFor('ws-6');
|
||||||
|
expect(entry.refCount).toBe(1);
|
||||||
|
|
||||||
|
service.invalidate('ws-6');
|
||||||
|
// invalidate evicts asynchronously once the build promise resolves.
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(entry.evicted).toBe(true);
|
||||||
|
// Still leased: the mid-turn eviction must not pull the transport.
|
||||||
|
expect(client.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await lease.clients[0].close();
|
||||||
|
expect(client.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,10 @@ describe('AiAgentRolesController admin gate', () => {
|
|||||||
create: jest.fn().mockResolvedValue({ id: 'r1' }),
|
create: jest.fn().mockResolvedValue({ id: 'r1' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'r1' }),
|
update: jest.fn().mockResolvedValue({ id: 'r1' }),
|
||||||
remove: jest.fn().mockResolvedValue({ success: true }),
|
remove: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
getCatalog: jest.fn().mockResolvedValue({ languages: [], bundles: [] }),
|
||||||
|
getCatalogBundle: jest.fn().mockResolvedValue({ roles: [] }),
|
||||||
|
importFromCatalog: jest.fn().mockResolvedValue({ created: 0 }),
|
||||||
|
updateFromCatalog: jest.fn().mockResolvedValue({ updated: false }),
|
||||||
};
|
};
|
||||||
const controller = new AiAgentRolesController(
|
const controller = new AiAgentRolesController(
|
||||||
rolesService as never,
|
rolesService as never,
|
||||||
@@ -109,6 +113,90 @@ describe('AiAgentRolesController admin gate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Catalog routes (browse + import) are ALL admin-only: a non-admin caller must
|
||||||
|
// get ForbiddenException with the service untouched; an admin delegates with
|
||||||
|
// the right arguments (import/update-from-catalog carry workspace.id).
|
||||||
|
describe('catalog routes admin gate', () => {
|
||||||
|
const catalogDto = { language: 'en' } as never;
|
||||||
|
const bundleDto = { bundleId: 'general', language: 'en' } as never;
|
||||||
|
const importDto = {
|
||||||
|
bundleId: 'general',
|
||||||
|
language: 'en',
|
||||||
|
conflict: 'skip',
|
||||||
|
} as never;
|
||||||
|
const updateDto = { id: 'r1' } as never;
|
||||||
|
|
||||||
|
describe('non-admin is rejected and the service is NOT called', () => {
|
||||||
|
it('catalog', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.catalog(catalogDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.getCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalog/bundle', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.catalogBundle(bundleDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.getCatalogBundle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('import', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.import(importDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.importFromCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update-from-catalog', async () => {
|
||||||
|
const { controller, rolesService } = makeController(false);
|
||||||
|
await expect(
|
||||||
|
controller.updateFromCatalog(updateDto, user, workspace),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(rolesService.updateFromCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin delegates to the service', () => {
|
||||||
|
it('catalog passes the requested language', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.catalog(catalogDto, user, workspace);
|
||||||
|
expect(rolesService.getCatalog).toHaveBeenCalledWith('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalog/bundle passes bundleId + language', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.catalogBundle(bundleDto, user, workspace);
|
||||||
|
expect(rolesService.getCatalogBundle).toHaveBeenCalledWith(
|
||||||
|
'general',
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('import passes workspace.id + user.id + dto', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.import(importDto, user, workspace);
|
||||||
|
expect(rolesService.importFromCatalog).toHaveBeenCalledWith(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
importDto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update-from-catalog passes workspace.id + dto', async () => {
|
||||||
|
const { controller, rolesService } = makeController(true);
|
||||||
|
await controller.updateFromCatalog(updateDto, user, workspace);
|
||||||
|
expect(rolesService.updateFromCatalog).toHaveBeenCalledWith(
|
||||||
|
'ws-1',
|
||||||
|
updateDto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('list (member-reachable)', () => {
|
describe('list (member-reachable)', () => {
|
||||||
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
|
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
|
||||||
const { controller, rolesService } = makeController(false);
|
const { controller, rolesService } = makeController(false);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user