Compare commits
2 Commits
fix/embedd
...
2ffdc150d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffdc150d5 | ||
|
|
f09becd1bf |
128
.env.example
128
.env.example
@@ -2,38 +2,6 @@
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# --- Security / reverse proxy ---
|
||||
# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via
|
||||
# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only
|
||||
# from proxies on the configured trusted networks. Deploy this app behind a
|
||||
# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For`
|
||||
# with the real client IP. If XFF is trusted from an untrusted source, any
|
||||
# per-IP throttling — including the /mcp Basic brute-force limiter — can be
|
||||
# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs.
|
||||
# (The /mcp limiter keeps a global per-email key as an IP-independent backstop,
|
||||
# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.)
|
||||
#
|
||||
# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For.
|
||||
# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted
|
||||
# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip.
|
||||
# This is the safe default for the common case where the reverse proxy runs on
|
||||
# loopback or a private network; req.ip still resolves to the real client.
|
||||
# WARNING: this changed the previous default of trust-all. If your reverse proxy
|
||||
# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the
|
||||
# proxy's IP — set TRUST_PROXY accordingly. Accepted values:
|
||||
# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites
|
||||
# X-Forwarded-For; otherwise clients can spoof their IP)
|
||||
# - false never trust X-Forwarded-For (req.ip is the socket peer)
|
||||
# - <int> number of trusted proxy hops in front of the app
|
||||
# - <list> comma-separated CIDR/IP list of trusted proxies, e.g.
|
||||
# `127.0.0.1, 10.0.0.0/8`
|
||||
# TRUST_PROXY=
|
||||
|
||||
# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that
|
||||
# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you
|
||||
# change APP_SECRET after setup, every stored AI API key becomes undecryptable —
|
||||
# you must re-enter them in AI settings — and all existing sessions/JWTs are
|
||||
# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB.
|
||||
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||
|
||||
@@ -101,105 +69,15 @@ DEBUG_DB=false
|
||||
# Log http requests
|
||||
LOG_HTTP=false
|
||||
|
||||
# MCP server (community): the embedded /mcp endpoint authenticates PER USER.
|
||||
# An MCP client authenticates with one of:
|
||||
# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own
|
||||
# Docmost login/password. The server validates the credentials and the MCP
|
||||
# session then acts under that user's permissions (edits attributed to them).
|
||||
# - Bearer access JWT: `Authorization: Bearer <access-jwt>` (the user's
|
||||
# `authToken` cookie value). Validated as an ACCESS token.
|
||||
#
|
||||
# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR
|
||||
# Bearer credentials and these are set, the MCP session falls back to this
|
||||
# shared service account (back-compat; useful for CI/scripts). Leave BLANK to
|
||||
# require per-user credentials.
|
||||
# MCP server (community): service account the embedded MCP uses to talk to this Docmost instance
|
||||
MCP_DOCMOST_EMAIL=
|
||||
MCP_DOCMOST_PASSWORD=
|
||||
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
|
||||
# Optional shared guard for the /mcp endpoint. When set, every /mcp request must
|
||||
# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now
|
||||
# carries the per-user credentials). When unset, /mcp relies on the per-user
|
||||
# credentials above plus the workspace MCP toggle and network isolation (do not
|
||||
# expose the port publicly).
|
||||
# Optional bearer token to protect the /mcp endpoint. If unset, /mcp relies on
|
||||
# the workspace MCP toggle and network isolation (do not expose the port publicly).
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
#
|
||||
# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"):
|
||||
# attribution is driven by a per-user `is_agent` flag on the users row. There is
|
||||
# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service
|
||||
# account for the MCP fallback above and flag ONLY that account, e.g.:
|
||||
# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain';
|
||||
# NEVER set is_agent on a human or shared account — every action by that account
|
||||
# (including normal human edits) would then be mis-attributed as AI.
|
||||
|
||||
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
|
||||
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
|
||||
# baked into the Docker image at build time per branch (see the Dockerfile ARG
|
||||
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
|
||||
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
|
||||
# admin feature is unavailable. Local-filesystem sources are no longer supported.
|
||||
# AI_AGENT_ROLES_CATALOG_URL=
|
||||
|
||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||
|
||||
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
|
||||
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
|
||||
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
|
||||
# provider is eventually broken instead of leaking forever. Default 900000 (15 min).
|
||||
# AI_STREAM_TIMEOUT_MS=900000
|
||||
|
||||
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
||||
# A pooled connection idle longer than this is closed instead of reused, so a
|
||||
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
||||
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
|
||||
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
|
||||
# AI_STREAM_KEEPALIVE_MS=10000
|
||||
|
||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
||||
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
||||
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
||||
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
||||
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
|
||||
# AI_MCP_STREAM_TIMEOUT_MS=300000
|
||||
|
||||
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
||||
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||
# but never returns a result — which the silence timeout above never breaks.
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
# share at POST /api/shares/ai/stream. The assistant is read-only and hard-scoped
|
||||
# to the single share tree, but every call spends real tokens on the workspace
|
||||
# owner's configured AI provider.
|
||||
#
|
||||
# DEPLOYMENT REQUIREMENT: the per-IP rate limit on this endpoint is only
|
||||
# effective behind a trusted reverse proxy that OVERWRITES (not appends)
|
||||
# X-Forwarded-For with the real client IP. The app runs with trustProxy, so
|
||||
# without such a proxy an attacker can rotate X-Forwarded-For to evade the
|
||||
# per-IP limit. Put this endpoint (and the app) behind a proxy you control that
|
||||
# sets X-Forwarded-For to the real client IP.
|
||||
#
|
||||
# Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent,
|
||||
# keyed by the server-resolved workspace id) bounds the owner's bill even if the
|
||||
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
|
||||
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
|
||||
# offline is safer than an unbounded bill). Override the hourly cap below
|
||||
# (default: 100 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
|
||||
#
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
# SHARE_AI_MAX_OUTPUT_TOKENS=512
|
||||
#
|
||||
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
|
||||
# (input re-sent per step + output, summed across every accepted turn). The
|
||||
# hourly request cap above bounds how MANY calls run, not how expensive each is,
|
||||
# so this caps the owner's actual provider bill directly. Like the request cap it
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
165
.github/workflows/develop.yml
vendored
165
.github/workflows/develop.yml
vendored
@@ -3,7 +3,7 @@ name: Develop
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -18,12 +18,7 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -52,165 +47,7 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||
REDIS_URL: redis://localhost:6379
|
||||
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||
APP_URL: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_DB: docmost
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Build server
|
||||
run: pnpm server:build
|
||||
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Start server (prod)
|
||||
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
|
||||
# migration mismatch) is diagnosable; without this the only signal is
|
||||
# the generic health-loop timeout below, ~120s later.
|
||||
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
|
||||
|
||||
- name: Wait for server health
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:3000/api/health > /dev/null; then
|
||||
echo "Server is healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Server did not become healthy in time"
|
||||
exit 1
|
||||
|
||||
- name: Dump server log on failure
|
||||
if: failure()
|
||||
run: cat /tmp/server.log || true
|
||||
|
||||
- name: Seed admin
|
||||
run: |
|
||||
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
|
||||
|
||||
- name: Run mcp e2e
|
||||
env:
|
||||
DOCMOST_API_URL: http://localhost:3000/api
|
||||
DOCMOST_EMAIL: e2e@example.com
|
||||
DOCMOST_PASSWORD: E2ePassword123
|
||||
run: pnpm --filter @docmost/mcp test:e2e
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -17,15 +17,9 @@ permissions:
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
build:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -58,7 +52,6 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.suffix }}
|
||||
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
|
||||
@@ -87,7 +80,6 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
|
||||
push: false
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
|
||||
79
.github/workflows/test.yml
vendored
79
.github/workflows/test.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Real Postgres + Redis so the server integration suite (`*.int-spec.ts`,
|
||||
# behind `pnpm --filter server test:int`) runs in CI (red-team finding #7).
|
||||
# Without it, cost-cap / FK-cascade / jsonb-round-trip / real-apply tests
|
||||
# only ran locally, so regressions in those paths stayed green in CI.
|
||||
# Postgres uses the pgvector image because migrations create vector columns
|
||||
# and global-setup runs `CREATE EXTENSION vector`. Credentials/db match the
|
||||
# defaults in apps/server/test/integration/db.ts + global-setup.ts
|
||||
# (docmost / docmost_dev_pw, maintenance db `docmost`, redis on 6379), so no
|
||||
# TEST_*_URL overrides are needed.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
env:
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost_dev_pw
|
||||
POSTGRES_DB: docmost
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U docmost"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Required for the client suite, which resolves @docmost/editor-ext via its
|
||||
# dist build (the server suite also rebuilds it through its own pretest).
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
# Integration suite against the real Postgres/Redis services above. Runs
|
||||
# the FK-cascade, cost-cap, jsonb-round-trip and real-apply specs that the
|
||||
# unit run (mocks only) cannot cover. global-setup drops/recreates the
|
||||
# isolated `docmost_test` DB and migrates it to latest.
|
||||
- name: Run server integration tests
|
||||
run: pnpm --filter server test:int
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -42,10 +42,3 @@ lerna-debug.log*
|
||||
.nx/installation
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
.claude/tmp/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||
apps/client/public/vad/
|
||||
|
||||
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
// VSCode tasks for this repo.
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "git push (github + gitea)",
|
||||
"type": "shell",
|
||||
"command": "git push github develop && git push gitea develop",
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
224
AGENTS.md
224
AGENTS.md
@@ -5,48 +5,45 @@ repository. It has two layers: **how to run a task end-to-end** (the
|
||||
sections below), and **how the codebase is built** (the technical sections
|
||||
further down, formerly in `CLAUDE.md`).
|
||||
|
||||
## Task lifecycle
|
||||
## Жизненный цикл задачи
|
||||
|
||||
### 1. Start: sync with develop
|
||||
### 1. Старт: синхронизация с develop
|
||||
|
||||
Before starting **any** work, update your local `develop` and branch off it:
|
||||
Перед началом **любой** работы обнови локальный `develop` и ветвись от него:
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git fetch gitea
|
||||
git pull --ff-only gitea develop
|
||||
git checkout -b <short-feature-name>
|
||||
git checkout -b <короткое-имя-фичи>
|
||||
```
|
||||
|
||||
Never build a feature directly on `develop`, and never branch off a stale
|
||||
`develop` — otherwise the PR will carry extra commits or conflict.
|
||||
Никогда не пилит фичу прямо в `develop` и не ветвись от устаревшего
|
||||
`develop` — иначе PR будет содержать лишние коммиты или конфликтовать.
|
||||
|
||||
### 2. Implementation
|
||||
### 2. Реализация
|
||||
|
||||
Run the task through the workflow from the system prompt (Phase 1 analysis →
|
||||
Phase 3 implementation → Phase 4 review → Phase 5 verification → Phase 6
|
||||
report). Delegate large changes to a general subagent; review via the review
|
||||
subagent.
|
||||
Веди задачу по workflow из системного промпта (Phase 1 анализ → Phase 3
|
||||
реализация → Phase 4 review → Phase 5 верификация → Phase 6 отчёт). Большие
|
||||
изменения делегируй в general subagent, ревьюй через review subagent.
|
||||
|
||||
**Create worktrees only inside the `.claude` folder** (e.g.
|
||||
`.claude/worktrees/<name>`). Creating a git worktree anywhere else — the repo
|
||||
root, sibling directories, or temp folders — is forbidden.
|
||||
### 3. Коммит — ТОЛЬКО в Gitea и ТОЛЬКО от `claude_code`
|
||||
|
||||
### 3. Commit — ONLY to Gitea and ONLY as `claude_code`
|
||||
Это правило без исключений:
|
||||
|
||||
This rule has no exceptions:
|
||||
|
||||
- **Where:** the only remote for commits/pushes is **`gitea`**
|
||||
(`gitea.vvzvlad.xyz`). **Never** push to `origin` (the GitHub mirror), and
|
||||
especially not to `upstream` (the original Docmost). The GitHub mirror is
|
||||
updated by the owner's CI process, not by the agent.
|
||||
- **Who:** commit **only** as the agent identity. Any commit whose author or
|
||||
committer is `vvzvlad` is an error and must be rewritten.
|
||||
- **Куда:** единственный remote для коммитов/пушей — **`gitea`**
|
||||
(`gitea.vvzvlad.xyz`). **Никогда** не пушь в `origin` (GitHub-зеркало) и
|
||||
тем более в `upstream` (оригинальный Docmost). GitHub-зеркало обновляется
|
||||
CI-процессом владельца, не агентом.
|
||||
- **От кого:** коммить **только** от агентского identity. Любой коммит,
|
||||
у которого author или committer — `vvzvlad`, считается ошибкой и должен
|
||||
быть переписан.
|
||||
- **name:** `claude_code`
|
||||
- **email:** `claude_code@vvzvlad.xyz`
|
||||
|
||||
Use `--reset-author` when amending, otherwise git keeps the original author
|
||||
(the default config on this machine is `vvzvlad`, so check after every commit):
|
||||
Используй `--reset-author` при amend, иначе git оставит оригинального
|
||||
автора (по умолчанию config на этой машине — `vvzvlad`, поэтому проверяй
|
||||
после каждого коммита):
|
||||
|
||||
```bash
|
||||
GIT_AUTHOR_NAME="claude_code" \
|
||||
@@ -56,33 +53,34 @@ GIT_COMMITTER_EMAIL="claude_code@vvzvlad.xyz" \
|
||||
git commit --amend --no-edit --reset-author
|
||||
```
|
||||
|
||||
For a regular new commit, set the branch-local config once and commit normally:
|
||||
Для обычного нового коммита достаточно один раз выставить локальный
|
||||
config ветки и коммитить штатно:
|
||||
|
||||
```bash
|
||||
git config user.name "claude_code"
|
||||
git config user.email "claude_code@vvzvlad.xyz"
|
||||
```
|
||||
|
||||
Check before push:
|
||||
Проверка перед push:
|
||||
|
||||
```bash
|
||||
git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
||||
# both lines must show claude_code <claude_code@vvzvlad.xyz>
|
||||
# обе строки должны показать claude_code <claude_code@vvzvlad.xyz>
|
||||
```
|
||||
|
||||
### 4. Push and PR to develop
|
||||
### 4. Push и PR в develop
|
||||
|
||||
PRs always target `develop`. The `claude_code` password lives in the macOS
|
||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||
conflict with the owner's account in the git credential helper):
|
||||
PR всегда в `develop`. Пароль `claude_code` лежит в macOS keychain как
|
||||
**generic password** под service `gitea-claude-code` (не дублируй его как
|
||||
internet-password для `gitea.vvzvlad.xyz` — это создаст конфликт с учёткой
|
||||
владельца в git credential helper):
|
||||
|
||||
```bash
|
||||
AGENT_PASS=$(security find-generic-password -s gitea-claude-code -w)
|
||||
```
|
||||
|
||||
Push by temporarily injecting the credentials into the remote URL, then always
|
||||
restore the URL to its clean form (the password must not linger in git
|
||||
Push — через временную подстановку кредов в remote URL, после чего URL
|
||||
обязательно возвращается в чистый вид (пароль не должен оседать в git
|
||||
config / reflog):
|
||||
|
||||
```bash
|
||||
@@ -94,7 +92,7 @@ git remote set-url gitea "$ORIG_URL"
|
||||
unset AGENT_PASS SAFE_PASS
|
||||
```
|
||||
|
||||
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
||||
PR создаётся через Gitea REST API (Basic Auth от `claude_code`):
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
@@ -104,75 +102,63 @@ curl -s -X POST \
|
||||
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
||||
```
|
||||
|
||||
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
||||
of scope, verification results (tsc/lint/tests).
|
||||
`base: develop`, `head: <branch>`. В теле PR — что сделано, что вне scope,
|
||||
результаты верификации (tsc/lint/tests).
|
||||
|
||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||
> the Gitea UI or `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code`
|
||||
> with `{"permission":"write"}` from their account).
|
||||
> Если push падает с `User permission denied for writing` — значит у
|
||||
> `claude_code` нет коллабораторских прав на репо. Попроси владельца
|
||||
> добавить (один раз, через Gitea UI или
|
||||
> `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code` с
|
||||
> `{"permission":"write"}` от его учётки).
|
||||
|
||||
### 5. Merge and cleanup
|
||||
### 5. Мерж и cleanup
|
||||
|
||||
- **The user merges the PR into develop** (not the agent). The agent does not
|
||||
press the merge button.
|
||||
- **After implementing a task, delete its plan from `docs/backlog/<task>.md`** —
|
||||
this is part of closing the task, not the user's work. Files in
|
||||
`docs/backlog/` are the work queue; completed items get cleaned out of it.
|
||||
Do this in a separate commit from the same `claude_code` on the same branch
|
||||
(or ask the user to delete it if the PR is already open and you don't want to
|
||||
repush it).
|
||||
- Any junk left uncommitted in the working tree? Check `git status` before the
|
||||
final report.
|
||||
- **Мерж PR в develop делает пользователь** (не агент). Агент не жмёт
|
||||
кнопку merge.
|
||||
- **После реализации задачи удали её план из `docs/backlog/<task>.md`** —
|
||||
это часть закрытия задачи, не пользовательская работа. Файлы в
|
||||
`docs/backlog/` — это очередь работы, выполненное из неё вычищается.
|
||||
Сделай это в отдельном коммите от того же `claude_code` в той же ветке
|
||||
(или попроси пользователя удалить, если PR уже открыт и ты не хочешь
|
||||
его перепушивать).
|
||||
- Не закоммичен ли мусор в рабочем дереве? Проверь `git status` перед
|
||||
финальным отчётом.
|
||||
|
||||
## Release cycle: staging a new version
|
||||
## Релизный цикл: набор на новую версию
|
||||
|
||||
When enough changes have accumulated on `develop` for a release, a **final
|
||||
review by three orchestrator skills** runs before the merge/tag:
|
||||
Когда в `develop` накопилось достаточно изменений для релиза, запускается
|
||||
**финальное ревью тремя скиллами-оркестраторами** перед мержем/тегом:
|
||||
|
||||
1. **test-orchestrator** (the `code-review-orchestrator` skill focused on test
|
||||
coverage) — verifies new code is covered by tests and there are no
|
||||
regressions in existing ones.
|
||||
2. **review-orchestrator** (the `code-review-orchestrator` skill) —
|
||||
multi-aspect code review: security, stability, convention conformance,
|
||||
regressions, over-complexity.
|
||||
3. **red-team-orchestrator** (the red-team skill) — adversarial analysis of
|
||||
attack scenarios against the affected components.
|
||||
1. **test-orchestrator** (skill `code-review-orchestrator` с фокусом на
|
||||
тестовом покрытии) — проверяет, что новый код покрыт тестами и нет
|
||||
регрессий в существующих.
|
||||
2. **review-orchestrator** (skill `code-review-orchestrator`) —
|
||||
мульти-аспектный код-ревью: безопасность, стабильность, соответствие
|
||||
конвенциям, регрессии, перегруженность.
|
||||
3. **red-team-orchestrator** (red-team скилл) — адверсариальный анализ
|
||||
атакующих сценариев на затронутые компоненты.
|
||||
|
||||
Order: the orchestrators return finding lists → the agent fixes everything they
|
||||
found (via a subagent or itself, per the delegation rules) → re-runs the review
|
||||
on the affected areas → cuts the tag per the "Cutting a release" procedure
|
||||
below.
|
||||
Порядок: оркестраторы возвращают списки находок → агент правит всё, что
|
||||
они нашли (через subagent или сам, по правилам делегирования) → повторно
|
||||
прогоняет ревью затронутых мест → режет тег по процедуре «Cutting a
|
||||
release» ниже.
|
||||
|
||||
## Accounts & endpoints cheat sheet
|
||||
## Шпаргалка по учёткам и endpoint'ам
|
||||
|
||||
| Item | Value |
|
||||
| Что | Значение |
|
||||
| --- | --- |
|
||||
| Only remote for commits | `gitea` → `https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` |
|
||||
| Agent user (Gitea/git) | `claude_code` |
|
||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
||||
| Base branch | `develop` |
|
||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||
| `upstream` | The original Docmost — **never push** |
|
||||
|
||||
## Creating issues (Gitea `tea` CLI)
|
||||
|
||||
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||
|
||||
```bash
|
||||
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
--title '<title>' --description "$(cat body.md)"
|
||||
```
|
||||
|
||||
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||
| Единственный remote для коммитов | `gitea` → `https://vvzvlad@gitea.vvzvlad.xyz/vvzvlad/gitmost.git` |
|
||||
| Агентский user (Gitea/git) | `claude_code` |
|
||||
| Агентский email | `claude_code@vvzvlad.xyz` |
|
||||
| Пароль в keychain | `security find-generic-password -s gitea-claude-code -w` |
|
||||
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (тут `gitmost` — реальный slug репо на сервере) |
|
||||
| Базовая ветка | `develop` |
|
||||
| `origin` | GitHub-зеркало `vvzvlad/gitmost` — **не пушить**, обновляется CI владельца |
|
||||
| `upstream` | Оригинальный Docmost — **не пушить никогда** |
|
||||
|
||||
---
|
||||
|
||||
# Architecture and codebase
|
||||
# Архитектура и кодовая база
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -223,14 +209,14 @@ pnpm --filter @docmost/mcp test # node --test (unit + mock)
|
||||
pnpm --filter @docmost/mcp test:e2e # MCP end-to-end against a live instance
|
||||
```
|
||||
|
||||
**Database migrations** (Kysely, run from `apps/server`). **Where they auto-apply:** in **production** (the built image / `start:prod`) pending migrations run automatically on server boot. In **local dev** (the `pnpm dev` stand / `nest start --watch`) they do **NOT** auto-run — after you pull or switch branches you must apply them yourself with `pnpm --filter server migration:latest`, or any endpoint touching a new column/table 500s (e.g. a freshly-added `ai_chats.page_id` blanket-500s all of AI chat until migrated).
|
||||
**Database migrations** (Kysely, run from `apps/server`; they auto-run on server startup too):
|
||||
```bash
|
||||
pnpm --filter server migration:create --name=my_change # new empty migration
|
||||
pnpm --filter server migration:latest # apply all pending
|
||||
pnpm --filter server migration:down # revert last
|
||||
pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB
|
||||
```
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`) and nullable columns — never drop/rewrite Docmost data.
|
||||
|
||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||
|
||||
@@ -254,7 +240,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
|
||||
|
||||
### The two AI subsystems (the main fork additions)
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (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.
|
||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. It authenticates as a service account configured via `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`; an admin enables it with a workspace toggle (Workspace settings → AI). Optionally protected by `MCP_TOKEN`.
|
||||
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
|
||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
@@ -277,53 +263,21 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
|
||||
## CI / release
|
||||
|
||||
- `.github/workflows/develop.yml` — on push to `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||
- `.github/workflows/develop.yml` — on push to `main`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||
- `.github/workflows/release.yml` — on `v*` tags (or manual dispatch), builds multi-arch (amd64 + arm64) images, pushes a manifest list to GHCR (`latest` + semver tags), and creates a draft GitHub Release with image tarballs. Uses the built-in `GITHUB_TOKEN` (not Docker Hub).
|
||||
- The `Dockerfile` is a multi-stage pnpm build; `APP_VERSION` is passed as a build arg because `.git` isn't in the build context.
|
||||
|
||||
### Cutting a release
|
||||
|
||||
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
|
||||
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
|
||||
|
||||
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
|
||||
1. Make sure `main` is clean and pushed (`git status`, `git push`).
|
||||
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
|
||||
4. For a real release (skip for a bare hotfix tag), update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
|
||||
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
|
||||
```bash
|
||||
git push gitea develop && git push gitea vX.Y.Z
|
||||
git push github develop && git push github vX.Y.Z
|
||||
```
|
||||
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
|
||||
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --ff-only develop # or a merge commit if fast-forward is not possible
|
||||
git push gitea main && git push github main
|
||||
```
|
||||
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
|
||||
|
||||
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
|
||||
|
||||
`git describe --tags --always` (see `vite.config.ts`) walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||
|
||||
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
|
||||
|
||||
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
|
||||
|
||||
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
|
||||
|
||||
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
|
||||
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just be aware.
|
||||
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now in scope.
|
||||
|
||||
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
|
||||
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
|
||||
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
|
||||
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
|
||||
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
|
||||
|
||||
## Planning docs
|
||||
|
||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation, arbitrary HTML embed). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||
|
||||
386
CHANGELOG.md
386
CHANGELOG.md
@@ -10,389 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Quick-create regular and temporary notes from the Home and Space screens.**
|
||||
The Home screen now shows a second action next to "New note" that creates a
|
||||
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
|
||||
resolving the target space the same way the regular button does — created
|
||||
directly when you can write to a single space, or via a space picker when
|
||||
several. Each space overview screen gains two buttons — "New note" and "New
|
||||
temporary note" — that create the page directly in that space and open it,
|
||||
mirroring the existing space-sidebar actions and shown only to members who can
|
||||
manage pages.
|
||||
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
|
||||
message gains a "send now" action that interrupts the streaming turn and
|
||||
immediately sends that message, keeping the agent's partial output. The
|
||||
follow-up turn is tagged as an interrupt so the model is told its previous
|
||||
answer was cut off and builds on it instead of restarting; the rest of the
|
||||
queue still flushes normally afterward. (#198)
|
||||
|
||||
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
|
||||
catalog of agent roles, grouped into bundles and offered in several languages,
|
||||
and import the ones they want into the workspace (with skip-or-rename handling
|
||||
for name collisions); the same role in a different language imports as a
|
||||
separate install. An imported role remembers its catalog origin and offers a
|
||||
one-click update when the catalog ships a newer revision. Backed by four new
|
||||
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
|
||||
`/catalog/bundle` (read one bundle's roles), `/import`, and
|
||||
`/update-from-catalog` — and a new `source` column linking a role to its
|
||||
catalog slug/language/version. The catalog source is configured via the
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
|
||||
catalog's raw files; the image ships a per-branch default baked in CI, and it
|
||||
can be overridden at runtime via the env var (see `.env.example`). (#222)
|
||||
- **Author footnotes inline from an agent, and deterministic server-side footnote
|
||||
canonicalization on every non-editor write path.** A new MCP `insert_footnote`
|
||||
tool places a footnote at a body anchor by content only — the agent supplies
|
||||
WHERE (anchor text) and WHAT (markdown); the number and the bottom
|
||||
`footnotesList` are derived server-side, so an agent can never assign a number,
|
||||
edit the list, or desync, and a same-content note reuses one definition. Under
|
||||
the hood, the editor's footnote-integrity invariant (one trailing list,
|
||||
numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now
|
||||
enforced as a pure `canonicalizeFootnotes(doc)` on the FULL-document write paths
|
||||
that bypass the editor's plugins: server markdown/HTML import, `PageService`
|
||||
create and full-document (`replace`) updates, the client markdown paste, and the
|
||||
MCP markdown page-import / `update_page` (markdown) / `update_page_json` /
|
||||
`docmost_transform` / `insert_footnote` / `copy_page_content` paths. It is
|
||||
idempotent (a no-op once canonical) and is deliberately NOT applied to
|
||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||
contain a standalone footnote definition, which canonicalization would drop.
|
||||
(#228)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
||||
a page "Shared to web" now defaults to the page alone; descendant pages become
|
||||
public only when you explicitly turn on the dedicated "Include sub-pages"
|
||||
toggle. Previously the create call defaulted to including sub-pages, silently
|
||||
exposing every child of a freshly shared page. (#216)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||
link whose target page name had no file extension (e.g. a bare title) was
|
||||
collapsed to empty text during export, producing an unclickable, label-less
|
||||
link; the page name is now preserved. (#204)
|
||||
- **Deep pages no longer render a blank breadcrumb while the sidebar tree loads.**
|
||||
The breadcrumb now falls back to the page's own ancestor chain (fetched
|
||||
independently of the lazily-built sidebar tree) so a deep page resolves its
|
||||
trail immediately; navigating away no longer leaves the previously-viewed
|
||||
page's breadcrumb showing until the new one resolves. (#206, #218)
|
||||
- **Pasted GitHub-style callouts (`> [!NOTE]` …) now convert to real callouts.**
|
||||
GitHub admonition blocks pasted as Markdown are recognized and rendered as
|
||||
callout blocks instead of plain block-quotes. (#192)
|
||||
- **The editor stays read-only until collaboration has synced.** While a page is
|
||||
connecting, the body is shown as a non-editable static view with a
|
||||
"Connecting… (read-only)" banner, so edits typed before the document finishes
|
||||
syncing can no longer be silently dropped. (#218)
|
||||
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
|
||||
page's vanity slug previously inserted a second `share_aliases` row instead of
|
||||
renaming the existing one, leaving the old `/l/<old>` link live forever and
|
||||
making the share modal's lookup nondeterministic. Slug edits and confirmed
|
||||
reassigns now rename/retarget the single row, and a new partial unique index on
|
||||
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
|
||||
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
|
||||
orphaned duplicate alias rows the old bug created (keeping the newest per
|
||||
page), so any previously-live duplicate `/l/<old>` link begins returning the
|
||||
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
|
||||
#227)
|
||||
- **Typing a custom address already used by another page no longer looks like a
|
||||
dead end.** The share modal previously flagged such a name with a red "This
|
||||
address is already in use" error, hiding the fact that saving offers to MOVE
|
||||
the address to the current page. The field now shows an informational hint —
|
||||
"This address is in use. Saving will move it to this page." — and keeps Save
|
||||
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` →
|
||||
"Move custom address?") is discoverable instead of reading as terminal. (#227)
|
||||
|
||||
### Security
|
||||
|
||||
- **The anonymous public-share page payload is trimmed to an explicit allowlist.**
|
||||
The `/shares/page-info` route (the only unauthenticated path serializing a
|
||||
page + its share) now returns only the fields the public renderer needs;
|
||||
internal metadata — creator/last-updater/contributor ids, space/workspace ids,
|
||||
AI/source bookkeeping, lock/template flags, parent/position and raw timestamps
|
||||
— is no longer exposed to anonymous viewers. (#218)
|
||||
- **A forged or mismatched share id can no longer render a page off its slug
|
||||
alone.** When the public URL carries a share id/key, the page must be reachable
|
||||
through that exact share (its own share or an ancestor `includeSubPages`
|
||||
share); any other value now returns the generic "not found" instead of
|
||||
serving the page. (#218)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
the database step by step and exported server-side, the desktop app no longer
|
||||
freezes at 100% CPU on long agent runs, and MCP writes are badged with
|
||||
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
|
||||
per-reference back-links), hardens page moves and duplication against cycles
|
||||
and lost edits, and caps the anonymous public-share assistant with a
|
||||
per-workspace rolling-day token budget.
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
|
||||
any publicly shared page a short, memorable, workspace-scoped vanity address
|
||||
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
|
||||
(never `301`, since the target is retargetable) to the canonical
|
||||
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
|
||||
serves the plain SPA index so that the existence of a name never leaks. An
|
||||
alias can be moved to another page (with a confirm-reassign guard) and the
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||
permanent first. The deadline is frozen at creation time, so later changes to
|
||||
the workspace setting never reschedule existing notes; an hourly background
|
||||
sweep trashes notes past their deadline (children ride along). An open
|
||||
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||
Operators configure the lifetime per workspace. (#201)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
finalized once to `completed`/`error`/`aborted`. A process that dies mid-turn
|
||||
keeps every finished step, and a startup sweep flips any dangling `streaming`
|
||||
row (untouched for 10 minutes) to `aborted`. Chat "Copy" now exports
|
||||
server-side from these rows (`POST /ai-chat/export`) rather than from live
|
||||
client state, so the export is identical whether a chat is freshly streaming,
|
||||
just switched to, or reloaded — and is available from the first turn of a new
|
||||
chat. (#183, #174)
|
||||
|
||||
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
|
||||
the MCP endpoint by a dedicated agent account are now badged as "AI", with
|
||||
unspoofable provenance derived from a per-user `is_agent` flag (not from the
|
||||
request body). **Operator setup:** use a _dedicated_ service account for the
|
||||
MCP fallback and set the flag with SQL —
|
||||
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
|
||||
human or shared account, or its normal edits get mis-attributed as AI. See the
|
||||
AI-agent block in `.env.example`. (#143)
|
||||
- **Footnote import diagnostics.** The MCP page-write tools (`create_page`,
|
||||
`update_page`, `import_page_markdown`) now return a `footnoteWarnings` array
|
||||
flagging dangling references, empty or duplicate definitions, and `[^id]`
|
||||
markers inside table rows, so an agent can fix its own markup. The page is
|
||||
still created; the field is omitted when there are no problems. (#166)
|
||||
- **AI chat "Protocol" setting (`chatApiStyle`).** A new admin choice in AI
|
||||
settings for the `openai` driver: `openai-compatible` (default) routes chat
|
||||
through `@ai-sdk/openai-compatible`, which surfaces a provider's streamed
|
||||
reasoning (`reasoning_content` → reasoning parts) for z.ai/GLM, DeepSeek,
|
||||
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
|
||||
reasoning-model request shaping). Chosen explicitly rather than inferred from
|
||||
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
|
||||
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
|
||||
now has an admin-authored `instructions` field ("how/when to use this server's
|
||||
tools") that is injected into the agent's system prompt next to that server's
|
||||
tool descriptions. Trusted text, rendered inside the prompt safety sandwich;
|
||||
shown only for a server that actually connected and contributed ≥1 callable
|
||||
tool. (#180)
|
||||
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
||||
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
||||
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
||||
- **Generate a page title from its content.** A "sparkles" button in the page
|
||||
byline reads the live editor content (including unsaved edits), generates a
|
||||
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
|
||||
applies it through the existing `/pages/update` route — reflecting it in the
|
||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||
flag and throttled per user. (#199)
|
||||
- **AI chat: header button auto-opens the chat bound to the current document.**
|
||||
Clicking the AI-chat button in the header while viewing a page now reopens the
|
||||
latest chat tied to that document instead of whatever chat was last active,
|
||||
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
|
||||
chat you created on the page wins; with no bound chat — or off a page, or if
|
||||
the lookup fails — it falls soft to a fresh chat and keeps the current
|
||||
selection otherwise. (#191)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI chat now feeds the model the full stored transcript.** The per-turn model
|
||||
conversation was rebuilt from a sliding window of the 50 most recent stored
|
||||
rows, which silently dropped the beginning of any longer chat. It is now
|
||||
rebuilt from the complete non-deleted transcript in chronological order, so
|
||||
the model sees every turn (a 5000-row backstop guards process memory — a
|
||||
safety net far above any realistic chat, not a conversational limit). On a
|
||||
very long chat this can eventually reach the model's context window; the
|
||||
client already surfaces that as "start a new chat". (#202)
|
||||
|
||||
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
||||
For the `openai` driver the chat provider defaults to the openai-compatible
|
||||
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
||||
model's reasoning out of the box. An endpoint that is real OpenAI behind a
|
||||
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
|
||||
|
||||
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
|
||||
same id are ONE footnote — one number, one definition, several back-references
|
||||
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
|
||||
first-wins on import (the rest are dropped and reported via `footnoteWarnings`),
|
||||
and a reference with no definition yields a single empty footnote rather than
|
||||
one per occurrence. This supersedes the 0.93.0 "survive duplicate-id
|
||||
definitions" behavior for the import path. (#166)
|
||||
|
||||
- **Public share AI: default per-workspace hourly assistant cap lowered
|
||||
300 → 100.** The limiter falls back to this default whenever
|
||||
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
|
||||
never set the env var has its anonymous public-share assistant hourly cap
|
||||
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
|
||||
keep the previous limit. (#62)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **AI chat: the desktop app no longer freezes at 100% CPU on long agent runs.**
|
||||
`useChat` re-rendered on every streamed token and `MessageItem`/`ReasoningBlock`
|
||||
re-parsed the whole transcript markdown (marked + DOMPurify) on every delta, so
|
||||
per-turn work grew quadratically and saturated the main thread. The stream is now
|
||||
throttled (`experimental_throttle`) to ~20 Hz and each finalized message row /
|
||||
markdown part / reasoning block is memoized, so a long turn no longer re-parses
|
||||
already-finished content. (#182)
|
||||
- **Editor: caret/selection landed on the wrong line when clicking inside code
|
||||
blocks and footnotes.** The affected NodeViews rendered their non-editable
|
||||
chrome (language menu, footnotes heading, footnote number marker) before the
|
||||
editable content, so the browser's click hit-testing missed the contentDOM and
|
||||
snapped the caret to a previous node. Content now renders first in the DOM
|
||||
(chrome is lifted back into place via CSS flex `order`), and scroll containers
|
||||
are nudged after a paste to refresh stale hit-testing geometry. The caret
|
||||
symptom is macOS-specific and was confirmed manually on macOS; the automated
|
||||
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
|
||||
- **AI chat: the live token counter now ticks between agent steps.** During a
|
||||
multi-step turn the header token badge (and the "Thinking… · N tokens" line)
|
||||
no longer froze on the previous step's authoritative usage; the current step's
|
||||
estimate is combined per-component with `max`, so the count rises smoothly and
|
||||
never jumps backwards. (#163)
|
||||
- **AI chat: "New chat" during a streaming first turn now resets the whole
|
||||
chat, not just the role badge.** Starting a new chat mid-stream cleared the
|
||||
header but left the in-flight turn's messages behind, so the fresh chat opened
|
||||
pre-populated with the previous conversation; it now fully resets. (#161)
|
||||
- **AI chat: a dropped tool argument now yields an actionable error.** When the
|
||||
model omitted a required parameter (typically `pageId`) in a parallel/batch
|
||||
tool call, the assistant forwarded zod's raw "expected string, received
|
||||
undefined" text; tool inputs now return a message naming each missing/invalid
|
||||
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
|
||||
(#190)
|
||||
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
|
||||
under one of its own descendants is rejected in the same transaction as the
|
||||
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
|
||||
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
|
||||
so a pre-existing cycle can no longer spin a query. (#207)
|
||||
- **Page/editor robustness batch.** Duplicating a page now copies shared
|
||||
attachments for every referencing page (not just the first); colliding block
|
||||
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
|
||||
wrong node; transient collab store failures are retried so autosave edits
|
||||
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
|
||||
(#206)
|
||||
|
||||
### Security
|
||||
|
||||
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
|
||||
share assistant now caps a workspace's actual token spend (input + output,
|
||||
summed across every accepted turn) over a trailing day, on top of the hourly
|
||||
request cap — so a caller who evades the per-IP throttle still cannot run up
|
||||
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
|
||||
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
|
||||
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
|
||||
|
||||
## [0.93.0] - 2026-06-21
|
||||
|
||||
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
|
||||
an anonymous AI assistant on public shares, server-side voice dictation, an
|
||||
editor footnotes model, live page-template embeds, and sandboxed arbitrary-HTML
|
||||
embeds — plus a large batch of security hardening and test coverage.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
|
||||
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
|
||||
`X-MCP-Token` header. The `Authorization` header is now reserved for per-user
|
||||
HTTP Basic / Bearer access-JWT credentials, so each `/mcp` request
|
||||
authenticates as a specific user (the `MCP_DOCMOST_*` service account is only
|
||||
a fallback). Existing MCP clients (e.g. Claude Desktop) configured with
|
||||
`Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
|
||||
`X-MCP-Token: <MCP_TOKEN>` instead. See `MCP_TOKEN` in `.env.example`. As a
|
||||
one-time aid, the server logs a single migration warning when it sees the
|
||||
old-style header.
|
||||
|
||||
### Added
|
||||
|
||||
- **AI agent roles**: admin-defined assistant personas with an optional
|
||||
per-role model override, selectable in chat.
|
||||
- **Anonymous AI assistant on public shares**: public-share visitors can chat
|
||||
with a selectable agent-role identity that reuses the internal chat
|
||||
presentation, with per-request output-token caps and a fail-closed Redis
|
||||
limiter.
|
||||
- **Voice dictation (STT)**: server-side speech-to-text with a mic button in
|
||||
the chat and the editor, OpenRouter STT support, an endpoint test, and real
|
||||
provider-error surfacing.
|
||||
- **Footnotes**: an editor footnotes model (inline references + a definitions
|
||||
list).
|
||||
- **Page templates**: live whole-page embed (MVP) with a template-marker icon
|
||||
in the page tree and a working Refresh action.
|
||||
- **Arbitrary HTML/CSS/JS embeds**: a sandboxed-iframe embed block gated by a
|
||||
per-workspace toggle (default OFF); insertable by any member when the toggle
|
||||
is on.
|
||||
- Admin-only **"Analytics / tracker"** workspace setting: a raw HTML/JS snippet
|
||||
injected into the `<head>` of public share pages only (for analytics such as
|
||||
Google Analytics or Yandex.Metrika), kept separate from the member-facing
|
||||
HTML-embed feature.
|
||||
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
|
||||
embedded `/mcp` endpoint.
|
||||
- **Page tree**: Expand all / Collapse all for the space tree, and
|
||||
server-authoritative realtime tree updates.
|
||||
- **AI chat UX**: a `get_current_page` tool for proxy-robust page context, a
|
||||
current-context-size readout, an agent step cap raised 8→20 with a forced
|
||||
final text answer, and auto-collapse of the chat window on page focus.
|
||||
- **AI settings**: a Clear control inside the API-key field and an endpoint
|
||||
status dot bound to "configured × enabled".
|
||||
- **Client**: an always-visible space grid replacing the space-switcher popover,
|
||||
removal of the sidebar Overview item, tighter comments-panel density, and no
|
||||
auto-open of the comments panel when adding a comment.
|
||||
|
||||
### Changed
|
||||
|
||||
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
|
||||
when the workspace HTML-embed toggle is on, can be inserted by any member
|
||||
(previously admin-only). Turning the toggle off hides existing embeds and
|
||||
stops serving them on public share pages.
|
||||
- Remove the server-side role-based stripping of HTML-embed blocks from the
|
||||
write paths (collab/REST/MCP, page create/duplicate, import, transclusion
|
||||
unsync); sandboxing makes per-write gating unnecessary. The only remaining
|
||||
server-side strip is the public-share read path, which still honors the
|
||||
workspace HTML-embed toggle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- AI chat: preserve scroll position during streaming, record chats that fail on
|
||||
their first turn, and resolve the current page for agent context behind
|
||||
proxies.
|
||||
- AI roles: guard `update()` against concurrent soft-delete; harden the model
|
||||
override, role-name uniqueness, and id validation; sandwich the safety
|
||||
framework around the role persona.
|
||||
- Auth: handle null-password (SSO/LDAP-only) accounts without a bcrypt throw.
|
||||
- Footnotes: survive duplicate-id definitions without collab divergence.
|
||||
- HTML embed: fix stale iframe height and damp the resize loop; strip embeds at
|
||||
serve time on authenticated read paths and the plain page-create path.
|
||||
- Page templates: import `ThrottleModule` so collab boots, never strand an
|
||||
in-flight page-embed id, and add defense-in-depth workspace checks.
|
||||
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
|
||||
- Import: surface the real error cause from `/pages/import` instead of a generic 400.
|
||||
|
||||
### Security
|
||||
|
||||
- MCP: close an SSO/MFA bypass on Basic auth and stop minting non-init sessions;
|
||||
close a brute-force limiter check-then-act race.
|
||||
- Public share: block restricted descendants in the anonymous assistant, cap
|
||||
per-request output, fail closed when Redis is unavailable, and reject non-text
|
||||
message parts to close a size-cap bypass.
|
||||
- Make `trustProxy` env-configurable with a safe default.
|
||||
|
||||
### Internal
|
||||
|
||||
- CI: gate the `develop` and release image builds on the test suite, run the
|
||||
suites on push/PR, and build the `:develop` image on push to `develop`.
|
||||
- Docs: replace `CLAUDE.md` with `AGENTS.md` codifying the agent workflow and
|
||||
the release procedure, add migration-ordering guidance, and prune implemented
|
||||
plans.
|
||||
- A large batch of new server/client test coverage.
|
||||
|
||||
## [0.91.0] - 2026-06-18
|
||||
|
||||
Gitmost is a community-focused fork of Docmost. This release drops the
|
||||
@@ -475,6 +92,5 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
|
||||
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
||||
Docker image to the GHCR registry.
|
||||
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
|
||||
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD
|
||||
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
||||
|
||||
@@ -23,11 +23,6 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Agent-roles catalog base URL: per-branch default set at build time (CI);
|
||||
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
|
||||
ARG AI_AGENT_ROLES_CATALOG_URL=""
|
||||
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
|
||||
|
||||
# Copy apps
|
||||
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
|
||||
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
|
||||
|
||||
20
README.md
20
README.md
@@ -34,7 +34,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
|
||||
| --- | --- |
|
||||
| **EE code removed** | Stripped all client and server Enterprise-Edition code; ships as a clean community/AGPL build with no license checks. |
|
||||
| **Comment resolution** | Re-implemented from scratch as a community feature (resolve / re-open with Open/Resolved tabs). No EE code reused, available to anyone who can comment. |
|
||||
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 39 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`, 38 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
|
||||
| **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. |
|
||||
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
|
||||
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
|
||||
@@ -44,7 +44,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
|
||||
### Embedded MCP server
|
||||
|
||||
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39
|
||||
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **38
|
||||
agent-native tools**: surgical per-block edits (patch / insert / delete by id),
|
||||
structure-preserving find/replace, scripted `(doc) => doc` transforms with a dry-run diff,
|
||||
structured table editing, version history with diff / restore, comments, images and share
|
||||
@@ -60,7 +60,7 @@ every little fix. And it needs no enterprise license.
|
||||
| | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
|
||||
| --- | :---: | :---: |
|
||||
| **Enterprise license** | Not required | Required |
|
||||
| **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
|
||||
| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
|
||||
| **Per-block edits / find-replace / scripted transforms** | ✅ | — |
|
||||
| **Structured table editing, version diff / restore** | ✅ | — |
|
||||
| **Comments, images, share links** | ✅ | — |
|
||||
@@ -101,10 +101,6 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs.
|
||||
- ✅ **AI chat** — built-in AI agent chat over your wiki content (read + write, RAG search, configurable provider, optional web access via external MCP).
|
||||
- ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it.
|
||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||
- ✅ **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
|
||||
|
||||
@@ -112,11 +108,14 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
|
||||
### Planned
|
||||
|
||||
- 🔭 **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks). See [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
||||
- 🔭 **Viewer comments** — let read-only viewers leave comments.
|
||||
- 🔭 **Public-share AI assistant** — let anonymous visitors of a shared page ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle. See [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
||||
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
|
||||
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
|
||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Offline mode** — offline sync & PWA support.
|
||||
- 🔭 **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP. See [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
||||
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.
|
||||
|
||||
## Getting started
|
||||
@@ -159,11 +158,6 @@ the existing data directory is reused as-is:
|
||||
start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the
|
||||
`page_embeddings` and AI tables); watch the logs for `Migration "..." executed successfully`.
|
||||
|
||||
> ⚠️ **Never change `APP_SECRET` after setup.** It does double duty: it signs JWTs *and* derives the
|
||||
> AES-256-GCM key that encrypts stored AI-provider credentials (API keys). Rotating it makes every
|
||||
> saved AI API key undecryptable (you'd have to re-enter them in AI settings) and invalidates all
|
||||
> existing sessions. Pick it once, keep it stable, and back it up together with your database.
|
||||
|
||||
### Notes
|
||||
|
||||
- **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the
|
||||
|
||||
21
README.ru.md
21
README.ru.md
@@ -33,7 +33,7 @@
|
||||
| --- | --- |
|
||||
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
|
||||
| **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. |
|
||||
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 39 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
|
||||
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 38 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
|
||||
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
|
||||
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
||||
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
|
||||
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
|
||||
**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||
**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
|
||||
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
|
||||
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
|
||||
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
|
||||
@@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
|
||||
| --- | :---: | :---: |
|
||||
| **Enterprise-лицензия** | Не нужна | Нужна |
|
||||
| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||
| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
|
||||
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
|
||||
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
|
||||
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
|
||||
@@ -102,10 +102,6 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов.
|
||||
- ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP).
|
||||
- ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия.
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||
|
||||
### В процессе
|
||||
|
||||
@@ -113,11 +109,14 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
|
||||
### В планах
|
||||
|
||||
- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
||||
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
||||
- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
||||
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
||||
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
||||
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
||||
- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
||||
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
||||
|
||||
## С чего начать
|
||||
@@ -160,12 +159,6 @@ dump/restore, существующий каталог данных переис
|
||||
новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы
|
||||
`page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`.
|
||||
|
||||
> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT
|
||||
> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров
|
||||
> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить
|
||||
> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите
|
||||
> неизменным и бэкапьте вместе с базой данных.
|
||||
|
||||
|
||||
## Возможности
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# 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.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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 } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "agent-roles-catalog",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "node scripts/check.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
#!/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");
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 3,
|
||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.94.1",
|
||||
"version": "0.91.0",
|
||||
"scripts": {
|
||||
"dev": "node scripts/copy-vad-assets.mjs && vite",
|
||||
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||
@@ -28,7 +28,6 @@
|
||||
"@mantine/modals": "8.3.18",
|
||||
"@mantine/notifications": "8.3.18",
|
||||
"@mantine/spotlight": "8.3.18",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"@slidoapp/emoji-mart": "5.8.7",
|
||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||
@@ -54,7 +53,6 @@
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mitt": "3.0.1",
|
||||
"onnxruntime-web": "^1.27.0",
|
||||
"posthog-js": "1.372.2",
|
||||
"react": "18.3.1",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
|
||||
"Toggle AI search": "KI-Suche umschalten",
|
||||
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
|
||||
"Toggle generative AI": "Generative KI umschalten",
|
||||
"Upgrade your plan": "Upgrade Ihres Plans",
|
||||
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
|
||||
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
|
||||
|
||||
@@ -119,8 +119,6 @@
|
||||
"Name": "Name",
|
||||
"New email": "New email",
|
||||
"New page": "New page",
|
||||
"New note": "New note",
|
||||
"Create in space": "Create in space",
|
||||
"New password": "New password",
|
||||
"No group found": "No group found",
|
||||
"No page history saved yet.": "No page history saved yet.",
|
||||
@@ -185,7 +183,6 @@
|
||||
"Successfully imported": "Successfully imported",
|
||||
"Successfully restored": "Successfully restored",
|
||||
"System settings": "System settings",
|
||||
"Template": "Template",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
@@ -258,7 +255,6 @@
|
||||
"Copy to space": "Copy to space",
|
||||
"Copy chat": "Copy chat",
|
||||
"Copied": "Copied",
|
||||
"Failed to export chat": "Failed to export chat",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
@@ -421,8 +417,6 @@
|
||||
"{{count}} command available_other": "{{count}} commands available",
|
||||
"{{count}} result available_one": "1 result available",
|
||||
"{{count}} result available_other": "{{count}} results available",
|
||||
"{{count}} result found_one": "{{count}} result found",
|
||||
"{{count}} result found_other": "{{count}} results found",
|
||||
"Equal columns": "Equal columns",
|
||||
"Left sidebar": "Left sidebar",
|
||||
"Right sidebar": "Right sidebar",
|
||||
@@ -479,7 +473,6 @@
|
||||
"Make sub-pages public too": "Make sub-pages public too",
|
||||
"Allow search engines to index page": "Allow search engines to index page",
|
||||
"Open page": "Open page",
|
||||
"Open source page": "Open source page",
|
||||
"Page": "Page",
|
||||
"Delete public share link": "Delete public share link",
|
||||
"Delete share": "Delete share",
|
||||
@@ -536,7 +529,6 @@
|
||||
"Add 2FA method": "Add 2FA method",
|
||||
"Backup codes": "Backup codes",
|
||||
"Disable": "Disable",
|
||||
"disabled": "disabled",
|
||||
"Invalid verification code": "Invalid verification code",
|
||||
"New backup codes have been generated": "New backup codes have been generated",
|
||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
||||
@@ -598,17 +590,6 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
|
||||
"Move to trash": "Move to trash",
|
||||
"Make temporary": "Make temporary",
|
||||
"Make permanent": "Make permanent",
|
||||
"New temporary note": "New temporary note",
|
||||
"Temporary note": "Temporary note",
|
||||
"Temporary notes": "Temporary notes",
|
||||
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
|
||||
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
|
||||
"Note is now permanent": "Note is now permanent",
|
||||
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Permanently delete": "Permanently delete",
|
||||
@@ -687,6 +668,9 @@
|
||||
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
"Toggle AI search": "Toggle AI search",
|
||||
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
||||
"Toggle generative AI": "Toggle generative AI",
|
||||
"Upgrade your plan": "Upgrade your plan",
|
||||
"Available with a paid license": "Available with a paid license",
|
||||
"Upgrade your license tier.": "Upgrade your license tier.",
|
||||
@@ -719,12 +703,10 @@
|
||||
"Authorization header": "Authorization header",
|
||||
"Tool allowlist": "Tool allowlist",
|
||||
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
|
||||
"Use Tavily preset": "Use Tavily preset",
|
||||
"Test": "Test",
|
||||
"Available tools": "Available tools",
|
||||
"No tools available": "No tools available",
|
||||
"Failed": "Failed",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Created successfully": "Created successfully",
|
||||
"Deleted successfully": "Deleted successfully",
|
||||
"Clear": "Clear",
|
||||
@@ -966,7 +948,6 @@
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Try again": "Try again",
|
||||
"Untitled chat": "Untitled chat",
|
||||
"No document": "No document",
|
||||
"You": "You",
|
||||
"What can I help you with?": "What can I help you with?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
|
||||
@@ -996,9 +977,6 @@
|
||||
"Page menu": "Page menu",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Expand all": "Expand all",
|
||||
"Collapse all": "Collapse all",
|
||||
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
|
||||
"Comment menu": "Comment menu",
|
||||
"Group menu": "Group menu",
|
||||
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||
@@ -1089,8 +1067,6 @@
|
||||
"Undo": "Undo",
|
||||
"Redo": "Redo",
|
||||
"Backlinks": "Backlinks",
|
||||
"Back to references": "Back to references",
|
||||
"Back to reference {{label}}": "Back to reference {{label}}",
|
||||
"Last updated by": "Last updated by",
|
||||
"Last updated": "Last updated",
|
||||
"Stats": "Stats",
|
||||
@@ -1143,57 +1119,15 @@
|
||||
"Removed from favorites": "Removed from favorites",
|
||||
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
||||
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
||||
"Label added": "Label added",
|
||||
"Label removed": "Label removed",
|
||||
"Image updated": "Image updated",
|
||||
"Unsupported image type": "Unsupported image type",
|
||||
"Member deactivated": "Member deactivated",
|
||||
"Member activated": "Member activated",
|
||||
"Name is required": "Name is required",
|
||||
"Name must be 40 characters or fewer": "Name must be 40 characters or fewer",
|
||||
"Group name must be at least 2 characters": "Group name must be at least 2 characters",
|
||||
"Group name must be 100 characters or fewer": "Group name must be 100 characters or fewer",
|
||||
"Description must be 500 characters or fewer": "Description must be 500 characters or fewer",
|
||||
"Invalid invitation link": "Invalid invitation link",
|
||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||
"AI chat": "AI chat",
|
||||
"Ask a question about this documentation.": "Ask a question about this documentation.",
|
||||
"Ask a question…": "Ask a question…",
|
||||
"Thinking…": "Thinking…",
|
||||
"Thinking… · {{count}} tokens": "Thinking… · {{count}} tokens",
|
||||
"Thinking… · {{count}} tokens_one": "Thinking… · {{count}} token",
|
||||
"Thinking… · {{count}} tokens_other": "Thinking… · {{count}} tokens",
|
||||
"Thinking · {{count}} tokens": "Thinking · {{count}} tokens",
|
||||
"Thinking · {{count}} tokens_one": "Thinking · {{count}} token",
|
||||
"Thinking · {{count}} tokens_other": "Thinking · {{count}} tokens",
|
||||
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
|
||||
"Public share assistant": "Public share assistant",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||
"Public assistant model": "Public assistant model",
|
||||
"Defaults to the chat model": "Defaults to the chat model",
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.",
|
||||
"Assistant identity": "Assistant identity",
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Context size / model limit": "Context size / model limit",
|
||||
"Context window (tokens)": "Context window (tokens)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||
"Current context size": "Current context size",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
"{{name}} is typing…": "{{name}} is typing…",
|
||||
"Send": "Send",
|
||||
"Send when the agent finishes": "Send when the agent finishes",
|
||||
"Queue message": "Queue message",
|
||||
"Remove queued message": "Remove queued message",
|
||||
"Send now": "Send now",
|
||||
"Interrupt and send now": "Interrupt and send now",
|
||||
"Stop": "Stop",
|
||||
"Response stopped.": "Response stopped.",
|
||||
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
|
||||
"Response stopped (manually or the connection dropped).": "Response stopped (manually or the connection dropped).",
|
||||
"Chat menu": "Chat menu",
|
||||
"No chats yet.": "No chats yet.",
|
||||
"Delete this chat?": "Delete this chat?",
|
||||
@@ -1225,16 +1159,9 @@
|
||||
"Semantic search": "Semantic search",
|
||||
"Voice / STT": "Voice / STT",
|
||||
"Voice dictation": "Voice dictation",
|
||||
"Streaming dictation": "Streaming dictation",
|
||||
"Transcribe as you speak, cutting on pauses": "Transcribe as you speak, cutting on pauses",
|
||||
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||
"Test endpoint": "Test endpoint",
|
||||
"Save and test": "Save and test",
|
||||
"Save endpoints": "Save endpoints",
|
||||
"Configured and enabled": "Configured and enabled",
|
||||
"Configured but disabled": "Configured but disabled",
|
||||
"Enabled but not configured": "Enabled but not configured",
|
||||
"Not configured": "Not configured",
|
||||
"External tools": "External tools",
|
||||
"Gitmost as MCP client": "Gitmost as MCP client",
|
||||
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
||||
@@ -1262,108 +1189,11 @@
|
||||
"No microphone found": "No microphone found",
|
||||
"Could not start recording": "Could not start recording",
|
||||
"Transcription failed": "Transcription failed",
|
||||
"Transcribe": "Transcribe",
|
||||
"No speech detected": "No speech detected",
|
||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
||||
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||
"Request format": "Request format",
|
||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
||||
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)",
|
||||
"Dictation language": "Dictation language",
|
||||
"Auto-detect": "Auto-detect",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
|
||||
"Agent role": "Agent role",
|
||||
"Universal assistant": "Universal assistant",
|
||||
"Add role": "Add role",
|
||||
"Edit role": "Edit role",
|
||||
"Role name": "Role name",
|
||||
"e.g. Proofreader": "e.g. Proofreader",
|
||||
"Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.",
|
||||
"Optional. A short note about what this role does.": "Optional. A short note about what this role does.",
|
||||
"Instructions": "Instructions",
|
||||
"The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.",
|
||||
"Model provider override": "Model provider override",
|
||||
"Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.",
|
||||
"Model override": "Model override",
|
||||
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
||||
"Start automatically": "Start automatically",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
|
||||
"Launch message": "Launch message",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
|
||||
"Agent roles": "Agent roles",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
"No roles configured": "No roles configured",
|
||||
"Delete role": "Delete role",
|
||||
"Are you sure you want to delete this role?": "Are you sure you want to delete this role?",
|
||||
"HTML embed": "HTML embed",
|
||||
"Edit HTML embed": "Edit HTML embed",
|
||||
"HTML embed is disabled in this workspace": "HTML embed is disabled in this workspace",
|
||||
"Click to add HTML / CSS / JS": "Click to add HTML / CSS / JS",
|
||||
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.",
|
||||
"<script>...</script>": "<script>...</script>",
|
||||
"Height (px, blank = auto)": "Height (px, blank = auto)",
|
||||
"advanced": "advanced",
|
||||
"Enable HTML embed": "Enable HTML embed",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||
"Analytics / tracker": "Analytics / tracker",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
|
||||
"Go to login page": "Go to login page",
|
||||
"Move to space": "Move to space",
|
||||
"Float left (wrap text)": "Float left (wrap text)",
|
||||
"Float right (wrap text)": "Float right (wrap text)",
|
||||
"Switch to tree": "Switch to tree",
|
||||
"Switch to flat list": "Switch to flat list",
|
||||
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
|
||||
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
|
||||
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
|
||||
"Showing {{count}} subpages_other": "Showing {{count}} subpages",
|
||||
"Protocol": "Protocol",
|
||||
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
|
||||
"OpenAI (official)": "OpenAI (official)",
|
||||
"Custom address": "Custom address",
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address",
|
||||
"Generate title with AI": "Generate title with AI",
|
||||
"Title generated": "Title generated",
|
||||
"Failed to generate title": "Failed to generate title",
|
||||
"The note is empty": "The note is empty",
|
||||
"Could not generate a title": "Could not generate a title",
|
||||
"AI title generation is disabled": "AI title generation is disabled",
|
||||
"AI is not configured": "AI is not configured",
|
||||
"Too many requests, please try again later": "Too many requests, please try again later",
|
||||
"Import from catalog": "Import from catalog",
|
||||
"Browse the catalog": "Browse the catalog",
|
||||
"Role catalog": "Role catalog",
|
||||
"On name conflict": "On name conflict",
|
||||
"Skip": "Skip",
|
||||
"Import": "Import",
|
||||
"Installed": "Installed",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
|
||||
"Connecting… (read-only)": "Connecting… (read-only)"
|
||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)"
|
||||
}
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
|
||||
"Toggle AI search": "Alternar búsqueda de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
|
||||
"Toggle generative AI": "Activar IA generativa",
|
||||
"Upgrade your plan": "Mejora tu plan",
|
||||
"Available with a paid license": "Disponible con una licencia de pago",
|
||||
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
|
||||
"Toggle AI search": "Basculer la recherche IA",
|
||||
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
|
||||
"Toggle generative AI": "Activer/désactiver l'IA générative",
|
||||
"Upgrade your plan": "Mettez à niveau votre forfait",
|
||||
"Available with a paid license": "Disponible avec une licence payante",
|
||||
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
|
||||
"Toggle AI search": "Attiva/disattiva ricerca AI",
|
||||
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
|
||||
"Toggle generative AI": "Attiva/Disattiva AI generativa",
|
||||
"Upgrade your plan": "Aggiorna il tuo piano",
|
||||
"Available with a paid license": "Disponibile con una licenza a pagamento",
|
||||
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||
"Toggle AI search": "AI検索を切り替え",
|
||||
"Generative AI (Ask AI)": "生成AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
||||
"Toggle generative AI": "生成AIを切り替える",
|
||||
"Upgrade your plan": "プランをアップグレードする",
|
||||
"Available with a paid license": "有料ライセンスで利用可能",
|
||||
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||
"Toggle AI search": "AI 검색 전환",
|
||||
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
||||
"Toggle generative AI": "생성 AI 토글",
|
||||
"Upgrade your plan": "요금제를 업그레이드하세요",
|
||||
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
|
||||
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
|
||||
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
|
||||
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
|
||||
"Toggle generative AI": "Generatieve AI schakelen",
|
||||
"Upgrade your plan": "Upgrade je abonnement",
|
||||
"Available with a paid license": "Beschikbaar met een betaalde licentie",
|
||||
"Upgrade your license tier.": "Upgrade je licentieniveau.",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
|
||||
"Toggle AI search": "Alternar pesquisa de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
|
||||
"Toggle generative AI": "Alternar IA generativa",
|
||||
"Upgrade your plan": "Faça upgrade do seu plano",
|
||||
"Available with a paid license": "Disponível com uma licença paga",
|
||||
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
|
||||
|
||||
@@ -119,8 +119,6 @@
|
||||
"Name": "Имя",
|
||||
"New email": "Новый электронный адрес",
|
||||
"New page": "Новая страница",
|
||||
"New note": "Новая заметка",
|
||||
"Create in space": "Создать в пространстве",
|
||||
"New password": "Новый пароль",
|
||||
"No group found": "Группа не найдена",
|
||||
"No page history saved yet.": "История страниц ещё не сохранена.",
|
||||
@@ -185,7 +183,6 @@
|
||||
"Successfully imported": "Успешно импортировано",
|
||||
"Successfully restored": "Успешно восстановлено",
|
||||
"System settings": "Системные настройки",
|
||||
"Template": "Шаблон",
|
||||
"Templates": "Шаблоны",
|
||||
"Theme": "Тема",
|
||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||
@@ -257,7 +254,6 @@
|
||||
"Copy": "Копировать",
|
||||
"Copy to space": "Копировать в пространство",
|
||||
"Copied": "Скопировано",
|
||||
"Failed to export chat": "Не удалось экспортировать чат",
|
||||
"Duplicate": "Дублировать",
|
||||
"Select a user": "Выберите пользователя",
|
||||
"Select a group": "Выберите группу",
|
||||
@@ -386,11 +382,6 @@
|
||||
"Quote": "Цитата",
|
||||
"Image": "Изображение",
|
||||
"Audio": "Аудио",
|
||||
"Transcribe": "Транскрибировать",
|
||||
"Transcribing…": "Транскрибация…",
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Embed PDF": "Встроить PDF",
|
||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||
"Embed as PDF": "Встроить как PDF",
|
||||
@@ -400,15 +391,6 @@
|
||||
"Toggle block": "Сворачиваемый блок",
|
||||
"Callout": "Выноска",
|
||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||
"Footnote": "Сноска",
|
||||
"Insert a footnote reference.": "Вставить ссылку на сноску.",
|
||||
"Footnotes": "Примечания",
|
||||
"Footnote {{number}}": "Сноска {{number}}",
|
||||
"Go to footnote": "Перейти к сноске",
|
||||
"Back to reference": "Вернуться к ссылке",
|
||||
"Back to references": "Вернуться к ссылкам",
|
||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||
"Empty footnote": "Пустая сноска",
|
||||
"Math inline": "Строчная формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формулы",
|
||||
@@ -489,7 +471,6 @@
|
||||
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
|
||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
||||
"Open page": "Открыть страницу",
|
||||
"Open source page": "Открыть исходную страницу",
|
||||
"Page": "Страница",
|
||||
"Delete public share link": "Удалить публичную ссылку",
|
||||
"Delete share": "Удалить общий доступ",
|
||||
@@ -607,17 +588,6 @@
|
||||
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
|
||||
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
|
||||
"Move to trash": "Переместить в корзину",
|
||||
"Make temporary": "Сделать временной",
|
||||
"Make permanent": "Сделать постоянной",
|
||||
"New temporary note": "Новая временная заметка",
|
||||
"Temporary note": "Временная заметка",
|
||||
"Temporary notes": "Временные заметки",
|
||||
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
|
||||
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
|
||||
"Note is now permanent": "Заметка теперь постоянная",
|
||||
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
|
||||
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
|
||||
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
|
||||
"Move this page to trash?": "Переместить эту страницу в корзину?",
|
||||
"Restore page": "Восстановить страницу",
|
||||
"Permanently delete": "Удалить навсегда",
|
||||
@@ -689,59 +659,6 @@
|
||||
"AI search": "Поиск ИИ",
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Context size / model limit": "Размер контекста / лимит модели",
|
||||
"Context window (tokens)": "Окно контекста (токены)",
|
||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{n}}": "OK · {{n}}",
|
||||
"Test": "Тест",
|
||||
"No tools available": "Инструменты недоступны",
|
||||
"Available tools": "Доступные инструменты",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||
"Queue message": "Поставить в очередь",
|
||||
"Remove queued message": "Убрать из очереди",
|
||||
"Send now": "Отправить сейчас",
|
||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
@@ -749,6 +666,9 @@
|
||||
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
|
||||
"Toggle AI search": "Переключить поиск ИИ",
|
||||
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
|
||||
"Toggle generative AI": "Переключить генеративный ИИ",
|
||||
"Upgrade your plan": "Обновите свой тарифный план",
|
||||
"Available with a paid license": "Доступно с платной лицензией",
|
||||
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
|
||||
@@ -768,8 +688,6 @@
|
||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||
"Instructions": "Инструкции",
|
||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||
"Sources": "Источники",
|
||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||
"No answer available": "Ответ недоступен",
|
||||
@@ -996,7 +914,6 @@
|
||||
"Try a different search term.": "Попробуйте другой поисковый запрос.",
|
||||
"Try again": "Попробовать снова",
|
||||
"Untitled chat": "Чат без названия",
|
||||
"No document": "Без документа",
|
||||
"What can I help you with?": "Чем я могу вам помочь?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||
@@ -1168,60 +1085,5 @@
|
||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||
"Float left (wrap text)": "Обтекание слева",
|
||||
"Float right (wrap text)": "Обтекание справа",
|
||||
"Switch to tree": "Переключить на дерево",
|
||||
"Switch to flat list": "Переключить на плоский список",
|
||||
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
|
||||
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
|
||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||
"Protocol": "Протокол",
|
||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Custom address": "Пользовательский адрес",
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
|
||||
"Generate title with AI": "Сгенерировать название через AI",
|
||||
"Title generated": "Название сгенерировано",
|
||||
"Failed to generate title": "Не удалось сгенерировать название",
|
||||
"The note is empty": "Заметка пустая",
|
||||
"Could not generate a title": "Не удалось придумать название",
|
||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||
"AI is not configured": "AI не настроен",
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
|
||||
"Import from catalog": "Импорт из каталога",
|
||||
"Browse the catalog": "Открыть каталог",
|
||||
"Role catalog": "Каталог ролей",
|
||||
"On name conflict": "При конфликте имён",
|
||||
"Skip": "Пропустить",
|
||||
"Import": "Импортировать",
|
||||
"Installed": "Установлено",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
|
||||
"Connecting… (read-only)": "Подключение… (только чтение)"
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
|
||||
}
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
|
||||
"Toggle AI search": "Переключити пошук з ШІ",
|
||||
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
|
||||
"Toggle generative AI": "Переключити генеративний ШІ",
|
||||
"Upgrade your plan": "Оновіть свій тарифний план",
|
||||
"Available with a paid license": "Доступно за платною ліцензією",
|
||||
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
|
||||
|
||||
@@ -665,6 +665,9 @@
|
||||
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||
"Toggle AI search": "切换AI搜索",
|
||||
"Generative AI (Ask AI)": "生成型AI (询问AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
||||
"Toggle generative AI": "切换生成型AI",
|
||||
"Upgrade your plan": "升级您的方案",
|
||||
"Available with a paid license": "需付费许可才可用",
|
||||
"Upgrade your license tier.": "升级您的许可等级。",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Self-host the @ricky0123/vad-web + onnxruntime-web runtime assets under
|
||||
// apps/client/public/vad/.
|
||||
//
|
||||
// WHY THIS EXISTS:
|
||||
// Both vad-web and onnxruntime-web resolve their assets by URL *at runtime* (the
|
||||
// VAD audio worklet + Silero model, and ORT's wasm/mjs backend). In vad-web
|
||||
// 0.0.30 the default baseAssetPath / onnxWASMBasePath is "./" — i.e. relative to
|
||||
// the current page URL — NOT a CDN. In this SPA that "./" request hits the
|
||||
// client-side catch-all route and gets served index.html (text/html), so the
|
||||
// onnxruntime ESM/wasm backend fails to initialize ("'text/html' is not a valid
|
||||
// JavaScript MIME type"). We fix that by copying the needed runtime files into
|
||||
// public/vad/ and pointing both path constants at the fixed absolute "/vad/".
|
||||
//
|
||||
// These copies are NOT committed (the ORT wasm is ~26 MB); this script runs
|
||||
// before `dev` and `build` (see package.json) to repopulate them from
|
||||
// node_modules. It is idempotent: it (re)creates the dir and overwrites.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = path.join(here, "..", "public", "vad");
|
||||
|
||||
// vad-web exposes ./package.json, so derive its dist dir from there.
|
||||
const vadDist = path.join(
|
||||
path.dirname(require.resolve("@ricky0123/vad-web/package.json")),
|
||||
"dist",
|
||||
);
|
||||
|
||||
// onnxruntime-web's "exports" map does NOT expose ./package.json, so resolving
|
||||
// it would throw ERR_PACKAGE_PATH_NOT_EXPORTED. It DOES export the exact asset
|
||||
// subpaths we need, so resolve those files directly.
|
||||
//
|
||||
// ORT ships several wasm backends and which one the app bundle references depends
|
||||
// on the resolver: Vite dev resolves the JSEP build (ort-wasm-simd-threaded.jsep.*)
|
||||
// while the production rolldown build resolves the plain build
|
||||
// (ort-wasm-simd-threaded.*). Ship BOTH variants so the runtime fetch hits a real
|
||||
// file under /vad/ regardless of which the bundle picked (each .mjs proxy fetches
|
||||
// its matching .wasm at init).
|
||||
const ortJsepMjs = require.resolve(
|
||||
"onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs",
|
||||
);
|
||||
const ortJsepWasm = require.resolve(
|
||||
"onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm",
|
||||
);
|
||||
const ortMjs = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.mjs");
|
||||
const ortWasm = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.wasm");
|
||||
|
||||
// [absolute source path, output filename]
|
||||
const files = [
|
||||
[path.join(vadDist, "vad.worklet.bundle.min.js"), "vad.worklet.bundle.min.js"],
|
||||
[path.join(vadDist, "silero_vad_v5.onnx"), "silero_vad_v5.onnx"],
|
||||
[ortJsepMjs, "ort-wasm-simd-threaded.jsep.mjs"],
|
||||
[ortJsepWasm, "ort-wasm-simd-threaded.jsep.wasm"],
|
||||
[ortMjs, "ort-wasm-simd-threaded.mjs"],
|
||||
[ortWasm, "ort-wasm-simd-threaded.wasm"],
|
||||
];
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
for (const [src, name] of files) {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error(`[copy-vad-assets] missing source: ${src}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.copyFileSync(src, path.join(outDir, name));
|
||||
console.log(`[copy-vad-assets] ${name}`);
|
||||
}
|
||||
@@ -42,23 +42,6 @@ export default function AvatarUploader({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type. The `accept` attribute only filters the dialog;
|
||||
// a user can still select a non-image file, which previously failed
|
||||
// silently. Surface a visible error instead (issue #133). Accept any
|
||||
// image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
|
||||
// what the server accepts; only genuinely non-image files are rejected.
|
||||
if (!file.type.startsWith("image/")) {
|
||||
notifications.show({
|
||||
message: t("Unsupported image type"),
|
||||
color: "red",
|
||||
});
|
||||
// Reset the input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||
if (file.size > maxSizeInBytes) {
|
||||
@@ -75,8 +58,6 @@ export default function AvatarUploader({
|
||||
|
||||
try {
|
||||
await onUpload(file);
|
||||
// Notify on success so the upload gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Image updated") });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.show({
|
||||
@@ -136,7 +117,7 @@ export default function AvatarUploader({
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
accept="image/*"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
style={{ display: "none" }}
|
||||
|
||||
@@ -67,7 +67,6 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
tt="none"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
|
||||
@@ -9,10 +9,8 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
// rem(size) returns a `calc(...)` string, which is invalid for the raw
|
||||
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
|
||||
// it via CSS style instead (matching the other icon components).
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
width={rem(size)}
|
||||
height={rem(size)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -9,10 +9,8 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
// rem(size) returns a `calc(...)` string, which is invalid for the raw
|
||||
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
|
||||
// it via CSS style instead (matching the other icon components).
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
width={rem(size)}
|
||||
height={rem(size)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
@@ -34,3 +33,21 @@
|
||||
that is ~9.3px, minus the font descent (~2px) ≈ 7px. */
|
||||
margin-bottom: rem(7px);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
padding: rem(8px) rem(12px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
text-decoration: none;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,18 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconMessage } from "@tabler/icons-react";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import classes from "./app-header.module.css";
|
||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
|
||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-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 SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
@@ -29,6 +30,10 @@ import {
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
|
||||
const links = [
|
||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
||||
];
|
||||
|
||||
export function AppHeader() {
|
||||
const { t } = useTranslation();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
@@ -38,12 +43,16 @@ export function AppHeader() {
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
// 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();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||
|
||||
const items = links.map((link) => (
|
||||
<Link key={link.label} to={link.link} className={classes.link}>
|
||||
{t(link.label)}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||
@@ -88,6 +97,10 @@ export function AppHeader() {
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group ml="xl" gap={5} className={classes.links} visibleFrom="sm">
|
||||
{items}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
@@ -107,9 +120,9 @@ export function AppHeader() {
|
||||
color="dark"
|
||||
size="sm"
|
||||
aria-label={t("AI chat")}
|
||||
onClick={openAiChat}
|
||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||
>
|
||||
<IconMessage size={20} />
|
||||
<IconSparkles size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function Aside() {
|
||||
|
||||
switch (tab) {
|
||||
case "comments":
|
||||
component = <CommentListWithTabs onClose={closeAside} />;
|
||||
component = <CommentListWithTabs />;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
@@ -44,27 +44,26 @@ export default function Aside() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={0} style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component &&
|
||||
(tab === "comments" ? (
|
||||
component
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" mb="sm">
|
||||
<Title order={2} size="h6" fw={500}>
|
||||
{t(title)}
|
||||
</Title>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{component && (
|
||||
<>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{tab === "comments" ? (
|
||||
component
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ height: "85vh" }}
|
||||
scrollbarSize={5}
|
||||
@@ -72,8 +71,9 @@ export default function Aside() {
|
||||
>
|
||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
@@ -95,12 +94,12 @@ export default function GlobalAppShell({
|
||||
}}
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 420,
|
||||
width: 350,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
padding={{ base: "xs", sm: "md" }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header px="md" className={classes.header}>
|
||||
<AppHeader />
|
||||
@@ -139,7 +138,7 @@ export default function GlobalAppShell({
|
||||
id={ASIDE_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
className={classes.aside}
|
||||
p="sm"
|
||||
p="md"
|
||||
withBorder={false}
|
||||
aria-label={
|
||||
asideTab === "comments"
|
||||
@@ -158,10 +157,6 @@ export default function GlobalAppShell({
|
||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
||||
and self-hides when closed, so its place in the tree is not critical. */}
|
||||
<AiChatWindow />
|
||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||
createPageWithRecording on window.gitmost so the native host can
|
||||
create a page with a recording even when no page editor is open. */}
|
||||
<GitmostGlobalBridge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,29 +20,18 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { Link, useMatch } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
|
||||
export default function TopMenu() {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const { logout } = useAuth();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
// Detect the currently viewed space so the "Space settings" item is only
|
||||
// offered while the user is inside a space. The "/*" splat also matches the
|
||||
// bare "/s/:spaceSlug" route (the splat matches an empty segment).
|
||||
const spaceMatch = useMatch("/s/:spaceSlug/*");
|
||||
const spaceSlug = spaceMatch?.params?.spaceSlug;
|
||||
const [
|
||||
spaceSettingsOpened,
|
||||
{ open: openSpaceSettings, close: closeSpaceSettings },
|
||||
] = useDisclosure(false);
|
||||
|
||||
const user = currentUser?.user;
|
||||
const workspace = currentUser?.workspace;
|
||||
@@ -52,143 +41,124 @@ export default function TopMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
type={AvatarIconType.WORKSPACE_ICON}
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace?.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Workspace settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
{t("Manage members")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>{t("Account")}</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
>
|
||||
{t("My profile")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
>
|
||||
{t("Workspace settings")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||
leftSection={<IconBrush size={16} />}
|
||||
>
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
{spaceSlug && (
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={openSpaceSettings}
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Space settings")}
|
||||
{t("Light")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
{t("Manage members")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Label>{t("Account")}</Menu.Label>
|
||||
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||
<Group wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
|
||||
<div style={{ width: 190 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
>
|
||||
{t("My profile")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||
leftSection={<IconBrush size={16} />}
|
||||
>
|
||||
{t("My preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||
{t("Theme")}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
|
||||
<Menu.Sub.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("light")}
|
||||
leftSection={<IconSun size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Light")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("dark")}
|
||||
leftSection={<IconMoon size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("Dark")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setColorScheme("auto")}
|
||||
leftSection={<IconDeviceDesktop size={16} />}
|
||||
rightSection={
|
||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||
}
|
||||
>
|
||||
{t("System settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
{t("Logout")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{spaceSlug && (
|
||||
<SpaceSettingsModal
|
||||
spaceId={spaceSlug}
|
||||
opened={spaceSettingsOpened}
|
||||
onClose={closeSpaceSettings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||
{t("Logout")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
prefetchSpaces,
|
||||
prefetchWorkspaceMembers,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||
@@ -140,6 +141,8 @@ export default function SettingsSidebar() {
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
|
||||
<AppVersion />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AiAgentBadge } from "./ai-agent-badge";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<AiAgentBadge {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||
// assertable. Returns the store and spies.
|
||||
function setupClickable() {
|
||||
const store = createStore();
|
||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||
const onActivate = vi.fn();
|
||||
const onParentClick = vi.fn();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MantineProvider>
|
||||
<div onClick={onParentClick}>
|
||||
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||
}
|
||||
|
||||
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||
}
|
||||
|
||||
describe("AiAgentBadge", () => {
|
||||
it("renders the AI-agent label", () => {
|
||||
renderBadge({ authorName: "Bot" });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
});
|
||||
|
||||
it("is clickable (accessible button) when aiChatId is present", () => {
|
||||
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
|
||||
const badge = screen.getByRole("button");
|
||||
expect(badge).toBeDefined();
|
||||
expect(badge.textContent).toContain("AI-agent");
|
||||
});
|
||||
|
||||
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||
fireEvent.click(badge);
|
||||
expectDeepLinked(store, onActivate);
|
||||
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||
});
|
||||
|
||||
it.each(["Enter", " "])(
|
||||
"keyboard %j activates the deep-link (same side effects as click)",
|
||||
(key) => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key });
|
||||
expectDeepLinked(store, onActivate);
|
||||
},
|
||||
);
|
||||
|
||||
it("an unrelated key does NOT activate the badge", () => {
|
||||
const { store, onActivate, badge } = setupClickable();
|
||||
fireEvent.keyDown(badge, { key: "Tab" });
|
||||
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||
expect(onActivate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ aiChatId: null }, {}])(
|
||||
"is a plain non-clickable label without a chat target (%o)",
|
||||
(props) => {
|
||||
renderBadge({ authorName: "Bot", ...props });
|
||||
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||
// No interactive role is exposed when there is no chat to deep-link into.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
|
||||
interface AiAgentBadgeProps {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||
// context (e.g. the page-history row closes the history modal) so this generic
|
||||
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
|
||||
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
|
||||
* page-history list and the comments sidebar.
|
||||
*
|
||||
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||
* also trigger an enclosing row's click handler.
|
||||
*/
|
||||
export function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
onActivate,
|
||||
}: AiAgentBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
// Switching to another chat must start with a clean composer — clear any
|
||||
// unsent draft so it does not leak from the previously open chat.
|
||||
setDraft("");
|
||||
setAiChatWindowOpen(true);
|
||||
onActivate?.();
|
||||
},
|
||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
radius="sm"
|
||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||
{...(aiChatId
|
||||
? {
|
||||
// Keep the default Badge root element (not a <button>) to avoid an
|
||||
// invalid <button>-in-<button> nesting inside a row's
|
||||
// UnstyledButton; expose it as an accessible button via
|
||||
// role/keyboard.
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{t("AI-agent")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AiAgentBadge;
|
||||
@@ -27,7 +27,6 @@ export function BrandLogo({
|
||||
src={src}
|
||||
alt="Gitmost"
|
||||
className={className}
|
||||
draggable={false}
|
||||
style={{ height, width: "auto", display: "block", userSelect: "none" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
/**
|
||||
* Persisted floating AI chat window geometry (position + size). Held in
|
||||
* localStorage so a drag/resize survives a full page reload. `null` means
|
||||
* "never placed yet" — the window then computes an initial top-right placement.
|
||||
* On restore the value is clamped to the current viewport (see AiChatWindow).
|
||||
*/
|
||||
export type AiChatWindowGeom = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
|
||||
"ai-chat-window-geom",
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
|
||||
@@ -31,15 +13,6 @@ export const activeAiChatIdAtom = atom(null as string | null);
|
||||
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
||||
export const aiChatWindowOpenAtom = atom<boolean>(false);
|
||||
|
||||
/**
|
||||
* The agent role selected for the NEXT new chat. `null` = "Universal assistant"
|
||||
* (no role). Consulted ONLY when creating a chat (its first message): the server
|
||||
* persists it to ai_chats.role_id and the role is immutable afterwards. Reset to
|
||||
* null when starting a new chat. It does NOT affect already-created chats.
|
||||
*/
|
||||
// Cast default for the same jotai overload reason as activeAiChatIdAtom above.
|
||||
export const selectedAiRoleIdAtom = atom(null as string | null);
|
||||
|
||||
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
|
||||
// ChatThread — so it survives the thread remount that happens when a brand-new
|
||||
// chat adopts its freshly created id after the first turn finishes. If it lived
|
||||
|
||||
@@ -57,12 +57,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* In the collapsed state the header expands the window on click, so hint that
|
||||
it is clickable (override the drag `grab` cursor). */
|
||||
.minimized .dragBar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dragBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,34 +18,24 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatWindowGeomAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||
@@ -80,31 +70,17 @@ function computeInitialGeom() {
|
||||
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
|
||||
);
|
||||
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
|
||||
const maxTop = Math.max(
|
||||
EDGE_MARGIN,
|
||||
window.innerHeight - height - EDGE_MARGIN,
|
||||
);
|
||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN);
|
||||
const top = Math.min(60, maxTop);
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
// Clamp a geometry so the window stays within the current viewport.
|
||||
function clampGeom(g: {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
function clampGeom(g: { left: number; top: number; width: number; height: number }) {
|
||||
const effWidth = Math.max(g.width, MIN_WIDTH);
|
||||
const effHeight = Math.max(g.height, MIN_HEIGHT);
|
||||
const maxLeft = Math.max(
|
||||
EDGE_MARGIN,
|
||||
window.innerWidth - effWidth - EDGE_MARGIN,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
EDGE_MARGIN,
|
||||
window.innerHeight - effHeight - EDGE_MARGIN,
|
||||
);
|
||||
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN);
|
||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN);
|
||||
return {
|
||||
...g,
|
||||
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
|
||||
@@ -115,63 +91,48 @@ function clampGeom(g: {
|
||||
/**
|
||||
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
|
||||
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
|
||||
* chat, new chat, in-place id adoption from streamed metadata, open-page
|
||||
* context, token sum) and wraps the
|
||||
* chat, new chat, adopt-new-chat, open-page context, token sum) and wraps the
|
||||
* reused inner components (ConversationList + ChatThread) in window chrome
|
||||
* ported from the GitmostAgent.jsx design.
|
||||
*/
|
||||
export default function AiChatWindow() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const queryClient = useQueryClient();
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
// The role chosen for the next new chat (null = universal assistant).
|
||||
const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// History section starts collapsed (matches the former panel's behavior).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
// Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag),
|
||||
// which would otherwise close over a stale value. Kept in sync below.
|
||||
const minimizedRef = useRef(minimized);
|
||||
minimizedRef.current = minimized;
|
||||
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
// Live window geometry (position + size); persisted to localStorage so a
|
||||
// drag/resize survives a full page reload (and close/reopen). `null` means
|
||||
// "never placed yet" — the layout effect below then computes an initial
|
||||
// top-right placement anchored to the current viewport, and on restore it is
|
||||
// re-clamped to the viewport (so a placement saved on a larger screen is not
|
||||
// left partly off-screen).
|
||||
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||
// Live window geometry (position + size); initialized lazily on first open so
|
||||
// it is anchored to the current viewport (top-right corner). Kept in state so
|
||||
// a user resize survives close/reopen and can be re-clamped to the viewport.
|
||||
const [geom, setGeom] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
// Track whether we are awaiting the id of a just-created (new) chat, so we
|
||||
// can adopt it once the chat list refreshes after the first turn finishes.
|
||||
const adoptNewChat = useRef(false);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
// The new-chat picker only offers ENABLED roles. The list endpoint returns
|
||||
// all live roles (so the admin settings section can manage disabled ones), so
|
||||
// we filter to `enabled` here, client-side, for the composer picker only.
|
||||
const enabledRoles = useMemo(
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
// resolves regardless of where this component is mounted. On a non-page route
|
||||
// the match is null, so `pageSlug` is undefined, the query is disabled and
|
||||
// `openPage` is null. This is passed to the chat thread as context so the
|
||||
// agent knows what "this page"/"the current page" refers to; the agent still
|
||||
// reads/writes via its CASL-enforced page tools using the id.
|
||||
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||
// The page the user is currently viewing, derived from the route (same
|
||||
// source the breadcrumb uses). On a non-page route `pageSlug` is undefined,
|
||||
// so the query is disabled and `openPage` is null. This is passed to the
|
||||
// chat thread as context so the agent knows what "this page"/"the current
|
||||
// page" refers to; the agent still reads/writes via its CASL-enforced page
|
||||
// tools using the id.
|
||||
const { pageSlug } = useParams();
|
||||
const { data: openPageData } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
@@ -179,115 +140,69 @@ export default function AiChatWindow() {
|
||||
? { id: openPageData.id, title: openPageData.title }
|
||||
: null;
|
||||
|
||||
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||
// The invalidate closures are passed inline: `onTurnFinished` is read live by
|
||||
// useChat's onFinish (never in an effect dep array), so their identity does not
|
||||
// matter — no memoization ceremony needed.
|
||||
const {
|
||||
threadKey,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
} = useChatSession({
|
||||
activeChatId,
|
||||
setActiveChatId,
|
||||
chats,
|
||||
messagesLoading,
|
||||
onInvalidateChatList: () =>
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
|
||||
onInvalidateChatMessages: (id) =>
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
|
||||
});
|
||||
|
||||
// startNewChat/selectChat set the public atom; the hook's render-phase
|
||||
// reconciler handles the remount when activeChatId actually CHANGES. But
|
||||
// pressing "New chat" while already in a new chat leaves activeChatId === null
|
||||
// (a no-op for the atom), so the reconciler never fires — explicitly disarm any
|
||||
// armed error-path fallback here so a late refetch can't yank the user into a
|
||||
// just-failed chat after they chose a fresh one.
|
||||
const startNewChat = useCallback((): void => {
|
||||
cancelPendingAdoption();
|
||||
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
|
||||
// while a brand-new chat's first turn is still streaming leaves activeChatId
|
||||
// null (the real id is adopted only at turn end), so setActiveChatId(null)
|
||||
// alone is a no-op and the reconciler never remounts — the chat/stream/history
|
||||
// would persist and only the role badge would drop. This always remounts the
|
||||
// thread into a clean new chat.
|
||||
startFreshThread();
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [
|
||||
cancelPendingAdoption,
|
||||
startFreshThread,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
setSelectedRoleId,
|
||||
]);
|
||||
}, [setActiveChatId, setDraft]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
cancelPendingAdoption();
|
||||
setActiveChatId(chatId);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Reset the card-picked role so a stale pick can't leak into the existing
|
||||
// chat's header/assistant-name (which prefers the chat's persisted role).
|
||||
setSelectedRoleId(null);
|
||||
},
|
||||
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
|
||||
[setActiveChatId, setDraft],
|
||||
);
|
||||
|
||||
// The active chat object (for its title) and an export gate. The export is now
|
||||
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
|
||||
// row is persisted upfront + per step, so even a brand-new chat whose first
|
||||
// turn is streaming/interrupted has a server row to render. Enable the button
|
||||
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
|
||||
// chat that id is adopted EARLY — at the stream's `start` chunk via
|
||||
// onServerChatId (#174) — so the Copy button is available during the first
|
||||
// turn's stream, not only after it terminates.
|
||||
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
||||
// yet), the server has just created the row; adopt the newest chat id so the
|
||||
// thread switches from "new" to the persisted chat (and loads its history on
|
||||
// later opens).
|
||||
const onTurnFinished = useCallback(() => {
|
||||
if (activeChatId === null) adoptNewChat.current = true;
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
}, [activeChatId, queryClient]);
|
||||
|
||||
// The active chat object (for its title) and an export gate: only enable the
|
||||
// export button when an existing chat with loaded persisted rows is active.
|
||||
const activeChat = useMemo(
|
||||
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
||||
[chats, activeChatId],
|
||||
);
|
||||
const canExport = !!activeChatId;
|
||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
||||
|
||||
// The role to display in the header and as the assistant's name. Prefer the
|
||||
// persisted role of an existing chat (chat-list JOIN); fall back to the role
|
||||
// picked via a card click for a brand-new or just-adopted chat. selectChat
|
||||
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
|
||||
const currentRole = useMemo<{
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
} | null>(() => {
|
||||
if (activeChat?.roleName) {
|
||||
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
||||
}
|
||||
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
|
||||
return picked ? { name: picked.name, emoji: picked.emoji } : null;
|
||||
}, [activeChat, enabledRoles, selectedRoleId]);
|
||||
// Build a Markdown export from the already-loaded persisted rows (no network
|
||||
// call) and copy it to the clipboard. The "Copied" notification is the
|
||||
// feedback.
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
||||
const markdown = buildChatMarkdown({
|
||||
title: activeChat?.title ?? null,
|
||||
chatId: activeChatId,
|
||||
rows: messageRows,
|
||||
t,
|
||||
});
|
||||
clipboard.copy(markdown);
|
||||
notifications.show({ message: t("Copied") });
|
||||
}, [activeChatId, messageRows, activeChat, clipboard, t]);
|
||||
|
||||
// Fetch the server-rendered Markdown export and copy it to the clipboard. The
|
||||
// server is the single source of truth (#183): it renders the transcript from
|
||||
// the persisted rows — including an interrupted turn's in-progress row — so the
|
||||
// export is identical whether the chat is freshly streaming, just switched to,
|
||||
// or reloaded. The `lang` of the active i18n drives the few localized labels.
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!activeChatId) return;
|
||||
try {
|
||||
const markdown = await exportAiChat(activeChatId, i18n.language);
|
||||
clipboard.copy(markdown);
|
||||
notifications.show({ message: t("Copied") });
|
||||
} catch {
|
||||
notifications.show({ message: t("Failed to export chat"), color: "red" });
|
||||
// When awaiting a new chat's id, adopt the most-recent chat (the list is
|
||||
// ordered newest-first) once it appears.
|
||||
useEffect(() => {
|
||||
if (!adoptNewChat.current) return;
|
||||
const newest = chats?.items?.[0];
|
||||
if (newest) {
|
||||
adoptNewChat.current = false;
|
||||
setActiveChatId(newest.id);
|
||||
}
|
||||
}, [activeChatId, clipboard, t, i18n.language]);
|
||||
}, [chats, setActiveChatId]);
|
||||
|
||||
// The thread is remounted when the active chat changes so initial messages
|
||||
// re-seed. For a new chat we key on "new"; adopting the id remounts the
|
||||
// thread with the persisted history loaded.
|
||||
const threadKey = activeChatId ?? "new";
|
||||
const waitingForHistory = activeChatId !== null && messagesLoading;
|
||||
|
||||
// Current context size for the active chat: how much the conversation now
|
||||
// occupies in the model's context window — NOT the cumulative tokens spent.
|
||||
@@ -296,19 +211,24 @@ export default function AiChatWindow() {
|
||||
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
||||
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
||||
// mid-stream — acceptable for v1.
|
||||
//
|
||||
// The denominator `maxContextTokens` (the model's configured max window) is
|
||||
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
|
||||
// on a completed turn, but the numerator and denominator are taken from the
|
||||
// most recent row carrying EACH value independently — they may land on
|
||||
// different rows (e.g. a fresh error row can carry contextTokens but not
|
||||
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
|
||||
// no row has it (older rows, or no admin-configured limit) — the badge then
|
||||
// shows just the current size with no denominator.
|
||||
const { contextTokens, maxContextTokens } = useMemo(
|
||||
() => selectContextBadge(activeChatId ? messageRows : undefined),
|
||||
[activeChatId, messageRows],
|
||||
);
|
||||
const contextTokens = useMemo(() => {
|
||||
if (!activeChatId || !messageRows) return 0;
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
return meta.contextTokens;
|
||||
}
|
||||
const usage = meta.usage;
|
||||
if (usage) {
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) return fallback;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [activeChatId, messageRows]);
|
||||
|
||||
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||
// first-frame jump): compute an initial top-right placement the first time,
|
||||
@@ -318,54 +238,26 @@ export default function AiChatWindow() {
|
||||
useLayoutEffect(() => {
|
||||
if (!windowOpen) return;
|
||||
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
|
||||
// Always show the window expanded on (re)open: a collapsed state from a
|
||||
// previous open session must not stick. Runs before paint so the first
|
||||
// frame is already expanded. The composer's autofocus is a focus INSIDE the
|
||||
// window (not an outside mousedown), so it cannot self-collapse the window.
|
||||
setMinimized(false);
|
||||
}, [windowOpen]);
|
||||
|
||||
// Auto-collapse the window into its header as soon as the user interacts with
|
||||
// anything outside it (clicks the page/editor). Armed ONLY while the window is
|
||||
// open and expanded, so it never fires repeatedly and never collapses on the
|
||||
// open→reset transition. Capture phase so a page handler's stopPropagation in
|
||||
// the bubble phase can't hide the event from us; the in-window/portal guards
|
||||
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
|
||||
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const onPointerDown = (e: MouseEvent): void => {
|
||||
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
|
||||
setMinimized(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown, true);
|
||||
}, [windowOpen, minimized]);
|
||||
|
||||
// Persist the user's resize into state so it survives close/reopen. Skipped
|
||||
// while minimized so the collapsed (auto) height is never captured. The
|
||||
// equality guard avoids an update loop.
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const el = winRef.current;
|
||||
// `geom` is in the deps so this re-runs once geometry is settled and the
|
||||
// window is actually rendered (on the first open `geom` is still null on the
|
||||
// render that flips windowOpen, so winRef.current is null then — without the
|
||||
// geom dep the observer would never attach and resizes would not persist).
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const width = el.offsetWidth;
|
||||
const height = el.offsetHeight;
|
||||
setGeom((prev) => {
|
||||
if (!prev || (prev.width === width && prev.height === height))
|
||||
return prev;
|
||||
if (!prev || (prev.width === width && prev.height === height)) return prev;
|
||||
return { ...prev, width, height };
|
||||
});
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [windowOpen, minimized, geom !== null]);
|
||||
}, [windowOpen, minimized]);
|
||||
|
||||
const startDrag = useCallback((e: React.MouseEvent): void => {
|
||||
// Ignore drags that originate on a button (minimize/close/new chat).
|
||||
@@ -395,21 +287,10 @@ export default function AiChatWindow() {
|
||||
el.style.top = `${nt}px`;
|
||||
};
|
||||
|
||||
const up = (ev: MouseEvent): void => {
|
||||
const up = (): void => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = "";
|
||||
// Treat a near-zero-movement press as a click (not a drag). When the
|
||||
// window is minimized, a header click expands it; nothing to persist
|
||||
// because the position did not change. minimizedRef avoids the stale
|
||||
// `minimized` captured by useCallback([]).
|
||||
if (
|
||||
minimizedRef.current &&
|
||||
isHeaderClick(sx, sy, ev.clientX, ev.clientY)
|
||||
) {
|
||||
setMinimized(false);
|
||||
return;
|
||||
}
|
||||
const el2 = winRef.current;
|
||||
// Persist the final position back into state (preserving the size) so
|
||||
// re-renders keep it.
|
||||
@@ -453,66 +334,21 @@ export default function AiChatWindow() {
|
||||
height: minimized ? undefined : geom.height,
|
||||
}}
|
||||
>
|
||||
{/* drag bar / header. Mouse users expand a minimized window by clicking
|
||||
anywhere on the bar (the click-vs-drag logic in startDrag, which
|
||||
excludes the buttons). The keyboard/screen-reader Expand affordance
|
||||
lives on the title element below — NOT on this container — so we never
|
||||
nest the Minimize/Close <button>s inside an element with
|
||||
role="button" (invalid ARIA: nested interactive controls). */}
|
||||
{/* drag bar / header */}
|
||||
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
style={{ flex: "none" }}
|
||||
/>
|
||||
{/* When minimized, the title doubles as the keyboard Expand button:
|
||||
it carries role/tabIndex/aria-label and an Enter/Space handler, and
|
||||
unlike the dragBar it contains no nested <button>s. When expanded it
|
||||
is a plain, non-focusable label. */}
|
||||
<span
|
||||
className={classes.title}
|
||||
role={minimized ? "button" : undefined}
|
||||
tabIndex={minimized ? 0 : undefined}
|
||||
aria-label={minimized ? t("Expand") : undefined}
|
||||
onKeyDown={
|
||||
minimized
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setMinimized(false);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("AI chat")}
|
||||
</span>
|
||||
|
||||
{/* Role badge (emoji + name). Shows the persisted role of an existing
|
||||
chat, or the role picked via a card for a brand-new chat. Hidden for
|
||||
a universal (no-role) chat. */}
|
||||
{currentRole && (
|
||||
<span className={classes.badge} title={t("Agent role")}>
|
||||
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
|
||||
{currentRole.name}
|
||||
</span>
|
||||
)}
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{/* Always show the persisted "current / max" context. The denominator
|
||||
(the admin-configured model limit) is appended only when known;
|
||||
not clamped when current > max (shown as-is, e.g. "210k / 200k").
|
||||
Hidden entirely until a turn has recorded a context figure. */}
|
||||
{contextTokens > 0 ? (
|
||||
<Tooltip label={t("Context size / model limit")} withArrow>
|
||||
<span className={classes.badge}>
|
||||
{formatTokens(contextTokens)}
|
||||
{maxContextTokens > 0
|
||||
? ` / ${formatTokens(maxContextTokens)}`
|
||||
: ""}
|
||||
</span>
|
||||
{contextTokens > 0 && (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
@@ -524,11 +360,7 @@ export default function AiChatWindow() {
|
||||
aria-label={t("Copy chat")}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{clipboard.copied ? (
|
||||
<IconCheck size={14} />
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)}
|
||||
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -568,16 +400,7 @@ export default function AiChatWindow() {
|
||||
>
|
||||
<div
|
||||
className={classes.historyHeader}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={historyOpen}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setHistoryOpen((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
@@ -609,11 +432,6 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The role picker for a NEW chat is rendered as the chat's empty-state
|
||||
(colored role cards centered in the empty window) by ChatThread
|
||||
itself — clicking a card starts the chat with that role. Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead. */}
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
{waitingForHistory ? (
|
||||
@@ -623,19 +441,10 @@ export default function AiChatWindow() {
|
||||
) : (
|
||||
<ChatThread
|
||||
key={threadKey}
|
||||
threadKey={threadKey}
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
// Role cards are the new-chat empty-state; offered only when this
|
||||
// is a brand-new chat. Clicking a card starts the chat with it.
|
||||
roles={activeChatId === null ? enabledRoles : undefined}
|
||||
onRolePicked={(role) => setSelectedRoleId(role.id)}
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -55,45 +55,6 @@
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
|
||||
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
||||
wide LLM table must scroll horizontally instead of collapsing its columns:
|
||||
`.markdown` sets `word-break: break-word`, which (with the default table
|
||||
layout) shrinks columns to a single glyph and wraps headers mid-word
|
||||
("Секция" -> "Секци / я"). Make the table a horizontally scrollable block,
|
||||
give cells a readable minimum width, and restore word-boundary wrapping. */
|
||||
.markdown table {
|
||||
display: block;
|
||||
/* lets the table scroll horizontally on its own */
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin-block-end: 0.5em;
|
||||
}
|
||||
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding: 3px 8px;
|
||||
/* readable floor; the block scrolls when the row exceeds the panel */
|
||||
min-width: 6em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
/* cancel the inherited break-word so words don't split mid-glyph */
|
||||
word-break: normal;
|
||||
/* still wrap genuinely long words / URLs at the cell edge */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown th {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* GFM wraps cell text in <p>; drop its default block margin inside cells. */
|
||||
.markdown table p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Animated three-dot "typing" indicator shown while the agent is thinking but
|
||||
has not yet produced any visible text/tool parts. */
|
||||
.typingDots {
|
||||
@@ -127,18 +88,16 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
/* Bounce height is driven by --bounce so reduced-motion can dampen it
|
||||
(below) without disabling the animation outright. */
|
||||
transform: translateY(var(--bounce, -6px));
|
||||
transform: translateY(-3px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced-motion preferences: keep a smaller bounce instead of a full
|
||||
stop, so the "thinking" indicator still reads as active rather than frozen. */
|
||||
/* Respect reduced-motion preferences: fall back to a static dimmed state. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typingDots span {
|
||||
--bounce: -3px;
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,28 +109,6 @@
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
/* Collapsible "Thinking" (reasoning) block: a subtle left rule, dimmer than the
|
||||
answer so it reads as secondary thinking context above the real answer. */
|
||||
.reasoningBlock {
|
||||
border-left: 2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.reasoningText {
|
||||
margin-top: 4px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||
rendered markdown <div> it would turn the newlines between block tags
|
||||
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
||||
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
||||
inline itself (see reasoning-block.tsx). */
|
||||
}
|
||||
|
||||
.reasoningText p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
flex: 0 0 auto;
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
@@ -189,29 +126,3 @@
|
||||
.conversationItemActive {
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
|
||||
/* Pending messages queued by the user while a turn is still streaming. They
|
||||
are sent automatically, FIFO, once the current turn finishes. */
|
||||
.queuedList {
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.queuedItem {
|
||||
background: var(--mantine-color-gray-light);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.queuedIcon {
|
||||
flex: none;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.queuedText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
/**
|
||||
* A classified AI chat error banner: a warning icon + bold heading on the first
|
||||
* row, with the detail text spanning the full width below. Rendered for BOTH the
|
||||
* live stream error (ChatThread) and a persisted assistant error (MessageItem),
|
||||
* so this markup lives in one place. The detail is full-width (no hanging indent
|
||||
* under the heading) so it wraps less and leaves no stranded icon / empty gap.
|
||||
* The heading reuses Mantine's adaptive red "light" colour so it stays correct
|
||||
* in dark mode. Layout-only props (mb/mt/...) are forwarded to the Alert root.
|
||||
*/
|
||||
interface ChatErrorAlertProps extends Omit<AlertProps, "title" | "children"> {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export default function ChatErrorAlert({
|
||||
title,
|
||||
detail,
|
||||
style,
|
||||
...alertProps
|
||||
}: ChatErrorAlertProps) {
|
||||
// Mantine's own "light" alert colour, adaptive across light/dark schemes.
|
||||
const accent = "var(--mantine-color-red-light-color)";
|
||||
return (
|
||||
// flexShrink: 0 keeps the banner fully visible. Mantine's Alert root is
|
||||
// `overflow: hidden`, so as a flex child of the chat panel it can otherwise
|
||||
// be compressed below its content height and clip the detail text; the
|
||||
// scrollable message list absorbs the height pressure instead.
|
||||
<Alert
|
||||
{...alertProps}
|
||||
variant="light"
|
||||
color="red"
|
||||
p="xs"
|
||||
style={[{ flexShrink: 0 }, style]}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center" mb={4}>
|
||||
<IconAlertTriangle size={18} style={{ flex: "none", color: accent }} />
|
||||
<Text fw={700} size="sm" lh={1.2} style={{ color: accent }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" lh={1.4}>
|
||||
{detail}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -9,24 +9,18 @@ import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string) => void;
|
||||
/** Called instead of `onSend` while a turn is streaming: the text is queued
|
||||
* and sent automatically once the current turn finishes. */
|
||||
onQueue: (text: string) => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message composer. Enter submits, Shift+Enter inserts a newline. While the
|
||||
* agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
|
||||
* dropping it — it is sent automatically once the current turn finishes; the
|
||||
* Stop button (calls `stop()`) is also shown. The textarea stays usable so the
|
||||
* user can draft / queue the next turn while the agent is busy.
|
||||
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent
|
||||
* is streaming, the send button becomes a Stop button (calls `stop()`); the
|
||||
* textarea stays usable so the user can draft the next turn.
|
||||
*/
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
onQueue,
|
||||
onStop,
|
||||
isStreaming,
|
||||
disabled,
|
||||
@@ -35,23 +29,18 @@ export default function ChatInput({
|
||||
const [value, setValue] = useAtom(aiChatDraftAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
// Streaming (silence-cut) dictation is opt-in per workspace; absent/false
|
||||
// keeps the stable batch path.
|
||||
const streamingDictation =
|
||||
workspace?.settings?.ai?.dictationStreaming === true;
|
||||
|
||||
const submit = (): void => {
|
||||
const send = (): void => {
|
||||
const text = value.trim();
|
||||
if (!text || disabled) return;
|
||||
if (isStreaming) onQueue(text);
|
||||
else onSend(text);
|
||||
if (!text || isStreaming || disabled) return;
|
||||
onSend(text);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,43 +64,28 @@ export default function ChatInput({
|
||||
{isDictationEnabled && (
|
||||
<MicButton
|
||||
size="lg"
|
||||
streaming={streamingDictation}
|
||||
disabled={isStreaming || disabled}
|
||||
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
|
||||
/>
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{value.trim().length > 0 && (
|
||||
<Tooltip label={t("Send when the agent finishes")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={submit}
|
||||
aria-label={t("Queue message")}
|
||||
>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t("Stop")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
aria-label={t("Stop")}
|
||||
>
|
||||
<IconPlayerStopFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Tooltip label={t("Stop")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
aria-label={t("Stop")}
|
||||
>
|
||||
<IconPlayerStopFilled size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("Send")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={submit}
|
||||
onClick={send}
|
||||
disabled={disabled || value.trim().length === 0}
|
||||
aria-label={t("Send")}
|
||||
>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Alert, Group, Text, type AlertProps } from "@mantine/core";
|
||||
import { IconPlayerStopFilled } from "@tabler/icons-react";
|
||||
|
||||
/**
|
||||
* A neutral "turn was interrupted" notice (NOT an error). Rendered for an
|
||||
* aborted turn — a manual Stop or a dropped connection — both live (ChatThread)
|
||||
* and in reopened history (MessageItem). Deliberately gray/subtle so it reads as
|
||||
* an informational marker, distinct from the red ChatErrorAlert. Layout-only
|
||||
* props (mt/mb/...) are forwarded to the Alert root.
|
||||
*/
|
||||
interface ChatStoppedNoticeProps extends Omit<AlertProps, "title" | "children"> {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function ChatStoppedNotice({
|
||||
text,
|
||||
style,
|
||||
...alertProps
|
||||
}: ChatStoppedNoticeProps) {
|
||||
return (
|
||||
<Alert
|
||||
{...alertProps}
|
||||
variant="light"
|
||||
color="gray"
|
||||
p="xs"
|
||||
// flexShrink: 0 mirrors ChatErrorAlert so the notice is not compressed as a
|
||||
// flex child of the chat panel.
|
||||
style={[{ flexShrink: 0 }, style]}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<IconPlayerStopFilled
|
||||
size={16}
|
||||
style={{ flex: "none", color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
<Text size="sm" lh={1.3} c="dimmed">
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
// above the imports) can expose the captured useChat callbacks / transport and
|
||||
// the spies back to the test body.
|
||||
const h = vi.hoisted(() => ({
|
||||
state: {
|
||||
status: "streaming" as string,
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||
vi.mock("@ai-sdk/react", () => ({
|
||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||
h.state.onFinish = opts.onFinish ?? null;
|
||||
return {
|
||||
messages: [],
|
||||
sendMessage: h.state.sendMessage,
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||
vi.mock("ai", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
generateId: () => `gid-${counter++}`,
|
||||
DefaultChatTransport: class {
|
||||
constructor(opts: {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
}) {
|
||||
h.state.transport = opts;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||
// interaction this test needs to populate the queue while "streaming".
|
||||
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
||||
default: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import ChatThread from "./chat-thread";
|
||||
|
||||
function renderThread() {
|
||||
const onTurnFinished = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
describe("ChatThread — send now (#198)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
|
||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||
renderThread();
|
||||
|
||||
// Queue a message while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
const sendNowBtn = screen.getByLabelText("Send now");
|
||||
expect(sendNowBtn).toBeTruthy();
|
||||
|
||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||
// the resend happens once the abort lands in onFinish.
|
||||
fireEvent.click(sendNowBtn);
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: true,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("tags exactly the next send as interrupted (one-shot flag)", () => {
|
||||
renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
// The send right after "send now" carries interrupted: true...
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||
// ...and only that one (the flag is read-and-cleared).
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
|
||||
it("sends immediately without an interrupt when not streaming", () => {
|
||||
h.state.status = "ready";
|
||||
renderThread();
|
||||
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||
expect(h.state.stop).not.toHaveBeenCalled();
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconClockHour4,
|
||||
IconPlayerPlayFilled,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Alert, Box, Stack } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
||||
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import {
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
roleLaunchMessage,
|
||||
shouldResetRolePicked,
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
promoteToHead,
|
||||
removeQueuedById,
|
||||
type QueuedMessage,
|
||||
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
// Throttle how often the streamed `messages` state triggers a re-render. Without
|
||||
// it, useChat updates state on EVERY token, so the whole transcript's markdown
|
||||
// (marked + DOMPurify) is re-parsed per token — on a long agent run that grows
|
||||
// into a quadratic CPU storm that pins the main thread and freezes the UI.
|
||||
// ~50ms (20 Hz) keeps streaming visually smooth while decoupling re-render cost
|
||||
// from the token rate.
|
||||
const STREAM_THROTTLE_MS = 50;
|
||||
|
||||
/** The page the user is currently viewing, sent as chat context. */
|
||||
export interface OpenPageContext {
|
||||
id: string;
|
||||
@@ -50,42 +20,14 @@ export interface OpenPageContext {
|
||||
interface ChatThreadProps {
|
||||
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
||||
chatId: string | null;
|
||||
/** This thread's mount key (the same value the parent uses as React `key`).
|
||||
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
|
||||
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
|
||||
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
|
||||
threadKey?: string;
|
||||
/** Persisted rows to seed initial messages (existing chats only). */
|
||||
initialRows?: IAiChatMessageRow[];
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
roleId?: string | null;
|
||||
/** Enabled roles for the new-chat empty state (only meaningful when
|
||||
* `chatId === null`). Rendered as the colored role cards. */
|
||||
roles?: IAiRole[];
|
||||
/** Notify the parent which role was picked via a card, so it can update the
|
||||
* header badge / assistant name for the brand-new chat. */
|
||||
onRolePicked?: (role: IAiRole) => void;
|
||||
/** Display name for the assistant label / typing line (the role name);
|
||||
* forwarded to MessageList. Absent => the generic "AI agent". */
|
||||
assistantName?: string;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for a
|
||||
* new chat, adopts the freshly created chat id. `serverChatId` is the
|
||||
* authoritative id the server streamed on the assistant message metadata, or
|
||||
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
|
||||
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
|
||||
* finishing on a thread already abandoned by New chat mid-stream (#161). */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
|
||||
* chat id streamed on the assistant message metadata, so a brand-new chat
|
||||
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
||||
* a new chat, adopts the freshly created chat id. */
|
||||
onTurnFinished: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,18 +42,13 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
||||
? row.metadata.parts
|
||||
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
||||
const error = row.metadata?.error;
|
||||
const finishReason = row.metadata?.finishReason;
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (error) metadata.error = error;
|
||||
if (finishReason) metadata.finishReason = finishReason;
|
||||
return {
|
||||
id: row.id,
|
||||
role,
|
||||
parts,
|
||||
// Carry persisted turn outcome (error text and/or finishReason) so MessageItem
|
||||
// can render the error banner / "stopped" marker after a remount and in
|
||||
// reopened history.
|
||||
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||
// Carry a persisted turn error so MessageItem can render it after a remount
|
||||
// (e.g. when a new chat adopts its id) and in reopened chat history.
|
||||
...(error ? { metadata: { error } } : {}),
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
@@ -122,15 +59,9 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
||||
*/
|
||||
export default function ChatThread({
|
||||
chatId,
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -153,12 +84,6 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
const roleIdRef = useRef<string | null>(roleId ?? null);
|
||||
roleIdRef.current = roleId ?? null;
|
||||
|
||||
// Stable `useChat` store key for the lifetime of THIS mount.
|
||||
//
|
||||
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
||||
@@ -177,68 +102,7 @@ export default function ChatThread({
|
||||
// The id only needs to be stable per mount — the parent remounts this via
|
||||
// `key` on chat switch, which re-seeds cleanly.
|
||||
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
|
||||
// Stable for the LIFETIME of this mount. When a brand-new chat adopts its
|
||||
// server id, the parent now updates the `chatId` prop WITHOUT remounting this
|
||||
// thread, so the store id must NOT follow `chatId`: recreating the useChat
|
||||
// store would wipe the live (just-finished) turn. The server still resolves
|
||||
// the real chat from `chatId` in the request body (see chatIdRef /
|
||||
// prepareSendMessagesRequest), so this purely-client store key can stay fixed.
|
||||
const chatStoreId = stableIdRef.current;
|
||||
|
||||
// Pending messages the user composed WHILE a turn was streaming. They are sent
|
||||
// automatically, FIFO, on successful turn completion (`onFinish`). The queue is
|
||||
// LOCAL state so it is scoped to this conversation: it is cleared when the user
|
||||
// deliberately switches chat / starts a new chat (the parent remounts this via
|
||||
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
|
||||
// message queued during a brand-new chat's first turn is not lost. On Stop or
|
||||
// error the queue is intentionally preserved (onFinish does not fire then) so
|
||||
// the user decides what to do with the pending messages.
|
||||
const [queued, setQueued] = useState<QueuedMessage[]>([]);
|
||||
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
|
||||
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
|
||||
const queuedRef = useRef<QueuedMessage[]>([]);
|
||||
const setQueue = useCallback((next: QueuedMessage[]) => {
|
||||
queuedRef.current = next;
|
||||
setQueued(next);
|
||||
}, []);
|
||||
|
||||
// Capture the latest `sendMessage` (returned by useChat below) so the flush
|
||||
// helper can call the current instance from the stable `onFinish` callback.
|
||||
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||
|
||||
// "Send now" single-flight flags. Kept in refs (not state) so they are read
|
||||
// inside the stable `onFinish` callback and the transport closure WITHOUT a
|
||||
// re-render or a stale closure. Both are one-shot (read-and-clear).
|
||||
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
|
||||
// though an aborted turn normally keeps the queue intact.
|
||||
// - interruptNextSendRef: tag the next send as a user interrupt so the server
|
||||
// injects the "your previous answer was interrupted" note for that turn only.
|
||||
const flushOnAbortRef = useRef(false);
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
// Returns whether a message was actually sent, so callers can tell an empty
|
||||
// dequeue (nothing to flush) from a real send.
|
||||
const flushNext = useCallback(() => {
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return false;
|
||||
setQueue(rest);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
return true;
|
||||
}, [setQueue]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(text: string) => {
|
||||
setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
|
||||
},
|
||||
[setQueue],
|
||||
);
|
||||
const removeQueued = useCallback(
|
||||
(id: string) => {
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
},
|
||||
[setQueue],
|
||||
);
|
||||
const chatStoreId = chatId ?? stableIdRef.current;
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
@@ -250,26 +114,14 @@ export default function ChatThread({
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
// are read live from refs so changing chats/pages does NOT recreate the
|
||||
// transport. `openPage` is null on a non-page route.
|
||||
prepareSendMessagesRequest: ({ messages, body }) => {
|
||||
// Read-and-clear the interrupt flag so the "you were interrupted" note
|
||||
// is carried by ONLY this request (the one resending the promoted
|
||||
// message right after we aborted the previous turn). The server still
|
||||
// confirms it against history before acting on it.
|
||||
const interrupted = interruptNextSendRef.current;
|
||||
interruptNextSendRef.current = false; // one-shot
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
interrupted,
|
||||
messages,
|
||||
},
|
||||
};
|
||||
},
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -281,264 +133,30 @@ export default function ChatThread({
|
||||
id: chatStoreId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
// See STREAM_THROTTLE_MS — bounds re-render/markdown-reparse frequency.
|
||||
experimental_throttle: STREAM_THROTTLE_MS,
|
||||
// `onFinish` (ai@6 useChat) fires from a `finally` on EVERY terminal outcome
|
||||
// — success, user Stop/abort (`isAbort`), network drop (`isDisconnect`), and
|
||||
// stream error (`isError`). Keep calling `onTurnFinished()` on all of them
|
||||
// (chat-list refresh + new-chat id adoption must happen even on a failed
|
||||
// first turn), but flush the pending queue ONLY on a clean finish: auto-
|
||||
// sending after the user hit Stop — or blindly retrying after a failure —
|
||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||
// the user to decide.
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||
// session ignore this finish if it belongs to a thread abandoned by New chat
|
||||
// mid-stream (#161).
|
||||
onTurnFinished(extractServerChatId(message), threadKey);
|
||||
// Show a neutral "stopped" marker for an aborted turn; the red error banner
|
||||
// (via `error`) already covers isError, and a clean finish clears any marker.
|
||||
if (isError) setStopNotice(null);
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||
// immediately send the promoted head. Flush it even though the turn was
|
||||
// aborted (the normal abort path below keeps the queue intact). The
|
||||
// interrupt note travels with this send via interruptNextSendRef.
|
||||
if (flushOnAbortRef.current) {
|
||||
flushOnAbortRef.current = false;
|
||||
// Suppress the "Response stopped." flash for an intentional interrupt.
|
||||
setStopNotice(null);
|
||||
// If the promoted head vanished (e.g. the user removed it before the
|
||||
// abort landed) flushNext sends nothing — clear the one-shot interrupt
|
||||
// tag so it can't leak onto the next unrelated send. On a real send the
|
||||
// tag is consumed by prepareSendMessagesRequest and stays untouched.
|
||||
if (!flushNext()) interruptNextSendRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isAbort || isDisconnect || isError) return;
|
||||
flushNext();
|
||||
},
|
||||
// `onError` runs in addition to `onFinish` (which ai@6 also calls on error).
|
||||
// Log the raw failure here for devtools; the UI shows a friendly classified
|
||||
// banner via `error` below. We still call `onTurnFinished()` with NO server id
|
||||
// (idempotent with the onFinish call): for a brand-new chat that ARMS the
|
||||
// bounded list-refetch fallback (adopt the single newly-appeared chat once the
|
||||
// refetch lands); for an existing chat it just refreshes the chat list
|
||||
// immediately rather than after a manual refresh.
|
||||
onError: (streamError) => {
|
||||
// Surface the raw failure in the browser console (devtools) for debugging;
|
||||
// the UI separately shows a friendly classified banner (see errorView).
|
||||
console.error("AI chat stream error:", streamError);
|
||||
onTurnFinished(undefined, threadKey);
|
||||
},
|
||||
onFinish: () => onTurnFinished(),
|
||||
});
|
||||
|
||||
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||
sendMessageRef.current = sendMessage;
|
||||
|
||||
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
|
||||
// CURRENT status rather than a value captured in a stale render closure — a turn
|
||||
// can finish between render and click, and arming the interrupt refs against a
|
||||
// no-op stop() would leave them set to leak into a later, unrelated Stop.
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
|
||||
// on the assistant message metadata at the `start` chunk (message.metadata.
|
||||
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
|
||||
// AS SOON AS it appears (mid-stream), so a brand-new chat adopts its real id
|
||||
// WHILE the first turn is still streaming and activeChatId-gated affordances
|
||||
// (the Copy/export button) light up immediately, instead of only at onFinish.
|
||||
// Keyed by the last-seen id so we forward each distinct id exactly once. The
|
||||
// parent's onServerChatId is idempotent and a no-op once the chat has an id.
|
||||
const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!onServerChatId) return;
|
||||
const tail = messages[messages.length - 1];
|
||||
if (tail?.role !== "assistant") return;
|
||||
const serverChatId = extractServerChatId(tail);
|
||||
if (!serverChatId || serverChatId === lastForwardedChatIdRef.current)
|
||||
return;
|
||||
lastForwardedChatIdRef.current = serverChatId;
|
||||
onServerChatId(serverChatId);
|
||||
}, [messages, onServerChatId]);
|
||||
|
||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
|
||||
// (`isDisconnect`) — a distinction only available live (the server persists
|
||||
// both as finishReason 'aborted'). Cleared when the next turn starts.
|
||||
const [stopNotice, setStopNotice] = useState<null | "manual" | "disconnect">(
|
||||
null,
|
||||
);
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
// queue/flush machinery: promote the target to the head, then abort — the
|
||||
// onFinish flush-on-abort branch sends exactly that head, tagged as an
|
||||
// interrupt so the server notes the previous answer was cut off.
|
||||
const sendNow = useCallback(
|
||||
(id: string) => {
|
||||
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
|
||||
// the turn may have finished between this render and the click, in which case
|
||||
// stop() is a no-op and arming the interrupt refs would strand them for a
|
||||
// later, unrelated Stop. Reading the ref always sees the current status.
|
||||
const liveStreaming =
|
||||
statusRef.current === "submitted" || statusRef.current === "streaming";
|
||||
if (liveStreaming) {
|
||||
// Promote to head so the onFinish -> flushNext path sends exactly it.
|
||||
setQueue(promoteToHead(queuedRef.current, id));
|
||||
flushOnAbortRef.current = true;
|
||||
interruptNextSendRef.current = true;
|
||||
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
|
||||
} else {
|
||||
// Nothing to interrupt: just send it now (no interrupt note).
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop],
|
||||
);
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
|
||||
// the race where a flag was armed but the expected abort never fired (the turn
|
||||
// finished in the same tick as the click), so it cannot leak into a later turn.
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Classify the turn error into a heading + detail so the banner names the cause
|
||||
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
||||
// of a generic "Something went wrong". Computed here (not only in the JSX) so
|
||||
// the SAME on-screen banner text can be mirrored into the export (issue #160).
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||
// so the user can type the first message themselves. roleIdRef is already set,
|
||||
// so that first manual message carries the roleId.
|
||||
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
|
||||
|
||||
// Clicking a role card always binds the role to THIS new chat. Whether it also
|
||||
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
|
||||
// synchronously here because the parent's selectedRoleId state update would
|
||||
// only reach roleIdRef on the next render — after this synchronous sendMessage
|
||||
// has already read it.
|
||||
const handleRolePick = (role: IAiRole): void => {
|
||||
roleIdRef.current = role.id;
|
||||
onRolePicked?.(role);
|
||||
const launch = roleLaunchMessage(
|
||||
role,
|
||||
t("Take a look at the current document"),
|
||||
);
|
||||
if (launch !== null) {
|
||||
sendMessage({ text: launch });
|
||||
} else {
|
||||
// autoStart=false -> bind only: hide the cards, show the composer.
|
||||
setRolePickedNoSend(true);
|
||||
}
|
||||
};
|
||||
// Reset the "picked, not sent" flag when the thread returns to a truly empty,
|
||||
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
|
||||
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
|
||||
// chatId null, so the thread never remounts and the flag would stay set, hiding
|
||||
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
|
||||
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
|
||||
// change"): one-shot — it re-renders with the flag false and the guard no longer
|
||||
// matches, so it cannot loop. (Review of #149.)
|
||||
if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
|
||||
setRolePickedNoSend(false);
|
||||
}
|
||||
const showRoleCards =
|
||||
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
|
||||
const roleCardsEmptyState = showRoleCards ? (
|
||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Box className={classes.panel}>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={roleCardsEmptyState}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
<MessageList messages={messages} isStreaming={isStreaming} />
|
||||
|
||||
{errorView ? (
|
||||
<ChatErrorAlert
|
||||
title={errorView.title}
|
||||
detail={errorView.detail}
|
||||
{error && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mb="xs"
|
||||
/>
|
||||
) : stopNotice ? (
|
||||
<ChatStoppedNotice
|
||||
text={
|
||||
stopNotice === "manual"
|
||||
? t("Response stopped.")
|
||||
: t("Connection lost — the answer was interrupted.")
|
||||
}
|
||||
mb="xs"
|
||||
/>
|
||||
) : null}
|
||||
title={t("Something went wrong")}
|
||||
>
|
||||
{describeChatError(error.message ?? "", t)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap={0} className={classes.inputWrapper}>
|
||||
{queued.length > 0 && (
|
||||
<Stack gap={4} className={classes.queuedList}>
|
||||
{queued.map((m) => (
|
||||
<Group
|
||||
key={m.id}
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
className={classes.queuedItem}
|
||||
>
|
||||
<IconClockHour4 size={14} className={classes.queuedIcon} />
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => removeQueued(m.id)}
|
||||
aria-label={t("Remove queued message")}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<ChatInput
|
||||
onSend={(text) => sendMessage({ text })}
|
||||
onQueue={enqueue}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
@@ -18,31 +18,8 @@ import {
|
||||
useRenameAiChatMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
/**
|
||||
* The dimmed second line of a chat row: how long ago the chat was created and
|
||||
* the document it was created in. Its own component so the self-updating
|
||||
* `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`).
|
||||
*/
|
||||
function ChatMetaLine({
|
||||
createdAt,
|
||||
pageTitle,
|
||||
}: {
|
||||
createdAt: string;
|
||||
pageTitle?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ago = useTimeAgo(createdAt);
|
||||
// e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document"
|
||||
return (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{ago} · {pageTitle || t("No document")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationListProps {
|
||||
activeChatId: string | null;
|
||||
onSelect: (chatId: string) => void;
|
||||
@@ -138,36 +115,11 @@ export default function ConversationList({
|
||||
classes.conversationItem,
|
||||
isActive && classes.conversationItemActive,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Activate on Enter/Space like a native button; the inner menu
|
||||
// button stops propagation so its own keys never reach this row.
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(chat.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text
|
||||
size="sm"
|
||||
span
|
||||
title={chat.roleName}
|
||||
style={{ flex: "none" }}
|
||||
>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
|
||||
</Box>
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (the component reads `useTranslation`). Mirrors the stub in
|
||||
// reasoning-block.test.tsx.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Spy on `renderChatMarkdown` so we can count parse calls per text. We keep every
|
||||
// OTHER named export of markdown.ts intact via `importActual`, and override only
|
||||
// `renderChatMarkdown` with a `vi.fn()` that returns simple HTML so the component
|
||||
// still renders. This is the seam that proves the MarkdownPart memo works: a
|
||||
// finalized text part must NOT be re-parsed on a later streamed delta.
|
||||
// `vi.hoisted` so the spy exists when the hoisted `vi.mock` factory runs.
|
||||
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
||||
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/features/ai-chat/utils/markdown.ts")
|
||||
>("@/features/ai-chat/utils/markdown.ts");
|
||||
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
||||
});
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
|
||||
// as the memo key. The signature must NOT be recomputed inside the memo from the
|
||||
// live (mutable) message — see message-item.tsx.
|
||||
const renderRow = (message: UIMessage) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
/** Count how many spy calls parsed exactly `text` (filtering by the first arg). */
|
||||
const callsFor = (text: string) =>
|
||||
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === text).length;
|
||||
|
||||
describe("MessageItem markdown memoization", () => {
|
||||
it("does not re-parse finalized text parts when only a tail part grows", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
|
||||
// Two finalized text parts.
|
||||
const first = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
]);
|
||||
const { rerender } = renderRow(first);
|
||||
|
||||
// Both finalized parts parsed exactly once on the initial render.
|
||||
expect(callsFor("alpha")).toBe(1);
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
|
||||
// A streamed delta: a NEW message object where only a third tail part grows;
|
||||
// the first two parts' text is byte-identical.
|
||||
const next = msg([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
{ type: "text", text: "gamm" },
|
||||
]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={next} signature={messageSignature(next)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The finalized parts hit the MarkdownPart memo: still parsed at most once
|
||||
// each across BOTH renders (the resilient invariant). The only new parse is
|
||||
// for the changed/added tail part.
|
||||
expect(callsFor("alpha")).toBe(1);
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
expect(callsFor("gamm")).toBe(1);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
|
||||
// `parts` IN PLACE and reusing the message object. A row that mounted empty
|
||||
// (reasoning-first providers render nothing at first) must still stream its text
|
||||
// in once the parent hands down a fresh signature snapshot. Before the fix the
|
||||
// memo recomputed the signature from the (mutated) message — identical on both
|
||||
// sides — and froze the row at its empty render, so the answer never appeared.
|
||||
it("streams text in after the row mounted empty and parts mutated in place", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does).
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Empty text part: nothing visible rendered yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders (the memo did NOT freeze the empty mount).
|
||||
expect(callsFor("streamed answer")).toBe(1);
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next: importing the component module pulls in `useTranslation`,
|
||||
// and we only exercise the pure `arePropsEqual` comparator (no rendering), so a
|
||||
// minimal `t` that echoes the key is enough. Mirrors the stub in
|
||||
// reasoning-block.test.tsx.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { arePropsEqual } from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
/**
|
||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||
* return false on any visible prop/content change (so the row re-renders) and
|
||||
* true when nothing visible changed (so a finalized row is skipped). The memo key
|
||||
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
|
||||
* per render via `messageSignature(message)`. A FIXED message id is used so a
|
||||
* content-identical clone yields an equal signature.
|
||||
*/
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Build the props the parent would pass, INCLUDING the snapshot signature it
|
||||
// computes during its own render (the load-bearing part — see message-item.tsx:
|
||||
// the signature must never be recomputed inside arePropsEqual).
|
||||
const props = (
|
||||
message: UIMessage,
|
||||
over: Record<string, unknown> = {},
|
||||
) => ({
|
||||
message,
|
||||
signature: messageSignature(message),
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("arePropsEqual", () => {
|
||||
it("returns false when showCitations differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { showCitations: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when neutralizeInternalLinks differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { neutralizeInternalLinks: true })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when assistantName differs", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(
|
||||
arePropsEqual(props(m), props(m, { assistantName: "Other" })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for the same content in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer" }]);
|
||||
expect(a).not.toBe(b);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when content changed in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
|
||||
// `parts` in place and handing back a message wrapper that SHARES them. So the
|
||||
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
|
||||
// recomputing `messageSignature(message)` inside the comparator would read
|
||||
// identical (latest) content on BOTH sides → always "equal" → the memo skips
|
||||
// every streamed update and the assistant row freezes at its initial empty
|
||||
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
|
||||
// the parent captured at each render. This fails against the old implementation
|
||||
// (a `prev.message === next.message` fast path + a signature recomputed from the
|
||||
// live objects).
|
||||
it("re-renders when parts were mutated in place but the snapshot changed", () => {
|
||||
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
|
||||
const prevSig = messageSignature(message); // snapshot BEFORE the delta
|
||||
// SDK streams a delta by mutating the shared part IN PLACE:
|
||||
(message.parts[0] as { text: string }).text = "hello world";
|
||||
const nextSig = messageSignature(message); // snapshot AFTER the delta
|
||||
expect(prevSig).not.toBe(nextSig);
|
||||
// Same object reference on both sides (the SDK reuses it), differing snapshots.
|
||||
const base = {
|
||||
message,
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
};
|
||||
expect(
|
||||
arePropsEqual(
|
||||
{ ...base, signature: prevSig },
|
||||
{ ...base, signature: nextSig },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +1,21 @@
|
||||
import { memo } from "react";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Alert, Box, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
/**
|
||||
* Immutable content signature for `message`, computed by the PARENT
|
||||
* (MessageList) during its render via `messageSignature(message)`. This is the
|
||||
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
|
||||
* NOT recomputed from `message` inside `arePropsEqual`.
|
||||
*
|
||||
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
|
||||
* array/objects in place and handing back a message wrapper that SHARES those
|
||||
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
|
||||
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
|
||||
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
|
||||
* post-mount render, and the assistant row freezes at its initial empty (null)
|
||||
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
|
||||
* providers start empty, so NOTHING shows). Snapshotting the signature into this
|
||||
* immutable string prop in the parent fixes that: `prev.signature` holds the
|
||||
* value from the previous render (old content) and `next.signature` the new
|
||||
* content, so they differ as the turn streams in and the row re-renders.
|
||||
*/
|
||||
signature: string;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
* links stay clickable). The anonymous public share passes true so internal
|
||||
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the dimmed assistant label. Defaults to "AI agent" when
|
||||
* absent; the public share passes the configured identity (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
||||
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||
* `text` string is compared by value).
|
||||
*/
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
neutralizeInternalLinks,
|
||||
}: {
|
||||
text: string;
|
||||
neutralizeInternalLinks: boolean;
|
||||
}) {
|
||||
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Fallback when markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single UIMessage by iterating its `parts`:
|
||||
@@ -94,20 +24,12 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
||||
* User messages render their text as a right-aligned plain bubble.
|
||||
*
|
||||
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
|
||||
* per-message content signature: the streaming TAIL message's signature changes
|
||||
* on each delta so it still re-renders and streams in, while finalized rows are
|
||||
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
|
||||
* long turn no longer re-parses the whole transcript on every token.
|
||||
* This component is intentionally NOT memoized: `useChat` replaces the streaming
|
||||
* assistant message with a freshly cloned object on every streamed delta, so the
|
||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
||||
* text parts on each delta is what makes the answer stream in progressively.
|
||||
*/
|
||||
function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
// `signature` is intentionally not read in the body — it exists solely as the
|
||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||
export default function MessageItem({ message }: MessageItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -125,63 +47,38 @@ function MessageItem({
|
||||
);
|
||||
}
|
||||
|
||||
// An assistant message with nothing visible to render yet (an empty streaming
|
||||
// text part, or a reasoning/step-start part while the model is still thinking)
|
||||
// renders nothing here. The standalone TypingIndicator stands in for the nascent
|
||||
// bubble (name + dots) until real content arrives, so exactly one element owns
|
||||
// the agent name during the pre-content gap and the layout never jumps. Persisted
|
||||
// errored/aborted turns DO have visible content per the helper (metadata.error /
|
||||
// finishReason === "aborted"), so their banners below still render — this early
|
||||
// return won't fire for them.
|
||||
if (!assistantMessageHasVisibleContent(message)) return null;
|
||||
|
||||
// Authoritative reasoning token count to attribute to a reasoning block, or
|
||||
// undefined when the block must estimate on its own. See reasoningTokensForPart
|
||||
// for the #151 anti-double-count rule (only a single reasoning part may carry
|
||||
// the turn total). The authoritative turn total is still surfaced live in the
|
||||
// header badge regardless.
|
||||
const reasoningTokens = reasoningTokensForPart(message);
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
{t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "reasoning") {
|
||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||
// count. Empty/whitespace reasoning with no authoritative count carries
|
||||
// nothing to show, so skip it (avoids an empty 0-token block).
|
||||
const text = (part as { text?: string }).text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
return (
|
||||
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
// Skip empty/whitespace-only text parts (a streaming message often
|
||||
// starts with an empty text part before the first token arrives); the
|
||||
// typing indicator covers that gap until real content streams in.
|
||||
if (!part.text.trim()) return null;
|
||||
const html = renderChatMarkdown(part.text);
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classes.markdown}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Fallback when markdown could not render synchronously: raw text.
|
||||
return (
|
||||
<MarkdownPart
|
||||
key={index}
|
||||
text={part.text}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
/>
|
||||
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||
{part.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (isToolPart(part.type)) {
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
/>
|
||||
);
|
||||
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -191,62 +88,17 @@ function MessageItem({
|
||||
{(() => {
|
||||
const errorText = (message.metadata as { error?: string } | undefined)?.error;
|
||||
if (!errorText) return null;
|
||||
// Same classified-error banner as the live chat: a heading naming the
|
||||
// cause plus a one-line detail.
|
||||
const errorView = describeChatError(errorText, t);
|
||||
return (
|
||||
<ChatErrorAlert
|
||||
title={errorView.title}
|
||||
detail={errorView.detail}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mt={4}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* A persisted turn that was aborted (manual Stop or a dropped connection)
|
||||
with no error banner. The server cannot tell a manual Stop from a
|
||||
connection drop (both persist as finishReason 'aborted'), so reopened
|
||||
history uses a combined wording. */}
|
||||
{(() => {
|
||||
const meta = message.metadata as
|
||||
| { error?: string; finishReason?: string }
|
||||
| undefined;
|
||||
if (meta?.error || meta?.finishReason !== "aborted") return null;
|
||||
return (
|
||||
<ChatStoppedNotice
|
||||
text={t("Response stopped (manually or the connection dropped).")}
|
||||
mt={4}
|
||||
/>
|
||||
>
|
||||
{describeChatError(errorText, t)}
|
||||
</Alert>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
|
||||
* parent), so it still re-renders and streams in; every FINALIZED message keeps
|
||||
* the same signature and is skipped, turning a per-token whole-transcript
|
||||
* re-render into a tail-only one.
|
||||
*
|
||||
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
|
||||
* at its own render), NEVER `messageSignature(prev.message)` vs
|
||||
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
|
||||
* place, so both `prev.message` and `next.message` reflect the latest content
|
||||
* here — recomputing the signature from them yields equal strings every time and
|
||||
* freezes the row at its initial empty render (the bug this guards against). See
|
||||
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
|
||||
* fast path: same-reference-but-mutated must still re-render when the snapshot
|
||||
* signature changed. */
|
||||
export function arePropsEqual(
|
||||
prev: MessageItemProps,
|
||||
next: MessageItemProps,
|
||||
): boolean {
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageItem, arePropsEqual);
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,19 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
/**
|
||||
* Content shown when the transcript is empty and no turn is in flight.
|
||||
* Defaults to the internal chat's prompt. The public share passes its own
|
||||
* documentation-focused copy. This is purely the empty-state text; the
|
||||
* streaming/typing/markdown/tool-card paths below are shared verbatim.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
|
||||
* citation links. Defaults to true (internal chat). The public share passes
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
* Defaults to false (internal chat). The public share passes true so internal
|
||||
* UUIDs/routes don't leak as clickable links to anonymous readers.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
/**
|
||||
* Display name for the assistant's dimmed row label and typing indicator.
|
||||
* Defaults to "AI agent" when absent. The public share passes the configured
|
||||
* identity (agent role) name; the internal chat omits it.
|
||||
*/
|
||||
assistantName?: string;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
// Distance (px) from the bottom within which the viewport still counts as
|
||||
@@ -45,68 +21,23 @@ interface MessageListProps {
|
||||
const BOTTOM_THRESHOLD = 40;
|
||||
|
||||
/**
|
||||
* Whether to show the standalone "Thinking…" indicator. It bridges every
|
||||
* gap in a turn where the assistant is working but nothing visible is actively
|
||||
* being produced yet — so it shows while a turn is in flight AND the latest
|
||||
* assistant message's LAST part is not live output:
|
||||
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
|
||||
* gap between sending and the first streamed content, so it shows only while a
|
||||
* turn is in flight AND the latest assistant message has nothing visible yet:
|
||||
* - the last message is still the user's (assistant hasn't started a row), or
|
||||
* - the assistant row has no parts yet, or
|
||||
* - its last part is an empty/whitespace text part, or a finished ("done")
|
||||
* text part while the turn continues (the model paused after some narration
|
||||
* and is thinking about its next step), or
|
||||
* - its last part is a finished/errored tool (the model is thinking about the
|
||||
* next step between tool calls).
|
||||
* It hides only while output is actively rendering: a non-empty streaming text
|
||||
* part, or a tool that is still running (ToolCallCard shows its own Loader).
|
||||
* - the last (assistant) message has no non-empty text and no tool part.
|
||||
* Once any text/tool part arrives, MessageItem renders it and this hides.
|
||||
*/
|
||||
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||
if (!isStreaming) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last) return true; // submitted with nothing rendered yet.
|
||||
if (last.role !== "assistant") return true; // assistant row not started.
|
||||
const lastPart = last.parts[last.parts.length - 1];
|
||||
if (!lastPart) return true; // assistant row exists but has no parts yet.
|
||||
// The answer text is actively streaming in -> MessageItem renders it; no dots.
|
||||
// Only while it is STILL streaming, though: once a non-empty text part is
|
||||
// finalized ("done") but the turn is still in flight, the model has paused
|
||||
// after some narration and is working on its next step (e.g. about to call a
|
||||
// tool) — nothing is visibly progressing, so the dots must show. A text part
|
||||
// without a `state` is treated as still-rendering (kept suppressed); this
|
||||
// branch only runs while streaming, where live parts always carry a state.
|
||||
if (
|
||||
lastPart.type === "text" &&
|
||||
lastPart.text.trim().length > 0 &&
|
||||
(lastPart as { state?: "streaming" | "done" }).state !== "done"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// A tool still in flight shows its own Loader in ToolCallCard -> no dots.
|
||||
if (
|
||||
isToolPart(lastPart.type) &&
|
||||
toolRunState((lastPart as unknown as ToolUiPart).state) === "running"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Otherwise the turn is in flight but nothing is actively producing visible
|
||||
// output yet: a finished/errored tool with no follow-up content, or an empty
|
||||
// trailing text part. The model is thinking between steps -> show the dots.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the standalone typing indicator should render its own assistant-name
|
||||
* label. The indicator OWNS the name while the tail assistant row has no visible
|
||||
* content yet (an empty streaming text part, or reasoning/step-start while the
|
||||
* model is still thinking): in that gap the assistant MessageItem renders nothing,
|
||||
* so the indicator stands in for the nascent bubble (name + dots) at a constant
|
||||
* gap. It hides the name only once that row shows visible content, because then
|
||||
* MessageItem draws the same name — avoids a duplicate stacked label and the
|
||||
* layout jump that switching owners mid-stream used to cause.
|
||||
*/
|
||||
export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || last.role !== "assistant") return true;
|
||||
return !assistantMessageHasVisibleContent(last);
|
||||
const hasVisible = last.parts.some(
|
||||
(p) =>
|
||||
(p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type),
|
||||
);
|
||||
return !hasVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,14 +45,7 @@ export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
|
||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||
* earlier messages, streamed deltas no longer yank them back down.
|
||||
*/
|
||||
export default function MessageList({
|
||||
messages,
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
export default function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
// Whether the viewport is currently pinned to the bottom. Starts true so the
|
||||
@@ -184,11 +108,9 @@ export default function MessageList({
|
||||
if (messages.length === 0 && !typing) {
|
||||
return (
|
||||
<Center className={classes.messages}>
|
||||
{emptyState ?? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Ask the AI agent anything about your workspace.")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -197,27 +119,9 @@ export default function MessageList({
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||
// string and handed to MessageItem as its memo key. It must NOT be
|
||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||
// shared `parts` in place, so prev/next message objects both read the
|
||||
// latest content there and the memo would skip every streamed update
|
||||
// (freezing the row at its empty render). See message-item.tsx.
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
<MessageItem key={message.id} message={message} />
|
||||
))}
|
||||
{typing && (
|
||||
<TypingIndicator
|
||||
assistantName={assistantName}
|
||||
showName={typingIndicatorShowsName(messages)}
|
||||
/>
|
||||
)}
|
||||
{typing && <TypingIndicator />}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
||||
// keeps the assertions on the component's OWN count logic (authoritative vs
|
||||
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
||||
// other component tests in the repo.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { count?: number }) =>
|
||||
opts && typeof opts.count === "number"
|
||||
? key.replace("{{count}}", String(opts.count))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import ReasoningBlock from "./reasoning-block";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBlock(props: { text: string; tokens?: number }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ReasoningBlock {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ReasoningBlock", () => {
|
||||
it("shows the authoritative count in the header when tokens > 0", () => {
|
||||
// Text "thinking…" estimates to ceil(9/4) = 3, but the authoritative 42
|
||||
// must win, so the header shows 42 (and NOT the 3-token estimate).
|
||||
renderBlock({ text: "thinking…", tokens: 42 });
|
||||
expect(screen.getByText("Thinking · 42 tokens")).toBeDefined();
|
||||
expect(screen.queryByText("Thinking · 3 tokens")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the text-length estimate when no authoritative tokens", () => {
|
||||
const text = "some reasoning prose that streams in";
|
||||
const estimate = estimateTokens(text);
|
||||
renderBlock({ text });
|
||||
expect(estimate).toBeGreaterThan(0);
|
||||
expect(screen.getByText(new RegExp(`${estimate} tokens`))).toBeDefined();
|
||||
});
|
||||
|
||||
it("header-only when text is empty but an authoritative count is present", () => {
|
||||
renderBlock({ text: "", tokens: 17 });
|
||||
expect(screen.getByText(/17 tokens/)).toBeDefined();
|
||||
// No disclosure body to expand: the toggle button is disabled.
|
||||
const button = screen.getByRole("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the reasoning body (markdown or raw-text fallback)", () => {
|
||||
renderBlock({ text: "**bold** reasoning", tokens: 5 });
|
||||
// The toggle is enabled because there IS body text to expand.
|
||||
const button = screen.getByRole("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
// The body prose renders (markdown -> sanitized html, or raw-text fallback);
|
||||
// either way the text is present in the document.
|
||||
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ReasoningBlockProps {
|
||||
/** The streamed/persisted reasoning (thinking) text. May be empty when the
|
||||
* provider reports only a reasoning token COUNT without the text. */
|
||||
text: string;
|
||||
/** Authoritative reasoning token count from `usage.reasoningTokens`, when the
|
||||
* step/turn has finished. When absent (or 0) the count is estimated from the
|
||||
* text length so it ticks live as the reasoning streams in. */
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible "Thinking" block for an assistant `reasoning` part. Mirrors Claude
|
||||
* Code's surfacing of the model's thinking: a header that shows the thinking
|
||||
* token count (authoritative when the step has reported usage, else a live
|
||||
* estimate from the streamed text) and an expandable body with the reasoning
|
||||
* prose. Collapsed by default so it never crowds out the answer.
|
||||
*
|
||||
* Providers that don't stream reasoning TEXT still render this block from the
|
||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||
*/
|
||||
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
||||
// paragraphs) — ONLY here, not in the normal answer.
|
||||
const html = useMemo(
|
||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
||||
[trimmed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={classes.reasoningBlock} mb={6}>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
// No body to expand when the provider reported only a token count.
|
||||
disabled={!trimmed}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
style={{
|
||||
transform: open ? "none" : "rotate(-90deg)",
|
||||
transition: "transform 150ms ease",
|
||||
opacity: trimmed ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{count > 0
|
||||
? t("Thinking · {{count}} tokens", { count })
|
||||
: t("Thinking")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{trimmed && (
|
||||
<Collapse in={open}>
|
||||
{html ? (
|
||||
<div
|
||||
className={classes.reasoningText}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
className={classes.reasoningText}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{trimmed}
|
||||
</Text>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
||||
export default memo(ReasoningBlock);
|
||||
@@ -1,65 +0,0 @@
|
||||
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
/* flex-start keeps the first row reachable when the wrapped cards overflow and
|
||||
the container scrolls. With align-content: center, an overflowing top row is
|
||||
pushed out of the scrollable area and becomes unreachable. The parent Mantine
|
||||
Center still vertically centers the whole block when it fits. */
|
||||
align-content: flex-start;
|
||||
gap: 10px;
|
||||
/* Cap the height so a large number of roles scrolls instead of blowing out
|
||||
the empty chat area. */
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
/* Grow to fill the row so cards use the available window width instead of
|
||||
leaving large side gaps; the flex-basis sets how many fit per row before
|
||||
wrapping (≈2 columns at the default window width, more as it widens). */
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
min-height: 90px;
|
||||
padding: 12px 10px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The description: small and slightly muted, inheriting the card's color. We
|
||||
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
|
||||
with the card's inline color. */
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
line-height: 1.3;
|
||||
/* Break long unbreakable tokens (URLs, long foreign words) in the
|
||||
admin-configured description so they wrap instead of overflowing the card
|
||||
width now that the line clamp no longer caps the text. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import RoleCards from "./role-cards";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const roles: IAiRole[] = [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Pirate",
|
||||
emoji: "🏴☠️",
|
||||
description: "Talks like a pirate",
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "Grandpa",
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
function renderCards(onPick = vi.fn()) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RoleCards roles={roles} onPick={onPick} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return onPick;
|
||||
}
|
||||
|
||||
describe("RoleCards", () => {
|
||||
it("renders one card per role with name, emoji, and description", () => {
|
||||
renderCards();
|
||||
expect(screen.getByText("Pirate")).toBeDefined();
|
||||
expect(screen.getByText("Talks like a pirate")).toBeDefined();
|
||||
expect(screen.getByText("Grandpa")).toBeDefined();
|
||||
// The emoji is shown for the role that has one.
|
||||
expect(screen.getByText("🏴☠️")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render a Universal assistant card", () => {
|
||||
renderCards();
|
||||
expect(screen.queryByText("Universal assistant")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onPick with the role object when a card is clicked", () => {
|
||||
const onPick = renderCards();
|
||||
fireEvent.click(screen.getByText("Pirate"));
|
||||
expect(onPick).toHaveBeenCalledWith(roles[0]);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { UnstyledButton, Text } from "@mantine/core";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
|
||||
import classes from "@/features/ai-chat/components/role-cards.module.css";
|
||||
|
||||
interface RoleCardsProps {
|
||||
/** The enabled roles to render (one card each). */
|
||||
roles: IAiRole[];
|
||||
/** Called with the picked role when a card is clicked. The parent starts the
|
||||
* chat with this role (binds it and sends the opening message). */
|
||||
onPick: (role: IAiRole) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
|
||||
* they render correctly in both light and dark themes; the CSS module owns only
|
||||
* the layout. The card shows the emoji (if any), the role name, and a small
|
||||
* dimmed description line (if any).
|
||||
*/
|
||||
function RoleCard({
|
||||
color,
|
||||
name,
|
||||
emoji,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
color: string;
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
description?: string | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.card}
|
||||
style={{
|
||||
backgroundColor: `var(--mantine-color-${color}-light)`,
|
||||
color: `var(--mantine-color-${color}-light-color)`,
|
||||
}}
|
||||
title={description ?? name}
|
||||
onClick={onClick}
|
||||
>
|
||||
{emoji && <span className={classes.emoji}>{emoji}</span>}
|
||||
<Text size="sm" fw={600} lineClamp={2}>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="xs" className={classes.description}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored role cards rendered as the empty-state of a brand-new chat. There is
|
||||
* no Universal assistant card — the universal assistant is the implicit default
|
||||
* the user gets by simply typing into the composer without picking a card.
|
||||
* Clicking a card immediately STARTS the chat with that role (the parent binds
|
||||
* the role to the new chat and sends the opening message).
|
||||
*/
|
||||
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{roles.map((role, index) => (
|
||||
<RoleCard
|
||||
key={role.id}
|
||||
color={roleCardColor(index)}
|
||||
name={role.name}
|
||||
emoji={role.emoji}
|
||||
description={role.description}
|
||||
onClick={() => onPick(role)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the typing-indicator bridging logic that the internal
|
||||
* chat and the public share widget now share. This is the behavior that decides
|
||||
* whether the animated "Thinking…" placeholder shows in the gap
|
||||
* between sending and the first streamed token.
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: UIMessage["parts"],
|
||||
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
|
||||
|
||||
describe("showTypingIndicator", () => {
|
||||
it("is hidden when not streaming", () => {
|
||||
expect(showTypingIndicator([], false)).toBe(false);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming with no messages yet (just submitted)", () => {
|
||||
expect(showTypingIndicator([], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the last message is still the user's", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when the assistant row has no visible content", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides once the assistant streams non-empty text", () => {
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once a tool part appears (even before any text)", () => {
|
||||
const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [toolPart])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming after a tool has finished (thinking between steps)", () => {
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [doneTool])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows while streaming when a finished tool is the last part after some text", () => {
|
||||
const text = { type: "text", text: "Let me check" } as unknown as UIMessage["parts"][number];
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [text, doneTool])], true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides while a tool is still running", () => {
|
||||
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [runningTool])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once the assistant streams non-empty text after a finished tool", () => {
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
const text = { type: "text", text: "The answer is 42" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
showTypingIndicator([msg("assistant", [doneTool, text])], true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows while streaming after a text part is finalized (paused before the next step)", () => {
|
||||
const doneText = { type: "text", text: "Now creating the page in", state: "done" } as unknown as UIMessage["parts"][number];
|
||||
expect(showTypingIndicator([msg("assistant", [doneText])], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides while a text part is actively streaming (state: streaming)", () => {
|
||||
const streamingText = { type: "text", text: "Now writ", state: "streaming" } as unknown as UIMessage["parts"][number];
|
||||
expect(showTypingIndicator([msg("assistant", [streamingText])], true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,6 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolUiPart;
|
||||
/**
|
||||
* Whether to render page citation links. Defaults to true (the internal chat,
|
||||
* where the reader is authenticated and the `/p/{id}` links resolve). The
|
||||
* public share passes false: an anonymous reader cannot open internal pages,
|
||||
* so the links would 404/redirect to login. Suppressing them keeps the card
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,15 +20,12 @@ interface ToolCallCardProps {
|
||||
* agent DID (the agent writes without confirmation — D2), its run state
|
||||
* (running / done / error), and citation link(s) to any referenced page(s).
|
||||
*/
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
}: ToolCallCardProps) {
|
||||
export default function ToolCallCard({ part }: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
const citations = toolCitations(part);
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { typingIndicatorShowsName } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for whether the standalone "Thinking…" indicator renders its
|
||||
* own dimmed assistant-name label. The indicator OWNS the name while the tail
|
||||
* assistant row has no visible content yet (an empty streaming text part, or
|
||||
* reasoning/step-start while the model is still thinking) — in that gap the
|
||||
* assistant MessageItem renders nothing, so the indicator stands in for the
|
||||
* nascent bubble (name + dots). It hides the name only once the tail assistant
|
||||
* row shows visible content, because then MessageItem draws the same name — this
|
||||
* avoids a duplicate stacked label and the layout jump that switching owners
|
||||
* mid-stream used to cause.
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: UIMessage["parts"],
|
||||
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
|
||||
|
||||
describe("typingIndicatorShowsName", () => {
|
||||
it("shows the name with no messages yet (standalone, just submitted)", () => {
|
||||
expect(typingIndicatorShowsName([])).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the name when the last message is still the user's", () => {
|
||||
expect(
|
||||
typingIndicatorShowsName([msg("user", [{ type: "text", text: "q" }])]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the name when the tail assistant row has no visible content yet (empty text part)", () => {
|
||||
// The empty streaming text part has no visible content, so MessageItem renders
|
||||
// nothing and the indicator owns the name (the nascent bubble).
|
||||
expect(
|
||||
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "" }])]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the name once the tail assistant row shows content (a tool part)", () => {
|
||||
const doneTool = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(
|
||||
typingIndicatorShowsName([msg("assistant", [doneTool])]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the name once the tail assistant row shows content (non-empty text)", () => {
|
||||
expect(
|
||||
typingIndicatorShowsName([msg("assistant", [{ type: "text", text: "answer" }])]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,33 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
/**
|
||||
* Display name for the dimmed label and the "… is typing…" line. Defaults to
|
||||
* "AI agent" when absent; the public share passes the configured identity
|
||||
* (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
/**
|
||||
* Whether to render the dimmed assistant-name label. Defaults to true
|
||||
* (standalone behavior preserved). Set false between agent steps where the
|
||||
* assistant row above already shows the same name, to avoid a duplicate label.
|
||||
*/
|
||||
showName?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "… is typing…" placeholder shown while a turn is in flight but the latest
|
||||
* assistant message has no visible content yet (no rendered text/tool parts). It
|
||||
* covers the gap between sending and the first streamed token, and is replaced by
|
||||
* the real assistant message once content starts arriving.
|
||||
* Live "AI agent is typing…" placeholder shown while a turn is in flight but the
|
||||
* latest assistant message has no visible content yet (no rendered text/tool
|
||||
* parts). It covers the gap between sending and the first streamed token, and is
|
||||
* replaced by the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||
* as the assistant's bubble taking shape. The dimmed label uses the configured
|
||||
* identity name when provided (otherwise the generic "AI agent"); below it the
|
||||
* animated dots stand in for the nascent bubble until content arrives.
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
|
||||
* so it reads as the assistant's bubble taking shape.
|
||||
*/
|
||||
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
|
||||
export default function TypingIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const name = resolveAssistantName(assistantName);
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
{showName !== false && (
|
||||
// Extra bottom gap (vs MessageItem's mb={4}) gives the small bouncing
|
||||
// dots room below the name label; without it they crowd the label. Only
|
||||
// applies when the name is shown — the nameless case spaces fine on its own.
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
{name ?? t("AI agent")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<span className={classes.typingDots} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("AI agent is typing…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useChatSession } from "./use-chat-session";
|
||||
import type { UseChatSessionOptions } from "./use-chat-session";
|
||||
|
||||
// The props the test drives: the parent-owned subset of UseChatSessionOptions
|
||||
// (the spies are injected by setup, not per-render). messagesLoading is optional
|
||||
// here (defaulted to false in setup) for terser test call sites.
|
||||
type DriverProps = Pick<UseChatSessionOptions, "activeChatId" | "chats"> & {
|
||||
messagesLoading?: boolean;
|
||||
};
|
||||
|
||||
// Drive the hook the way the window does: the parent owns `activeChatId` and
|
||||
// passes it back in. `setActiveChatId` is a spy so we can assert the EXACT id the
|
||||
// hook adopts (the #137 regression: it must be the authoritative streamed id, not
|
||||
// the newest chat in the list).
|
||||
function setup(initial: DriverProps) {
|
||||
const setActiveChatId = vi.fn();
|
||||
const onInvalidateChatList = vi.fn();
|
||||
const onInvalidateChatMessages = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
(props: DriverProps) =>
|
||||
useChatSession({
|
||||
activeChatId: props.activeChatId,
|
||||
setActiveChatId,
|
||||
chats: props.chats,
|
||||
messagesLoading: props.messagesLoading ?? false,
|
||||
onInvalidateChatList,
|
||||
onInvalidateChatMessages,
|
||||
}),
|
||||
{ initialProps: initial },
|
||||
);
|
||||
return {
|
||||
result,
|
||||
rerender,
|
||||
setActiveChatId,
|
||||
onInvalidateChatList,
|
||||
onInvalidateChatMessages,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatSession", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("#137 REGRESSION LOCK: adopts the authoritative streamed id, NOT items[0]", () => {
|
||||
// Brand-new chat, list already holds a SIBLING chat B as items[0] (a second
|
||||
// tab just created it). The server streams the real id "A" for THIS chat.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "B" }] },
|
||||
});
|
||||
result.current.onTurnFinished("A");
|
||||
// Must adopt the authoritative id, not the newest-in-list guess.
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("B");
|
||||
});
|
||||
|
||||
it("fallback adopt: arms on a server-id-less finish, adopts the single new id after refetch", () => {
|
||||
const { result, rerender, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }] },
|
||||
});
|
||||
// No server id => arm the fallback (no adoption yet).
|
||||
result.current.onTurnFinished(undefined);
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
// The refetch lands with the new row => adopt it.
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }, { id: "new" }] },
|
||||
});
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
||||
});
|
||||
|
||||
it("fallback ambiguous: two new ids appear => no adoption", () => {
|
||||
const { result, rerender, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }] },
|
||||
});
|
||||
result.current.onTurnFinished(undefined);
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }, { id: "n1" }, { id: "n2" }] },
|
||||
});
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fallback add+delete in one window: adopts the new id (membership compare)", () => {
|
||||
const { result, rerender, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "a" }, { id: "b" }] },
|
||||
});
|
||||
result.current.onTurnFinished(undefined);
|
||||
// a was deleted, new was added — same length, but membership changed.
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "b" }, { id: "new" }] },
|
||||
});
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
||||
});
|
||||
|
||||
it("disarm on reconcile: a fallback armed then switched away is NOT adopted by a late refetch", () => {
|
||||
// Arm the error-path fallback on a brand-new chat (snapshot before=["x"]).
|
||||
const { result, rerender, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }] },
|
||||
});
|
||||
result.current.onTurnFinished(undefined);
|
||||
// The user switches to an existing chat C BEFORE the refetch lands; the
|
||||
// render-phase reconciler must DISARM the pending fallback.
|
||||
rerender({ activeChatId: "C", chats: { items: [{ id: "x" }] } });
|
||||
// ...then starts a fresh new chat again (back to null), without re-arming.
|
||||
rerender({ activeChatId: null, chats: { items: [{ id: "x" }] } });
|
||||
// A late refetch now brings a new row. Because the earlier fallback was
|
||||
// disarmed on the switch (not left armed with the stale ["x"] snapshot), it
|
||||
// must NOT be adopted. (Without the disarm this would wrongly adopt "new".)
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }, { id: "new" }] },
|
||||
});
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("new");
|
||||
});
|
||||
|
||||
it("startNewChat while already in a new chat: cancelPendingAdoption stops a late refetch adopting the failed chat", () => {
|
||||
// The Warning path the render-phase reconciler can't catch: pressing "New
|
||||
// chat" while already in a new chat keeps activeChatId === null (a no-op for
|
||||
// the atom), so only the explicit cancelPendingAdoption() disarms.
|
||||
const { result, rerender, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }] },
|
||||
});
|
||||
result.current.onTurnFinished(undefined); // first turn failed → arm (before=["x"])
|
||||
result.current.cancelPendingAdoption(); // window calls this from startNewChat
|
||||
// The just-failed row lands in a late refetch; it must NOT be adopted.
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }, { id: "failed" }] },
|
||||
});
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("failed");
|
||||
});
|
||||
|
||||
it("onTurnFinished for an existing chat: no adoption, invalidates that chat's messages", () => {
|
||||
const {
|
||||
result,
|
||||
setActiveChatId,
|
||||
onInvalidateChatList,
|
||||
onInvalidateChatMessages,
|
||||
} = setup({ activeChatId: "chat-1", chats: { items: [{ id: "chat-1" }] } });
|
||||
result.current.onTurnFinished("chat-1");
|
||||
expect(setActiveChatId).not.toHaveBeenCalled(); // existing chat is never re-adopted
|
||||
expect(onInvalidateChatList).toHaveBeenCalled();
|
||||
expect(onInvalidateChatMessages).toHaveBeenCalledWith("chat-1");
|
||||
});
|
||||
|
||||
it("double onTurnFinished on a failed-after-start turn: primary adopt, 2nd no-id call does NOT re-arm the fallback", () => {
|
||||
// ai@6 fires onFinish AND onError on a failed turn. If the failure happened
|
||||
// AFTER the `start` chunk, onFinish carries the streamed id and onError does
|
||||
// not — so onTurnFinished runs twice in one turn (id, then no-id) before any
|
||||
// re-render. The 2nd call must NOT re-arm the fallback off the still-null
|
||||
// closure; otherwise a late refetch (parent hasn't reflected the adoption yet)
|
||||
// would wrongly adopt a sibling row.
|
||||
const { result, rerender, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }] },
|
||||
});
|
||||
result.current.onTurnFinished("A"); // onFinish: primary adoption
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
result.current.onTurnFinished(undefined); // onError: same turn, no id
|
||||
// Even in the worst case (the parent has NOT yet reflected activeChatId="A"
|
||||
// and a late refetch lands a new row), the just-failed sibling must NOT be
|
||||
// adopted. Two layers guarantee this: the ref guard keeps the 2nd call from
|
||||
// re-arming at the source, and the render-phase reconciler disarms anything
|
||||
// stale once thread.chatId ("A") diverges from the still-null activeChatId.
|
||||
rerender({
|
||||
activeChatId: null,
|
||||
chats: { items: [{ id: "x" }, { id: "late" }] },
|
||||
});
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
|
||||
});
|
||||
|
||||
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
|
||||
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
|
||||
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
|
||||
// fires at the terminal outcome). The hook must adopt it immediately so the
|
||||
// window's activeChatId-gated Copy/export button lights up during the stream.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
result.current.onServerChatId("A");
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
|
||||
const chats = { items: [] };
|
||||
const { result, rerender } = setup({ activeChatId: null, chats });
|
||||
const keyBefore = result.current.threadKey;
|
||||
result.current.onServerChatId("A");
|
||||
// Parent reflects the adopted id back in; the SAME mount key is kept so the
|
||||
// in-flight useChat store (the streaming turn) is preserved.
|
||||
rerender({ activeChatId: "A", chats });
|
||||
expect(result.current.threadKey).toBe(keyBefore);
|
||||
});
|
||||
|
||||
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: "chat-1",
|
||||
chats: { items: [{ id: "chat-1" }] },
|
||||
});
|
||||
result.current.onServerChatId("chat-1"); // already has an id
|
||||
result.current.onServerChatId(undefined); // no streamed id
|
||||
expect(setActiveChatId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
|
||||
const chats = { items: [{ id: "B" }] };
|
||||
const { result, rerender } = setup({ activeChatId: null, chats });
|
||||
const keyBefore = result.current.threadKey;
|
||||
// Adopt the streamed id; the PARENT then reflects activeChatId="A" back in.
|
||||
result.current.onTurnFinished("A");
|
||||
rerender({ activeChatId: "A", chats });
|
||||
// In-place adoption: SAME mount key (the live useChat store is preserved).
|
||||
expect(result.current.threadKey).toBe(keyBefore);
|
||||
|
||||
// An EXTERNAL switch (not via adopt) to a different chat must remount: the
|
||||
// key becomes the chat id.
|
||||
rerender({ activeChatId: "C", chats });
|
||||
expect(result.current.threadKey).toBe("C");
|
||||
});
|
||||
|
||||
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
|
||||
// Brand-new chat whose first turn is still streaming: the id is adopted only
|
||||
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
|
||||
// chat" must still remount to a clean thread even though the atom is unchanged
|
||||
// — the render-phase reconciler (null === null) would otherwise do nothing,
|
||||
// leaving the old chat/stream/history in place (the bug: only the role badge
|
||||
// dropped).
|
||||
const { result } = setup({ activeChatId: null, chats: { items: [] } });
|
||||
const keyBefore = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(keyBefore);
|
||||
});
|
||||
|
||||
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
|
||||
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
|
||||
// abort the abandoned stream on unmount: its onFinish still fires later with
|
||||
// the real server id, tagged with the OLD (abandoned) mount key. That must not
|
||||
// adopt — it would yank the user back into the chat they just left.
|
||||
const { result, setActiveChatId, onInvalidateChatList } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const abandonedKey = result.current.threadKey;
|
||||
act(() => result.current.startFreshThread());
|
||||
expect(result.current.threadKey).not.toBe(abandonedKey);
|
||||
// The abandoned turn finishes in the background, streaming its real id "A".
|
||||
result.current.onTurnFinished("A", abandonedKey);
|
||||
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
|
||||
// It still refreshes the chat list so the left-behind chat shows in history.
|
||||
expect(onInvalidateChatList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
|
||||
// The happy path must keep working: onTurnFinished tagged with the mounted
|
||||
// thread's own key adopts in place as before.
|
||||
const { result, setActiveChatId } = setup({
|
||||
activeChatId: null,
|
||||
chats: { items: [] },
|
||||
});
|
||||
const currentKey = result.current.threadKey;
|
||||
result.current.onTurnFinished("A", currentKey);
|
||||
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
|
||||
// Open an existing chat whose history is still loading => loader on.
|
||||
const { result, rerender } = setup({
|
||||
activeChatId: "chat-1",
|
||||
chats: { items: [{ id: "chat-1" }] },
|
||||
messagesLoading: true,
|
||||
});
|
||||
expect(result.current.waitingForHistory).toBe(true);
|
||||
// Once loading finishes, the latch flips and the loader is off.
|
||||
rerender({
|
||||
activeChatId: "chat-1",
|
||||
chats: { items: [{ id: "chat-1" }] },
|
||||
messagesLoading: false,
|
||||
});
|
||||
expect(result.current.waitingForHistory).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,322 +0,0 @@
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { generateId } from "ai";
|
||||
import {
|
||||
resolveAdoptedChatId,
|
||||
newlyAddedChatIds,
|
||||
} from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import {
|
||||
newThread,
|
||||
switchThread,
|
||||
threadSessionReducer,
|
||||
} from "@/features/ai-chat/utils/thread-identity.ts";
|
||||
|
||||
/** Inputs to {@link useChatSession}. `activeChatId`/`setActiveChatId` are the
|
||||
* public selection atom (also written from outside the window, e.g. page
|
||||
* history); the rest is read-only context the hook needs. */
|
||||
export interface UseChatSessionOptions {
|
||||
activeChatId: string | null;
|
||||
setActiveChatId: (id: string | null) => void;
|
||||
chats: { items?: { id: string }[] } | undefined;
|
||||
messagesLoading: boolean;
|
||||
/** Wraps queryClient.invalidateQueries(AI_CHATS_RQ_KEY). */
|
||||
onInvalidateChatList: () => void;
|
||||
/** Wraps the per-chat messages invalidation. */
|
||||
onInvalidateChatMessages: (chatId: string) => void;
|
||||
}
|
||||
|
||||
/** What the window needs from a chat session: the ChatThread mount key, the
|
||||
* history-loader gate, and the turn-finished callback. */
|
||||
export interface UseChatSessionResult {
|
||||
/** ChatThread mount key (was `thread.key`). */
|
||||
threadKey: string;
|
||||
/** Show the history loader instead of the live thread. */
|
||||
waitingForHistory: boolean;
|
||||
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
|
||||
* even when `activeChatId` is unchanged. The window calls this from
|
||||
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
|
||||
* still streaming (activeChatId still null, nothing to diverge) actually
|
||||
* resets the chat instead of only dropping the role badge (#161). */
|
||||
startFreshThread: () => void;
|
||||
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
|
||||
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
|
||||
* thread that produced the turn (omit => "current thread", back-compatible):
|
||||
* a turn ABANDONED by New chat mid-stream still fires this after its thread
|
||||
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
|
||||
* new-chat id adoption + invalidations. */
|
||||
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
||||
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
|
||||
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
|
||||
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
|
||||
* button, #174) available immediately. In-place adoption only (same mount key,
|
||||
* no list/messages invalidation — that is left to onTurnFinished at the end).
|
||||
* Idempotent and a no-op once the chat already has an id. */
|
||||
onServerChatId: (serverChatId?: string) => void;
|
||||
/** Disarm any pending error-path new-chat fallback. The window calls this from
|
||||
* startNewChat/selectChat so a late refetch can't yank the user back into a
|
||||
* just-failed chat after they explicitly moved on. */
|
||||
cancelPendingAdoption: () => void;
|
||||
}
|
||||
|
||||
/** Project a chat list to its id array (the before/after snapshot for the
|
||||
* error-path fallback). */
|
||||
function chatIdSnapshot(
|
||||
chats: { items?: { id: string }[] } | undefined,
|
||||
): string[] {
|
||||
return chats?.items?.map((c) => c.id) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the AI-chat thread-identity lifecycle: the single atomic thread identity,
|
||||
* both new-chat id adoption paths (primary streamed-metadata + bounded error-path
|
||||
* fallback), the history-loaded latch, and the render-phase reconciler that keeps
|
||||
* the thread's mount key in sync with the public `activeChatId` atom.
|
||||
*
|
||||
* This is the twice-bugged area for the #137 two-tab adoption race; the canonical
|
||||
* explanation of the adoption design lives in adopt-chat-id.ts.
|
||||
*/
|
||||
export function useChatSession(
|
||||
params: UseChatSessionOptions,
|
||||
): UseChatSessionResult {
|
||||
const {
|
||||
activeChatId,
|
||||
setActiveChatId,
|
||||
chats,
|
||||
messagesLoading,
|
||||
onInvalidateChatList,
|
||||
onInvalidateChatMessages,
|
||||
} = params;
|
||||
|
||||
// Live mirror of `activeChatId`, read by onTurnFinished. ai@6 fires both
|
||||
// onFinish AND onError on a failed turn, so onTurnFinished can run twice in one
|
||||
// turn (once with the streamed id, once without) BEFORE a re-render. Reading
|
||||
// the ref — which the primary-adoption branch updates imperatively — makes that
|
||||
// second call see the just-adopted id, so it cannot re-arm the fallback. (A
|
||||
// plain closure over `activeChatId` would still read null on the second call.)
|
||||
const activeChatIdRef = useRef(activeChatId);
|
||||
activeChatIdRef.current = activeChatId;
|
||||
|
||||
// The mounted thread's identity: ONE atomic value tying ChatThread's mount key
|
||||
// (`thread.key`) to the chat id that mounted thread holds (`thread.chatId`).
|
||||
// Consolidating these makes the "key vs chat id diverged" state unrepresentable
|
||||
// — every change goes through an explicit transition (see thread-identity.ts):
|
||||
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
|
||||
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
|
||||
// session key with no chat id yet.
|
||||
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
|
||||
activeChatId === null
|
||||
? newThread(`new-${generateId()}`)
|
||||
: switchThread(activeChatId),
|
||||
);
|
||||
|
||||
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
|
||||
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
|
||||
// does not abort a stream on unmount and proxies callbacks through a ref, so an
|
||||
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
|
||||
// matching its key against this ref keeps that late finish from adopting the
|
||||
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
|
||||
const threadKeyRef = useRef(thread.key);
|
||||
threadKeyRef.current = thread.key;
|
||||
|
||||
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
|
||||
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
|
||||
// reaches the client, so the primary metadata adoption cannot run. We then ARM
|
||||
// this ref with a snapshot of the currently-known chat ids; once the list
|
||||
// refetch lands with the just-created row, the fallback effect below adopts the
|
||||
// SINGLE newly-appeared id. `null` = not armed. See adopt-chat-id.ts (#137).
|
||||
const pendingNewChatRef = useRef<string[] | null>(null);
|
||||
|
||||
// Latch: the chat id whose full persisted history has finished loading while
|
||||
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
|
||||
// messages invalidation) never tears the live thread back down to the loader.
|
||||
const historyLoadedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
||||
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
|
||||
// list, which races a second tab — #137; see adopt-chat-id.ts).
|
||||
const onTurnFinished = useCallback(
|
||||
(serverChatId?: string, finishingThreadKey?: string) => {
|
||||
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
|
||||
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
|
||||
// not abort on unmount and proxies callbacks through a ref). If that late
|
||||
// finish ran the adoption path it would set activeChatId to the abandoned
|
||||
// chat's real id and yank the user out of the fresh chat they just opened.
|
||||
// So adopt / arm the fallback ONLY for the still-mounted thread; an
|
||||
// abandoned one merely refreshes the chat list (so the left-behind chat
|
||||
// surfaces in history) and does nothing else. A missing key (undefined)
|
||||
// means "current thread" — keeps old call sites/tests working.
|
||||
if (
|
||||
finishingThreadKey !== undefined &&
|
||||
finishingThreadKey !== threadKeyRef.current
|
||||
) {
|
||||
onInvalidateChatList();
|
||||
return;
|
||||
}
|
||||
// Read the live id from the ref, not the closure: on a failed turn this can
|
||||
// run twice in one turn (onFinish + onError) before any re-render, and the
|
||||
// primary branch below updates the ref so the second call sees the adopted id.
|
||||
const current = activeChatIdRef.current;
|
||||
const adopted = resolveAdoptedChatId(current, serverChatId);
|
||||
if (adopted) {
|
||||
// PRIMARY path. In-place adoption: set the public selection and the
|
||||
// thread identity to the real id together. `adopt` keeps the SAME mount
|
||||
// key, so the render-phase reconciler sees `activeChatId === thread.chatId`
|
||||
// and keeps the SAME mounted thread (its useChat already holds the
|
||||
// just-finished turn) instead of remounting + re-seeding from
|
||||
// not-yet-persisted history.
|
||||
activeChatIdRef.current = adopted; // a same-turn 2nd call now sees the id
|
||||
setActiveChatId(adopted);
|
||||
dispatch({ type: "adopt", chatId: adopted });
|
||||
// Primary adoption won — disarm any previously-armed fallback.
|
||||
pendingNewChatRef.current = null;
|
||||
} else if (current === null) {
|
||||
// FALLBACK path: a brand-new chat finished with NO server id (the first
|
||||
// turn errored before the `start` chunk). Arm the bounded list-refetch
|
||||
// fallback by snapshotting the currently-known chat ids. `chats` is still
|
||||
// the pre-refetch list here, so the just-created row is NOT yet in it; the
|
||||
// effect below adopts the single id that newly appears after the refetch.
|
||||
pendingNewChatRef.current = chatIdSnapshot(chats);
|
||||
}
|
||||
onInvalidateChatList();
|
||||
// Re-sync the persisted message rows for the active chat so the Markdown
|
||||
// export and token counters reflect the just-finished turn. The live thread
|
||||
// renders from its own useChat store (stable thread.key), so this never
|
||||
// re-seeds or tears down the open thread. For a brand-new chat `current` is
|
||||
// still null here; later turns hit this with the adopted id.
|
||||
if (current) {
|
||||
onInvalidateChatMessages(current);
|
||||
}
|
||||
},
|
||||
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
|
||||
);
|
||||
|
||||
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
|
||||
// the server emits it on the `start` chunk, so a brand-new chat gets its real
|
||||
// `activeChatId` WHILE its first turn streams — not only at terminal
|
||||
// onTurnFinished. This makes the activeChatId-gated Copy/export button
|
||||
// available during the first turn. Pure in-place adoption (same mount key, like
|
||||
// the primary path) with NO invalidation: the list/messages refresh stays on
|
||||
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
|
||||
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
|
||||
// still-new chat).
|
||||
const onServerChatId = useCallback(
|
||||
(serverChatId?: string) => {
|
||||
const adopted = resolveAdoptedChatId(
|
||||
activeChatIdRef.current,
|
||||
serverChatId,
|
||||
);
|
||||
if (!adopted) return;
|
||||
activeChatIdRef.current = adopted;
|
||||
setActiveChatId(adopted);
|
||||
dispatch({ type: "adopt", chatId: adopted });
|
||||
// Early adoption beat the error-path fallback to it — disarm.
|
||||
pendingNewChatRef.current = null;
|
||||
},
|
||||
[setActiveChatId],
|
||||
);
|
||||
|
||||
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
|
||||
// turn errored before the `start` chunk (no authoritative id streamed). Once
|
||||
// the per-user list refetch lands with the just-created row, adopt the SINGLE
|
||||
// id that newly appeared relative to the pre-refetch snapshot. Adoption is IN
|
||||
// PLACE (set activeChatId + `adopt` together) like the primary path, so the
|
||||
// render-phase reconciler does not remount.
|
||||
useEffect(() => {
|
||||
const before = pendingNewChatRef.current;
|
||||
if (before === null || activeChatId !== null) return; // not armed / already adopted
|
||||
const after = chatIdSnapshot(chats);
|
||||
const added = newlyAddedChatIds(before, after);
|
||||
// Keep waiting until a genuinely-new id appears. Set-based, so it is robust
|
||||
// to an add+delete in the same window (a length compare would miss it), and
|
||||
// it deliberately keeps waiting through an unrelated deletion (no new id yet)
|
||||
// until the just-created row actually lands, rather than giving up early.
|
||||
if (added.size === 0) return; // list not refetched yet — keep waiting
|
||||
pendingNewChatRef.current = null; // resolved — disarm
|
||||
if (added.size === 1) {
|
||||
// single unambiguous new id; >1 = ambiguous → give up
|
||||
const adopted = [...added][0];
|
||||
setActiveChatId(adopted);
|
||||
dispatch({ type: "adopt", chatId: adopted });
|
||||
}
|
||||
}, [chats, activeChatId, setActiveChatId]);
|
||||
|
||||
// Reconcile the thread identity against the active-chat atom during render when
|
||||
// they diverge — the React-sanctioned alternative to an effect (re-renders
|
||||
// before paint, no extra commit, and converges since the next render finds them
|
||||
// equal). This reconciliation MUST remain: `activeChatId` is the public
|
||||
// selection and is ALSO set from OUTSIDE this component (e.g. page-history opens
|
||||
// a referenced chat via setActiveChatId). A divergence here is a genuine SWITCH
|
||||
// (external atom change OR user switch via selectChat/startNewChat), so
|
||||
// `reconcile` remounts + reseeds. In-place adoption never reaches this branch:
|
||||
// it set activeChatId and thread.chatId to the same value.
|
||||
if (activeChatId !== thread.chatId) {
|
||||
// A genuine switch makes any pending error-path new-chat fallback moot.
|
||||
pendingNewChatRef.current = null;
|
||||
dispatch({
|
||||
type: "reconcile",
|
||||
chatId: activeChatId,
|
||||
newKey: `new-${generateId()}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Latch the active chat once its full history has loaded and its thread is
|
||||
// mounted, so a later background refetch (the post-turn messages invalidation,
|
||||
// which can transiently flip hasNextPage for a chat whose message count is an
|
||||
// exact multiple of the server page size) does not tear the live thread down to
|
||||
// a loader and lose its in-progress useChat state.
|
||||
if (
|
||||
activeChatId !== null &&
|
||||
thread.key === activeChatId &&
|
||||
!messagesLoading &&
|
||||
historyLoadedKeyRef.current !== activeChatId
|
||||
) {
|
||||
historyLoadedKeyRef.current = activeChatId;
|
||||
}
|
||||
|
||||
// Show the history loader only when freshly OPENING an existing chat (the key
|
||||
// equals the chat id) whose history has not been fully loaded yet. For a live
|
||||
// in-place thread that adopted its id, the key is still the "new-…" session
|
||||
// key, so the live thread keeps rendering; and once a chat's history has loaded,
|
||||
// a later background refetch no longer tears it down (see the latch above).
|
||||
const waitingForHistory =
|
||||
activeChatId !== null &&
|
||||
messagesLoading &&
|
||||
thread.key === activeChatId &&
|
||||
historyLoadedKeyRef.current !== activeChatId;
|
||||
|
||||
// Explicit disarm for startNewChat/selectChat. The render-phase reconciler only
|
||||
// disarms when activeChatId actually changes, but "New chat" pressed while the
|
||||
// user is ALREADY in a new chat is a no-op for the atom (activeChatId stays
|
||||
// null), so the reconciler never fires — without this an armed fallback could
|
||||
// adopt the just-failed chat from a late refetch and yank the user out of their
|
||||
// fresh chat. Stable identity (writes a ref).
|
||||
const cancelPendingAdoption = useCallback(() => {
|
||||
pendingNewChatRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
|
||||
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
|
||||
// so "New chat" pressed while a brand-new chat's first turn is still streaming
|
||||
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
|
||||
// end of the turn) is a no-op for it and the abandoned thread/stream/history
|
||||
// would persist. Dispatching reconcile with a fresh key and chatId:null here
|
||||
// always produces a new mount key, so React remounts ChatThread (a clean useChat
|
||||
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
|
||||
// keeps the reconciler from interfering. Also disarms any pending fallback.
|
||||
const startFreshThread = useCallback(() => {
|
||||
pendingNewChatRef.current = null;
|
||||
dispatch({
|
||||
type: "reconcile",
|
||||
chatId: null,
|
||||
newKey: `new-${generateId()}`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
threadKey: thread.key,
|
||||
waitingForHistory,
|
||||
startFreshThread,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
cancelPendingAdoption,
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -4,49 +4,22 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
createAiRole,
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
getAiRoles,
|
||||
importAiRolesFromCatalog,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
updateAiRoleFromCatalog,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
// Catalog reads resolve bundle names per language, so the language is part of
|
||||
// the cache key (a language switch refetches rather than reusing stale names).
|
||||
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
|
||||
"ai-role-catalog",
|
||||
language,
|
||||
];
|
||||
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
|
||||
bundleId: string,
|
||||
language: string,
|
||||
) => ["ai-role-catalog-bundle", bundleId, language];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -94,31 +67,6 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
// (and both the Markdown export and the model-history seed) require the
|
||||
// COMPLETE thread, so keep pulling subsequent pages until the server reports
|
||||
// none remain. The isFetchingNextPage guard issues one request at a time;
|
||||
// when chatId is undefined the query is disabled and hasNextPage is false, so
|
||||
// this is a no-op. The isFetchNextPageError guard is critical: the app sets a
|
||||
// global `retry: false`, so a rejected fetchNextPage leaves hasNextPage true
|
||||
// and isFetchingNextPage false — without this guard the effect would re-fire
|
||||
// immediately and hammer the endpoint in a tight loop. isFetchNextPageError
|
||||
// latches the last next-page failure and clears once a fetch succeeds.
|
||||
useEffect(() => {
|
||||
if (
|
||||
query.hasNextPage &&
|
||||
!query.isFetchingNextPage &&
|
||||
!query.isFetchNextPageError
|
||||
) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
query.hasNextPage,
|
||||
query.isFetchingNextPage,
|
||||
query.isFetchNextPageError,
|
||||
query.fetchNextPage,
|
||||
]);
|
||||
|
||||
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
|
||||
if (!query.data) return undefined;
|
||||
return query.data.pages.flatMap((p) => p.items);
|
||||
@@ -166,185 +114,3 @@ export function useDeleteAiChatMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the workspace's agent roles. Available to any workspace member (used by
|
||||
* the chat-creation role picker and the admin management section). `enabled`
|
||||
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
||||
*/
|
||||
export function useAiRolesQuery(enabled: boolean = true) {
|
||||
return useQuery<IAiRole[], Error>({
|
||||
queryKey: AI_ROLES_RQ_KEY,
|
||||
queryFn: () => getAiRoles(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
||||
mutationFn: (data) => createAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Created successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
||||
mutationFn: (data) => updateAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// The role badge denormalized onto the chat list may have changed.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ success: true }, Error, string>({
|
||||
mutationFn: (id) => deleteAiRole(id),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Deleted successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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)",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
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,14 +5,6 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -42,17 +34,6 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
*/
|
||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||
pageId,
|
||||
});
|
||||
return req.data.chatId;
|
||||
}
|
||||
|
||||
/** Rename a chat. */
|
||||
export async function renameAiChat(data: {
|
||||
chatId: string;
|
||||
@@ -65,117 +46,3 @@ export async function renameAiChat(data: {
|
||||
export async function deleteAiChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai-chat/delete", { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a chat to Markdown (#183). The server renders the transcript from the
|
||||
* persisted rows (the DB is the single source of truth — including an
|
||||
* interrupted turn's in-progress row, persisted upfront + per step), so the
|
||||
* client just copies the returned string. `lang` localizes the few fixed
|
||||
* role/tool labels; defaults to English server-side when omitted.
|
||||
*/
|
||||
export async function exportAiChat(
|
||||
chatId: string,
|
||||
lang?: string,
|
||||
): Promise<string> {
|
||||
const req = await api.post<{ markdown: string }>("/ai-chat/export", {
|
||||
chatId,
|
||||
lang,
|
||||
});
|
||||
return req.data.markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from note content (markdown). One-shot, non-streaming
|
||||
* (#199): the server only summarizes the supplied text and returns a suggestion;
|
||||
* it never writes the page. The caller applies the title via /pages/update.
|
||||
*/
|
||||
export async function generatePageTitle(content: string): Promise<string> {
|
||||
const req = await api.post<{ title: string }>(
|
||||
"/ai-chat/generate-page-title",
|
||||
{ content },
|
||||
);
|
||||
return req.data.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** List the workspace's agent roles. */
|
||||
export async function getAiRoles(): Promise<IAiRole[]> {
|
||||
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Create a role (admin). */
|
||||
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update a role (admin). */
|
||||
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Soft-delete a role (admin). */
|
||||
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
|
||||
id,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -13,146 +13,6 @@ export interface IAiChat {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
// The agent role bound to this chat, if any (immutable after creation).
|
||||
roleId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response (the bound role's badge).
|
||||
// Null when the chat has no role or the role was soft-deleted.
|
||||
roleName?: string | null;
|
||||
roleEmoji?: string | null;
|
||||
// The document the chat was created in (ai_chats.page_id). Null when started
|
||||
// outside any document.
|
||||
pageId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response: the origin page's title.
|
||||
// Null when there is no origin page (or it was hard-deleted).
|
||||
pageTitle?: string | null;
|
||||
}
|
||||
|
||||
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */
|
||||
export type AiRoleDriver = "openai" | "gemini" | "ollama";
|
||||
|
||||
/** Optional per-role model override (mirrors `model_config`). */
|
||||
export interface IAiRoleModelConfig {
|
||||
driver?: AiRoleDriver;
|
||||
chatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent role (mirrors the server role views). A role replaces the agent's
|
||||
* persona (instructions) and may optionally override the model. The safety
|
||||
* framework is always still applied server-side.
|
||||
*
|
||||
* The list endpoint returns the FULL view to admins and a reduced picker view to
|
||||
* ordinary members, so the admin-only fields (`instructions`, `modelConfig`,
|
||||
* `createdAt`, `updatedAt`) are optional here — present only for admins.
|
||||
*/
|
||||
export interface IAiRole {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
// Whether picking the role auto-sends a launch message and starts the chat.
|
||||
autoStart: boolean;
|
||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||
launchMessage: string | null;
|
||||
// Catalog origin of an imported role, or null for a manually-created one.
|
||||
// Admin-only (present only in the admin list view); the picker view omits it.
|
||||
// The admin UI compares `version` against the catalog to offer an update.
|
||||
source?: { slug: string; language: string; version: number } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
|
||||
export interface IAiRoleCatalogBundleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}
|
||||
|
||||
/** The browsable catalog index (mirrors `getCatalog()`). */
|
||||
export interface IAiRoleCatalog {
|
||||
languages: string[];
|
||||
bundles: IAiRoleCatalogBundleSummary[];
|
||||
}
|
||||
|
||||
/** A single role inside an opened catalog bundle (localized content + version). */
|
||||
export interface IAiRoleCatalogRole {
|
||||
slug: string;
|
||||
emoji: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
|
||||
export interface IAiRoleCatalogBundle {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
roles: IAiRoleCatalogRole[];
|
||||
}
|
||||
|
||||
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
|
||||
export interface IAiRoleImportPayload {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
// Omitted => import the whole bundle; otherwise only these slugs.
|
||||
slugs?: string[];
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
|
||||
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
|
||||
* to a specific message; a successful update carries the version bump + new role.
|
||||
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
|
||||
* comparisons be compiler-checked.
|
||||
*/
|
||||
export type IAiRoleUpdateFromCatalogResult =
|
||||
| {
|
||||
updated: false;
|
||||
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
|
||||
}
|
||||
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
export interface IAiRoleUpdate {
|
||||
id: string;
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,27 +35,15 @@ export interface IAiChatMessageRow {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
// Reasoning (thinking) tokens, when the provider reports them. Optional so
|
||||
// old history rows (recorded before this shipped) stay valid. Included in
|
||||
// `outputTokens` per the AI SDK usage shape.
|
||||
reasoningTokens?: number;
|
||||
};
|
||||
// Current context size for the turn = final-step (input+output) tokens, i.e.
|
||||
// how much the conversation occupies in the model's context window after this
|
||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||
// floating window's header badge.
|
||||
contextTokens?: number;
|
||||
// The model's max context window (denominator for the header badge); set
|
||||
// alongside contextTokens on a completed turn; absent on older rows.
|
||||
maxContextTokens?: number;
|
||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||
error?: string;
|
||||
// Terminal outcome of the assistant turn: 'error' (provider/stream error,
|
||||
// paired with `error`), 'aborted' (client disconnect — a manual Stop or a
|
||||
// dropped connection), or the SDK's finish reason on a clean turn. The UI
|
||||
// renders a "stopped" marker on interrupted turns.
|
||||
finishReason?: string;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveAdoptedChatId,
|
||||
newlyAddedChatIds,
|
||||
extractServerChatId,
|
||||
} from "./adopt-chat-id";
|
||||
|
||||
describe("resolveAdoptedChatId", () => {
|
||||
it("adopts the server id for a brand-new chat (activeChatId null + id)", () => {
|
||||
expect(resolveAdoptedChatId(null, "chat-1")).toBe("chat-1");
|
||||
});
|
||||
|
||||
it("returns null for an existing chat even with a server id", () => {
|
||||
expect(resolveAdoptedChatId("chat-existing", "chat-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a new chat with no server id", () => {
|
||||
expect(resolveAdoptedChatId(null, undefined)).toBeNull();
|
||||
expect(resolveAdoptedChatId(null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("newlyAddedChatIds", () => {
|
||||
it("returns the single new id", () => {
|
||||
expect([...newlyAddedChatIds(["a", "b"], ["a", "b", "c"])]).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("returns an empty set when nothing was added", () => {
|
||||
expect(newlyAddedChatIds(["a", "b"], ["b", "a"]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns both new ids when two were added", () => {
|
||||
expect(newlyAddedChatIds(["a"], ["a", "b", "c"])).toEqual(
|
||||
new Set(["b", "c"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the new id across an add+delete in the same window", () => {
|
||||
// before [a,b] -> after [b,new]: a was deleted, new was added.
|
||||
expect([...newlyAddedChatIds(["a", "b"], ["b", "new"])]).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("dedupes a repeated new id to a single entry", () => {
|
||||
expect(newlyAddedChatIds(["a"], ["a", "new", "new"])).toEqual(
|
||||
new Set(["new"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractServerChatId", () => {
|
||||
it("returns the chatId when present on metadata", () => {
|
||||
expect(extractServerChatId({ metadata: { chatId: "chat-1" } })).toBe(
|
||||
"chat-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when the message has no metadata", () => {
|
||||
expect(extractServerChatId({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when metadata lacks chatId", () => {
|
||||
expect(extractServerChatId({ metadata: { other: 1 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-string chatId", () => {
|
||||
expect(extractServerChatId({ metadata: { chatId: 42 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an undefined message", () => {
|
||||
expect(extractServerChatId(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for adopting a brand-new chat's authoritative server id.
|
||||
*
|
||||
* ============================ CANONICAL #137 NOTE ============================
|
||||
* This docblock is the single authoritative explanation of the new-chat id
|
||||
* adoption design and the #137 two-tab race it fixes. Other call sites
|
||||
* (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
|
||||
* rather than restating it.
|
||||
*
|
||||
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat
|
||||
* id yet (`activeChatId === null`). The server creates the row and the client
|
||||
* must "adopt" that row's real id so the SECOND turn targets the same chat.
|
||||
*
|
||||
* The OLD heuristic adopted `items[0]` — the newest chat in the refetched list.
|
||||
* That races a second tab: if another tab created a chat in the same moment,
|
||||
* its row could be `items[0]`, so this tab would adopt the SIBLING chat and
|
||||
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways:
|
||||
*
|
||||
* PRIMARY path: the server streams the real chat id on the assistant message
|
||||
* metadata's `start` part (see `chatStreamMetadata` server-side);
|
||||
* `extractServerChatId` reads it off the finished message and
|
||||
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
|
||||
* authoritative and immune to the race.
|
||||
*
|
||||
* FALLBACK path (only when a new chat's first turn errors BEFORE the `start`
|
||||
* chunk, so no metadata id ever reached the client): adopt the single chat that
|
||||
* NEWLY appeared in the per-user list relative to a pre-refetch snapshot —
|
||||
* `newlyAddedChatIds` (the fallback effect adopts only when exactly one id is
|
||||
* new). This is unambiguous and does not race a second tab the way the old
|
||||
* "newest chat in the list" guess did.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the id to adopt from the server-streamed metadata. Returns
|
||||
* `serverChatId` only for a brand-new chat (`activeChatId === null`) that
|
||||
* received a truthy id; otherwise null (existing chat, or no id streamed).
|
||||
*/
|
||||
export function resolveAdoptedChatId(
|
||||
activeChatId: string | null,
|
||||
serverChatId: string | null | undefined,
|
||||
): string | null {
|
||||
return activeChatId === null && serverChatId ? serverChatId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the authoritative server chat id off a finished assistant message. The
|
||||
* server attaches it as `message.metadata.chatId` on the `start` part (see
|
||||
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
|
||||
* a missing message, missing metadata, or a non-string `chatId`.
|
||||
*/
|
||||
export function extractServerChatId(
|
||||
message: { metadata?: unknown } | undefined,
|
||||
): string | undefined {
|
||||
const m = message?.metadata as { chatId?: string } | undefined;
|
||||
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
||||
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
||||
* chat must not read as multiple from a duplicate.
|
||||
*/
|
||||
export function newlyAddedChatIds(
|
||||
beforeIds: readonly string[],
|
||||
afterIds: readonly string[],
|
||||
): Set<string> {
|
||||
const before = new Set(beforeIds);
|
||||
return new Set(afterIds.filter((id) => !before.has(id)));
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveAssistantName } from "./assistant-name";
|
||||
|
||||
describe("resolveAssistantName", () => {
|
||||
it("returns a real name unchanged", () => {
|
||||
expect(resolveAssistantName("Ada")).toBe("Ada");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from a real name", () => {
|
||||
expect(resolveAssistantName(" Ada ")).toBe("Ada");
|
||||
});
|
||||
|
||||
it("returns null for a whitespace-only name (the reason for .trim())", () => {
|
||||
expect(resolveAssistantName(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the name is undefined", () => {
|
||||
expect(resolveAssistantName(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(resolveAssistantName("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
// Pure helper for resolving the assistant's display name. Kept free of React so
|
||||
// it can be unit-tested in isolation (see assistant-name.test.ts) and shared by
|
||||
// the components that render the assistant identity (TypingIndicator, MessageItem).
|
||||
|
||||
/**
|
||||
* Resolve the assistant's display name from the optional configured identity.
|
||||
*
|
||||
* Returns the trimmed name when it has visible (non-whitespace) characters, or
|
||||
* `null` when the name is absent or whitespace-only. Callers fall back to a
|
||||
* generic "AI agent" label on `null`. The `.trim()` is why a name of " " must
|
||||
* resolve to `null` rather than rendering an empty label.
|
||||
*/
|
||||
export function resolveAssistantName(assistantName?: string): string | null {
|
||||
const name = assistantName?.trim();
|
||||
return name ? name : null;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
165
apps/client/src/features/ai-chat/utils/chat-markdown.ts
Normal file
165
apps/client/src/features/ai-chat/utils/chat-markdown.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Client-only Markdown builder for an AI agent chat. Serializes the already
|
||||
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
|
||||
* Markdown string suitable for copying to the clipboard. NO network call is
|
||||
* made and NO server/DB code is touched — this reuses the rich "request
|
||||
* internals" (tool calls with input/output, per-message token usage,
|
||||
* finish/error info) that the chat already holds client-side.
|
||||
*
|
||||
* Only role labels and tool action labels are localized via the passed-in `t`
|
||||
* translator; the structural document words (Input/Output/Error/Tokens/...) are
|
||||
* plain English constants because the output is a technical artifact.
|
||||
*/
|
||||
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
ToolUiPart,
|
||||
getToolName,
|
||||
toolRunState,
|
||||
toolLabelKey,
|
||||
} from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
|
||||
// Minimal translator signature compatible with react-i18next's `t`.
|
||||
type Translate = (key: string, values?: Record<string, unknown>) => string;
|
||||
|
||||
interface BuildChatMarkdownArgs {
|
||||
title: string | null;
|
||||
chatId: string;
|
||||
rows: IAiChatMessageRow[];
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
/** A single AI SDK UIMessage part (text part or other). */
|
||||
interface TextLikePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||
* `String(value)` if serialization throws (e.g. a circular structure).
|
||||
*/
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
|
||||
* the longest backtick run inside the content, so embedded backticks (or even
|
||||
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
|
||||
*/
|
||||
function fence(code: string, lang = ""): string {
|
||||
const runs: string[] = code.match(/`+/g) ?? [];
|
||||
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const delim = "`".repeat(Math.max(3, longest + 1));
|
||||
return `${delim}${lang}\n${code}\n${delim}`;
|
||||
}
|
||||
|
||||
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
|
||||
function rowTokens(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
||||
* export timestamp), so it is straightforward to unit-test.
|
||||
*/
|
||||
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
||||
const { title, chatId, rows, t } = args;
|
||||
const blocks: string[] = [];
|
||||
|
||||
const heading = (title ?? "").trim() || t("Untitled chat");
|
||||
blocks.push(`# ${heading}`);
|
||||
|
||||
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
||||
const totalTokens = rows.reduce((sum, row) => {
|
||||
const usage = row.metadata?.usage;
|
||||
return usage ? sum + rowTokens(usage) : sum;
|
||||
}, 0);
|
||||
const meta = [
|
||||
`- Chat ID: \`${chatId}\``,
|
||||
`- Exported: ${new Date().toISOString()}`,
|
||||
`- Messages: ${rows.length}`,
|
||||
];
|
||||
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
||||
blocks.push(meta.join("\n"));
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
blocks.push("---");
|
||||
|
||||
const roleLabel = row.role === "assistant" ? t("AI agent") : t("You");
|
||||
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
||||
|
||||
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
||||
blocks.push(`<!-- ${row.createdAt} -->`);
|
||||
|
||||
// Resolve parts: prefer the rich persisted parts, else a single text part
|
||||
// built from the plain-text content (mirrors `rowToUiMessage`).
|
||||
const parts: TextLikePart[] =
|
||||
Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
||||
? (row.metadata.parts as TextLikePart[])
|
||||
: [{ type: "text", text: row.content ?? "" }];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
const text = (part.text ?? "").trim();
|
||||
// Skip empty/whitespace-only text parts (matches MessageItem).
|
||||
if (text.length > 0) blocks.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isToolPart =
|
||||
part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
if (!isToolPart) continue;
|
||||
|
||||
const tp = part as unknown as ToolUiPart;
|
||||
const name = getToolName(tp);
|
||||
const { key, values } = toolLabelKey(name);
|
||||
const label = t(key, values);
|
||||
const state = toolRunState(tp.state);
|
||||
|
||||
const toolLines: string[] = [
|
||||
`**Tool: ${label}** (\`${name}\`) — ${state}`,
|
||||
];
|
||||
if (tp.input !== undefined) {
|
||||
toolLines.push("Input:");
|
||||
toolLines.push(fence(stringify(tp.input), "json"));
|
||||
}
|
||||
if (tp.output !== undefined) {
|
||||
toolLines.push("Output:");
|
||||
toolLines.push(fence(stringify(tp.output), "json"));
|
||||
}
|
||||
if (tp.errorText) {
|
||||
toolLines.push(`**Error:** ${tp.errorText}`);
|
||||
}
|
||||
blocks.push(toolLines.join("\n\n"));
|
||||
}
|
||||
|
||||
if (row.metadata?.error) {
|
||||
blocks.push(`**⚠️ Error:** ${row.metadata.error}`);
|
||||
}
|
||||
|
||||
const usage = row.metadata?.usage;
|
||||
if (usage) {
|
||||
const total = usage.totalTokens ?? rowTokens(usage);
|
||||
blocks.push(
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Blank line between blocks so the Markdown renders cleanly.
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
describe("collapseBlankLines", () => {
|
||||
it("collapses a run of 2+ newlines to a single newline", () => {
|
||||
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
|
||||
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("keeps single newlines untouched", () => {
|
||||
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
it("preserves blank lines INSIDE a fenced code block", () => {
|
||||
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
|
||||
// Prose blanks collapse; the blank lines between the ``` fences survive.
|
||||
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
|
||||
});
|
||||
|
||||
it("handles a tilde fence and preserves its interior blanks", () => {
|
||||
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
|
||||
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
|
||||
});
|
||||
|
||||
it("leaves an unclosed fence's remaining lines verbatim", () => {
|
||||
const src = "intro\n\n```\nstill\n\nopen";
|
||||
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
|
||||
});
|
||||
|
||||
it("is a no-op for text with no blank lines", () => {
|
||||
expect(collapseBlankLines("just one line")).toBe("just one line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
||||
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
||||
const loose =
|
||||
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
||||
expect(html).toContain("<li>item one</li>");
|
||||
expect(html).not.toContain("<li><p>");
|
||||
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
||||
expect(html).toContain("<ul>");
|
||||
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||
});
|
||||
|
||||
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
||||
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||
expect(html).toContain("<ol>");
|
||||
expect(html).toContain("<li>first</li>");
|
||||
expect(html).not.toContain("<li><p>");
|
||||
});
|
||||
|
||||
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
||||
const loose = "- a\n\n- b";
|
||||
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
|
||||
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
|
||||
|
||||
/**
|
||||
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
|
||||
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
|
||||
*
|
||||
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
|
||||
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
|
||||
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
|
||||
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
|
||||
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
|
||||
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
|
||||
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
|
||||
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
|
||||
* normal assistant answer (where paragraph spacing is intentional).
|
||||
*
|
||||
* Fenced code is preserved verbatim: a fence opens on a line whose first
|
||||
* non-space characters are ``` or ~~~ and closes on the next line that starts
|
||||
* with the same fence character. Blank lines between fences (significant for
|
||||
* code formatting) are never collapsed.
|
||||
*/
|
||||
export function collapseBlankLines(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
const out: string[] = [];
|
||||
let inFence = false;
|
||||
let fenceChar = "";
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const ch = fenceMatch[1][0];
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceChar = ch;
|
||||
} else if (ch === fenceChar) {
|
||||
inFence = false;
|
||||
}
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside a fenced block every line (including blanks) is significant.
|
||||
if (inFence) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
|
||||
// `\n` between the surrounding content lines.
|
||||
if (line.trim() === "") continue;
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
isHeaderClick,
|
||||
} from "./collapse-helpers";
|
||||
|
||||
describe("shouldCollapseOnOutsidePointer", () => {
|
||||
let windowEl: HTMLDivElement;
|
||||
let inside: HTMLSpanElement;
|
||||
let portal: HTMLDivElement;
|
||||
let portalChild: HTMLButtonElement;
|
||||
let page: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// The floating window with a child node.
|
||||
windowEl = document.createElement("div");
|
||||
inside = document.createElement("span");
|
||||
windowEl.appendChild(inside);
|
||||
|
||||
// A Mantine-style portal (data-portal="true") with a child (e.g. a menu item).
|
||||
portal = document.createElement("div");
|
||||
portal.setAttribute("data-portal", "true");
|
||||
portalChild = document.createElement("button");
|
||||
portal.appendChild(portalChild);
|
||||
|
||||
// An unrelated page element.
|
||||
page = document.createElement("div");
|
||||
|
||||
document.body.append(windowEl, portal, page);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("returns false for a target inside the window", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(inside, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(windowEl, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a target inside a Mantine portal", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(portal, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(portalChild, windowEl)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a target on the page (outside window and portals)", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, windowEl)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when there is no window element", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(page, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a non-Element target", () => {
|
||||
expect(shouldCollapseOnOutsidePointer(null, windowEl)).toBe(false);
|
||||
expect(shouldCollapseOnOutsidePointer(document, windowEl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeaderClick", () => {
|
||||
it("treats a zero-movement press as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 100, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement within the threshold as a click", () => {
|
||||
expect(isHeaderClick(100, 100, 103, 97)).toBe(true);
|
||||
expect(isHeaderClick(100, 100, 104, 104)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats movement beyond the threshold (either axis) as a drag", () => {
|
||||
expect(isHeaderClick(100, 100, 105, 100)).toBe(false);
|
||||
expect(isHeaderClick(100, 100, 100, 105)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a custom threshold", () => {
|
||||
expect(isHeaderClick(0, 0, 8, 0, 10)).toBe(true);
|
||||
expect(isHeaderClick(0, 0, 11, 0, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// Pure helpers for the AI chat window auto-collapse behavior. Kept free of React
|
||||
// so they can be unit-tested in isolation (see collapse-helpers.test.ts).
|
||||
|
||||
/**
|
||||
* Decide whether an outside pointer (mousedown) should collapse the chat window.
|
||||
*
|
||||
* Returns true only when the pointer target is genuinely "on the page": NOT
|
||||
* inside the window element AND NOT inside a Mantine portal. Mantine renders
|
||||
* dropdown menus (chat-list kebab), modals (delete-confirm), tooltips and
|
||||
* notifications into portals tagged with `data-portal="true"`; clicks on those
|
||||
* are part of operating the chat, so they must not collapse it.
|
||||
*/
|
||||
export function shouldCollapseOnOutsidePointer(
|
||||
target: EventTarget | null,
|
||||
windowEl: HTMLElement | null,
|
||||
): boolean {
|
||||
if (!windowEl) return false;
|
||||
if (!(target instanceof Element)) return false;
|
||||
// Inside the window itself -> not an "away" interaction (drag, resize, typing).
|
||||
if (windowEl.contains(target)) return false;
|
||||
// Inside a Mantine portal the chat owns (kebab menu, confirm modal, tooltip,
|
||||
// notifications). data-portal="true" reliably excludes all of them.
|
||||
if (target.closest("[data-portal]")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-vs-drag discrimination for the window header: a press whose pointer
|
||||
* moved less than `threshold` px on both axes between mousedown and mouseup is
|
||||
* treated as a click (which expands a collapsed window), not a drag (which
|
||||
* repositions it).
|
||||
*/
|
||||
export function isHeaderClick(
|
||||
downX: number,
|
||||
downY: number,
|
||||
upX: number,
|
||||
upY: number,
|
||||
threshold = 4,
|
||||
): boolean {
|
||||
return Math.abs(upX - downX) <= threshold && Math.abs(upY - downY) <= threshold;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for the header context badge selection. Covers the two
|
||||
* non-obvious rules: numerator and denominator are each taken from the most
|
||||
* recent row carrying THAT value (they may live on different rows), and a fresh
|
||||
* row with a zero/absent value must NOT shadow an older positive one.
|
||||
*/
|
||||
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
content: null,
|
||||
metadata,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
describe("selectContextBadge", () => {
|
||||
it("returns zeros for empty / nullish input", () => {
|
||||
expect(selectContextBadge(undefined)).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect(selectContextBadge(null)).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
expect(selectContextBadge([])).toEqual({
|
||||
contextTokens: 0,
|
||||
maxContextTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads both figures from the most recent row that carries them", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1500, maxContextTokens: 200000 }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("falls back to legacy usage total for older rows without contextTokens", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
|
||||
|
||||
expect(
|
||||
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
|
||||
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
|
||||
});
|
||||
|
||||
it("takes numerator and denominator from different rows", () => {
|
||||
// Freshest row (an error turn) carries contextTokens but no max; the older
|
||||
// completed turn carries the max. Each is picked from its own latest row.
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 800, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1200, error: "402: nope" }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("does not let a fresh zero/absent max shadow an older positive max", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||
row({ contextTokens: 1200, maxContextTokens: 0 }),
|
||||
]),
|
||||
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("skips rows with null metadata", () => {
|
||||
expect(
|
||||
selectContextBadge([
|
||||
row({ contextTokens: 500, maxContextTokens: 200000 }),
|
||||
row(null),
|
||||
]),
|
||||
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
|
||||
});
|
||||
|
||||
it("reports current > max as-is (no clamp)", () => {
|
||||
expect(
|
||||
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
|
||||
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Derive the header context badge figures from the persisted message rows.
|
||||
*
|
||||
* - `contextTokens` (numerator): how much the conversation now occupies in the
|
||||
* model's context window. Read from the most recent row carrying a context
|
||||
* figure — `contextTokens` (final-step input+output) on rows recorded after
|
||||
* this shipped, else that turn's legacy `usage` total for older rows.
|
||||
* - `maxContextTokens` (denominator): the model's configured max window, stamped
|
||||
* alongside `contextTokens` on a completed turn.
|
||||
*
|
||||
* Each value is taken from the most recent row carrying THAT value
|
||||
* independently — they may land on different rows (e.g. a fresh error row can
|
||||
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
|
||||
* whichever is still unset. `0` means "no row has it" (older rows, or no
|
||||
* admin-configured limit); the badge then omits the value.
|
||||
*/
|
||||
export function selectContextBadge(
|
||||
messageRows: readonly IAiChatMessageRow[] | undefined | null,
|
||||
): { contextTokens: number; maxContextTokens: number } {
|
||||
let contextTokens = 0;
|
||||
let maxContextTokens = 0;
|
||||
if (!messageRows) return { contextTokens, maxContextTokens };
|
||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||
const meta = messageRows[i].metadata;
|
||||
if (!meta) continue;
|
||||
if (contextTokens === 0) {
|
||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||
contextTokens = meta.contextTokens;
|
||||
} else if (meta.usage) {
|
||||
const usage = meta.usage;
|
||||
const fallback =
|
||||
usage.totalTokens ??
|
||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||
if (fallback > 0) contextTokens = fallback;
|
||||
}
|
||||
}
|
||||
if (
|
||||
maxContextTokens === 0 &&
|
||||
typeof meta.maxContextTokens === "number" &&
|
||||
meta.maxContextTokens > 0
|
||||
) {
|
||||
maxContextTokens = meta.maxContextTokens;
|
||||
}
|
||||
if (contextTokens !== 0 && maxContextTokens !== 0) break;
|
||||
}
|
||||
return { contextTokens, maxContextTokens };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("returns 0 for the empty string", () => {
|
||||
expect(estimateTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
||||
expect(estimateTokens("a")).toBe(1);
|
||||
expect(estimateTokens("abcd")).toBe(1);
|
||||
expect(estimateTokens("abcde")).toBe(2);
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Rough client-side token estimation for AI-chat UI affordances.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||
* ("Thinking · N tokens").
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
||||
* non-empty text counts as at least one token.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeChatError } from "./error-message";
|
||||
|
||||
// Identity translator: assert on the raw English key so the tests do not depend
|
||||
// on the i18n catalog.
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("describeChatError", () => {
|
||||
it('maps a {"statusCode":403} body to the disabled heading', () => {
|
||||
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "AI chat is disabled",
|
||||
detail: "AI chat is disabled for this workspace.",
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a {"statusCode":503} body to the not-configured heading', () => {
|
||||
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "AI provider not configured",
|
||||
detail:
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||
expect(
|
||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||
).toBe("Lost connection to the AI provider");
|
||||
});
|
||||
|
||||
it('classifies "fetch failed" as a lost-connection error', () => {
|
||||
expect(describeChatError("fetch failed", t).title).toBe(
|
||||
"Lost connection to the AI provider",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies ETIMEDOUT as a timeout", () => {
|
||||
expect(describeChatError("ETIMEDOUT", t).title).toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies "504: Gateway Timeout" as a timeout', () => {
|
||||
expect(describeChatError("504: Gateway Timeout", t).title).toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies "429: Too Many Requests" as rate limited', () => {
|
||||
expect(describeChatError("429: Too Many Requests", t).title).toBe(
|
||||
"Rate limited by the AI provider",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT misclassify a body that merely contains "403" as disabled', () => {
|
||||
// Regression intent: a provider message mentioning the number 403 must never
|
||||
// be folded into the "AI chat is disabled" gating heading. Here the 429
|
||||
// signature wins (checked before any bare-403 logic exists), so it maps to
|
||||
// the rate-limit category instead.
|
||||
const view = describeChatError("429: rate limited after 403 attempts", t);
|
||||
expect(view.title).toBe("Rate limited by the AI provider");
|
||||
expect(view.title).not.toBe("AI chat is disabled");
|
||||
});
|
||||
|
||||
it("classifies a context-window overflow as too-large", () => {
|
||||
expect(
|
||||
describeChatError(
|
||||
"This model's maximum context length is 128000 tokens",
|
||||
t,
|
||||
).title,
|
||||
).toBe("The conversation is too large");
|
||||
});
|
||||
|
||||
it('classifies "402: Insufficient credits" as quota exceeded', () => {
|
||||
expect(describeChatError("402: Insufficient credits", t).title).toBe(
|
||||
"AI provider quota exceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies "401: Unauthorized" as an auth failure', () => {
|
||||
expect(describeChatError("401: Unauthorized", t).title).toBe(
|
||||
"AI provider authentication failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the generic heading + detail for empty input", () => {
|
||||
expect(describeChatError("", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "The AI agent could not respond. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the generic heading + detail for "An error occurred."', () => {
|
||||
expect(describeChatError("An error occurred.", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "The AI agent could not respond. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the generic heading + detail for "Internal server error"', () => {
|
||||
expect(describeChatError("Internal server error", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "The AI agent could not respond. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
|
||||
expect(describeChatError("418: I'm a teapot", t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: "418: I'm a teapot",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
|
||||
// The real status (500) leads the string; the "401" lives in the snippet and
|
||||
// must not trigger the auth category. The verbatim provider text is surfaced.
|
||||
const body =
|
||||
"500: Server error | response body: model gpt-4o-401-preview not found";
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: body,
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT treat a passing mention of billing as a quota error", () => {
|
||||
// "billing" is no longer a quota signature; the verbatim text is surfaced.
|
||||
const body = "502: Bad Gateway | response body: see our billing page";
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: body,
|
||||
});
|
||||
});
|
||||
|
||||
it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
|
||||
const view = describeChatError("429: rate limited after 403 attempts", t);
|
||||
expect(view.title).toBe("Rate limited by the AI provider");
|
||||
expect(view.title).not.toBe("AI chat is disabled");
|
||||
});
|
||||
|
||||
it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
|
||||
// The textual rate-limit phrase lives only in the response-body snippet, and
|
||||
// the leading 500 is not a classified numeric code, so it must not leak into
|
||||
// the rate-limit category. (The detail itself falls back to the generic line
|
||||
// here because the leading message contains "Internal Server Error", which
|
||||
// providerDetail suppresses — the title is what this case pins.)
|
||||
const body =
|
||||
"500: Internal Server Error | response body: rate limit info: see our docs";
|
||||
expect(describeChatError(body, t).title).toBe("Something went wrong");
|
||||
expect(describeChatError(body, t).title).not.toBe(
|
||||
"Rate limited by the AI provider",
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
|
||||
// The 503 leads the string but is not a classified numeric code, and the
|
||||
// ETIMEDOUT signature appears only in the body, so it must not leak into the
|
||||
// timeout category; the verbatim text is surfaced under the generic heading.
|
||||
const body = "503: x | response body: ETIMEDOUT appears in this log line";
|
||||
expect(describeChatError(body, t)).toEqual({
|
||||
title: "Something went wrong",
|
||||
detail: body,
|
||||
});
|
||||
expect(describeChatError(body, t).title).not.toBe(
|
||||
"The AI provider timed out",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +1,24 @@
|
||||
/**
|
||||
* A classified AI chat error: a short bold heading naming the cause category and
|
||||
* a one-line human-readable detail / next step. Both strings are already passed
|
||||
* through `t`, so callers render them directly.
|
||||
*/
|
||||
export interface ChatErrorView {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an AI chat error message into a friendly heading + detail. Used for BOTH
|
||||
* the live `useChat().error` (its `.message`) and a persisted assistant error in
|
||||
* `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
|
||||
* body carrying a numeric "statusCode" (matched precisely, not by bare substring,
|
||||
* so a provider message that merely contains "403"/"503" is never misclassified).
|
||||
* Known provider/network failures (connection reset, timeout, rate limit, context
|
||||
* overflow, quota, auth) are mapped to a clear category; anything else falls back
|
||||
* to the raw provider detail (or a generic line) under the original heading.
|
||||
* Turn an AI chat error message into a friendly inline string. Used for BOTH the
|
||||
* live `useChat().error` (its `.message`) and a persisted assistant error stored
|
||||
* in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
|
||||
* body carrying a numeric "statusCode" field (matched precisely, not by bare
|
||||
* substring, so a provider message that merely contains "403"/"503"/"disabled" is
|
||||
* never misclassified). Everything else — provider stream failures forwarded as
|
||||
* "<status>: <message>" (402 credits, 429 rate limit, ...) — is surfaced verbatim.
|
||||
*/
|
||||
export function describeChatError(
|
||||
message: string,
|
||||
t: (key: string) => string,
|
||||
): ChatErrorView {
|
||||
): string {
|
||||
const msg = message ?? "";
|
||||
|
||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||
return {
|
||||
title: t("AI chat is disabled"),
|
||||
detail: t("AI chat is disabled for this workspace."),
|
||||
};
|
||||
return t("AI chat is disabled for this workspace.");
|
||||
}
|
||||
if (/"statusCode"\s*:\s*503\b/.test(msg)) {
|
||||
return {
|
||||
title: t("AI provider not configured"),
|
||||
detail: t(
|
||||
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||
),
|
||||
};
|
||||
return t("The AI provider is not configured. Ask an administrator to set it up.");
|
||||
}
|
||||
|
||||
const category = classifyProviderError(msg);
|
||||
if (category) {
|
||||
return { title: t(category.title), detail: t(category.detail) };
|
||||
}
|
||||
|
||||
// Unknown error: surface the raw provider detail when it is informative,
|
||||
// otherwise a generic line. The heading stays the original generic one.
|
||||
return {
|
||||
title: t("Something went wrong"),
|
||||
detail:
|
||||
providerDetail(msg) ??
|
||||
t("The AI agent could not respond. Please try again."),
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorCategory {
|
||||
/** English key for the bold heading. */
|
||||
title: string;
|
||||
/** English key for the one-line explanation. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider/network error string to a friendly category. Order matters: the
|
||||
* most specific signatures are tested first. Returns null when nothing matches,
|
||||
* so the caller can fall back to the raw provider text. The English keys returned
|
||||
* here are passed through `t` by the caller.
|
||||
*
|
||||
* The server formats provider errors as "<statusCode>: <message> | response body:
|
||||
* <snippet>" (see server-side describeProviderError), so the HTTP status is always
|
||||
* the LEADING token. We match a numeric code only when it leads the string, so a
|
||||
* number inside the response-body snippet never triggers a category; textual
|
||||
* signatures are matched only against the leading message (before the response
|
||||
* body), so a phrase inside the snippet never triggers a category either.
|
||||
*/
|
||||
function classifyProviderError(msg: string): ErrorCategory | null {
|
||||
const code = /^\s*(\d{3})\b/.exec(msg)?.[1] ?? "";
|
||||
// The server appends "| response body: <snippet>" to provider errors; match
|
||||
// textual signatures only against the leading provider message so a phrase
|
||||
// inside the response-body snippet never triggers a wrong category. The numeric
|
||||
// status code is read from the start of the full string above.
|
||||
const head = msg.split(/\|\s*response body:/i)[0];
|
||||
|
||||
// The browser's OWN fetch-failure messages — WebKit/Safari "Load failed",
|
||||
// Chrome "Failed to fetch", Firefox "NetworkError when attempting to fetch
|
||||
// resource". These mean the streaming connection between the browser and THIS
|
||||
// server (/api/ai-chat/stream) dropped mid-answer: the browser<->server link,
|
||||
// NOT the server<->AI-provider link, so do NOT blame the provider. A failed
|
||||
// fetch carries no status/body, so the browser has no further detail — the real
|
||||
// cause is in the server logs (the stream controller logs the disconnect) and
|
||||
// the reverse proxy (often buffering or timing out the long-lived SSE).
|
||||
if (/failed to fetch|load failed|networkerror/i.test(head)) {
|
||||
return {
|
||||
title: "Lost connection to the server",
|
||||
detail:
|
||||
"The streaming connection to the server dropped before the answer finished. The browser reports no further detail — the cause is in the server logs and the reverse proxy (often buffering or timing out the stream). Reload and try again.",
|
||||
};
|
||||
}
|
||||
// Connection dropped / provider unreachable. ECONNRESET is the production case:
|
||||
// the LLM socket was reset mid-stream (surfaced by the server's error
|
||||
// formatter). "terminated" is scoped to a connection/stream context so it does
|
||||
// not match benign "... was terminated" messages.
|
||||
if (
|
||||
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
title: "Lost connection to the AI provider",
|
||||
detail:
|
||||
"The connection to the AI provider dropped before the answer finished. Please try again.",
|
||||
};
|
||||
}
|
||||
// Timeout.
|
||||
if (
|
||||
code === "504" ||
|
||||
code === "408" ||
|
||||
/ETIMEDOUT|timed[\s-]?out|\btimeout\b/i.test(head)
|
||||
) {
|
||||
return {
|
||||
title: "The AI provider timed out",
|
||||
detail: "The AI provider took too long to respond. Please try again.",
|
||||
};
|
||||
}
|
||||
// Rate limited.
|
||||
if (code === "429" || /rate[\s-]?limit|too many requests/i.test(head)) {
|
||||
return {
|
||||
title: "Rate limited by the AI provider",
|
||||
detail:
|
||||
"The AI provider is rate-limiting requests. Wait a moment and try again.",
|
||||
};
|
||||
}
|
||||
// Context window / token budget exceeded.
|
||||
if (
|
||||
code === "413" ||
|
||||
/context[\s_-]?(?:length|window)|maximum context|context_length_exceeded|too many tokens|maximum[^.]*tokens|reduce the length/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
title: "The conversation is too large",
|
||||
detail:
|
||||
"The document and search results exceeded the model's context window. Start a new chat or narrow the request.",
|
||||
};
|
||||
}
|
||||
// Out of credits / quota / payment required.
|
||||
if (
|
||||
code === "402" ||
|
||||
/payment required|insufficient (?:credits|quota|funds|balance)|out of credits|quota (?:exceeded|exhausted)/i.test(
|
||||
head,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
title: "AI provider quota exceeded",
|
||||
detail:
|
||||
"The AI provider rejected the request because of credits or quota. Check the provider account.",
|
||||
};
|
||||
}
|
||||
// Authentication / bad API key.
|
||||
if (
|
||||
code === "401" ||
|
||||
/\bunauthorized\b|invalid api key|user not found|\bauthentication\b/i.test(head)
|
||||
) {
|
||||
return {
|
||||
title: "AI provider authentication failed",
|
||||
detail:
|
||||
"The AI provider rejected the credentials. Ask an administrator to check the API key.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return providerDetail(msg) ?? t("The AI agent could not respond. Please try again.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
/**
|
||||
* Tests for the internal-link neutralization used by the anonymous public
|
||||
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
|
||||
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
|
||||
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
|
||||
* routes. With the flag ON those links are made inert (href removed) while the
|
||||
* visible text and the rest of the markdown formatting are preserved; genuinely
|
||||
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
|
||||
* with a safe rel/target, while absolute links back to our OWN origin are
|
||||
* neutralized too. With the flag OFF (internal default) links keep their href so
|
||||
* the authenticated chat is unchanged.
|
||||
*/
|
||||
|
||||
/** Parse the rendered HTML and return the first <a> element (or null). */
|
||||
function firstAnchor(html: string): HTMLAnchorElement | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
return doc.querySelector("a");
|
||||
}
|
||||
|
||||
describe("renderChatMarkdown — internal link neutralization", () => {
|
||||
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
// Visible link text is preserved.
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes bare-fragment links when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[here](#section)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
|
||||
const html = renderChatMarkdown("[y](https://example.com/x)", {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("https://example.com/x");
|
||||
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a!.getAttribute("target")).toBe("_blank");
|
||||
});
|
||||
|
||||
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
|
||||
// An LLM can emit an absolute URL back at our own host (e.g.
|
||||
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
|
||||
// relative `/p/...` link, not kept clickable as if it were external.
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.hasAttribute("href")).toBe(false);
|
||||
expect(a!.hasAttribute("target")).toBe(false);
|
||||
expect(a!.textContent).toBe("x");
|
||||
});
|
||||
|
||||
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
|
||||
// javascript:, data:, and protocol-relative `//...` must never stay
|
||||
// clickable on the anonymous share — they are not genuinely external
|
||||
// http(s) links to a different host, so the href is dropped (or sanitized
|
||||
// away entirely by DOMPurify).
|
||||
for (const markdown of [
|
||||
"[a](javascript:alert(1))",
|
||||
"[b](data:text/html,<script>alert(1)</script>)",
|
||||
"[c](//evil.com/x)",
|
||||
]) {
|
||||
const html = renderChatMarkdown(markdown, {
|
||||
neutralizeInternalLinks: true,
|
||||
});
|
||||
const a = firstAnchor(html);
|
||||
// Either the anchor was stripped of its href, or DOMPurify removed the
|
||||
// unsafe href outright; in both cases nothing dangerous remains.
|
||||
if (a !== null) {
|
||||
expect(a.hasAttribute("href")).toBe(false);
|
||||
expect(a.hasAttribute("target")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
|
||||
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
|
||||
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||
const html = renderChatMarkdown(`[x](${ownOrigin})`);
|
||||
const a = firstAnchor(html);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.getAttribute("href")).toBe(ownOrigin);
|
||||
});
|
||||
|
||||
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
|
||||
// A neutralizing render first, then an internal render: the internal link
|
||||
// must survive (the hook is removed after the share render).
|
||||
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
|
||||
const html = renderChatMarkdown("[x](/p/abc)");
|
||||
const a = firstAnchor(html);
|
||||
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||
});
|
||||
});
|
||||
@@ -1,66 +1,6 @@
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface RenderChatMarkdownOptions {
|
||||
/**
|
||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||
* Used by the anonymous public share: the assistant's answer can contain
|
||||
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
|
||||
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
|
||||
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
|
||||
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
|
||||
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
|
||||
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
|
||||
* neutralized too. Defaults to false — the internal chat keeps internal links
|
||||
* clickable for authenticated users.
|
||||
*/
|
||||
neutralizeInternalLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
|
||||
* anonymous reader to follow. A link qualifies only if it is absolute
|
||||
* `http(s)://` AND its host differs from the app's own origin
|
||||
* (`window.location.host`): absolute links back to our OWN host (e.g.
|
||||
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
|
||||
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
|
||||
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
|
||||
* parse) is treated as internal/unsafe and neutralized — fail closed.
|
||||
*/
|
||||
function isExternalHttpUrl(href: string): boolean {
|
||||
const value = href.trim();
|
||||
if (!/^https?:\/\//i.test(value)) return false;
|
||||
try {
|
||||
// External only if it points at a DIFFERENT host than the app's own origin.
|
||||
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
|
||||
// internal and must be neutralized, same as relative `/p/...` links.
|
||||
return new URL(value).host !== window.location.host;
|
||||
} catch {
|
||||
return false; // unparseable -> treat as internal/unsafe, neutralize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
|
||||
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
|
||||
* for the duration of a single sanitize call (added then removed in
|
||||
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
|
||||
*/
|
||||
function neutralizeInternalLinksHook(node: Element): void {
|
||||
if (node.nodeName !== "A") return;
|
||||
const href = node.getAttribute("href");
|
||||
if (href !== null && isExternalHttpUrl(href)) {
|
||||
// Genuinely external link: keep it, but force a safe rel/target.
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
node.setAttribute("target", "_blank");
|
||||
return;
|
||||
}
|
||||
// Internal/relative/fragment link (or no href): make it inert text. Drop the
|
||||
// href and any target so it is no longer clickable; the visible text stays.
|
||||
node.removeAttribute("href");
|
||||
node.removeAttribute("target");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
||||
@@ -72,31 +12,9 @@ function neutralizeInternalLinksHook(node: Element): void {
|
||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
||||
* for that branch (the caller renders the raw text fallback instead).
|
||||
*/
|
||||
export function renderChatMarkdown(
|
||||
markdown: string,
|
||||
options: RenderChatMarkdownOptions = {},
|
||||
): string {
|
||||
export function renderChatMarkdown(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
const html = markdownToHtml(markdown);
|
||||
if (typeof html !== "string") return "";
|
||||
|
||||
if (!options.neutralizeInternalLinks) {
|
||||
// Internal chat: unchanged behavior, no hook registered.
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// Public share: register the neutralization hook only for THIS sanitize call,
|
||||
// then remove it immediately so it can never affect the internal chat (hooks
|
||||
// are global on the shared DOMPurify instance).
|
||||
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
|
||||
try {
|
||||
return DOMPurify.sanitize(html);
|
||||
} finally {
|
||||
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
|
||||
// robust to any other afterSanitizeAttributes hook registered in future.
|
||||
DOMPurify.removeHook(
|
||||
"afterSanitizeAttributes",
|
||||
neutralizeInternalLinksHook,
|
||||
);
|
||||
}
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for `assistantMessageHasVisibleContent`, the single source of
|
||||
* truth shared by MessageItem (whether to render the bubble) and
|
||||
* typingIndicatorShowsName (whether the standalone indicator owns the name). It
|
||||
* must mirror MessageItem's render decisions exactly so exactly one element owns
|
||||
* the agent name during the pre-content "thinking" gap.
|
||||
*/
|
||||
const msg = (
|
||||
parts: UIMessage["parts"],
|
||||
metadata?: unknown,
|
||||
): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
|
||||
describe("assistantMessageHasVisibleContent", () => {
|
||||
it("is false for an empty text part", () => {
|
||||
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }]))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a whitespace-only text part", () => {
|
||||
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: " " }]))).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a non-empty text part", () => {
|
||||
expect(assistantMessageHasVisibleContent(msg([{ type: "text", text: "answer" }]))).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for a tool part", () => {
|
||||
const toolPart = { type: "tool-getPage", state: "output-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(assistantMessageHasVisibleContent(msg([toolPart]))).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when metadata.error is set (persisted error banner)", () => {
|
||||
expect(
|
||||
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { error: "boom" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when metadata.finishReason is 'aborted' (persisted stopped notice)", () => {
|
||||
expect(
|
||||
assistantMessageHasVisibleContent(msg([], { finishReason: "aborted" })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a message with no parts and no metadata", () => {
|
||||
expect(assistantMessageHasVisibleContent(msg([]))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for an unsupported part kind (reasoning)", () => {
|
||||
const reasoning = { type: "reasoning", text: "let me think" } as unknown as UIMessage["parts"][number];
|
||||
expect(assistantMessageHasVisibleContent(msg([reasoning]))).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a running tool part (input-available)", () => {
|
||||
// Tool visibility does not depend on tool state: MessageItem renders a
|
||||
// ToolCallCard for any tool part, so a still-running tool is visible.
|
||||
const runningTool = { type: "tool-getPage", state: "input-available" } as unknown as UIMessage["parts"][number];
|
||||
expect(assistantMessageHasVisibleContent(msg([runningTool]))).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for an empty leading text part followed by a non-empty one", () => {
|
||||
// An empty leading text part followed by a non-empty one is still visible
|
||||
// (mirrors the real streaming sequence where text arrives incrementally).
|
||||
expect(
|
||||
assistantMessageHasVisibleContent(
|
||||
msg([{ type: "text", text: "" }, { type: "text", text: "answer" }]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for an empty completed turn (finishReason 'stop')", () => {
|
||||
// A completed turn with no text/tools and a non-aborted finishReason renders
|
||||
// nothing — this is intentional (hiding a dangling name-only row), distinct
|
||||
// from the `aborted`/`error` cases which DO render.
|
||||
expect(
|
||||
assistantMessageHasVisibleContent(msg([{ type: "text", text: "" }], { finishReason: "stop" })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for a parts-less message (the `?? []` guard makes it safe)", () => {
|
||||
// The `?? []` guard makes a parts-less object safe instead of throwing.
|
||||
expect(
|
||||
assistantMessageHasVisibleContent({ id: "x", role: "assistant" } as unknown as UIMessage),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user