120
.env.example
120
.env.example
@@ -2,6 +2,38 @@
|
|||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:3000
|
||||||
PORT=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
|
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
||||||
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
||||||
|
|
||||||
@@ -69,15 +101,97 @@ DEBUG_DB=false
|
|||||||
# Log http requests
|
# Log http requests
|
||||||
LOG_HTTP=false
|
LOG_HTTP=false
|
||||||
|
|
||||||
# MCP server (community): service account the embedded MCP uses to talk to this Docmost instance
|
# MCP server (community): the embedded /mcp endpoint authenticates PER USER.
|
||||||
|
# An MCP client authenticates with one of:
|
||||||
|
# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own
|
||||||
|
# Docmost login/password. The server validates the credentials and the MCP
|
||||||
|
# session then acts under that user's permissions (edits attributed to them).
|
||||||
|
# - Bearer access JWT: `Authorization: Bearer <access-jwt>` (the user's
|
||||||
|
# `authToken` cookie value). Validated as an ACCESS token.
|
||||||
|
#
|
||||||
|
# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR
|
||||||
|
# Bearer credentials and these are set, the MCP session falls back to this
|
||||||
|
# shared service account (back-compat; useful for CI/scripts). Leave BLANK to
|
||||||
|
# require per-user credentials.
|
||||||
MCP_DOCMOST_EMAIL=
|
MCP_DOCMOST_EMAIL=
|
||||||
MCP_DOCMOST_PASSWORD=
|
MCP_DOCMOST_PASSWORD=
|
||||||
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
|
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
|
||||||
# Optional bearer token to protect the /mcp endpoint. If unset, /mcp relies on
|
# Optional shared guard for the /mcp endpoint. When set, every /mcp request must
|
||||||
# the workspace MCP toggle and network isolation (do not expose the port publicly).
|
# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now
|
||||||
|
# carries the per-user credentials). When unset, /mcp relies on the per-user
|
||||||
|
# credentials above plus the workspace MCP toggle and network isolation (do not
|
||||||
|
# expose the port publicly).
|
||||||
# MCP_TOKEN=
|
# MCP_TOKEN=
|
||||||
# MCP_SESSION_IDLE_MS=1800000
|
# 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.
|
||||||
|
|
||||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
164
.github/workflows/develop.yml
vendored
164
.github/workflows/develop.yml
vendored
@@ -3,7 +3,7 @@ name: Develop
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- develop
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -18,7 +18,12 @@ env:
|
|||||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Run the reusable test suite first so a failing test blocks the image build.
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/test.yml
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -51,3 +56,160 @@ jobs:
|
|||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
cache-from: type=gha,scope=develop-amd64
|
||||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
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
|
||||||
|
|||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -19,7 +19,12 @@ env:
|
|||||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Run the reusable test suite first so a failing test blocks the image build.
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/test.yml
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: test
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
|
|||||||
79
.github/workflows/test.yml
vendored
Normal file
79
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -42,3 +42,9 @@ lerna-debug.log*
|
|||||||
.nx/installation
|
.nx/installation
|
||||||
.nx/cache
|
.nx/cache
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# TypeScript incremental build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||||
|
apps/client/public/vad/
|
||||||
|
|||||||
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
// 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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,178 @@
|
|||||||
# CLAUDE.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file guides AI agents (Claude Code, opencode, …) working in this
|
||||||
|
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
|
||||||
|
|
||||||
|
Before starting **any** work, update your local `develop` and branch off it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git fetch gitea
|
||||||
|
git pull --ff-only gitea develop
|
||||||
|
git checkout -b <short-feature-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Never build a feature directly on `develop`, and never branch off a stale
|
||||||
|
`develop` — otherwise the PR will carry extra commits or conflict.
|
||||||
|
|
||||||
|
### 2. Implementation
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Create worktrees only inside the `.claude` folder** (e.g.
|
||||||
|
`.claude/worktrees/<name>`). Creating a git worktree anywhere else — the repo
|
||||||
|
root, sibling directories, or temp folders — is forbidden.
|
||||||
|
|
||||||
|
### 3. Commit — ONLY to Gitea and ONLY as `claude_code`
|
||||||
|
|
||||||
|
This rule has no exceptions:
|
||||||
|
|
||||||
|
- **Where:** the only remote for commits/pushes is **`gitea`**
|
||||||
|
(`gitea.vvzvlad.xyz`). **Never** push to `origin` (the GitHub mirror), and
|
||||||
|
especially not to `upstream` (the original Docmost). The GitHub mirror is
|
||||||
|
updated by the owner's CI process, not by the agent.
|
||||||
|
- **Who:** commit **only** as the agent identity. Any commit whose author or
|
||||||
|
committer is `vvzvlad` is an error and must be rewritten.
|
||||||
|
- **name:** `claude_code`
|
||||||
|
- **email:** `claude_code@vvzvlad.xyz`
|
||||||
|
|
||||||
|
Use `--reset-author` when amending, otherwise git keeps the original author
|
||||||
|
(the default config on this machine is `vvzvlad`, so check after every commit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_AUTHOR_NAME="claude_code" \
|
||||||
|
GIT_AUTHOR_EMAIL="claude_code@vvzvlad.xyz" \
|
||||||
|
GIT_COMMITTER_NAME="claude_code" \
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config user.name "claude_code"
|
||||||
|
git config user.email "claude_code@vvzvlad.xyz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
||||||
|
# both lines must show claude_code <claude_code@vvzvlad.xyz>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Push and PR to 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):
|
||||||
|
|
||||||
|
```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
|
||||||
|
config / reflog):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ORIG_URL=$(git remote get-url gitea)
|
||||||
|
SAFE_PASS=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$AGENT_PASS")
|
||||||
|
git remote set-url gitea "https://claude_code:${SAFE_PASS}@gitea.vvzvlad.xyz/vvzvlad/gitmost.git"
|
||||||
|
git push -u gitea <branch>
|
||||||
|
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`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @pr_body.json \
|
||||||
|
"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).
|
||||||
|
|
||||||
|
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||||
|
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||||
|
> the Gitea UI or `PUT /api/v1/repos/vvzvlad/gitmost/collaborators/claude_code`
|
||||||
|
> with `{"permission":"write"}` from their account).
|
||||||
|
|
||||||
|
### 5. Merge and 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.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Accounts & endpoints cheat sheet
|
||||||
|
|
||||||
|
| 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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture and codebase
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
@@ -51,14 +223,14 @@ pnpm --filter @docmost/mcp test # node --test (unit + mock)
|
|||||||
pnpm --filter @docmost/mcp test:e2e # MCP end-to-end against a live instance
|
pnpm --filter @docmost/mcp test:e2e # MCP end-to-end against a live instance
|
||||||
```
|
```
|
||||||
|
|
||||||
**Database migrations** (Kysely, run from `apps/server`; they auto-run on server startup too):
|
**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).
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter server migration:create --name=my_change # new empty migration
|
pnpm --filter server migration:create --name=my_change # new empty migration
|
||||||
pnpm --filter server migration:latest # apply all pending
|
pnpm --filter server migration:latest # apply all pending
|
||||||
pnpm --filter server migration:down # revert last
|
pnpm --filter server migration:down # revert last
|
||||||
pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB
|
pnpm --filter server migration:codegen # regenerate src/database/types/db.d.ts from the live DB
|
||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`) and nullable columns — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**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.
|
||||||
|
|
||||||
@@ -82,7 +254,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.
|
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
|
||||||
|
|
||||||
### The two AI subsystems (the main fork additions)
|
### The two AI subsystems (the main fork additions)
|
||||||
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. It authenticates as a service account configured via `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`; an admin enables it with a workspace toggle (Workspace settings → AI). Optionally protected by `MCP_TOKEN`.
|
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
|
||||||
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
|
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
|
||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
@@ -105,7 +277,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|
||||||
- `.github/workflows/develop.yml` — on push to `main`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
- `.github/workflows/develop.yml` — on push to `develop`, builds and pushes `ghcr.io/vvzvlad/gitmost:develop`.
|
||||||
- `.github/workflows/release.yml` — on `v*` tags (or manual dispatch), builds multi-arch (amd64 + arm64) images, pushes a manifest list to GHCR (`latest` + semver tags), and creates a draft GitHub Release with image tarballs. Uses the built-in `GITHUB_TOKEN` (not Docker Hub).
|
- `.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.
|
- The `Dockerfile` is a multi-stage pnpm build; `APP_VERSION` is passed as a build arg because `.git` isn't in the build context.
|
||||||
|
|
||||||
@@ -119,8 +291,30 @@ The git tag is the source of truth for the displayed version (UI reads `git desc
|
|||||||
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
|
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`.
|
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).
|
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
|
||||||
|
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
|
||||||
|
|
||||||
|
#### Why develop keeps showing the *previous* version (and why step 7 matters)
|
||||||
|
|
||||||
|
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
|
||||||
|
|
||||||
|
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
|
||||||
|
|
||||||
|
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
|
||||||
|
|
||||||
|
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
|
||||||
|
|
||||||
|
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
|
||||||
|
|
||||||
|
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
|
||||||
|
|
||||||
|
Fix / checklist when develop still shows the old version after a back-merge:
|
||||||
|
|
||||||
|
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
|
||||||
|
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
|
||||||
|
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
|
||||||
|
|
||||||
|
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
|
||||||
|
|
||||||
## Planning docs
|
## Planning docs
|
||||||
|
|
||||||
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation, arbitrary HTML embed). `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
`docs/*.md` hold design plans for in-progress / planned features (mobile app, offline sync, RAG improvements, voice dictation). Arbitrary HTML embed has **shipped** — it renders inside a sandboxed iframe and, when the `htmlEmbed` workspace toggle is on, is insertable by any member (no longer admin-only); turning the toggle off hides/stops serving existing embeds on public share pages. `docs/backlog/*.md` track known issues / follow-ups (e.g. AI-chat review follow-ups). Consult the relevant plan before working on one of those areas.
|
||||||
236
CHANGELOG.md
236
CHANGELOG.md
@@ -10,6 +10,239 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **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
|
## [0.91.0] - 2026-06-18
|
||||||
|
|
||||||
Gitmost is a community-focused fork of Docmost. This release drops the
|
Gitmost is a community-focused fork of Docmost. This release drops the
|
||||||
@@ -92,5 +325,6 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
|
|||||||
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
||||||
Docker image to the GHCR registry.
|
Docker image to the GHCR registry.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD
|
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
|
||||||
|
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
|
||||||
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -101,6 +101,9 @@ community feature, with no enterprise license. Open it from the page header; the
|
|||||||
- ✅ **macOS app** — native macOS app ([gitmost-app](https://github.com/vvzvlad/gitmost-app)) that embeds the UI with multi-server tabs.
|
- ✅ **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).
|
- ✅ **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.
|
- ✅ **Voice dictation** — microphone button in the AI agent chat and the page editor; audio is transcribed server-side (Whisper / OpenAI-compatible STT) via the workspace AI provider, with an admin toggle to show/hide it.
|
||||||
|
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||||
|
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||||
|
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||||
|
|
||||||
### In progress
|
### In progress
|
||||||
|
|
||||||
@@ -108,14 +111,11 @@ community feature, with no enterprise license. Open it from the page header; the
|
|||||||
|
|
||||||
### Planned
|
### 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.
|
- 🔭 **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.
|
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
|
||||||
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
|
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
|
||||||
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||||
- 🔭 **Offline mode** — offline sync & PWA support.
|
- 🔭 **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.
|
- 🔭 **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
|
## Getting started
|
||||||
@@ -158,6 +158,11 @@ the existing data directory is reused as-is:
|
|||||||
start the new migrations apply on top of your existing schema (`CREATE EXTENSION vector` plus the
|
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`.
|
`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
|
### Notes
|
||||||
|
|
||||||
- **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the
|
- **Back up first.** Take a `pg_dump` before swapping — migrations apply in place, and the
|
||||||
|
|||||||
14
README.ru.md
14
README.ru.md
@@ -102,6 +102,9 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
- ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов.
|
- ✅ **Приложение для macOS** — нативное приложение для macOS ([gitmost-app](https://github.com/vvzvlad/gitmost-app)), встраивающее UI с вкладками для нескольких серверов.
|
||||||
- ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP).
|
- ✅ **AI-чат** — встроенный чат с AI-агентом по содержимому вики (чтение + запись, RAG-поиск, настраиваемый провайдер, опциональный доступ в интернет через внешние MCP).
|
||||||
- ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия.
|
- ✅ **Голосовая диктовка** — кнопка-микрофон в чате AI-агента и в редакторе страниц; аудио распознаётся на сервере (Whisper / OpenAI-совместимый STT) через AI-провайдер воркспейса, с тумблером админа для показа/скрытия.
|
||||||
|
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||||
|
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||||
|
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||||
|
|
||||||
### В процессе
|
### В процессе
|
||||||
|
|
||||||
@@ -109,14 +112,11 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
|
|
||||||
### В планах
|
### В планах
|
||||||
|
|
||||||
- 🔭 **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков). См. [docs/page-templates-plan.md](docs/page-templates-plan.md).
|
|
||||||
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
|
||||||
- 🔭 **AI-ассистент на публичных шарах** — возможность анонимному зрителю расшаренной страницы спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса. См. [docs/public-share-assistant-plan.md](docs/public-share-assistant-plan.md).
|
|
||||||
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
|
||||||
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
|
||||||
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
|
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
|
||||||
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
|
||||||
- 🔭 **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP. См. [docs/footnotes-plan.md](docs/footnotes-plan.md).
|
|
||||||
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.
|
||||||
|
|
||||||
## С чего начать
|
## С чего начать
|
||||||
@@ -159,6 +159,12 @@ dump/restore, существующий каталог данных переис
|
|||||||
новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы
|
новые миграции применяются поверх вашей схемы (`CREATE EXTENSION vector` плюс таблицы
|
||||||
`page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`.
|
`page_embeddings` и AI-таблицы); следите в логах за строками `Migration "..." executed successfully`.
|
||||||
|
|
||||||
|
> ⚠️ **Никогда не меняйте `APP_SECRET` после установки.** Он выполняет двойную роль: подписывает JWT
|
||||||
|
> *и* служит материалом для ключа AES-256-GCM, которым шифруются сохранённые ключи AI-провайдеров
|
||||||
|
> (API-ключи). Смена секрета сделает все сохранённые AI-ключи нерасшифровываемыми (придётся вводить
|
||||||
|
> их заново в настройках AI) и инвалидирует все текущие сессии. Задайте его один раз, держите
|
||||||
|
> неизменным и бэкапьте вместе с базой данных.
|
||||||
|
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.91.0",
|
"version": "0.94.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node scripts/copy-vad-assets.mjs && vite",
|
||||||
"build": "tsc && vite build",
|
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@mantine/modals": "8.3.18",
|
"@mantine/modals": "8.3.18",
|
||||||
"@mantine/notifications": "8.3.18",
|
"@mantine/notifications": "8.3.18",
|
||||||
"@mantine/spotlight": "8.3.18",
|
"@mantine/spotlight": "8.3.18",
|
||||||
|
"@ricky0123/vad-web": "^0.0.30",
|
||||||
"@slidoapp/emoji-mart": "5.8.7",
|
"@slidoapp/emoji-mart": "5.8.7",
|
||||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"mantine-form-zod-resolver": "1.3.0",
|
"mantine-form-zod-resolver": "1.3.0",
|
||||||
"mermaid": "11.15.0",
|
"mermaid": "11.15.0",
|
||||||
"mitt": "3.0.1",
|
"mitt": "3.0.1",
|
||||||
|
"onnxruntime-web": "^1.27.0",
|
||||||
"posthog-js": "1.372.2",
|
"posthog-js": "1.372.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
|
|||||||
@@ -119,6 +119,8 @@
|
|||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
"New email": "New email",
|
"New email": "New email",
|
||||||
"New page": "New page",
|
"New page": "New page",
|
||||||
|
"New note": "New note",
|
||||||
|
"Create in space": "Create in space",
|
||||||
"New password": "New password",
|
"New password": "New password",
|
||||||
"No group found": "No group found",
|
"No group found": "No group found",
|
||||||
"No page history saved yet.": "No page history saved yet.",
|
"No page history saved yet.": "No page history saved yet.",
|
||||||
@@ -183,6 +185,7 @@
|
|||||||
"Successfully imported": "Successfully imported",
|
"Successfully imported": "Successfully imported",
|
||||||
"Successfully restored": "Successfully restored",
|
"Successfully restored": "Successfully restored",
|
||||||
"System settings": "System settings",
|
"System settings": "System settings",
|
||||||
|
"Template": "Template",
|
||||||
"Templates": "Templates",
|
"Templates": "Templates",
|
||||||
"Theme": "Theme",
|
"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.",
|
"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.",
|
||||||
@@ -255,6 +258,7 @@
|
|||||||
"Copy to space": "Copy to space",
|
"Copy to space": "Copy to space",
|
||||||
"Copy chat": "Copy chat",
|
"Copy chat": "Copy chat",
|
||||||
"Copied": "Copied",
|
"Copied": "Copied",
|
||||||
|
"Failed to export chat": "Failed to export chat",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
"Select a user": "Select a user",
|
"Select a user": "Select a user",
|
||||||
"Select a group": "Select a group",
|
"Select a group": "Select a group",
|
||||||
@@ -417,6 +421,8 @@
|
|||||||
"{{count}} command available_other": "{{count}} commands available",
|
"{{count}} command available_other": "{{count}} commands available",
|
||||||
"{{count}} result available_one": "1 result available",
|
"{{count}} result available_one": "1 result available",
|
||||||
"{{count}} result available_other": "{{count}} results 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",
|
"Equal columns": "Equal columns",
|
||||||
"Left sidebar": "Left sidebar",
|
"Left sidebar": "Left sidebar",
|
||||||
"Right sidebar": "Right sidebar",
|
"Right sidebar": "Right sidebar",
|
||||||
@@ -473,6 +479,7 @@
|
|||||||
"Make sub-pages public too": "Make sub-pages public too",
|
"Make sub-pages public too": "Make sub-pages public too",
|
||||||
"Allow search engines to index page": "Allow search engines to index page",
|
"Allow search engines to index page": "Allow search engines to index page",
|
||||||
"Open page": "Open page",
|
"Open page": "Open page",
|
||||||
|
"Open source page": "Open source page",
|
||||||
"Page": "Page",
|
"Page": "Page",
|
||||||
"Delete public share link": "Delete public share link",
|
"Delete public share link": "Delete public share link",
|
||||||
"Delete share": "Delete share",
|
"Delete share": "Delete share",
|
||||||
@@ -529,6 +536,7 @@
|
|||||||
"Add 2FA method": "Add 2FA method",
|
"Add 2FA method": "Add 2FA method",
|
||||||
"Backup codes": "Backup codes",
|
"Backup codes": "Backup codes",
|
||||||
"Disable": "Disable",
|
"Disable": "Disable",
|
||||||
|
"disabled": "disabled",
|
||||||
"Invalid verification code": "Invalid verification code",
|
"Invalid verification code": "Invalid verification code",
|
||||||
"New backup codes have been generated": "New backup codes have been generated",
|
"New backup codes have been generated": "New backup codes have been generated",
|
||||||
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
"Failed to regenerate backup codes": "Failed to regenerate backup codes",
|
||||||
@@ -703,10 +711,12 @@
|
|||||||
"Authorization header": "Authorization header",
|
"Authorization header": "Authorization header",
|
||||||
"Tool allowlist": "Tool allowlist",
|
"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. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
|
||||||
"Use Tavily preset": "Use Tavily preset",
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
|
||||||
"Test": "Test",
|
"Test": "Test",
|
||||||
"Available tools": "Available tools",
|
"Available tools": "Available tools",
|
||||||
"No tools available": "No tools available",
|
"No tools available": "No tools available",
|
||||||
|
"Failed": "Failed",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
"Created successfully": "Created successfully",
|
"Created successfully": "Created successfully",
|
||||||
"Deleted successfully": "Deleted successfully",
|
"Deleted successfully": "Deleted successfully",
|
||||||
"Clear": "Clear",
|
"Clear": "Clear",
|
||||||
@@ -948,6 +958,7 @@
|
|||||||
"Try a different search term.": "Try a different search term.",
|
"Try a different search term.": "Try a different search term.",
|
||||||
"Try again": "Try again",
|
"Try again": "Try again",
|
||||||
"Untitled chat": "Untitled chat",
|
"Untitled chat": "Untitled chat",
|
||||||
|
"No document": "No document",
|
||||||
"You": "You",
|
"You": "You",
|
||||||
"What can I help you with?": "What can I help you with?",
|
"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}}",
|
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
|
||||||
@@ -977,6 +988,9 @@
|
|||||||
"Page menu": "Page menu",
|
"Page menu": "Page menu",
|
||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Collapse": "Collapse",
|
"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",
|
"Comment menu": "Comment menu",
|
||||||
"Group menu": "Group menu",
|
"Group menu": "Group menu",
|
||||||
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||||
@@ -1067,6 +1081,8 @@
|
|||||||
"Undo": "Undo",
|
"Undo": "Undo",
|
||||||
"Redo": "Redo",
|
"Redo": "Redo",
|
||||||
"Backlinks": "Backlinks",
|
"Backlinks": "Backlinks",
|
||||||
|
"Back to references": "Back to references",
|
||||||
|
"Back to reference {{label}}": "Back to reference {{label}}",
|
||||||
"Last updated by": "Last updated by",
|
"Last updated by": "Last updated by",
|
||||||
"Last updated": "Last updated",
|
"Last updated": "Last updated",
|
||||||
"Stats": "Stats",
|
"Stats": "Stats",
|
||||||
@@ -1119,15 +1135,55 @@
|
|||||||
"Removed from favorites": "Removed from favorites",
|
"Removed from favorites": "Removed from favorites",
|
||||||
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
||||||
"Removed {{name}} from favorites": "Removed {{name}} from 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}}",
|
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||||
"AI chat": "AI chat",
|
"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",
|
"Minimize": "Minimize",
|
||||||
"Current context size": "Current context size",
|
"Context size / model limit": "Context size / model limit",
|
||||||
|
"Context window (tokens)": "Context window (tokens)",
|
||||||
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||||
"AI agent": "AI agent",
|
"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…",
|
"AI agent is typing…": "AI agent is typing…",
|
||||||
|
"{{name}} is typing…": "{{name}} is typing…",
|
||||||
"Send": "Send",
|
"Send": "Send",
|
||||||
|
"Send when the agent finishes": "Send when the agent finishes",
|
||||||
|
"Queue message": "Queue message",
|
||||||
|
"Remove queued message": "Remove queued message",
|
||||||
"Stop": "Stop",
|
"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",
|
"Chat menu": "Chat menu",
|
||||||
"No chats yet.": "No chats yet.",
|
"No chats yet.": "No chats yet.",
|
||||||
"Delete this chat?": "Delete this chat?",
|
"Delete this chat?": "Delete this chat?",
|
||||||
@@ -1159,9 +1215,16 @@
|
|||||||
"Semantic search": "Semantic search",
|
"Semantic search": "Semantic search",
|
||||||
"Voice / STT": "Voice / STT",
|
"Voice / STT": "Voice / STT",
|
||||||
"Voice dictation": "Voice dictation",
|
"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.",
|
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||||
"Test endpoint": "Test endpoint",
|
"Test endpoint": "Test endpoint",
|
||||||
|
"Save and test": "Save and test",
|
||||||
"Save endpoints": "Save endpoints",
|
"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",
|
"External tools": "External tools",
|
||||||
"Gitmost as MCP client": "Gitmost as MCP client",
|
"Gitmost as MCP client": "Gitmost as MCP client",
|
||||||
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
||||||
@@ -1189,11 +1252,71 @@
|
|||||||
"No microphone found": "No microphone found",
|
"No microphone found": "No microphone found",
|
||||||
"Could not start recording": "Could not start recording",
|
"Could not start recording": "Could not start recording",
|
||||||
"Transcription failed": "Transcription failed",
|
"Transcription failed": "Transcription failed",
|
||||||
|
"Transcribe": "Transcribe",
|
||||||
|
"No speech detected": "No speech detected",
|
||||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
"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",
|
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||||
"Request format": "Request format",
|
"Request format": "Request format",
|
||||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
"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)",
|
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||||
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)"
|
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)",
|
||||||
|
"Dictation language": "Dictation language",
|
||||||
|
"Auto-detect": "Auto-detect",
|
||||||
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.",
|
||||||
|
"Agent role": "Agent role",
|
||||||
|
"Universal assistant": "Universal assistant",
|
||||||
|
"Add role": "Add role",
|
||||||
|
"Edit role": "Edit role",
|
||||||
|
"Role name": "Role name",
|
||||||
|
"e.g. Proofreader": "e.g. Proofreader",
|
||||||
|
"Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.",
|
||||||
|
"Optional. A short note about what this role does.": "Optional. A short note about what this role does.",
|
||||||
|
"Instructions": "Instructions",
|
||||||
|
"The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.",
|
||||||
|
"Model provider override": "Model provider override",
|
||||||
|
"Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.",
|
||||||
|
"Model override": "Model override",
|
||||||
|
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||||
|
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
||||||
|
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
||||||
|
"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)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,8 @@
|
|||||||
"Name": "Имя",
|
"Name": "Имя",
|
||||||
"New email": "Новый электронный адрес",
|
"New email": "Новый электронный адрес",
|
||||||
"New page": "Новая страница",
|
"New page": "Новая страница",
|
||||||
|
"New note": "Новая заметка",
|
||||||
|
"Create in space": "Создать в пространстве",
|
||||||
"New password": "Новый пароль",
|
"New password": "Новый пароль",
|
||||||
"No group found": "Группа не найдена",
|
"No group found": "Группа не найдена",
|
||||||
"No page history saved yet.": "История страниц ещё не сохранена.",
|
"No page history saved yet.": "История страниц ещё не сохранена.",
|
||||||
@@ -183,6 +185,7 @@
|
|||||||
"Successfully imported": "Успешно импортировано",
|
"Successfully imported": "Успешно импортировано",
|
||||||
"Successfully restored": "Успешно восстановлено",
|
"Successfully restored": "Успешно восстановлено",
|
||||||
"System settings": "Системные настройки",
|
"System settings": "Системные настройки",
|
||||||
|
"Template": "Шаблон",
|
||||||
"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.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copy to space": "Копировать в пространство",
|
"Copy to space": "Копировать в пространство",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
|
"Failed to export chat": "Не удалось экспортировать чат",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
"Select a user": "Выберите пользователя",
|
"Select a user": "Выберите пользователя",
|
||||||
"Select a group": "Выберите группу",
|
"Select a group": "Выберите группу",
|
||||||
@@ -382,6 +386,11 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
|
"Transcribe": "Транскрибировать",
|
||||||
|
"Transcribing…": "Транскрибация…",
|
||||||
|
"No speech detected": "Речь не распознана",
|
||||||
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -391,6 +400,15 @@
|
|||||||
"Toggle block": "Сворачиваемый блок",
|
"Toggle block": "Сворачиваемый блок",
|
||||||
"Callout": "Выноска",
|
"Callout": "Выноска",
|
||||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
"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": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
"Math block": "Блок формулы",
|
"Math block": "Блок формулы",
|
||||||
@@ -471,6 +489,7 @@
|
|||||||
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
|
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
|
||||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
||||||
"Open page": "Открыть страницу",
|
"Open page": "Открыть страницу",
|
||||||
|
"Open source page": "Открыть исходную страницу",
|
||||||
"Page": "Страница",
|
"Page": "Страница",
|
||||||
"Delete public share link": "Удалить публичную ссылку",
|
"Delete public share link": "Удалить публичную ссылку",
|
||||||
"Delete share": "Удалить общий доступ",
|
"Delete share": "Удалить общий доступ",
|
||||||
@@ -659,6 +678,57 @@
|
|||||||
"AI search": "Поиск ИИ",
|
"AI search": "Поиск ИИ",
|
||||||
"AI Answer": "Ответ ИИ",
|
"AI Answer": "Ответ ИИ",
|
||||||
"Ask AI": "Спросить ИИ",
|
"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": "Убрать из очереди",
|
||||||
|
"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...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Thinking": "Думаю",
|
"Thinking": "Думаю",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"Ask a question...": "Задайте вопрос...",
|
||||||
@@ -688,6 +758,8 @@
|
|||||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"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>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</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": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -914,6 +986,7 @@
|
|||||||
"Try a different search term.": "Попробуйте другой поисковый запрос.",
|
"Try a different search term.": "Попробуйте другой поисковый запрос.",
|
||||||
"Try again": "Попробовать снова",
|
"Try again": "Попробовать снова",
|
||||||
"Untitled chat": "Чат без названия",
|
"Untitled chat": "Чат без названия",
|
||||||
|
"No document": "Без документа",
|
||||||
"What can I help you with?": "Чем я могу вам помочь?",
|
"What can I help you with?": "Чем я могу вам помочь?",
|
||||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||||
@@ -1085,5 +1158,22 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
|
"Dictation language": "Язык диктовки",
|
||||||
|
"Auto-detect": "Автоопределение",
|
||||||
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
|
"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 (официальный)"
|
||||||
}
|
}
|
||||||
|
|||||||
70
apps/client/scripts/copy-vad-assets.mjs
Normal file
70
apps/client/scripts/copy-vad-assets.mjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Self-host the @ricky0123/vad-web + onnxruntime-web runtime assets under
|
||||||
|
// apps/client/public/vad/.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS:
|
||||||
|
// Both vad-web and onnxruntime-web resolve their assets by URL *at runtime* (the
|
||||||
|
// VAD audio worklet + Silero model, and ORT's wasm/mjs backend). In vad-web
|
||||||
|
// 0.0.30 the default baseAssetPath / onnxWASMBasePath is "./" — i.e. relative to
|
||||||
|
// the current page URL — NOT a CDN. In this SPA that "./" request hits the
|
||||||
|
// client-side catch-all route and gets served index.html (text/html), so the
|
||||||
|
// onnxruntime ESM/wasm backend fails to initialize ("'text/html' is not a valid
|
||||||
|
// JavaScript MIME type"). We fix that by copying the needed runtime files into
|
||||||
|
// public/vad/ and pointing both path constants at the fixed absolute "/vad/".
|
||||||
|
//
|
||||||
|
// These copies are NOT committed (the ORT wasm is ~26 MB); this script runs
|
||||||
|
// before `dev` and `build` (see package.json) to repopulate them from
|
||||||
|
// node_modules. It is idempotent: it (re)creates the dir and overwrites.
|
||||||
|
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outDir = path.join(here, "..", "public", "vad");
|
||||||
|
|
||||||
|
// vad-web exposes ./package.json, so derive its dist dir from there.
|
||||||
|
const vadDist = path.join(
|
||||||
|
path.dirname(require.resolve("@ricky0123/vad-web/package.json")),
|
||||||
|
"dist",
|
||||||
|
);
|
||||||
|
|
||||||
|
// onnxruntime-web's "exports" map does NOT expose ./package.json, so resolving
|
||||||
|
// it would throw ERR_PACKAGE_PATH_NOT_EXPORTED. It DOES export the exact asset
|
||||||
|
// subpaths we need, so resolve those files directly.
|
||||||
|
//
|
||||||
|
// ORT ships several wasm backends and which one the app bundle references depends
|
||||||
|
// on the resolver: Vite dev resolves the JSEP build (ort-wasm-simd-threaded.jsep.*)
|
||||||
|
// while the production rolldown build resolves the plain build
|
||||||
|
// (ort-wasm-simd-threaded.*). Ship BOTH variants so the runtime fetch hits a real
|
||||||
|
// file under /vad/ regardless of which the bundle picked (each .mjs proxy fetches
|
||||||
|
// its matching .wasm at init).
|
||||||
|
const ortJsepMjs = require.resolve(
|
||||||
|
"onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs",
|
||||||
|
);
|
||||||
|
const ortJsepWasm = require.resolve(
|
||||||
|
"onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm",
|
||||||
|
);
|
||||||
|
const ortMjs = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.mjs");
|
||||||
|
const ortWasm = require.resolve("onnxruntime-web/ort-wasm-simd-threaded.wasm");
|
||||||
|
|
||||||
|
// [absolute source path, output filename]
|
||||||
|
const files = [
|
||||||
|
[path.join(vadDist, "vad.worklet.bundle.min.js"), "vad.worklet.bundle.min.js"],
|
||||||
|
[path.join(vadDist, "silero_vad_v5.onnx"), "silero_vad_v5.onnx"],
|
||||||
|
[ortJsepMjs, "ort-wasm-simd-threaded.jsep.mjs"],
|
||||||
|
[ortJsepWasm, "ort-wasm-simd-threaded.jsep.wasm"],
|
||||||
|
[ortMjs, "ort-wasm-simd-threaded.mjs"],
|
||||||
|
[ortWasm, "ort-wasm-simd-threaded.wasm"],
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
for (const [src, name] of files) {
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
console.error(`[copy-vad-assets] missing source: ${src}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, path.join(outDir, name));
|
||||||
|
console.log(`[copy-vad-assets] ${name}`);
|
||||||
|
}
|
||||||
@@ -42,6 +42,23 @@ export default function AvatarUploader({
|
|||||||
return;
|
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)
|
// Validate file size (max 10MB)
|
||||||
const maxSizeInBytes = 10 * 1024 * 1024;
|
const maxSizeInBytes = 10 * 1024 * 1024;
|
||||||
if (file.size > maxSizeInBytes) {
|
if (file.size > maxSizeInBytes) {
|
||||||
@@ -58,6 +75,8 @@ export default function AvatarUploader({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onUpload(file);
|
await onUpload(file);
|
||||||
|
// Notify on success so the upload gives visible feedback (issue #128)
|
||||||
|
notifications.show({ message: t("Image updated") });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -117,7 +136,7 @@ export default function AvatarUploader({
|
|||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
accept="image/*"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) {
|
|||||||
<Badge
|
<Badge
|
||||||
color={getInitialsColor(page?.space.name)}
|
color={getInitialsColor(page?.space.name)}
|
||||||
variant="light"
|
variant="light"
|
||||||
|
tt="none"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={rem(size)}
|
// rem(size) returns a `calc(...)` string, which is invalid for the raw
|
||||||
height={rem(size)}
|
// 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) }}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={rem(size)}
|
// rem(size) returns a `calc(...)` string, which is invalid for the raw
|
||||||
height={rem(size)}
|
// 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) }}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brandIcon {
|
.brandIcon {
|
||||||
@@ -33,21 +34,3 @@
|
|||||||
that is ~9.3px, minus the font descent (~2px) ≈ 7px. */
|
that is ~9.3px, minus the font descent (~2px) ≈ 7px. */
|
||||||
margin-bottom: rem(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,12 +5,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
import { IconMessage } from "@tabler/icons-react";
|
||||||
import classes from "./app-header.module.css";
|
import classes from "./app-header.module.css";
|
||||||
import { BrandLogo } from "@/components/ui/brand-logo";
|
import { BrandLogo } from "@/components/ui/brand-logo";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
@@ -30,10 +29,6 @@ import {
|
|||||||
} from "@/features/search/constants.ts";
|
} from "@/features/search/constants.ts";
|
||||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ link: APP_ROUTE.HOME, label: "Home" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
@@ -47,12 +42,6 @@ export function AppHeader() {
|
|||||||
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
// AI chat entry point: only shown when the workspace enables it (A7 gate).
|
||||||
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
|
|
||||||
const items = links.map((link) => (
|
|
||||||
<Link key={link.label} to={link.link} className={classes.link}>
|
|
||||||
{t(link.label)}
|
|
||||||
</Link>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
<Group h="100%" px="md" justify="space-between" wrap={"nowrap"}>
|
||||||
@@ -97,10 +86,6 @@ export function AppHeader() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group ml="xl" gap={5} className={classes.links} visibleFrom="sm">
|
|
||||||
{items}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -122,7 +107,7 @@ export function AppHeader() {
|
|||||||
aria-label={t("AI chat")}
|
aria-label={t("AI chat")}
|
||||||
onClick={() => setAiChatWindowOpen((v) => !v)}
|
onClick={() => setAiChatWindowOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<IconSparkles size={20} />
|
<IconMessage size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function Aside() {
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "comments":
|
case "comments":
|
||||||
component = <CommentListWithTabs />;
|
component = <CommentListWithTabs onClose={closeAside} />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
case "toc":
|
case "toc":
|
||||||
@@ -44,26 +44,27 @@ export default function Aside() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
<Box p={0} style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
{component && (
|
{component &&
|
||||||
<>
|
(tab === "comments" ? (
|
||||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
component
|
||||||
<Title order={2} size="h6" fw={500}>{t(title)}</Title>
|
) : (
|
||||||
<Tooltip label={t("Close")} withArrow>
|
<>
|
||||||
<ActionIcon
|
<Group justify="space-between" wrap="nowrap" mb="sm">
|
||||||
variant="subtle"
|
<Title order={2} size="h6" fw={500}>
|
||||||
color="gray"
|
{t(title)}
|
||||||
onClick={closeAside}
|
</Title>
|
||||||
aria-label={t("Close")}
|
<Tooltip label={t("Close")} withArrow>
|
||||||
>
|
<ActionIcon
|
||||||
<IconX size={18} />
|
variant="subtle"
|
||||||
</ActionIcon>
|
color="gray"
|
||||||
</Tooltip>
|
onClick={closeAside}
|
||||||
</Group>
|
aria-label={t("Close")}
|
||||||
|
>
|
||||||
{tab === "comments" ? (
|
<IconX size={18} />
|
||||||
component
|
</ActionIcon>
|
||||||
) : (
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
style={{ height: "85vh" }}
|
style={{ height: "85vh" }}
|
||||||
scrollbarSize={5}
|
scrollbarSize={5}
|
||||||
@@ -71,9 +72,8 @@ export default function Aside() {
|
|||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
<div style={{ paddingBottom: "200px" }}>{component}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
</>
|
||||||
</>
|
))}
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.
|
|||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.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 classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||||
@@ -94,12 +95,12 @@ export default function GlobalAppShell({
|
|||||||
}}
|
}}
|
||||||
aside={
|
aside={
|
||||||
isPageRoute && {
|
isPageRoute && {
|
||||||
width: 350,
|
width: 420,
|
||||||
breakpoint: "sm",
|
breakpoint: "sm",
|
||||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
padding="md"
|
padding={{ base: "xs", sm: "md" }}
|
||||||
>
|
>
|
||||||
<AppShell.Header px="md" className={classes.header}>
|
<AppShell.Header px="md" className={classes.header}>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
@@ -138,7 +139,7 @@ export default function GlobalAppShell({
|
|||||||
id={ASIDE_PANEL_ID}
|
id={ASIDE_PANEL_ID}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={classes.aside}
|
className={classes.aside}
|
||||||
p="md"
|
p="sm"
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
aria-label={
|
aria-label={
|
||||||
asideTab === "comments"
|
asideTab === "comments"
|
||||||
@@ -157,6 +158,10 @@ export default function GlobalAppShell({
|
|||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* 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. */}
|
and self-hides when closed, so its place in the tree is not critical. */}
|
||||||
<AiChatWindow />
|
<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,18 +20,29 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useMatch } from "react-router-dom";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
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() {
|
export default function TopMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
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 user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
@@ -41,124 +52,143 @@ export default function TopMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
<>
|
||||||
<Menu.Target>
|
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||||
<UnstyledButton>
|
<Menu.Target>
|
||||||
<Group gap={7} wrap={"nowrap"}>
|
<UnstyledButton>
|
||||||
<CustomAvatar
|
<Group gap={7} wrap={"nowrap"}>
|
||||||
avatarUrl={workspace?.logo}
|
<CustomAvatar
|
||||||
name={workspace?.name}
|
avatarUrl={workspace?.logo}
|
||||||
variant="filled"
|
name={workspace?.name}
|
||||||
size="sm"
|
variant="filled"
|
||||||
type={AvatarIconType.WORKSPACE_ICON}
|
size="sm"
|
||||||
/>
|
type={AvatarIconType.WORKSPACE_ICON}
|
||||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
/>
|
||||||
{workspace?.name}
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
</Text>
|
{workspace?.name}
|
||||||
<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>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" truncate="end">
|
<IconChevronDown size={16} />
|
||||||
{user.email}
|
</Group>
|
||||||
</Text>
|
</UnstyledButton>
|
||||||
</div>
|
</Menu.Target>
|
||||||
</Group>
|
<Menu.Dropdown>
|
||||||
</Menu.Item>
|
<Menu.Label>{t("Workspace")}</Menu.Label>
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
|
||||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
|
||||||
leftSection={<IconUserCircle size={16} />}
|
|
||||||
>
|
|
||||||
{t("My profile")}
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
to={APP_ROUTE.SETTINGS.WORKSPACE.GENERAL}
|
||||||
leftSection={<IconBrush size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
>
|
>
|
||||||
{t("My preferences")}
|
{t("Workspace settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Sub>
|
{spaceSlug && (
|
||||||
<Menu.Sub.Target>
|
|
||||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
|
||||||
{t("Theme")}
|
|
||||||
</Menu.Sub.Item>
|
|
||||||
</Menu.Sub.Target>
|
|
||||||
|
|
||||||
<Menu.Sub.Dropdown>
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => setColorScheme("light")}
|
onClick={openSpaceSettings}
|
||||||
leftSection={<IconSun size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
rightSection={
|
|
||||||
colorScheme === "light" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t("Light")}
|
{t("Space settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
)}
|
||||||
onClick={() => setColorScheme("dark")}
|
|
||||||
leftSection={<IconMoon size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Dark")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
onClick={() => setColorScheme("auto")}
|
|
||||||
leftSection={<IconDeviceDesktop size={16} />}
|
|
||||||
rightSection={
|
|
||||||
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("System settings")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Sub.Dropdown>
|
|
||||||
</Menu.Sub>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.WORKSPACE.MEMBERS}
|
||||||
|
leftSection={<IconUsers size={16} />}
|
||||||
|
>
|
||||||
|
{t("Manage members")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
<Menu.Divider />
|
||||||
{t("Logout")}
|
|
||||||
</Menu.Item>
|
<Menu.Label>{t("Account")}</Menu.Label>
|
||||||
</Menu.Dropdown>
|
<Menu.Item component={Link} to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}>
|
||||||
</Menu>
|
<Group wrap={"nowrap"}>
|
||||||
|
<CustomAvatar
|
||||||
|
size={"sm"}
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
name={user.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ width: 190 }}>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
|
{user.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate="end">
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PROFILE}
|
||||||
|
leftSection={<IconUserCircle size={16} />}
|
||||||
|
>
|
||||||
|
{t("My profile")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.SETTINGS.ACCOUNT.PREFERENCES}
|
||||||
|
leftSection={<IconBrush size={16} />}
|
||||||
|
>
|
||||||
|
{t("My preferences")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Sub>
|
||||||
|
<Menu.Sub.Target>
|
||||||
|
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||||
|
{t("Theme")}
|
||||||
|
</Menu.Sub.Item>
|
||||||
|
</Menu.Sub.Target>
|
||||||
|
|
||||||
|
<Menu.Sub.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("light")}
|
||||||
|
leftSection={<IconSun size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "light" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Light")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("dark")}
|
||||||
|
leftSection={<IconMoon size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "dark" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dark")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => setColorScheme("auto")}
|
||||||
|
leftSection={<IconDeviceDesktop size={16} />}
|
||||||
|
rightSection={
|
||||||
|
colorScheme === "auto" ? <IconCheck size={16} /> : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("System settings")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Sub.Dropdown>
|
||||||
|
</Menu.Sub>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item onClick={logout} leftSection={<IconLogout size={16} />}>
|
||||||
|
{t("Logout")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{spaceSlug && (
|
||||||
|
<SpaceSettingsModal
|
||||||
|
spaceId={spaceSlug}
|
||||||
|
opened={spaceSettingsOpened}
|
||||||
|
onClose={closeSpaceSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
prefetchSpaces,
|
prefetchSpaces,
|
||||||
prefetchWorkspaceMembers,
|
prefetchWorkspaceMembers,
|
||||||
} from "@/components/settings/settings-queries.tsx";
|
} 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 { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||||
@@ -141,8 +140,6 @@ export default function SettingsSidebar() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
|
|
||||||
<AppVersion />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
96
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
96
apps/client/src/components/ui/ai-agent-badge.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
99
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
99
apps/client/src/components/ui/ai-agent-badge.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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,6 +27,7 @@ export function BrandLogo({
|
|||||||
src={src}
|
src={src}
|
||||||
alt="Gitmost"
|
alt="Gitmost"
|
||||||
className={className}
|
className={className}
|
||||||
|
draggable={false}
|
||||||
style={{ height, width: "auto", display: "block", userSelect: "none" }}
|
style={{ height, width: "auto", display: "block", userSelect: "none" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
import { atom } from "jotai";
|
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:
|
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
|
||||||
@@ -13,6 +31,15 @@ export const activeAiChatIdAtom = atom(null as string | null);
|
|||||||
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
||||||
export const aiChatWindowOpenAtom = atom<boolean>(false);
|
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
|
// 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
|
// 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
|
// chat adopts its freshly created id after the first turn finishes. If it lived
|
||||||
|
|||||||
@@ -57,6 +57,12 @@
|
|||||||
display: none;
|
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 {
|
.dragBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -18,24 +18,34 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useParams } from "react-router-dom";
|
import { useMatch } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
activeAiChatIdAtom,
|
activeAiChatIdAtom,
|
||||||
aiChatWindowOpenAtom,
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatWindowGeomAtom,
|
||||||
aiChatDraftAtom,
|
aiChatDraftAtom,
|
||||||
|
selectedAiRoleIdAtom,
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import {
|
import {
|
||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
|
AI_CHAT_MESSAGES_RQ_KEY,
|
||||||
useAiChatMessagesQuery,
|
useAiChatMessagesQuery,
|
||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
|
import {
|
||||||
|
shouldCollapseOnOutsidePointer,
|
||||||
|
isHeaderClick,
|
||||||
|
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||||
|
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||||
import { useClipboard } from "@/hooks/use-clipboard";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||||
@@ -70,17 +80,31 @@ function computeInitialGeom() {
|
|||||||
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
|
Math.min(DEFAULT_HEIGHT, window.innerHeight - 2 * EDGE_MARGIN),
|
||||||
);
|
);
|
||||||
const left = Math.max(EDGE_MARGIN, window.innerWidth - width - 24);
|
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);
|
const top = Math.min(60, maxTop);
|
||||||
return { left, top, width, height };
|
return { left, top, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp a geometry so the window stays within the current viewport.
|
// 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 effWidth = Math.max(g.width, MIN_WIDTH);
|
||||||
const effHeight = Math.max(g.height, MIN_HEIGHT);
|
const effHeight = Math.max(g.height, MIN_HEIGHT);
|
||||||
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - effWidth - EDGE_MARGIN);
|
const maxLeft = Math.max(
|
||||||
const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - effHeight - EDGE_MARGIN);
|
EDGE_MARGIN,
|
||||||
|
window.innerWidth - effWidth - EDGE_MARGIN,
|
||||||
|
);
|
||||||
|
const maxTop = Math.max(
|
||||||
|
EDGE_MARGIN,
|
||||||
|
window.innerHeight - effHeight - EDGE_MARGIN,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...g,
|
...g,
|
||||||
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
|
left: Math.min(Math.max(EDGE_MARGIN, g.left), maxLeft),
|
||||||
@@ -91,48 +115,63 @@ function clampGeom(g: { left: number; top: number; width: number; height: number
|
|||||||
/**
|
/**
|
||||||
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
|
* Floating, draggable, resizable, minimizable AI chat window. Replaces the
|
||||||
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
|
* former right-aside `AiChatPanel`: it owns ALL chat orchestration (active
|
||||||
* chat, new chat, adopt-new-chat, open-page context, token sum) and wraps the
|
* chat, new chat, in-place id adoption from streamed metadata, open-page
|
||||||
|
* context, token sum) and wraps the
|
||||||
* reused inner components (ConversationList + ChatThread) in window chrome
|
* reused inner components (ConversationList + ChatThread) in window chrome
|
||||||
* ported from the GitmostAgent.jsx design.
|
* ported from the GitmostAgent.jsx design.
|
||||||
*/
|
*/
|
||||||
export default function AiChatWindow() {
|
export default function AiChatWindow() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
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).
|
// History section starts collapsed (matches the former panel's behavior).
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [minimized, setMinimized] = 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);
|
const winRef = useRef<HTMLDivElement>(null);
|
||||||
// Live window geometry (position + size); initialized lazily on first open so
|
// Live window geometry (position + size); persisted to localStorage so a
|
||||||
// it is anchored to the current viewport (top-right corner). Kept in state so
|
// drag/resize survives a full page reload (and close/reopen). `null` means
|
||||||
// a user resize survives close/reopen and can be re-clamped to the viewport.
|
// "never placed yet" — the layout effect below then computes an initial
|
||||||
const [geom, setGeom] = useState<{
|
// top-right placement anchored to the current viewport, and on restore it is
|
||||||
left: number;
|
// re-clamped to the viewport (so a placement saved on a larger screen is not
|
||||||
top: number;
|
// left partly off-screen).
|
||||||
width: number;
|
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||||
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();
|
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 } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||||
|
|
||||||
// The page the user is currently viewing, derived from the route (same
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// source the breadcrumb uses). On a non-page route `pageSlug` is undefined,
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
// so the query is disabled and `openPage` is null. This is passed to the
|
// pathname against the authenticated page route instead so "the current page"
|
||||||
// chat thread as context so the agent knows what "this page"/"the current
|
// resolves regardless of where this component is mounted. On a non-page route
|
||||||
// page" refers to; the agent still reads/writes via its CASL-enforced page
|
// the match is null, so `pageSlug` is undefined, the query is disabled and
|
||||||
// tools using the id.
|
// `openPage` is null. This is passed to the chat thread as context so the
|
||||||
const { pageSlug } = useParams();
|
// agent knows what "this page"/"the current page" refers to; the agent still
|
||||||
|
// reads/writes via its CASL-enforced page tools using the id.
|
||||||
|
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||||
|
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||||
const { data: openPageData } = usePageQuery({
|
const { data: openPageData } = usePageQuery({
|
||||||
pageId: extractPageSlugId(pageSlug),
|
pageId: extractPageSlugId(pageSlug),
|
||||||
});
|
});
|
||||||
@@ -140,69 +179,115 @@ export default function AiChatWindow() {
|
|||||||
? { id: openPageData.id, title: openPageData.title }
|
? { id: openPageData.id, title: openPageData.title }
|
||||||
: null;
|
: 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 => {
|
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);
|
setActiveChatId(null);
|
||||||
setHistoryOpen(false);
|
setHistoryOpen(false);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
}, [setActiveChatId, setDraft]);
|
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||||
|
setSelectedRoleId(null);
|
||||||
|
}, [
|
||||||
|
cancelPendingAdoption,
|
||||||
|
startFreshThread,
|
||||||
|
setActiveChatId,
|
||||||
|
setDraft,
|
||||||
|
setSelectedRoleId,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectChat = useCallback(
|
const selectChat = useCallback(
|
||||||
(chatId: string): void => {
|
(chatId: string): void => {
|
||||||
|
cancelPendingAdoption();
|
||||||
setActiveChatId(chatId);
|
setActiveChatId(chatId);
|
||||||
setHistoryOpen(false);
|
setHistoryOpen(false);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
// Reset the card-picked role so a stale pick can't leak into the existing
|
||||||
|
// chat's header/assistant-name (which prefers the chat's persisted role).
|
||||||
|
setSelectedRoleId(null);
|
||||||
},
|
},
|
||||||
[setActiveChatId, setDraft],
|
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
// The active chat object (for its title) and an export gate. The export is now
|
||||||
// yet), the server has just created the row; adopt the newest chat id so the
|
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
|
||||||
// thread switches from "new" to the persisted chat (and loads its history on
|
// row is persisted upfront + per step, so even a brand-new chat whose first
|
||||||
// later opens).
|
// turn is streaming/interrupted has a server row to render. Enable the button
|
||||||
const onTurnFinished = useCallback(() => {
|
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
|
||||||
if (activeChatId === null) adoptNewChat.current = true;
|
// chat that id is adopted EARLY — at the stream's `start` chunk via
|
||||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
// onServerChatId (#174) — so the Copy button is available during the first
|
||||||
}, [activeChatId, queryClient]);
|
// turn's stream, not only after it terminates.
|
||||||
|
|
||||||
// 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(
|
const activeChat = useMemo(
|
||||||
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
|
||||||
[chats, activeChatId],
|
[chats, activeChatId],
|
||||||
);
|
);
|
||||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
const canExport = !!activeChatId;
|
||||||
|
|
||||||
// Build a Markdown export from the already-loaded persisted rows (no network
|
// The role to display in the header and as the assistant's name. Prefer the
|
||||||
// call) and copy it to the clipboard. The "Copied" notification is the
|
// persisted role of an existing chat (chat-list JOIN); fall back to the role
|
||||||
// feedback.
|
// picked via a card click for a brand-new or just-adopted chat. selectChat
|
||||||
const handleCopy = useCallback(() => {
|
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
|
||||||
if (!activeChatId || !messageRows || messageRows.length === 0) return;
|
const currentRole = useMemo<{
|
||||||
const markdown = buildChatMarkdown({
|
name: string;
|
||||||
title: activeChat?.title ?? null,
|
emoji: string | null;
|
||||||
chatId: activeChatId,
|
} | null>(() => {
|
||||||
rows: messageRows,
|
if (activeChat?.roleName) {
|
||||||
t,
|
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
||||||
});
|
|
||||||
clipboard.copy(markdown);
|
|
||||||
notifications.show({ message: t("Copied") });
|
|
||||||
}, [activeChatId, messageRows, activeChat, clipboard, t]);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}, [chats, setActiveChatId]);
|
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
|
||||||
|
return picked ? { name: picked.name, emoji: picked.emoji } : null;
|
||||||
|
}, [activeChat, enabledRoles, selectedRoleId]);
|
||||||
|
|
||||||
// The thread is remounted when the active chat changes so initial messages
|
// Fetch the server-rendered Markdown export and copy it to the clipboard. The
|
||||||
// re-seed. For a new chat we key on "new"; adopting the id remounts the
|
// server is the single source of truth (#183): it renders the transcript from
|
||||||
// thread with the persisted history loaded.
|
// the persisted rows — including an interrupted turn's in-progress row — so the
|
||||||
const threadKey = activeChatId ?? "new";
|
// export is identical whether the chat is freshly streaming, just switched to,
|
||||||
const waitingForHistory = activeChatId !== null && messagesLoading;
|
// or reloaded. The `lang` of the active i18n drives the few localized labels.
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!activeChatId) return;
|
||||||
|
try {
|
||||||
|
const markdown = await exportAiChat(activeChatId, i18n.language);
|
||||||
|
clipboard.copy(markdown);
|
||||||
|
notifications.show({ message: t("Copied") });
|
||||||
|
} catch {
|
||||||
|
notifications.show({ message: t("Failed to export chat"), color: "red" });
|
||||||
|
}
|
||||||
|
}, [activeChatId, clipboard, t, i18n.language]);
|
||||||
|
|
||||||
// Current context size for the active chat: how much the conversation now
|
// Current context size for the active chat: how much the conversation now
|
||||||
// occupies in the model's context window — NOT the cumulative tokens spent.
|
// occupies in the model's context window — NOT the cumulative tokens spent.
|
||||||
@@ -211,24 +296,19 @@ export default function AiChatWindow() {
|
|||||||
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
// 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
|
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
||||||
// mid-stream — acceptable for v1.
|
// mid-stream — acceptable for v1.
|
||||||
const contextTokens = useMemo(() => {
|
//
|
||||||
if (!activeChatId || !messageRows) return 0;
|
// The denominator `maxContextTokens` (the model's configured max window) is
|
||||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
|
||||||
const meta = messageRows[i].metadata;
|
// on a completed turn, but the numerator and denominator are taken from the
|
||||||
if (!meta) continue;
|
// most recent row carrying EACH value independently — they may land on
|
||||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
// different rows (e.g. a fresh error row can carry contextTokens but not
|
||||||
return meta.contextTokens;
|
// 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
|
||||||
const usage = meta.usage;
|
// shows just the current size with no denominator.
|
||||||
if (usage) {
|
const { contextTokens, maxContextTokens } = useMemo(
|
||||||
const fallback =
|
() => selectContextBadge(activeChatId ? messageRows : undefined),
|
||||||
usage.totalTokens ??
|
[activeChatId, messageRows],
|
||||||
(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
|
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||||
// first-frame jump): compute an initial top-right placement the first time,
|
// first-frame jump): compute an initial top-right placement the first time,
|
||||||
@@ -238,26 +318,54 @@ export default function AiChatWindow() {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!windowOpen) return;
|
if (!windowOpen) return;
|
||||||
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
|
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]);
|
}, [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
|
// Persist the user's resize into state so it survives close/reopen. Skipped
|
||||||
// while minimized so the collapsed (auto) height is never captured. The
|
// while minimized so the collapsed (auto) height is never captured. The
|
||||||
// equality guard avoids an update loop.
|
// equality guard avoids an update loop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowOpen || minimized) return;
|
if (!windowOpen || minimized) return;
|
||||||
const el = winRef.current;
|
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;
|
if (!el) return;
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
const width = el.offsetWidth;
|
const width = el.offsetWidth;
|
||||||
const height = el.offsetHeight;
|
const height = el.offsetHeight;
|
||||||
setGeom((prev) => {
|
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 };
|
return { ...prev, width, height };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [windowOpen, minimized]);
|
}, [windowOpen, minimized, geom !== null]);
|
||||||
|
|
||||||
const startDrag = useCallback((e: React.MouseEvent): void => {
|
const startDrag = useCallback((e: React.MouseEvent): void => {
|
||||||
// Ignore drags that originate on a button (minimize/close/new chat).
|
// Ignore drags that originate on a button (minimize/close/new chat).
|
||||||
@@ -287,10 +395,21 @@ export default function AiChatWindow() {
|
|||||||
el.style.top = `${nt}px`;
|
el.style.top = `${nt}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const up = (): void => {
|
const up = (ev: MouseEvent): void => {
|
||||||
document.removeEventListener("mousemove", move);
|
document.removeEventListener("mousemove", move);
|
||||||
document.removeEventListener("mouseup", up);
|
document.removeEventListener("mouseup", up);
|
||||||
document.body.style.userSelect = "";
|
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;
|
const el2 = winRef.current;
|
||||||
// Persist the final position back into state (preserving the size) so
|
// Persist the final position back into state (preserving the size) so
|
||||||
// re-renders keep it.
|
// re-renders keep it.
|
||||||
@@ -334,21 +453,66 @@ export default function AiChatWindow() {
|
|||||||
height: minimized ? undefined : geom.height,
|
height: minimized ? undefined : geom.height,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* drag bar / header */}
|
{/* drag bar / header. Mouse users expand a minimized window by clicking
|
||||||
|
anywhere on the bar (the click-vs-drag logic in startDrag, which
|
||||||
|
excludes the buttons). The keyboard/screen-reader Expand affordance
|
||||||
|
lives on the title element below — NOT on this container — so we never
|
||||||
|
nest the Minimize/Close <button>s inside an element with
|
||||||
|
role="button" (invalid ARIA: nested interactive controls). */}
|
||||||
<div className={classes.dragBar} onMouseDown={startDrag}>
|
<div className={classes.dragBar} onMouseDown={startDrag}>
|
||||||
<IconGripVertical
|
<IconGripVertical
|
||||||
size={14}
|
size={14}
|
||||||
color="var(--mantine-color-gray-4)"
|
color="var(--mantine-color-gray-4)"
|
||||||
style={{ flex: "none" }}
|
style={{ flex: "none" }}
|
||||||
/>
|
/>
|
||||||
<span className={classes.title}>{t("AI chat")}</span>
|
{/* When minimized, the title doubles as the keyboard Expand button:
|
||||||
|
it carries role/tabIndex/aria-label and an Enter/Space handler, and
|
||||||
|
unlike the dragBar it contains no nested <button>s. When expanded it
|
||||||
|
is a plain, non-focusable label. */}
|
||||||
|
<span
|
||||||
|
className={classes.title}
|
||||||
|
role={minimized ? "button" : undefined}
|
||||||
|
tabIndex={minimized ? 0 : undefined}
|
||||||
|
aria-label={minimized ? t("Expand") : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
minimized
|
||||||
|
? (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setMinimized(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("AI chat")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Role badge (emoji + name). Shows the persisted role of an existing
|
||||||
|
chat, or the role picked via a card for a brand-new chat. Hidden for
|
||||||
|
a universal (no-role) chat. */}
|
||||||
|
{currentRole && (
|
||||||
|
<span className={classes.badge} title={t("Agent role")}>
|
||||||
|
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
|
||||||
|
{currentRole.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||||
{contextTokens > 0 && (
|
{/* Always show the persisted "current / max" context. The denominator
|
||||||
<Tooltip label={t("Current context size")} withArrow>
|
(the admin-configured model limit) is appended only when known;
|
||||||
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
|
not clamped when current > max (shown as-is, e.g. "210k / 200k").
|
||||||
|
Hidden entirely until a turn has recorded a context figure. */}
|
||||||
|
{contextTokens > 0 ? (
|
||||||
|
<Tooltip label={t("Context size / model limit")} withArrow>
|
||||||
|
<span className={classes.badge}>
|
||||||
|
{formatTokens(contextTokens)}
|
||||||
|
{maxContextTokens > 0
|
||||||
|
? ` / ${formatTokens(maxContextTokens)}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
@@ -360,7 +524,11 @@ export default function AiChatWindow() {
|
|||||||
aria-label={t("Copy chat")}
|
aria-label={t("Copy chat")}
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
{clipboard.copied ? (
|
||||||
|
<IconCheck size={14} />
|
||||||
|
) : (
|
||||||
|
<IconCopy size={14} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -400,7 +568,16 @@ export default function AiChatWindow() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classes.historyHeader}
|
className={classes.historyHeader}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={historyOpen}
|
||||||
onClick={() => setHistoryOpen((o) => !o)}
|
onClick={() => setHistoryOpen((o) => !o)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHistoryOpen((o) => !o);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IconChevronDown
|
<IconChevronDown
|
||||||
size={12}
|
size={12}
|
||||||
@@ -432,6 +609,11 @@ export default function AiChatWindow() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* body: active chat thread */}
|
||||||
<div className={classes.body}>
|
<div className={classes.body}>
|
||||||
{waitingForHistory ? (
|
{waitingForHistory ? (
|
||||||
@@ -441,10 +623,19 @@ export default function AiChatWindow() {
|
|||||||
) : (
|
) : (
|
||||||
<ChatThread
|
<ChatThread
|
||||||
key={threadKey}
|
key={threadKey}
|
||||||
|
threadKey={threadKey}
|
||||||
chatId={activeChatId}
|
chatId={activeChatId}
|
||||||
initialRows={activeChatId ? messageRows : []}
|
initialRows={activeChatId ? messageRows : []}
|
||||||
openPage={openPage}
|
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}
|
onTurnFinished={onTurnFinished}
|
||||||
|
onServerChatId={onServerChatId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,45 @@
|
|||||||
padding-inline-start: 1.4em;
|
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
|
/* Animated three-dot "typing" indicator shown while the agent is thinking but
|
||||||
has not yet produced any visible text/tool parts. */
|
has not yet produced any visible text/tool parts. */
|
||||||
.typingDots {
|
.typingDots {
|
||||||
@@ -88,16 +127,18 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
40% {
|
40% {
|
||||||
transform: translateY(-3px);
|
/* Bounce height is driven by --bounce so reduced-motion can dampen it
|
||||||
|
(below) without disabling the animation outright. */
|
||||||
|
transform: translateY(var(--bounce, -6px));
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Respect reduced-motion preferences: fall back to a static dimmed state. */
|
/* Respect reduced-motion preferences: keep a smaller bounce instead of a full
|
||||||
|
stop, so the "thinking" indicator still reads as active rather than frozen. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.typingDots span {
|
.typingDots span {
|
||||||
animation: none;
|
--bounce: -3px;
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +150,28 @@
|
|||||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-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 {
|
.inputWrapper {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding-top: var(--mantine-spacing-xs);
|
padding-top: var(--mantine-spacing-xs);
|
||||||
@@ -126,3 +189,29 @@
|
|||||||
.conversationItemActive {
|
.conversationItemActive {
|
||||||
background: var(--mantine-color-gray-light);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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,18 +9,24 @@ import { MicButton } from "@/features/dictation/components/mic-button";
|
|||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (text: string) => void;
|
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;
|
onStop: () => void;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message composer. Enter sends, Shift+Enter inserts a newline. While the agent
|
* Message composer. Enter submits, Shift+Enter inserts a newline. While the
|
||||||
* is streaming, the send button becomes a Stop button (calls `stop()`); the
|
* agent is streaming, submitting QUEUES the message (via `onQueue`) instead of
|
||||||
* textarea stays usable so the user can draft the next turn.
|
* dropping it — it is sent automatically once the current turn finishes; the
|
||||||
|
* Stop button (calls `stop()`) is also shown. The textarea stays usable so the
|
||||||
|
* user can draft / queue the next turn while the agent is busy.
|
||||||
*/
|
*/
|
||||||
export default function ChatInput({
|
export default function ChatInput({
|
||||||
onSend,
|
onSend,
|
||||||
|
onQueue,
|
||||||
onStop,
|
onStop,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -29,18 +35,23 @@ export default function ChatInput({
|
|||||||
const [value, setValue] = useAtom(aiChatDraftAtom);
|
const [value, setValue] = useAtom(aiChatDraftAtom);
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
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 send = (): void => {
|
const submit = (): void => {
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (!text || isStreaming || disabled) return;
|
if (!text || disabled) return;
|
||||||
onSend(text);
|
if (isStreaming) onQueue(text);
|
||||||
|
else onSend(text);
|
||||||
setValue("");
|
setValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
send();
|
submit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,28 +75,43 @@ export default function ChatInput({
|
|||||||
{isDictationEnabled && (
|
{isDictationEnabled && (
|
||||||
<MicButton
|
<MicButton
|
||||||
size="lg"
|
size="lg"
|
||||||
|
streaming={streamingDictation}
|
||||||
disabled={isStreaming || disabled}
|
disabled={isStreaming || disabled}
|
||||||
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
|
onText={(text) => setValue((v) => (v ? `${v} ${text}` : text))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<Tooltip label={t("Stop")} withArrow>
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ActionIcon
|
{value.trim().length > 0 && (
|
||||||
size="lg"
|
<Tooltip label={t("Send when the agent finishes")} withArrow>
|
||||||
color="red"
|
<ActionIcon
|
||||||
variant="light"
|
size="lg"
|
||||||
onClick={onStop}
|
variant="filled"
|
||||||
aria-label={t("Stop")}
|
onClick={submit}
|
||||||
>
|
aria-label={t("Queue message")}
|
||||||
<IconPlayerStopFilled size={18} />
|
>
|
||||||
</ActionIcon>
|
<IconSend size={18} />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label={t("Stop")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
onClick={onStop}
|
||||||
|
aria-label={t("Stop")}
|
||||||
|
>
|
||||||
|
<IconPlayerStopFilled size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip label={t("Send")} withArrow>
|
<Tooltip label={t("Send")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onClick={send}
|
onClick={submit}
|
||||||
disabled={disabled || value.trim().length === 0}
|
disabled={disabled || value.trim().length === 0}
|
||||||
aria-label={t("Send")}
|
aria-label={t("Send")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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,16 +1,41 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateId } from "ai";
|
import { generateId } from "ai";
|
||||||
import { Alert, Box, Stack } from "@mantine/core";
|
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
import { IconClockHour4, IconX } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||||
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
||||||
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
||||||
|
import 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 { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
|
import {
|
||||||
|
dequeue,
|
||||||
|
enqueueMessage,
|
||||||
|
removeQueuedById,
|
||||||
|
type QueuedMessage,
|
||||||
|
} from "@/features/ai-chat/utils/queue-helpers.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
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. */
|
/** The page the user is currently viewing, sent as chat context. */
|
||||||
export interface OpenPageContext {
|
export interface OpenPageContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,14 +45,42 @@ export interface OpenPageContext {
|
|||||||
interface ChatThreadProps {
|
interface ChatThreadProps {
|
||||||
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
|
||||||
chatId: string | null;
|
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). */
|
/** Persisted rows to seed initial messages (existing chats only). */
|
||||||
initialRows?: IAiChatMessageRow[];
|
initialRows?: IAiChatMessageRow[];
|
||||||
/** The page currently open in the workspace, or null on a non-page route.
|
/** 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. */
|
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||||
openPage?: OpenPageContext | null;
|
openPage?: OpenPageContext | null;
|
||||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||||
* a new chat, adopts the freshly created chat id. */
|
* in the request body so the server persists it on chat creation; ignored by
|
||||||
onTurnFinished: () => void;
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,13 +95,18 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
|||||||
? row.metadata.parts
|
? row.metadata.parts
|
||||||
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
: ([{ type: "text", text: row.content ?? "" }] as UIMessage["parts"]);
|
||||||
const error = row.metadata?.error;
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
role,
|
role,
|
||||||
parts,
|
parts,
|
||||||
// Carry a persisted turn error so MessageItem can render it after a remount
|
// Carry persisted turn outcome (error text and/or finishReason) so MessageItem
|
||||||
// (e.g. when a new chat adopts its id) and in reopened chat history.
|
// can render the error banner / "stopped" marker after a remount and in
|
||||||
...(error ? { metadata: { error } } : {}),
|
// reopened history.
|
||||||
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||||
} as UIMessage;
|
} as UIMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +117,15 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
|
|||||||
*/
|
*/
|
||||||
export default function ChatThread({
|
export default function ChatThread({
|
||||||
chatId,
|
chatId,
|
||||||
|
threadKey,
|
||||||
initialRows,
|
initialRows,
|
||||||
openPage,
|
openPage,
|
||||||
|
roleId,
|
||||||
|
roles,
|
||||||
|
onRolePicked,
|
||||||
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
|
onServerChatId,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -84,6 +148,12 @@ export default function ChatThread({
|
|||||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||||
openPageRef.current = 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.
|
// Stable `useChat` store key for the lifetime of THIS mount.
|
||||||
//
|
//
|
||||||
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
||||||
@@ -102,7 +172,55 @@ export default function ChatThread({
|
|||||||
// The id only needs to be stable per mount — the parent remounts this via
|
// The id only needs to be stable per mount — the parent remounts this via
|
||||||
// `key` on chat switch, which re-seeds cleanly.
|
// `key` on chat switch, which re-seeds cleanly.
|
||||||
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
|
const stableIdRef = useRef<string>(chatId ?? `new-${generateId()}`);
|
||||||
const chatStoreId = chatId ?? stableIdRef.current;
|
// Stable for the LIFETIME of this mount. When a brand-new chat adopts its
|
||||||
|
// server id, the parent now updates the `chatId` prop WITHOUT remounting this
|
||||||
|
// thread, so the store id must NOT follow `chatId`: recreating the useChat
|
||||||
|
// store would wipe the live (just-finished) turn. The server still resolves
|
||||||
|
// the real chat from `chatId` in the request body (see chatIdRef /
|
||||||
|
// prepareSendMessagesRequest), so this purely-client store key can stay fixed.
|
||||||
|
const chatStoreId = stableIdRef.current;
|
||||||
|
|
||||||
|
// Pending messages the user composed WHILE a turn was streaming. They are sent
|
||||||
|
// automatically, FIFO, on successful turn completion (`onFinish`). The queue is
|
||||||
|
// LOCAL state so it is scoped to this conversation: it is cleared when the user
|
||||||
|
// deliberately switches chat / starts a new chat (the parent remounts this via
|
||||||
|
// `key`), but it SURVIVES in-place new-chat id adoption (no remount), so a
|
||||||
|
// message queued during a brand-new chat's first turn is not lost. On Stop or
|
||||||
|
// error the queue is intentionally preserved (onFinish does not fire then) so
|
||||||
|
// the user decides what to do with the pending messages.
|
||||||
|
const [queued, setQueued] = useState<QueuedMessage[]>([]);
|
||||||
|
// Mirror the queue in a ref so the `onFinish` flush always reads the latest
|
||||||
|
// queue without a stale closure; `setQueue` updates BOTH the ref and the state.
|
||||||
|
const queuedRef = useRef<QueuedMessage[]>([]);
|
||||||
|
const setQueue = useCallback((next: QueuedMessage[]) => {
|
||||||
|
queuedRef.current = next;
|
||||||
|
setQueued(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Capture the latest `sendMessage` (returned by useChat below) so the flush
|
||||||
|
// helper can call the current instance from the stable `onFinish` callback.
|
||||||
|
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
|
||||||
|
|
||||||
|
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||||
|
const flushNext = useCallback(() => {
|
||||||
|
const { head, rest } = dequeue(queuedRef.current);
|
||||||
|
if (!head) return;
|
||||||
|
setQueue(rest);
|
||||||
|
sendMessageRef.current?.({ text: head.text });
|
||||||
|
}, [setQueue]);
|
||||||
|
|
||||||
|
const enqueue = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setQueue(enqueueMessage(queuedRef.current, { id: generateId(), text }));
|
||||||
|
},
|
||||||
|
[setQueue],
|
||||||
|
);
|
||||||
|
const removeQueued = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setQueue(removeQueuedById(queuedRef.current, id));
|
||||||
|
},
|
||||||
|
[setQueue],
|
||||||
|
);
|
||||||
|
|
||||||
const transport = useMemo(
|
const transport = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -119,6 +237,9 @@ export default function ChatThread({
|
|||||||
...body,
|
...body,
|
||||||
chatId: chatIdRef.current,
|
chatId: chatIdRef.current,
|
||||||
openPage: openPageRef.current,
|
openPage: openPageRef.current,
|
||||||
|
// Honoured by the server only when creating a new chat; null =>
|
||||||
|
// universal assistant.
|
||||||
|
roleId: roleIdRef.current,
|
||||||
messages,
|
messages,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -133,30 +254,191 @@ export default function ChatThread({
|
|||||||
id: chatStoreId,
|
id: chatStoreId,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
transport,
|
transport,
|
||||||
onFinish: () => onTurnFinished(),
|
// 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);
|
||||||
|
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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep the flush helper pointed at the latest sendMessage instance.
|
||||||
|
sendMessageRef.current = sendMessage;
|
||||||
|
|
||||||
|
// 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";
|
const isStreaming = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
|
// Clear the stopped marker as soon as a new turn begins streaming.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) setStopNotice(null);
|
||||||
|
}, [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 (
|
return (
|
||||||
<Box className={classes.panel}>
|
<Box className={classes.panel}>
|
||||||
<MessageList messages={messages} isStreaming={isStreaming} />
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
emptyState={roleCardsEmptyState}
|
||||||
|
assistantName={assistantName}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{errorView ? (
|
||||||
<Alert
|
<ChatErrorAlert
|
||||||
variant="light"
|
title={errorView.title}
|
||||||
color="red"
|
detail={errorView.detail}
|
||||||
icon={<IconAlertTriangle size={16} />}
|
|
||||||
mb="xs"
|
mb="xs"
|
||||||
title={t("Something went wrong")}
|
/>
|
||||||
>
|
) : stopNotice ? (
|
||||||
{describeChatError(error.message ?? "", t)}
|
<ChatStoppedNotice
|
||||||
</Alert>
|
text={
|
||||||
)}
|
stopNotice === "manual"
|
||||||
|
? t("Response stopped.")
|
||||||
|
: t("Connection lost — the answer was interrupted.")
|
||||||
|
}
|
||||||
|
mb="xs"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Stack gap={0} className={classes.inputWrapper}>
|
<Stack gap={0} className={classes.inputWrapper}>
|
||||||
|
{queued.length > 0 && (
|
||||||
|
<Stack gap={4} className={classes.queuedList}>
|
||||||
|
{queued.map((m) => (
|
||||||
|
<Group
|
||||||
|
key={m.id}
|
||||||
|
gap={6}
|
||||||
|
wrap="nowrap"
|
||||||
|
className={classes.queuedItem}
|
||||||
|
>
|
||||||
|
<IconClockHour4 size={14} className={classes.queuedIcon} />
|
||||||
|
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||||
|
{m.text}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => removeQueued(m.id)}
|
||||||
|
aria-label={t("Remove queued message")}
|
||||||
|
>
|
||||||
|
<IconX size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={(text) => sendMessage({ text })}
|
onSend={(text) => sendMessage({ text })}
|
||||||
|
onQueue={enqueue}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,8 +18,31 @@ import {
|
|||||||
useRenameAiChatMutation,
|
useRenameAiChatMutation,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.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";
|
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 {
|
interface ConversationListProps {
|
||||||
activeChatId: string | null;
|
activeChatId: string | null;
|
||||||
onSelect: (chatId: string) => void;
|
onSelect: (chatId: string) => void;
|
||||||
@@ -115,11 +138,36 @@ export default function ConversationList({
|
|||||||
classes.conversationItem,
|
classes.conversationItem,
|
||||||
isActive && classes.conversationItemActive,
|
isActive && classes.conversationItemActive,
|
||||||
)}
|
)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => onSelect(chat.id)}
|
onClick={() => onSelect(chat.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Activate on Enter/Space like a native button; the inner menu
|
||||||
|
// button stops propagation so its own keys never reach this row.
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(chat.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
{chat.title || t("Untitled chat")}
|
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
</Text>
|
{chat.roleName && (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
span
|
||||||
|
title={chat.roleName}
|
||||||
|
style={{ flex: "none" }}
|
||||||
|
>
|
||||||
|
{chat.roleEmoji || "🤖"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{chat.title || t("Untitled chat")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ChatMetaLine createdAt={chat.createdAt} pageTitle={chat.pageTitle} />
|
||||||
|
</Box>
|
||||||
<Menu shadow="md" width={180} position="bottom-end">
|
<Menu shadow="md" width={180} position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||||
|
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||||
|
|
||||||
|
const renderRow = (message: UIMessage) =>
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MessageItem message={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} />
|
||||||
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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). 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;
|
||||||
|
|
||||||
|
const props = (
|
||||||
|
message: UIMessage,
|
||||||
|
over: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
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 on the identity fast path (same message object, equal props)", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,73 @@
|
|||||||
import { Alert, Box, Text } from "@mantine/core";
|
import { memo } from "react";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
import { Box, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||||
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.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 { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
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 { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
/**
|
||||||
function isToolPart(type: string): boolean {
|
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
|
||||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
* so a finalized text part is NOT re-parsed on every streamed delta: during a
|
||||||
}
|
* turn only the actively-growing tail part changes its `text`, so every earlier
|
||||||
|
* part hits the memo and skips the expensive marked + DOMPurify pass. Props are
|
||||||
|
* primitives, so React.memo's default shallow compare is exactly right (the
|
||||||
|
* `text` string is compared by value).
|
||||||
|
*/
|
||||||
|
const MarkdownPart = memo(function MarkdownPart({
|
||||||
|
text,
|
||||||
|
neutralizeInternalLinks,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
neutralizeInternalLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
|
||||||
|
if (html) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classes.markdown}
|
||||||
|
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fallback when markdown could not render synchronously: raw text.
|
||||||
|
return (
|
||||||
|
<Text className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a single UIMessage by iterating its `parts`:
|
* Render a single UIMessage by iterating its `parts`:
|
||||||
@@ -24,12 +76,18 @@ function isToolPart(type: string): boolean {
|
|||||||
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
||||||
* User messages render their text as a right-aligned plain bubble.
|
* User messages render their text as a right-aligned plain bubble.
|
||||||
*
|
*
|
||||||
* This component is intentionally NOT memoized: `useChat` replaces the streaming
|
* This component is memoized (see `arePropsEqual` at the bottom) on a cheap
|
||||||
* assistant message with a freshly cloned object on every streamed delta, so the
|
* per-message content signature: the streaming TAIL message's signature changes
|
||||||
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
|
* on each delta so it still re-renders and streams in, while finalized rows are
|
||||||
* text parts on each delta is what makes the answer stream in progressively.
|
* skipped. Each text part's markdown is itself memoized via `MarkdownPart`, so a
|
||||||
|
* long turn no longer re-parses the whole transcript on every token.
|
||||||
*/
|
*/
|
||||||
export default function MessageItem({ message }: MessageItemProps) {
|
function MessageItem({
|
||||||
|
message,
|
||||||
|
showCitations = true,
|
||||||
|
neutralizeInternalLinks = false,
|
||||||
|
assistantName,
|
||||||
|
}: MessageItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
@@ -47,38 +105,63 @@ export default function MessageItem({ message }: MessageItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Box className={classes.messageRow}>
|
<Box className={classes.messageRow}>
|
||||||
<Text size="xs" c="dimmed" mb={4}>
|
<Text size="xs" c="dimmed" mb={4}>
|
||||||
{t("AI agent")}
|
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||||
</Text>
|
</Text>
|
||||||
{message.parts.map((part, index) => {
|
{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") {
|
if (part.type === "text") {
|
||||||
// Skip empty/whitespace-only text parts (a streaming message often
|
// Skip empty/whitespace-only text parts (a streaming message often
|
||||||
// starts with an empty text part before the first token arrives); the
|
// starts with an empty text part before the first token arrives); the
|
||||||
// typing indicator covers that gap until real content streams in.
|
// typing indicator covers that gap until real content streams in.
|
||||||
if (!part.text.trim()) return null;
|
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 (
|
return (
|
||||||
<Text key={index} className={classes.markdown} style={{ whiteSpace: "pre-wrap" }}>
|
<MarkdownPart
|
||||||
{part.text}
|
key={index}
|
||||||
</Text>
|
text={part.text}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isToolPart(part.type)) {
|
if (isToolPart(part.type)) {
|
||||||
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
|
return (
|
||||||
|
<ToolCallCard
|
||||||
|
key={index}
|
||||||
|
part={part as unknown as ToolUiPart}
|
||||||
|
showCitations={showCitations}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -88,17 +171,56 @@ export default function MessageItem({ message }: MessageItemProps) {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const errorText = (message.metadata as { error?: string } | undefined)?.error;
|
const errorText = (message.metadata as { error?: string } | undefined)?.error;
|
||||||
if (!errorText) return null;
|
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 (
|
return (
|
||||||
<Alert
|
<ChatErrorAlert
|
||||||
variant="light"
|
title={errorView.title}
|
||||||
color="red"
|
detail={errorView.detail}
|
||||||
icon={<IconAlertTriangle size={16} />}
|
|
||||||
mt={4}
|
mt={4}
|
||||||
>
|
/>
|
||||||
{describeChatError(errorText, t)}
|
);
|
||||||
</Alert>
|
})()}
|
||||||
|
{/* 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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||||
|
* TAIL message gets a fresh object whose signature changes each delta, so it
|
||||||
|
* still re-renders and streams in; every FINALIZED message is skipped, turning a
|
||||||
|
* per-token whole-transcript re-render into a tail-only one. */
|
||||||
|
export function arePropsEqual(
|
||||||
|
prev: MessageItemProps,
|
||||||
|
next: MessageItemProps,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
prev.showCitations !== next.showCitations ||
|
||||||
|
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
|
||||||
|
prev.assistantName !== next.assistantName
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Fast path: identical message object (finalized rows keep their identity
|
||||||
|
// across deltas) — skip without building signatures.
|
||||||
|
if (prev.message === next.message) return true;
|
||||||
|
return messageSignature(prev.message) === messageSignature(next.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MessageItem, arePropsEqual);
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { ReactNode, useEffect, useRef } from "react";
|
||||||
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { UIMessage } from "@ai-sdk/react";
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.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 classes from "@/features/ai-chat/components/ai-chat.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
/**
|
||||||
|
* Content shown when the transcript is empty and no turn is in flight.
|
||||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
* Defaults to the internal chat's prompt. The public share passes its own
|
||||||
function isToolPart(type: string): boolean {
|
* documentation-focused copy. This is purely the empty-state text; the
|
||||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
* streaming/typing/markdown/tool-card paths below are shared verbatim.
|
||||||
|
*/
|
||||||
|
emptyState?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
|
||||||
|
* citation links. Defaults to true (internal chat). The public share passes
|
||||||
|
* false because an anonymous reader cannot open the linked internal pages.
|
||||||
|
*/
|
||||||
|
showCitations?: boolean;
|
||||||
|
/**
|
||||||
|
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||||
|
* the rendered answers (drop their href so they render as inert text).
|
||||||
|
* Defaults to false (internal chat). The public share passes true so internal
|
||||||
|
* UUIDs/routes don't leak as clickable links to anonymous readers.
|
||||||
|
*/
|
||||||
|
neutralizeInternalLinks?: boolean;
|
||||||
|
/**
|
||||||
|
* Display name for the assistant's dimmed row label and typing indicator.
|
||||||
|
* Defaults to "AI agent" when absent. The public share passes the configured
|
||||||
|
* identity (agent role) name; the internal chat omits it.
|
||||||
|
*/
|
||||||
|
assistantName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distance (px) from the bottom within which the viewport still counts as
|
// Distance (px) from the bottom within which the viewport still counts as
|
||||||
@@ -21,23 +44,68 @@ function isToolPart(type: string): boolean {
|
|||||||
const BOTTOM_THRESHOLD = 40;
|
const BOTTOM_THRESHOLD = 40;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
|
* Whether to show the standalone "Thinking…" indicator. It bridges every
|
||||||
* gap between sending and the first streamed content, so it shows only while a
|
* gap in a turn where the assistant is working but nothing visible is actively
|
||||||
* turn is in flight AND the latest assistant message has nothing visible yet:
|
* being produced yet — so it shows while a turn is in flight AND the latest
|
||||||
|
* assistant message's LAST part is not live output:
|
||||||
* - the last message is still the user's (assistant hasn't started a row), or
|
* - the last message is still the user's (assistant hasn't started a row), or
|
||||||
* - the last (assistant) message has no non-empty text and no tool part.
|
* - the assistant row has no parts yet, or
|
||||||
* Once any text/tool part arrives, MessageItem renders it and this hides.
|
* - 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).
|
||||||
*/
|
*/
|
||||||
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
|
||||||
if (!isStreaming) return false;
|
if (!isStreaming) return false;
|
||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
if (!last) return true; // submitted with nothing rendered yet.
|
if (!last) return true; // submitted with nothing rendered yet.
|
||||||
if (last.role !== "assistant") return true; // assistant row not started.
|
if (last.role !== "assistant") return true; // assistant row not started.
|
||||||
const hasVisible = last.parts.some(
|
const lastPart = last.parts[last.parts.length - 1];
|
||||||
(p) =>
|
if (!lastPart) return true; // assistant row exists but has no parts yet.
|
||||||
(p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type),
|
// 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
|
||||||
return !hasVisible;
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +113,14 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
|
|||||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
* 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.
|
* earlier messages, streamed deltas no longer yank them back down.
|
||||||
*/
|
*/
|
||||||
export default function MessageList({ messages, isStreaming }: MessageListProps) {
|
export default function MessageList({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
emptyState,
|
||||||
|
showCitations = true,
|
||||||
|
neutralizeInternalLinks = false,
|
||||||
|
assistantName,
|
||||||
|
}: MessageListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
// Whether the viewport is currently pinned to the bottom. Starts true so the
|
// Whether the viewport is currently pinned to the bottom. Starts true so the
|
||||||
@@ -108,9 +183,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
|||||||
if (messages.length === 0 && !typing) {
|
if (messages.length === 0 && !typing) {
|
||||||
return (
|
return (
|
||||||
<Center className={classes.messages}>
|
<Center className={classes.messages}>
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
{emptyState ?? (
|
||||||
{t("Ask the AI agent anything about your workspace.")}
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
</Text>
|
{t("Ask the AI agent anything about your workspace.")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,9 +196,20 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
|||||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||||
<Stack gap={0} pr="xs">
|
<Stack gap={0} pr="xs">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageItem key={message.id} message={message} />
|
<MessageItem
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
showCitations={showCitations}
|
||||||
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
|
assistantName={assistantName}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{typing && <TypingIndicator />}
|
{typing && (
|
||||||
|
<TypingIndicator
|
||||||
|
assistantName={assistantName}
|
||||||
|
showName={typingIndicatorShowsName(messages)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
/* flex-start keeps the first row reachable when the wrapped cards overflow and
|
||||||
|
the container scrolls. With align-content: center, an overflowing top row is
|
||||||
|
pushed out of the scrollable area and becomes unreachable. The parent Mantine
|
||||||
|
Center still vertically centers the whole block when it fits. */
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
/* Cap the height so a large number of roles scrolls instead of blowing out
|
||||||
|
the empty chat area. */
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
/* Grow to fill the row so cards use the available window width instead of
|
||||||
|
leaving large side gaps; the flex-basis sets how many fit per row before
|
||||||
|
wrapping (≈2 columns at the default window width, more as it widens). */
|
||||||
|
flex: 1 1 240px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
min-height: 90px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
transform 120ms ease,
|
||||||
|
box-shadow 120ms ease,
|
||||||
|
border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--mantine-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The description: small and slightly muted, inheriting the card's color. We
|
||||||
|
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
|
||||||
|
with the card's inline color. */
|
||||||
|
.description {
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.3;
|
||||||
|
/* Break long unbreakable tokens (URLs, long foreign words) in the
|
||||||
|
admin-configured description so they wrap instead of overflowing the card
|
||||||
|
width now that the line clamp no longer caps the text. */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
78
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { UnstyledButton, Text } from "@mantine/core";
|
||||||
|
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
|
||||||
|
import classes from "@/features/ai-chat/components/role-cards.module.css";
|
||||||
|
|
||||||
|
interface RoleCardsProps {
|
||||||
|
/** The enabled roles to render (one card each). */
|
||||||
|
roles: IAiRole[];
|
||||||
|
/** Called with the picked role when a card is clicked. The parent starts the
|
||||||
|
* chat with this role (binds it and sends the opening message). */
|
||||||
|
onPick: (role: IAiRole) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
|
||||||
|
* they render correctly in both light and dark themes; the CSS module owns only
|
||||||
|
* the layout. The card shows the emoji (if any), the role name, and a small
|
||||||
|
* dimmed description line (if any).
|
||||||
|
*/
|
||||||
|
function RoleCard({
|
||||||
|
color,
|
||||||
|
name,
|
||||||
|
emoji,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
emoji?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
className={classes.card}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `var(--mantine-color-${color}-light)`,
|
||||||
|
color: `var(--mantine-color-${color}-light-color)`,
|
||||||
|
}}
|
||||||
|
title={description ?? name}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{emoji && <span className={classes.emoji}>{emoji}</span>}
|
||||||
|
<Text size="sm" fw={600} lineClamp={2}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{description && (
|
||||||
|
<Text size="xs" className={classes.description}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colored role cards rendered as the empty-state of a brand-new chat. There is
|
||||||
|
* no Universal assistant card — the universal assistant is the implicit default
|
||||||
|
* the user gets by simply typing into the composer without picking a card.
|
||||||
|
* Clicking a card immediately STARTS the chat with that role (the parent binds
|
||||||
|
* the role to the new chat and sends the opening message).
|
||||||
|
*/
|
||||||
|
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
{roles.map((role, index) => (
|
||||||
|
<RoleCard
|
||||||
|
key={role.id}
|
||||||
|
color={roleCardColor(index)}
|
||||||
|
name={role.name}
|
||||||
|
emoji={role.emoji}
|
||||||
|
description={role.description}
|
||||||
|
onClick={() => onPick(role)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
|||||||
|
|
||||||
interface ToolCallCardProps {
|
interface ToolCallCardProps {
|
||||||
part: ToolUiPart;
|
part: ToolUiPart;
|
||||||
|
/**
|
||||||
|
* Whether to render page citation links. Defaults to true (the internal chat,
|
||||||
|
* where the reader is authenticated and the `/p/{id}` links resolve). The
|
||||||
|
* public share passes false: an anonymous reader cannot open internal pages,
|
||||||
|
* so the links would 404/redirect to login. Suppressing them keeps the card
|
||||||
|
* (the action log itself) while dropping the unusable links.
|
||||||
|
*/
|
||||||
|
showCitations?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
|
|||||||
* agent DID (the agent writes without confirmation — D2), its run state
|
* agent DID (the agent writes without confirmation — D2), its run state
|
||||||
* (running / done / error), and citation link(s) to any referenced page(s).
|
* (running / done / error), and citation link(s) to any referenced page(s).
|
||||||
*/
|
*/
|
||||||
export default function ToolCallCard({ part }: ToolCallCardProps) {
|
export default function ToolCallCard({
|
||||||
|
part,
|
||||||
|
showCitations = true,
|
||||||
|
}: ToolCallCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toolName = getToolName(part);
|
const toolName = getToolName(part);
|
||||||
const state = toolRunState(part.state);
|
const state = toolRunState(part.state);
|
||||||
const { key, values } = toolLabelKey(toolName);
|
const { key, values } = toolLabelKey(toolName);
|
||||||
const citations = toolCitations(part);
|
const citations = showCitations ? toolCitations(part) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.toolCard}>
|
<div className={classes.toolCard}>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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,33 +1,54 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
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 "AI agent is typing…" placeholder shown while a turn is in flight but the
|
* Live "… is typing…" placeholder shown while a turn is in flight but the latest
|
||||||
* latest assistant message has no visible content yet (no rendered text/tool
|
* assistant message has no visible content yet (no rendered text/tool parts). It
|
||||||
* parts). It covers the gap between sending and the first streamed token, and is
|
* covers the gap between sending and the first streamed token, and is replaced by
|
||||||
* replaced by the real assistant message once content starts arriving.
|
* the real assistant message once content starts arriving.
|
||||||
*
|
*
|
||||||
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
|
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||||
* so it reads as the assistant's bubble taking shape.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export default function TypingIndicator() {
|
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const name = resolveAssistantName(assistantName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.messageRow}>
|
<Box className={classes.messageRow}>
|
||||||
<Text size="xs" c="dimmed" mb={4}>
|
{showName !== false && (
|
||||||
{t("AI agent")}
|
// Extra bottom gap (vs MessageItem's mb={4}) gives the small bouncing
|
||||||
</Text>
|
// 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>
|
||||||
|
)}
|
||||||
<Group gap={8} align="center">
|
<Group gap={8} align="center">
|
||||||
<span className={classes.typingDots} aria-hidden="true">
|
<span className={classes.typingDots} aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</span>
|
</span>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("AI agent is typing…")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
290
apps/client/src/features/ai-chat/hooks/use-chat-session.test.tsx
Normal file
290
apps/client/src/features/ai-chat/hooks/use-chat-session.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
322
apps/client/src/features/ai-chat/hooks/use-chat-session.ts
Normal file
322
apps/client/src/features/ai-chat/hooks/use-chat-session.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,22 +4,30 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
|
createAiRole,
|
||||||
deleteAiChat,
|
deleteAiChat,
|
||||||
|
deleteAiRole,
|
||||||
getAiChatMessages,
|
getAiChatMessages,
|
||||||
getAiChats,
|
getAiChats,
|
||||||
|
getAiRoles,
|
||||||
renameAiChat,
|
renameAiChat,
|
||||||
|
updateAiRole,
|
||||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import {
|
import {
|
||||||
IAiChat,
|
IAiChat,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCreate,
|
||||||
|
IAiRoleUpdate,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||||
|
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||||
"ai-chat-messages",
|
"ai-chat-messages",
|
||||||
chatId,
|
chatId,
|
||||||
@@ -67,6 +75,31 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
|||||||
enabled: !!chatId,
|
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>(() => {
|
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
|
||||||
if (!query.data) return undefined;
|
if (!query.data) return undefined;
|
||||||
return query.data.pages.flatMap((p) => p.items);
|
return query.data.pages.flatMap((p) => p.items);
|
||||||
@@ -114,3 +147,79 @@ export function useDeleteAiChatMutation() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the workspace's agent roles. Available to any workspace member (used by
|
||||||
|
* the chat-creation role picker and the admin management section). `enabled`
|
||||||
|
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
||||||
|
*/
|
||||||
|
export function useAiRolesQuery(enabled: boolean = true) {
|
||||||
|
return useQuery<IAiRole[], Error>({
|
||||||
|
queryKey: AI_ROLES_RQ_KEY,
|
||||||
|
queryFn: () => getAiRoles(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAiRoleMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
||||||
|
mutationFn: (data) => createAiRole(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: t("Created successfully") });
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAiRoleMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
||||||
|
mutationFn: (data) => updateAiRole(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: t("Updated successfully") });
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||||
|
// The role badge denormalized onto the chat list may have changed.
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAiRoleMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<{ success: true }, Error, string>({
|
||||||
|
mutationFn: (id) => deleteAiRole(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: t("Deleted successfully") });
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||||
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = error["response"]?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message: message ?? t("Failed to update data"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
IAiChatListParams,
|
IAiChatListParams,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiChatMessagesParams,
|
IAiChatMessagesParams,
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCreate,
|
||||||
|
IAiRoleUpdate,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,3 +49,53 @@ export async function renameAiChat(data: {
|
|||||||
export async function deleteAiChat(chatId: string): Promise<void> {
|
export async function deleteAiChat(chatId: string): Promise<void> {
|
||||||
await api.post("/ai-chat/delete", { chatId });
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||||
|
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||||
|
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** List the workspace's agent roles. */
|
||||||
|
export async function getAiRoles(): Promise<IAiRole[]> {
|
||||||
|
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a role (admin). */
|
||||||
|
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
||||||
|
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a role (admin). */
|
||||||
|
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||||
|
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete a role (admin). */
|
||||||
|
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||||
|
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,77 @@ export interface IAiChat {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt?: string | null;
|
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;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin create payload for a role. */
|
||||||
|
export interface IAiRoleCreate {
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
description?: string;
|
||||||
|
instructions: string;
|
||||||
|
modelConfig?: IAiRoleModelConfig | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,15 +106,27 @@ export interface IAiChatMessageRow {
|
|||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalTokens?: 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.
|
// 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
|
// how much the conversation occupies in the model's context window after this
|
||||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||||
// floating window's header badge.
|
// floating window's header badge.
|
||||||
contextTokens?: number;
|
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
|
// 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.
|
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||||
error?: string;
|
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;
|
} | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
72
apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts
Normal file
72
apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/client/src/features/ai-chat/utils/adopt-chat-id.ts
Normal file
70
apps/client/src/features/ai-chat/utils/adopt-chat-id.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveAssistantName } from "./assistant-name";
|
||||||
|
|
||||||
|
describe("resolveAssistantName", () => {
|
||||||
|
it("returns a real name unchanged", () => {
|
||||||
|
expect(resolveAssistantName("Ada")).toBe("Ada");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims surrounding whitespace from a real name", () => {
|
||||||
|
expect(resolveAssistantName(" Ada ")).toBe("Ada");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a whitespace-only name (the reason for .trim())", () => {
|
||||||
|
expect(resolveAssistantName(" ")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the name is undefined", () => {
|
||||||
|
expect(resolveAssistantName(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty string", () => {
|
||||||
|
expect(resolveAssistantName("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/client/src/features/ai-chat/utils/assistant-name.ts
Normal file
16
apps/client/src/features/ai-chat/utils/assistant-name.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Pure helper for resolving the assistant's display name. Kept free of React so
|
||||||
|
// it can be unit-tested in isolation (see assistant-name.test.ts) and shared by
|
||||||
|
// the components that render the assistant identity (TypingIndicator, MessageItem).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the assistant's display name from the optional configured identity.
|
||||||
|
*
|
||||||
|
* Returns the trimmed name when it has visible (non-whitespace) characters, or
|
||||||
|
* `null` when the name is absent or whitespace-only. Callers fall back to a
|
||||||
|
* generic "AI agent" label on `null`. The `.trim()` is why a name of " " must
|
||||||
|
* resolve to `null` rather than rendering an empty label.
|
||||||
|
*/
|
||||||
|
export function resolveAssistantName(assistantName?: string): string | null {
|
||||||
|
const name = assistantName?.trim();
|
||||||
|
return name ? name : null;
|
||||||
|
}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client-only Markdown builder for an AI agent chat. Serializes the already
|
|
||||||
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
|
|
||||||
* Markdown string suitable for copying to the clipboard. NO network call is
|
|
||||||
* made and NO server/DB code is touched — this reuses the rich "request
|
|
||||||
* internals" (tool calls with input/output, per-message token usage,
|
|
||||||
* finish/error info) that the chat already holds client-side.
|
|
||||||
*
|
|
||||||
* Only role labels and tool action labels are localized via the passed-in `t`
|
|
||||||
* translator; the structural document words (Input/Output/Error/Tokens/...) are
|
|
||||||
* plain English constants because the output is a technical artifact.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
import {
|
|
||||||
ToolUiPart,
|
|
||||||
getToolName,
|
|
||||||
toolRunState,
|
|
||||||
toolLabelKey,
|
|
||||||
} from "@/features/ai-chat/utils/tool-parts.tsx";
|
|
||||||
|
|
||||||
// Minimal translator signature compatible with react-i18next's `t`.
|
|
||||||
type Translate = (key: string, values?: Record<string, unknown>) => string;
|
|
||||||
|
|
||||||
interface BuildChatMarkdownArgs {
|
|
||||||
title: string | null;
|
|
||||||
chatId: string;
|
|
||||||
rows: IAiChatMessageRow[];
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
|
||||||
|
describe("collapseBlankLines", () => {
|
||||||
|
it("collapses a run of 2+ newlines to a single newline", () => {
|
||||||
|
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
|
||||||
|
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps single newlines untouched", () => {
|
||||||
|
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blank lines INSIDE a fenced code block", () => {
|
||||||
|
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
|
||||||
|
// Prose blanks collapse; the blank lines between the ``` fences survive.
|
||||||
|
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a tilde fence and preserves its interior blanks", () => {
|
||||||
|
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
|
||||||
|
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unclosed fence's remaining lines verbatim", () => {
|
||||||
|
const src = "intro\n\n```\nstill\n\nopen";
|
||||||
|
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for text with no blank lines", () => {
|
||||||
|
expect(collapseBlankLines("just one line")).toBe("just one line");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
||||||
|
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
||||||
|
const loose =
|
||||||
|
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||||
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
|
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
||||||
|
expect(html).toContain("<li>item one</li>");
|
||||||
|
expect(html).not.toContain("<li><p>");
|
||||||
|
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
||||||
|
expect(html).toContain("<ul>");
|
||||||
|
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
||||||
|
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||||
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
|
expect(html).toContain("<ol>");
|
||||||
|
expect(html).toContain("<li>first</li>");
|
||||||
|
expect(html).not.toContain("<li><p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
||||||
|
const loose = "- a\n\n- b";
|
||||||
|
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
|
||||||
|
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
|
||||||
|
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
|
||||||
|
*
|
||||||
|
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
|
||||||
|
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
|
||||||
|
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
|
||||||
|
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
|
||||||
|
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
|
||||||
|
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
|
||||||
|
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
|
||||||
|
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
|
||||||
|
* normal assistant answer (where paragraph spacing is intentional).
|
||||||
|
*
|
||||||
|
* Fenced code is preserved verbatim: a fence opens on a line whose first
|
||||||
|
* non-space characters are ``` or ~~~ and closes on the next line that starts
|
||||||
|
* with the same fence character. Blank lines between fences (significant for
|
||||||
|
* code formatting) are never collapsed.
|
||||||
|
*/
|
||||||
|
export function collapseBlankLines(text: string): string {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const out: string[] = [];
|
||||||
|
let inFence = false;
|
||||||
|
let fenceChar = "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const ch = fenceMatch[1][0];
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
fenceChar = ch;
|
||||||
|
} else if (ch === fenceChar) {
|
||||||
|
inFence = false;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a fenced block every line (including blanks) is significant.
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
|
||||||
|
// `\n` between the surrounding content lines.
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
shouldCollapseOnOutsidePointer,
|
||||||
|
isHeaderClick,
|
||||||
|
} from "./collapse-helpers";
|
||||||
|
|
||||||
|
describe("shouldCollapseOnOutsidePointer", () => {
|
||||||
|
let windowEl: HTMLDivElement;
|
||||||
|
let inside: HTMLSpanElement;
|
||||||
|
let portal: HTMLDivElement;
|
||||||
|
let portalChild: HTMLButtonElement;
|
||||||
|
let page: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// The floating window with a child node.
|
||||||
|
windowEl = document.createElement("div");
|
||||||
|
inside = document.createElement("span");
|
||||||
|
windowEl.appendChild(inside);
|
||||||
|
|
||||||
|
// A Mantine-style portal (data-portal="true") with a child (e.g. a menu item).
|
||||||
|
portal = document.createElement("div");
|
||||||
|
portal.setAttribute("data-portal", "true");
|
||||||
|
portalChild = document.createElement("button");
|
||||||
|
portal.appendChild(portalChild);
|
||||||
|
|
||||||
|
// An unrelated page element.
|
||||||
|
page = document.createElement("div");
|
||||||
|
|
||||||
|
document.body.append(windowEl, portal, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a target inside the window", () => {
|
||||||
|
expect(shouldCollapseOnOutsidePointer(inside, windowEl)).toBe(false);
|
||||||
|
expect(shouldCollapseOnOutsidePointer(windowEl, windowEl)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a target inside a Mantine portal", () => {
|
||||||
|
expect(shouldCollapseOnOutsidePointer(portal, windowEl)).toBe(false);
|
||||||
|
expect(shouldCollapseOnOutsidePointer(portalChild, windowEl)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a target on the page (outside window and portals)", () => {
|
||||||
|
expect(shouldCollapseOnOutsidePointer(page, windowEl)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when there is no window element", () => {
|
||||||
|
expect(shouldCollapseOnOutsidePointer(page, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a non-Element target", () => {
|
||||||
|
expect(shouldCollapseOnOutsidePointer(null, windowEl)).toBe(false);
|
||||||
|
expect(shouldCollapseOnOutsidePointer(document, windowEl)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isHeaderClick", () => {
|
||||||
|
it("treats a zero-movement press as a click", () => {
|
||||||
|
expect(isHeaderClick(100, 100, 100, 100)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats movement within the threshold as a click", () => {
|
||||||
|
expect(isHeaderClick(100, 100, 103, 97)).toBe(true);
|
||||||
|
expect(isHeaderClick(100, 100, 104, 104)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats movement beyond the threshold (either axis) as a drag", () => {
|
||||||
|
expect(isHeaderClick(100, 100, 105, 100)).toBe(false);
|
||||||
|
expect(isHeaderClick(100, 100, 100, 105)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a custom threshold", () => {
|
||||||
|
expect(isHeaderClick(0, 0, 8, 0, 10)).toBe(true);
|
||||||
|
expect(isHeaderClick(0, 0, 11, 0, 10)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
41
apps/client/src/features/ai-chat/utils/collapse-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Pure helpers for the AI chat window auto-collapse behavior. Kept free of React
|
||||||
|
// so they can be unit-tested in isolation (see collapse-helpers.test.ts).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether an outside pointer (mousedown) should collapse the chat window.
|
||||||
|
*
|
||||||
|
* Returns true only when the pointer target is genuinely "on the page": NOT
|
||||||
|
* inside the window element AND NOT inside a Mantine portal. Mantine renders
|
||||||
|
* dropdown menus (chat-list kebab), modals (delete-confirm), tooltips and
|
||||||
|
* notifications into portals tagged with `data-portal="true"`; clicks on those
|
||||||
|
* are part of operating the chat, so they must not collapse it.
|
||||||
|
*/
|
||||||
|
export function shouldCollapseOnOutsidePointer(
|
||||||
|
target: EventTarget | null,
|
||||||
|
windowEl: HTMLElement | null,
|
||||||
|
): boolean {
|
||||||
|
if (!windowEl) return false;
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
// Inside the window itself -> not an "away" interaction (drag, resize, typing).
|
||||||
|
if (windowEl.contains(target)) return false;
|
||||||
|
// Inside a Mantine portal the chat owns (kebab menu, confirm modal, tooltip,
|
||||||
|
// notifications). data-portal="true" reliably excludes all of them.
|
||||||
|
if (target.closest("[data-portal]")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-vs-drag discrimination for the window header: a press whose pointer
|
||||||
|
* moved less than `threshold` px on both axes between mousedown and mouseup is
|
||||||
|
* treated as a click (which expands a collapsed window), not a drag (which
|
||||||
|
* repositions it).
|
||||||
|
*/
|
||||||
|
export function isHeaderClick(
|
||||||
|
downX: number,
|
||||||
|
downY: number,
|
||||||
|
upX: number,
|
||||||
|
upY: number,
|
||||||
|
threshold = 4,
|
||||||
|
): boolean {
|
||||||
|
return Math.abs(upX - downX) <= threshold && Math.abs(upY - downY) <= threshold;
|
||||||
|
}
|
||||||
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for the header context badge selection. Covers the two
|
||||||
|
* non-obvious rules: numerator and denominator are each taken from the most
|
||||||
|
* recent row carrying THAT value (they may live on different rows), and a fresh
|
||||||
|
* row with a zero/absent value must NOT shadow an older positive one.
|
||||||
|
*/
|
||||||
|
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
metadata,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectContextBadge", () => {
|
||||||
|
it("returns zeros for empty / nullish input", () => {
|
||||||
|
expect(selectContextBadge(undefined)).toEqual({
|
||||||
|
contextTokens: 0,
|
||||||
|
maxContextTokens: 0,
|
||||||
|
});
|
||||||
|
expect(selectContextBadge(null)).toEqual({
|
||||||
|
contextTokens: 0,
|
||||||
|
maxContextTokens: 0,
|
||||||
|
});
|
||||||
|
expect(selectContextBadge([])).toEqual({
|
||||||
|
contextTokens: 0,
|
||||||
|
maxContextTokens: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads both figures from the most recent row that carries them", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||||
|
row({ contextTokens: 1500, maxContextTokens: 200000 }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy usage total for older rows without contextTokens", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
|
||||||
|
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes numerator and denominator from different rows", () => {
|
||||||
|
// Freshest row (an error turn) carries contextTokens but no max; the older
|
||||||
|
// completed turn carries the max. Each is picked from its own latest row.
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 800, maxContextTokens: 200000 }),
|
||||||
|
row({ contextTokens: 1200, error: "402: nope" }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a fresh zero/absent max shadow an older positive max", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||||
|
row({ contextTokens: 1200, maxContextTokens: 0 }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows with null metadata", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 500, maxContextTokens: 200000 }),
|
||||||
|
row(null),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports current > max as-is (no clamp)", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
|
||||||
|
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the header context badge figures from the persisted message rows.
|
||||||
|
*
|
||||||
|
* - `contextTokens` (numerator): how much the conversation now occupies in the
|
||||||
|
* model's context window. Read from the most recent row carrying a context
|
||||||
|
* figure — `contextTokens` (final-step input+output) on rows recorded after
|
||||||
|
* this shipped, else that turn's legacy `usage` total for older rows.
|
||||||
|
* - `maxContextTokens` (denominator): the model's configured max window, stamped
|
||||||
|
* alongside `contextTokens` on a completed turn.
|
||||||
|
*
|
||||||
|
* Each value is taken from the most recent row carrying THAT value
|
||||||
|
* independently — they may land on different rows (e.g. a fresh error row can
|
||||||
|
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
|
||||||
|
* whichever is still unset. `0` means "no row has it" (older rows, or no
|
||||||
|
* admin-configured limit); the badge then omits the value.
|
||||||
|
*/
|
||||||
|
export function selectContextBadge(
|
||||||
|
messageRows: readonly IAiChatMessageRow[] | undefined | null,
|
||||||
|
): { contextTokens: number; maxContextTokens: number } {
|
||||||
|
let contextTokens = 0;
|
||||||
|
let maxContextTokens = 0;
|
||||||
|
if (!messageRows) return { contextTokens, maxContextTokens };
|
||||||
|
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||||
|
const meta = messageRows[i].metadata;
|
||||||
|
if (!meta) continue;
|
||||||
|
if (contextTokens === 0) {
|
||||||
|
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||||
|
contextTokens = meta.contextTokens;
|
||||||
|
} else if (meta.usage) {
|
||||||
|
const usage = meta.usage;
|
||||||
|
const fallback =
|
||||||
|
usage.totalTokens ??
|
||||||
|
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||||
|
if (fallback > 0) contextTokens = fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
maxContextTokens === 0 &&
|
||||||
|
typeof meta.maxContextTokens === "number" &&
|
||||||
|
meta.maxContextTokens > 0
|
||||||
|
) {
|
||||||
|
maxContextTokens = meta.maxContextTokens;
|
||||||
|
}
|
||||||
|
if (contextTokens !== 0 && maxContextTokens !== 0) break;
|
||||||
|
}
|
||||||
|
return { contextTokens, maxContextTokens };
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
168
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
168
apps/client/src/features/ai-chat/utils/error-message.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { describeChatError } from "./error-message";
|
||||||
|
|
||||||
|
// Identity translator: assert on the raw English key so the tests do not depend
|
||||||
|
// on the i18n catalog.
|
||||||
|
const t = (key: string) => key;
|
||||||
|
|
||||||
|
describe("describeChatError", () => {
|
||||||
|
it('maps a {"statusCode":403} body to the disabled heading', () => {
|
||||||
|
const body = '{"statusCode":403,"message":"Forbidden"}';
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "AI chat is disabled",
|
||||||
|
detail: "AI chat is disabled for this workspace.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a {"statusCode":503} body to the not-configured heading', () => {
|
||||||
|
const body = '{"statusCode":503,"message":"Service Unavailable"}';
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "AI provider not configured",
|
||||||
|
detail:
|
||||||
|
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||||
|
expect(
|
||||||
|
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||||
|
).toBe("Lost connection to the AI provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "fetch failed" as a lost-connection error', () => {
|
||||||
|
expect(describeChatError("fetch failed", t).title).toBe(
|
||||||
|
"Lost connection to the AI provider",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies ETIMEDOUT as a timeout", () => {
|
||||||
|
expect(describeChatError("ETIMEDOUT", t).title).toBe(
|
||||||
|
"The AI provider timed out",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "504: Gateway Timeout" as a timeout', () => {
|
||||||
|
expect(describeChatError("504: Gateway Timeout", t).title).toBe(
|
||||||
|
"The AI provider timed out",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "429: Too Many Requests" as rate limited', () => {
|
||||||
|
expect(describeChatError("429: Too Many Requests", t).title).toBe(
|
||||||
|
"Rate limited by the AI provider",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT misclassify a body that merely contains "403" as disabled', () => {
|
||||||
|
// Regression intent: a provider message mentioning the number 403 must never
|
||||||
|
// be folded into the "AI chat is disabled" gating heading. Here the 429
|
||||||
|
// signature wins (checked before any bare-403 logic exists), so it maps to
|
||||||
|
// the rate-limit category instead.
|
||||||
|
const view = describeChatError("429: rate limited after 403 attempts", t);
|
||||||
|
expect(view.title).toBe("Rate limited by the AI provider");
|
||||||
|
expect(view.title).not.toBe("AI chat is disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a context-window overflow as too-large", () => {
|
||||||
|
expect(
|
||||||
|
describeChatError(
|
||||||
|
"This model's maximum context length is 128000 tokens",
|
||||||
|
t,
|
||||||
|
).title,
|
||||||
|
).toBe("The conversation is too large");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "402: Insufficient credits" as quota exceeded', () => {
|
||||||
|
expect(describeChatError("402: Insufficient credits", t).title).toBe(
|
||||||
|
"AI provider quota exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies "401: Unauthorized" as an auth failure', () => {
|
||||||
|
expect(describeChatError("401: Unauthorized", t).title).toBe(
|
||||||
|
"AI provider authentication failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the generic heading + detail for empty input", () => {
|
||||||
|
expect(describeChatError("", t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: "The AI agent could not respond. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the generic heading + detail for "An error occurred."', () => {
|
||||||
|
expect(describeChatError("An error occurred.", t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: "The AI agent could not respond. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the generic heading + detail for "Internal server error"', () => {
|
||||||
|
expect(describeChatError("Internal server error", t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: "The AI agent could not respond. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an unknown-but-informative provider detail verbatim under the generic heading", () => {
|
||||||
|
expect(describeChatError("418: I'm a teapot", t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: "418: I'm a teapot",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT treat a number inside the response body as a leading status code (no auth)", () => {
|
||||||
|
// The real status (500) leads the string; the "401" lives in the snippet and
|
||||||
|
// must not trigger the auth category. The verbatim provider text is surfaced.
|
||||||
|
const body =
|
||||||
|
"500: Server error | response body: model gpt-4o-401-preview not found";
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: body,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT treat a passing mention of billing as a quota error", () => {
|
||||||
|
// "billing" is no longer a quota signature; the verbatim text is surfaced.
|
||||||
|
const body = "502: Bad Gateway | response body: see our billing page";
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: body,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rate-limits "429: rate limited after 403 attempts" and never disables', () => {
|
||||||
|
const view = describeChatError("429: rate limited after 403 attempts", t);
|
||||||
|
expect(view.title).toBe("Rate limited by the AI provider");
|
||||||
|
expect(view.title).not.toBe("AI chat is disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT treat "rate limit" inside the response body as a rate-limit error', () => {
|
||||||
|
// The textual rate-limit phrase lives only in the response-body snippet, and
|
||||||
|
// the leading 500 is not a classified numeric code, so it must not leak into
|
||||||
|
// the rate-limit category. (The detail itself falls back to the generic line
|
||||||
|
// here because the leading message contains "Internal Server Error", which
|
||||||
|
// providerDetail suppresses — the title is what this case pins.)
|
||||||
|
const body =
|
||||||
|
"500: Internal Server Error | response body: rate limit info: see our docs";
|
||||||
|
expect(describeChatError(body, t).title).toBe("Something went wrong");
|
||||||
|
expect(describeChatError(body, t).title).not.toBe(
|
||||||
|
"Rate limited by the AI provider",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT treat ETIMEDOUT inside the response body as a timeout', () => {
|
||||||
|
// The 503 leads the string but is not a classified numeric code, and the
|
||||||
|
// ETIMEDOUT signature appears only in the body, so it must not leak into the
|
||||||
|
// timeout category; the verbatim text is surfaced under the generic heading.
|
||||||
|
const body = "503: x | response body: ETIMEDOUT appears in this log line";
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "Something went wrong",
|
||||||
|
detail: body,
|
||||||
|
});
|
||||||
|
expect(describeChatError(body, t).title).not.toBe(
|
||||||
|
"The AI provider timed out",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,174 @@
|
|||||||
/**
|
/**
|
||||||
* Turn an AI chat error message into a friendly inline string. Used for BOTH the
|
* A classified AI chat error: a short bold heading naming the cause category and
|
||||||
* live `useChat().error` (its `.message`) and a persisted assistant error stored
|
* a one-line human-readable detail / next step. Both strings are already passed
|
||||||
* in `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
|
* through `t`, so callers render them directly.
|
||||||
* body carrying a numeric "statusCode" field (matched precisely, not by bare
|
*/
|
||||||
* substring, so a provider message that merely contains "403"/"503"/"disabled" is
|
export interface ChatErrorView {
|
||||||
* never misclassified). Everything else — provider stream failures forwarded as
|
title: string;
|
||||||
* "<status>: <message>" (402 credits, 429 rate limit, ...) — is surfaced verbatim.
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn an AI chat error message into a friendly heading + detail. Used for BOTH
|
||||||
|
* the live `useChat().error` (its `.message`) and a persisted assistant error in
|
||||||
|
* `metadata.error`. Our own gating responses arrive as a raw NestJS JSON error
|
||||||
|
* body carrying a numeric "statusCode" (matched precisely, not by bare substring,
|
||||||
|
* so a provider message that merely contains "403"/"503" is never misclassified).
|
||||||
|
* Known provider/network failures (connection reset, timeout, rate limit, context
|
||||||
|
* overflow, quota, auth) are mapped to a clear category; anything else falls back
|
||||||
|
* to the raw provider detail (or a generic line) under the original heading.
|
||||||
*/
|
*/
|
||||||
export function describeChatError(
|
export function describeChatError(
|
||||||
message: string,
|
message: string,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
): string {
|
): ChatErrorView {
|
||||||
const msg = message ?? "";
|
const msg = message ?? "";
|
||||||
|
|
||||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||||
return t("AI chat is disabled for this workspace.");
|
return {
|
||||||
|
title: t("AI chat is disabled"),
|
||||||
|
detail: t("AI chat is disabled for this workspace."),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (/"statusCode"\s*:\s*503\b/.test(msg)) {
|
if (/"statusCode"\s*:\s*503\b/.test(msg)) {
|
||||||
return t("The AI provider is not configured. Ask an administrator to set it up.");
|
return {
|
||||||
|
title: t("AI provider not configured"),
|
||||||
|
detail: t(
|
||||||
|
"The AI provider is not configured. Ask an administrator to set it up.",
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return providerDetail(msg) ?? t("The AI agent could not respond. Please try again.");
|
|
||||||
|
const category = classifyProviderError(msg);
|
||||||
|
if (category) {
|
||||||
|
return { title: t(category.title), detail: t(category.detail) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown error: surface the raw provider detail when it is informative,
|
||||||
|
// otherwise a generic line. The heading stays the original generic one.
|
||||||
|
return {
|
||||||
|
title: t("Something went wrong"),
|
||||||
|
detail:
|
||||||
|
providerDetail(msg) ??
|
||||||
|
t("The AI agent could not respond. Please try again."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorCategory {
|
||||||
|
/** English key for the bold heading. */
|
||||||
|
title: string;
|
||||||
|
/** English key for the one-line explanation. */
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a provider/network error string to a friendly category. Order matters: the
|
||||||
|
* most specific signatures are tested first. Returns null when nothing matches,
|
||||||
|
* so the caller can fall back to the raw provider text. The English keys returned
|
||||||
|
* here are passed through `t` by the caller.
|
||||||
|
*
|
||||||
|
* The server formats provider errors as "<statusCode>: <message> | response body:
|
||||||
|
* <snippet>" (see server-side describeProviderError), so the HTTP status is always
|
||||||
|
* the LEADING token. We match a numeric code only when it leads the string, so a
|
||||||
|
* number inside the response-body snippet never triggers a category; textual
|
||||||
|
* signatures are matched only against the leading message (before the response
|
||||||
|
* body), so a phrase inside the snippet never triggers a category either.
|
||||||
|
*/
|
||||||
|
function classifyProviderError(msg: string): ErrorCategory | null {
|
||||||
|
const code = /^\s*(\d{3})\b/.exec(msg)?.[1] ?? "";
|
||||||
|
// The server appends "| response body: <snippet>" to provider errors; match
|
||||||
|
// textual signatures only against the leading provider message so a phrase
|
||||||
|
// inside the response-body snippet never triggers a wrong category. The numeric
|
||||||
|
// status code is read from the start of the full string above.
|
||||||
|
const head = msg.split(/\|\s*response body:/i)[0];
|
||||||
|
|
||||||
|
// The browser's OWN fetch-failure messages — WebKit/Safari "Load failed",
|
||||||
|
// Chrome "Failed to fetch", Firefox "NetworkError when attempting to fetch
|
||||||
|
// resource". These mean the streaming connection between the browser and THIS
|
||||||
|
// server (/api/ai-chat/stream) dropped mid-answer: the browser<->server link,
|
||||||
|
// NOT the server<->AI-provider link, so do NOT blame the provider. A failed
|
||||||
|
// fetch carries no status/body, so the browser has no further detail — the real
|
||||||
|
// cause is in the server logs (the stream controller logs the disconnect) and
|
||||||
|
// the reverse proxy (often buffering or timing out the long-lived SSE).
|
||||||
|
if (/failed to fetch|load failed|networkerror/i.test(head)) {
|
||||||
|
return {
|
||||||
|
title: "Lost connection to the server",
|
||||||
|
detail:
|
||||||
|
"The streaming connection to the server dropped before the answer finished. The browser reports no further detail — the cause is in the server logs and the reverse proxy (often buffering or timing out the stream). Reload and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Connection dropped / provider unreachable. ECONNRESET is the production case:
|
||||||
|
// the LLM socket was reset mid-stream (surfaced by the server's error
|
||||||
|
// formatter). "terminated" is scoped to a connection/stream context so it does
|
||||||
|
// not match benign "... was terminated" messages.
|
||||||
|
if (
|
||||||
|
/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|cannot connect|fetch failed|network error|connection (?:error|closed|reset|terminated)|stream terminated/i.test(
|
||||||
|
head,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: "Lost connection to the AI provider",
|
||||||
|
detail:
|
||||||
|
"The connection to the AI provider dropped before the answer finished. Please try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Timeout.
|
||||||
|
if (
|
||||||
|
code === "504" ||
|
||||||
|
code === "408" ||
|
||||||
|
/ETIMEDOUT|timed[\s-]?out|\btimeout\b/i.test(head)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: "The AI provider timed out",
|
||||||
|
detail: "The AI provider took too long to respond. Please try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Rate limited.
|
||||||
|
if (code === "429" || /rate[\s-]?limit|too many requests/i.test(head)) {
|
||||||
|
return {
|
||||||
|
title: "Rate limited by the AI provider",
|
||||||
|
detail:
|
||||||
|
"The AI provider is rate-limiting requests. Wait a moment and try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Context window / token budget exceeded.
|
||||||
|
if (
|
||||||
|
code === "413" ||
|
||||||
|
/context[\s_-]?(?:length|window)|maximum context|context_length_exceeded|too many tokens|maximum[^.]*tokens|reduce the length/i.test(
|
||||||
|
head,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: "The conversation is too large",
|
||||||
|
detail:
|
||||||
|
"The document and search results exceeded the model's context window. Start a new chat or narrow the request.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Out of credits / quota / payment required.
|
||||||
|
if (
|
||||||
|
code === "402" ||
|
||||||
|
/payment required|insufficient (?:credits|quota|funds|balance)|out of credits|quota (?:exceeded|exhausted)/i.test(
|
||||||
|
head,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: "AI provider quota exceeded",
|
||||||
|
detail:
|
||||||
|
"The AI provider rejected the request because of credits or quota. Check the provider account.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Authentication / bad API key.
|
||||||
|
if (
|
||||||
|
code === "401" ||
|
||||||
|
/\bunauthorized\b|invalid api key|user not found|\bauthentication\b/i.test(head)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: "AI provider authentication failed",
|
||||||
|
detail:
|
||||||
|
"The AI provider rejected the credentials. Ask an administrator to check the API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
117
apps/client/src/features/ai-chat/utils/markdown.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the internal-link neutralization used by the anonymous public
|
||||||
|
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
|
||||||
|
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
|
||||||
|
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
|
||||||
|
* routes. With the flag ON those links are made inert (href removed) while the
|
||||||
|
* visible text and the rest of the markdown formatting are preserved; genuinely
|
||||||
|
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
|
||||||
|
* with a safe rel/target, while absolute links back to our OWN origin are
|
||||||
|
* neutralized too. With the flag OFF (internal default) links keep their href so
|
||||||
|
* the authenticated chat is unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Parse the rendered HTML and return the first <a> element (or null). */
|
||||||
|
function firstAnchor(html: string): HTMLAnchorElement | null {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
return doc.querySelector("a");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderChatMarkdown — internal link neutralization", () => {
|
||||||
|
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
|
||||||
|
const html = renderChatMarkdown("[x](/p/abc)", {
|
||||||
|
neutralizeInternalLinks: true,
|
||||||
|
});
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a!.hasAttribute("href")).toBe(false);
|
||||||
|
expect(a!.hasAttribute("target")).toBe(false);
|
||||||
|
// Visible link text is preserved.
|
||||||
|
expect(a!.textContent).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes bare-fragment links when the flag is ON", () => {
|
||||||
|
const html = renderChatMarkdown("[here](#section)", {
|
||||||
|
neutralizeInternalLinks: true,
|
||||||
|
});
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a!.hasAttribute("href")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
|
||||||
|
const html = renderChatMarkdown("[y](https://example.com/x)", {
|
||||||
|
neutralizeInternalLinks: true,
|
||||||
|
});
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a!.getAttribute("href")).toBe("https://example.com/x");
|
||||||
|
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||||
|
expect(a!.getAttribute("target")).toBe("_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
|
||||||
|
// An LLM can emit an absolute URL back at our own host (e.g.
|
||||||
|
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
|
||||||
|
// relative `/p/...` link, not kept clickable as if it were external.
|
||||||
|
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||||
|
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
|
||||||
|
neutralizeInternalLinks: true,
|
||||||
|
});
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a!.hasAttribute("href")).toBe(false);
|
||||||
|
expect(a!.hasAttribute("target")).toBe(false);
|
||||||
|
expect(a!.textContent).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
|
||||||
|
// javascript:, data:, and protocol-relative `//...` must never stay
|
||||||
|
// clickable on the anonymous share — they are not genuinely external
|
||||||
|
// http(s) links to a different host, so the href is dropped (or sanitized
|
||||||
|
// away entirely by DOMPurify).
|
||||||
|
for (const markdown of [
|
||||||
|
"[a](javascript:alert(1))",
|
||||||
|
"[b](data:text/html,<script>alert(1)</script>)",
|
||||||
|
"[c](//evil.com/x)",
|
||||||
|
]) {
|
||||||
|
const html = renderChatMarkdown(markdown, {
|
||||||
|
neutralizeInternalLinks: true,
|
||||||
|
});
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
// Either the anchor was stripped of its href, or DOMPurify removed the
|
||||||
|
// unsafe href outright; in both cases nothing dangerous remains.
|
||||||
|
if (a !== null) {
|
||||||
|
expect(a.hasAttribute("href")).toBe(false);
|
||||||
|
expect(a.hasAttribute("target")).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
|
||||||
|
const html = renderChatMarkdown("[x](/p/abc)");
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
|
||||||
|
const ownOrigin = `${window.location.origin}/p/abc`;
|
||||||
|
const html = renderChatMarkdown(`[x](${ownOrigin})`);
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a!.getAttribute("href")).toBe(ownOrigin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
|
||||||
|
// A neutralizing render first, then an internal render: the internal link
|
||||||
|
// must survive (the hook is removed after the share render).
|
||||||
|
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
|
||||||
|
const html = renderChatMarkdown("[x](/p/abc)");
|
||||||
|
const a = firstAnchor(html);
|
||||||
|
expect(a!.getAttribute("href")).toBe("/p/abc");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,66 @@
|
|||||||
import { markdownToHtml } from "@docmost/editor-ext";
|
import { markdownToHtml } from "@docmost/editor-ext";
|
||||||
import DOMPurify from "dompurify";
|
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
|
* 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
|
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
||||||
@@ -12,9 +72,31 @@ import DOMPurify from "dompurify";
|
|||||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
* synchronously, but we guard the Promise case by returning a safe empty string
|
||||||
* for that branch (the caller renders the raw text fallback instead).
|
* for that branch (the caller renders the raw text fallback instead).
|
||||||
*/
|
*/
|
||||||
export function renderChatMarkdown(markdown: string): string {
|
export function renderChatMarkdown(
|
||||||
|
markdown: string,
|
||||||
|
options: RenderChatMarkdownOptions = {},
|
||||||
|
): string {
|
||||||
if (!markdown) return "";
|
if (!markdown) return "";
|
||||||
const html = markdownToHtml(markdown);
|
const html = markdownToHtml(markdown);
|
||||||
if (typeof html !== "string") return "";
|
if (typeof html !== "string") return "";
|
||||||
return DOMPurify.sanitize(html);
|
|
||||||
|
if (!options.neutralizeInternalLinks) {
|
||||||
|
// Internal chat: unchanged behavior, no hook registered.
|
||||||
|
return DOMPurify.sanitize(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public share: register the neutralization hook only for THIS sanitize call,
|
||||||
|
// then remove it immediately so it can never affect the internal chat (hooks
|
||||||
|
// are global on the shared DOMPurify instance).
|
||||||
|
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
|
||||||
|
try {
|
||||||
|
return DOMPurify.sanitize(html);
|
||||||
|
} finally {
|
||||||
|
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
|
||||||
|
// robust to any other afterSanitizeAttributes hook registered in future.
|
||||||
|
DOMPurify.removeHook(
|
||||||
|
"afterSanitizeAttributes",
|
||||||
|
neutralizeInternalLinksHook,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/client/src/features/ai-chat/utils/message-content.ts
Normal file
39
apps/client/src/features/ai-chat/utils/message-content.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an assistant `UIMessage` has anything visible to render in its bubble.
|
||||||
|
*
|
||||||
|
* This mirrors MessageItem's render decisions EXACTLY and is the single source of
|
||||||
|
* truth shared by both MessageItem (to decide whether to render the bubble at all)
|
||||||
|
* and typingIndicatorShowsName (to decide whether the standalone "Thinking…"
|
||||||
|
* indicator owns the dimmed agent-name label). Keeping one helper guarantees the
|
||||||
|
* two stay in lockstep, so exactly one element owns the name during the pre-content
|
||||||
|
* "thinking" gap and the layout never reflows mid-stream.
|
||||||
|
*
|
||||||
|
* An assistant message has visible content iff ANY of:
|
||||||
|
* - a `text` part whose trimmed length > 0 (non-empty markdown), OR
|
||||||
|
* - ANY tool part (`isToolPart(part.type)`), OR
|
||||||
|
* - `metadata.error` is truthy (a persisted error banner renders), OR
|
||||||
|
* - `metadata.finishReason === "aborted"` (a persisted "response stopped" notice).
|
||||||
|
* Empty/whitespace-only text parts and unsupported part kinds (reasoning, sources,
|
||||||
|
* files, step-start) are NOT visible.
|
||||||
|
*/
|
||||||
|
export function assistantMessageHasVisibleContent(message: UIMessage): boolean {
|
||||||
|
const meta = message.metadata as
|
||||||
|
| { error?: string; finishReason?: string }
|
||||||
|
| undefined;
|
||||||
|
// Persisted errored/aborted turns always render their banner/notice.
|
||||||
|
if (meta?.error) return true;
|
||||||
|
if (meta?.finishReason === "aborted") return true;
|
||||||
|
|
||||||
|
// `parts` may be empty (a nascent streaming message has no parts yet).
|
||||||
|
// `?? []` also guards a sparse/partial message object (metadata-only, no
|
||||||
|
// `parts`) so iterating cannot throw — it does not change behavior for any
|
||||||
|
// current input.
|
||||||
|
for (const part of message.parts ?? []) {
|
||||||
|
if (part.type === "text" && part.text.trim().length > 0) return true;
|
||||||
|
if (isToolPart(part.type)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
241
apps/client/src/features/ai-chat/utils/message-signature.test.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for `messageSignature`, the cheap per-message content
|
||||||
|
* signature that drives MessageItem's memo (a streaming row's signature must
|
||||||
|
* change on every delta so it re-renders, while a finalized row's stays stable
|
||||||
|
* so it is skipped). Each test exercises ONE change signal and asserts it flips
|
||||||
|
* the signature; a content-identical clone must keep an EQUAL signature.
|
||||||
|
*
|
||||||
|
* The signature embeds `message.id` and `message.role`, so the `msg` factory
|
||||||
|
* uses a FIXED id/role here (not `Math.random()`): otherwise two messages with
|
||||||
|
* identical content would get different signatures and the negative case would
|
||||||
|
* be impossible to express.
|
||||||
|
*/
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
metadata?: unknown,
|
||||||
|
): UIMessage =>
|
||||||
|
({
|
||||||
|
id: "m1",
|
||||||
|
role: "assistant",
|
||||||
|
parts,
|
||||||
|
metadata,
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
describe("messageSignature", () => {
|
||||||
|
it("changes when a text part grows", () => {
|
||||||
|
const before = msg([{ type: "text", text: "alpha" }]);
|
||||||
|
const after = msg([{ type: "text", text: "alpha beta" }]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a new part is appended", () => {
|
||||||
|
const before = msg([{ type: "text", text: "alpha" }]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "text", text: "beta" },
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a part's state flips", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "input-streaming" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a tool part gains an output", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-available",
|
||||||
|
output: { ok: true },
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a part gains an errorText", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-error" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "boom",
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when usage.reasoningTokens arrives on finish-step (text/state already frozen)", () => {
|
||||||
|
// The specifically-commented edge case: the authoritative turn total lands on
|
||||||
|
// the final finish-step AFTER the reasoning text length and state are frozen.
|
||||||
|
// Only the token count appears between these two snapshots, so the signature
|
||||||
|
// MUST still flip — otherwise the "Thinking · N tokens" header would never
|
||||||
|
// snap from the live estimate to the exact figure.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "reasoning", text: "thinking", state: "done" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg(
|
||||||
|
[{ type: "reasoning", text: "thinking", state: "done" } as never],
|
||||||
|
{ usage: { reasoningTokens: 42 } },
|
||||||
|
);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when metadata.error appears", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when metadata.finishReason changes (e.g. to 'aborted')", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "stop",
|
||||||
|
});
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "aborted",
|
||||||
|
});
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is UNCHANGED for a content-identical clone (different object, same values)", () => {
|
||||||
|
// A finalized row that is re-created as a fresh object (different parts array
|
||||||
|
// by reference, same parts by value) must keep an EQUAL signature, so the
|
||||||
|
// memo skips re-rendering it.
|
||||||
|
const a = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||||
|
]);
|
||||||
|
const b = msg([
|
||||||
|
{ type: "text", text: "alpha" },
|
||||||
|
{ type: "tool-getPage", state: "output-available", output: { ok: true } } as never,
|
||||||
|
]);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(messageSignature(a)).toBe(messageSignature(b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-part-kind coupling guard for the load-bearing invariant documented at the
|
||||||
|
* top of message-signature.ts: the signature MUST sample every VISIBLE field the
|
||||||
|
* MessageItem render body draws, or the memo freezes a stale row. This is an
|
||||||
|
* executable lock for the part kinds rendered TODAY — read alongside
|
||||||
|
* `MessageItem` (message-item.tsx) and the `assistantMessageHasVisibleContent`
|
||||||
|
* helper (message-content.ts), which "mirrors MessageItem's render decisions
|
||||||
|
* EXACTLY". For each kind, mutating a field the render body DRAWS must flip the
|
||||||
|
* signature. If a new visible field is rendered without being added here AND to
|
||||||
|
* the signature, the corresponding assertion below should fail — that is the
|
||||||
|
* guard. (This intentionally stops short of the render-descriptor refactor:
|
||||||
|
* adding a part kind or a visible field still requires a human to extend both
|
||||||
|
* the signature and this block.)
|
||||||
|
*/
|
||||||
|
describe("messageSignature ↔ render coupling (per visible part kind)", () => {
|
||||||
|
describe("text part — render draws part.text (MarkdownPart text={part.text})", () => {
|
||||||
|
it("flips when the visible text changes", () => {
|
||||||
|
// Streaming is append-only, so the visible text only grows; the signature
|
||||||
|
// samples its length, so the growth is the change signal.
|
||||||
|
const before = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const after = msg([{ type: "text", text: "answer extended" }]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reasoning part — render draws text + tokens (ReasoningBlock)", () => {
|
||||||
|
it("flips when the visible reasoning text changes", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "reasoning", text: "think", state: "streaming" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "reasoning", text: "think harder", state: "streaming" } as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when the visible token count (metadata.usage.reasoningTokens) lands", () => {
|
||||||
|
// The header's "Thinking · N tokens" reads reasoningTokensForPart, fed by
|
||||||
|
// metadata.usage.reasoningTokens — a VISIBLE field that arrives on the final
|
||||||
|
// finish-step after text length and state are frozen.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "reasoning", text: "think", state: "done" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg(
|
||||||
|
[{ type: "reasoning", text: "think", state: "done" } as never],
|
||||||
|
{ usage: { reasoningTokens: 99 } },
|
||||||
|
);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool-* part — render draws state/errorText/citations (ToolCallCard)", () => {
|
||||||
|
it("flips when the run state changes (running ↔ done icon + label)", () => {
|
||||||
|
// toolRunState(part.state) selects the spinner/check/error icon.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "input-available" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when output arrives (drives the rendered citation links)", () => {
|
||||||
|
// toolCitations reads part.output to render the "/p/{id}" anchors.
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-available" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-available",
|
||||||
|
output: { id: "page-1", title: "Doc" },
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when errorText appears (the visible red error detail line)", () => {
|
||||||
|
const before = msg([
|
||||||
|
{ type: "tool-getPage", state: "output-error" } as never,
|
||||||
|
]);
|
||||||
|
const after = msg([
|
||||||
|
{
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "permission denied",
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metadata banners — render draws error / aborted notices", () => {
|
||||||
|
it("flips when metadata.error appears (ChatErrorAlert banner)", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }]);
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], { error: "boom" });
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips when metadata.finishReason becomes 'aborted' (ChatStoppedNotice)", () => {
|
||||||
|
const before = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "stop",
|
||||||
|
});
|
||||||
|
const after = msg([{ type: "text", text: "answer" }], {
|
||||||
|
finishReason: "aborted",
|
||||||
|
});
|
||||||
|
expect(messageSignature(before)).not.toBe(messageSignature(after));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
44
apps/client/src/features/ai-chat/utils/message-signature.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
/** Cheap content signature for one message: changes iff something VISIBLE in the
|
||||||
|
* row changed. Streaming is APPEND-ONLY (text parts only grow, parts are only
|
||||||
|
* appended, a tool/text part flips state once), so a per-part [type, text
|
||||||
|
* length, state, error/output presence] tuple + the persisted metadata
|
||||||
|
* (error/finishReason) is a sufficient change signal without comparing full
|
||||||
|
* strings on every delta. WARNING — load-bearing for the MessageItem memo:
|
||||||
|
* if a future part kind's VISIBLE content can change WITHOUT changing [type,
|
||||||
|
* text length, state, error/output presence] (e.g. a tool that streams
|
||||||
|
* `preliminary` output, or a client-side regenerate that edits a finalized
|
||||||
|
* row in place), extend this signature or the memo will freeze a stale row. */
|
||||||
|
export function messageSignature(message: UIMessage): string {
|
||||||
|
const parts = message.parts
|
||||||
|
.map((p) => {
|
||||||
|
const any = p as {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
state?: string;
|
||||||
|
errorText?: string;
|
||||||
|
output?: unknown;
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
any.type,
|
||||||
|
any.text?.length ?? 0,
|
||||||
|
any.state ?? "",
|
||||||
|
any.errorText ? 1 : 0,
|
||||||
|
any.output !== undefined ? 1 : 0,
|
||||||
|
].join(":");
|
||||||
|
})
|
||||||
|
.join("|");
|
||||||
|
const meta = message.metadata as
|
||||||
|
| { error?: string; finishReason?: string; usage?: { reasoningTokens?: number } }
|
||||||
|
| undefined;
|
||||||
|
// `usage.reasoningTokens` is neither append-only nor part-bound: the authoritative
|
||||||
|
// turn total arrives on the final `finish-step` AFTER the reasoning text length and
|
||||||
|
// state are already frozen. Without it in the signature the row's signature would be
|
||||||
|
// unchanged at that point and the re-render skipped, so the "Thinking · N tokens"
|
||||||
|
// header (reasoningTokensForPart) would keep the live estimate instead of snapping
|
||||||
|
// to the exact figure.
|
||||||
|
return `${message.id}#${message.role}#${parts}#${meta?.error ?? ""}#${
|
||||||
|
meta?.finishReason ?? ""
|
||||||
|
}#${meta?.usage?.reasoningTokens ?? ""}`;
|
||||||
|
}
|
||||||
107
apps/client/src/features/ai-chat/utils/queue-helpers.test.ts
Normal file
107
apps/client/src/features/ai-chat/utils/queue-helpers.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
enqueueMessage,
|
||||||
|
dequeue,
|
||||||
|
removeQueuedById,
|
||||||
|
type QueuedMessage,
|
||||||
|
} from "./queue-helpers";
|
||||||
|
|
||||||
|
describe("enqueueMessage", () => {
|
||||||
|
it("appends a message to the end of the queue", () => {
|
||||||
|
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
||||||
|
const next = enqueueMessage(queue, { id: "b", text: "second" });
|
||||||
|
expect(next).toEqual([
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input queue", () => {
|
||||||
|
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
||||||
|
enqueueMessage(queue, { id: "b", text: "second" });
|
||||||
|
expect(queue).toEqual([{ id: "a", text: "first" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dequeue", () => {
|
||||||
|
it("returns {head:null, rest:[]} for an empty queue", () => {
|
||||||
|
expect(dequeue([])).toEqual({ head: null, rest: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the first item as head and the remainder as rest", () => {
|
||||||
|
const queue: QueuedMessage[] = [
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
{ id: "c", text: "third" },
|
||||||
|
];
|
||||||
|
const { head, rest } = dequeue(queue);
|
||||||
|
expect(head).toEqual({ id: "a", text: "first" });
|
||||||
|
expect(rest).toEqual([
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
{ id: "c", text: "third" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input queue", () => {
|
||||||
|
const queue: QueuedMessage[] = [
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
];
|
||||||
|
dequeue(queue);
|
||||||
|
expect(queue).toEqual([
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeQueuedById", () => {
|
||||||
|
it("removes the matching id and leaves the others", () => {
|
||||||
|
const queue: QueuedMessage[] = [
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
{ id: "c", text: "third" },
|
||||||
|
];
|
||||||
|
const next = removeQueuedById(queue, "b");
|
||||||
|
expect(next).toEqual([
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "c", text: "third" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an equivalent list when the id is not present", () => {
|
||||||
|
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
||||||
|
expect(removeQueuedById(queue, "missing")).toEqual([
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input queue", () => {
|
||||||
|
const queue: QueuedMessage[] = [
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
];
|
||||||
|
removeQueuedById(queue, "a");
|
||||||
|
expect(queue).toEqual([
|
||||||
|
{ id: "a", text: "first" },
|
||||||
|
{ id: "b", text: "second" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FIFO order", () => {
|
||||||
|
it("preserves order across enqueue -> dequeue", () => {
|
||||||
|
let queue: QueuedMessage[] = [];
|
||||||
|
queue = enqueueMessage(queue, { id: "1", text: "one" });
|
||||||
|
queue = enqueueMessage(queue, { id: "2", text: "two" });
|
||||||
|
queue = enqueueMessage(queue, { id: "3", text: "three" });
|
||||||
|
|
||||||
|
const order: string[] = [];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { head, rest } = dequeue(queue);
|
||||||
|
if (head) order.push(head.text);
|
||||||
|
queue = rest;
|
||||||
|
}
|
||||||
|
expect(order).toEqual(["one", "two", "three"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/client/src/features/ai-chat/utils/queue-helpers.ts
Normal file
34
apps/client/src/features/ai-chat/utils/queue-helpers.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Pure FIFO helpers for the AI-chat "send while the agent is busy" queue.
|
||||||
|
// Kept side-effect free so they can be unit-tested without React.
|
||||||
|
|
||||||
|
export interface QueuedMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a message to the end of the queue (returns a new array). */
|
||||||
|
export function enqueueMessage(
|
||||||
|
queue: QueuedMessage[],
|
||||||
|
message: QueuedMessage,
|
||||||
|
): QueuedMessage[] {
|
||||||
|
return [...queue, message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the queue into its first item (`head`) and the remainder (`rest`).
|
||||||
|
* `head` is null when the queue is empty. Does not mutate the input. */
|
||||||
|
export function dequeue(queue: QueuedMessage[]): {
|
||||||
|
head: QueuedMessage | null;
|
||||||
|
rest: QueuedMessage[];
|
||||||
|
} {
|
||||||
|
if (queue.length === 0) return { head: null, rest: [] };
|
||||||
|
const [head, ...rest] = queue;
|
||||||
|
return { head, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the queued message with the given id (returns a new array). */
|
||||||
|
export function removeQueuedById(
|
||||||
|
queue: QueuedMessage[],
|
||||||
|
id: string,
|
||||||
|
): QueuedMessage[] {
|
||||||
|
return queue.filter((m) => m.id !== id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for `reasoningTokensForPart`, the #151 anti-double-count
|
||||||
|
* rule: the authoritative `usage.reasoningTokens` is the TURN TOTAL, so it may
|
||||||
|
* only be attributed when the turn has exactly one reasoning part. With multiple
|
||||||
|
* reasoning parts (or no authoritative usage) every part falls back to its own
|
||||||
|
* per-part estimate, signalled here by `undefined`.
|
||||||
|
*/
|
||||||
|
const msg = (
|
||||||
|
parts: UIMessage["parts"],
|
||||||
|
metadata?: unknown,
|
||||||
|
): UIMessage =>
|
||||||
|
({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
parts,
|
||||||
|
metadata,
|
||||||
|
}) as UIMessage;
|
||||||
|
|
||||||
|
describe("reasoningTokensForPart", () => {
|
||||||
|
it("single reasoning part -> the authoritative turn total", () => {
|
||||||
|
const m = msg(
|
||||||
|
[
|
||||||
|
{ type: "reasoning", text: "thinking…" } as never,
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
],
|
||||||
|
{ usage: { reasoningTokens: 42 } },
|
||||||
|
);
|
||||||
|
expect(reasoningTokensForPart(m)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple reasoning parts -> undefined (each estimates on its own)", () => {
|
||||||
|
const m = msg(
|
||||||
|
[
|
||||||
|
{ type: "reasoning", text: "step one" } as never,
|
||||||
|
{ type: "reasoning", text: "step two" } as never,
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
],
|
||||||
|
{ usage: { reasoningTokens: 99 } },
|
||||||
|
);
|
||||||
|
// Even with an authoritative total, two reasoning parts must each estimate
|
||||||
|
// (attributing the total to one would double-count against the other).
|
||||||
|
expect(reasoningTokensForPart(m)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no authoritative usage -> undefined even for a single reasoning part", () => {
|
||||||
|
const m = msg([
|
||||||
|
{ type: "reasoning", text: "thinking…" } as never,
|
||||||
|
{ type: "text", text: "answer" },
|
||||||
|
]);
|
||||||
|
expect(reasoningTokensForPart(m)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/client/src/features/ai-chat/utils/reasoning-tokens.ts
Normal file
34
apps/client/src/features/ai-chat/utils/reasoning-tokens.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the authoritative reasoning token count to attribute to a single
|
||||||
|
* `reasoning` part of an assistant message — or `undefined` when the part should
|
||||||
|
* fall back to its own per-part estimate.
|
||||||
|
*
|
||||||
|
* `usage.reasoningTokens` is the TURN TOTAL, so it may only be attributed to a
|
||||||
|
* block when the turn has exactly ONE reasoning part (the common one-step turn):
|
||||||
|
* then that block can show the exact figure. With MULTIPLE reasoning parts (a
|
||||||
|
* multi-step agent turn) every block must fall back to its own estimate —
|
||||||
|
* attributing the turn total to one of them would double-count against the
|
||||||
|
* others' estimates (#151 review anti-double-count rule). When there is no
|
||||||
|
* authoritative usage at all, every part estimates.
|
||||||
|
*
|
||||||
|
* Returns the authoritative `reasoningTokens` only for the single-reasoning-part
|
||||||
|
* case; `undefined` otherwise (the caller estimates from the part text).
|
||||||
|
*/
|
||||||
|
export function reasoningTokensForPart(
|
||||||
|
message: UIMessage,
|
||||||
|
): number | undefined {
|
||||||
|
const reasoningTokens = (
|
||||||
|
message.metadata as { usage?: { reasoningTokens?: number } } | undefined
|
||||||
|
)?.usage?.reasoningTokens;
|
||||||
|
|
||||||
|
const reasoningPartCount = (message.parts ?? []).reduce(
|
||||||
|
(acc, p) => (p.type === "reasoning" ? acc + 1 : acc),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exactly one reasoning part -> attribute the authoritative turn total to it.
|
||||||
|
// Otherwise (zero or multiple) each part estimates on its own.
|
||||||
|
return reasoningPartCount === 1 ? reasoningTokens : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color";
|
||||||
|
|
||||||
|
describe("roleCardColor", () => {
|
||||||
|
it("has a 10-color palette", () => {
|
||||||
|
expect(ROLE_CARD_PALETTE).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps index 0 to the first palette color (blue)", () => {
|
||||||
|
expect(roleCardColor(0)).toBe("blue");
|
||||||
|
expect(roleCardColor(1)).toBe("grape");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around at the end of the palette", () => {
|
||||||
|
expect(roleCardColor(10)).toBe("blue");
|
||||||
|
expect(roleCardColor(11)).toBe("grape");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe for negative indices", () => {
|
||||||
|
expect(roleCardColor(-1)).toBe("violet");
|
||||||
|
expect(roleCardColor(-10)).toBe("blue");
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Fixed Mantine color palette for the new-chat role cards. Cards cycle through
|
||||||
|
// these names by index; the colors are applied via theme-aware Mantine CSS vars
|
||||||
|
// (`--mantine-color-<name>-light` etc.) so they are correct in both themes.
|
||||||
|
// Universal assistant uses neutral `gray` separately (not part of this palette).
|
||||||
|
export const ROLE_CARD_PALETTE = [
|
||||||
|
"blue",
|
||||||
|
"grape",
|
||||||
|
"teal",
|
||||||
|
"orange",
|
||||||
|
"pink",
|
||||||
|
"cyan",
|
||||||
|
"lime",
|
||||||
|
"indigo",
|
||||||
|
"red",
|
||||||
|
"violet",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a palette color name for a role card by its index. Cycles through the
|
||||||
|
* palette and is safe for negative indices.
|
||||||
|
*/
|
||||||
|
export function roleCardColor(index: number): string {
|
||||||
|
const len = ROLE_CARD_PALETTE.length;
|
||||||
|
return ROLE_CARD_PALETTE[((index % len) + len) % len];
|
||||||
|
}
|
||||||
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts";
|
||||||
|
|
||||||
|
const DEFAULT = "Take a look at the current document";
|
||||||
|
|
||||||
|
// Covers the three-way handleRolePick behavior (issue #149) without mounting the
|
||||||
|
// chat-thread component — the logic lives in these pure helpers.
|
||||||
|
describe("roleLaunchMessage", () => {
|
||||||
|
it("autoStart=true + custom launchMessage -> the trimmed custom text", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage(
|
||||||
|
{ autoStart: true, launchMessage: " Draft a plan " },
|
||||||
|
DEFAULT,
|
||||||
|
),
|
||||||
|
).toBe("Draft a plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=true + empty launchMessage -> the default fallback", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT),
|
||||||
|
).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT),
|
||||||
|
).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=true + null launchMessage -> the default fallback", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT),
|
||||||
|
).toBe(DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autoStart=false -> null (bind only, send nothing) regardless of message", () => {
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage(
|
||||||
|
{ autoStart: false, launchMessage: "ignored" },
|
||||||
|
DEFAULT,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression guard for #149: the "picked, not sent" flag must reset when the
|
||||||
|
// user starts a fresh chat after an autoStart=false pick. On pre-fix code there
|
||||||
|
// was no reset, so the flag stayed stuck and the role cards never returned —
|
||||||
|
// this is exactly the `true` case below (which the old code never acted on).
|
||||||
|
describe("shouldResetRolePicked", () => {
|
||||||
|
it("resets when the thread is empty and the bound role was cleared (New chat)", () => {
|
||||||
|
// chatId still null, roleId cleared by the parent, flag stuck -> reset.
|
||||||
|
expect(shouldResetRolePicked(null, null, true)).toBe(true);
|
||||||
|
expect(shouldResetRolePicked(null, undefined, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => {
|
||||||
|
// Right after the autoStart=false pick, roleId is the picked role -> keep hidden.
|
||||||
|
expect(shouldResetRolePicked(null, "role-1", true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reset once the chat exists (a message was sent / chat created)", () => {
|
||||||
|
expect(shouldResetRolePicked("chat-1", null, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the flag is already false", () => {
|
||||||
|
expect(shouldResetRolePicked(null, null, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what (if anything) to auto-send when an agent role card is picked
|
||||||
|
* (issue #149). Extracted as a pure function so the three-way behavior is
|
||||||
|
* unit-testable without mounting the chat-thread component:
|
||||||
|
* - autoStart=false -> null (bind the role only, send nothing)
|
||||||
|
* - autoStart=true + message -> the trimmed custom launchMessage
|
||||||
|
* - autoStart=true + empty/null -> the default fallback text
|
||||||
|
*/
|
||||||
|
export function roleLaunchMessage(
|
||||||
|
role: Pick<IAiRole, "autoStart" | "launchMessage">,
|
||||||
|
defaultText: string,
|
||||||
|
): string | null {
|
||||||
|
if (!role.autoStart) return null;
|
||||||
|
return role.launchMessage?.trim() || defaultText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`)
|
||||||
|
* should reset to false. After an autoStart=false pick the thread shows the
|
||||||
|
* composer with chatId still null; when the user then starts a fresh chat the
|
||||||
|
* parent clears the bound role (roleId -> null) but chatId stays null, so the
|
||||||
|
* thread never remounts and the flag would otherwise stay set — hiding the role
|
||||||
|
* cards forever. Reset exactly in that state; a still-bound role (roleId set)
|
||||||
|
* keeps the cards hidden. (Regression guard for #149.)
|
||||||
|
*/
|
||||||
|
export function shouldResetRolePicked(
|
||||||
|
chatId: string | null,
|
||||||
|
roleId: string | null | undefined,
|
||||||
|
rolePickedNoSend: boolean,
|
||||||
|
): boolean {
|
||||||
|
return chatId === null && roleId == null && rolePickedNoSend;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
newThread,
|
||||||
|
switchThread,
|
||||||
|
adoptThread,
|
||||||
|
threadSessionReducer,
|
||||||
|
} from "./thread-identity";
|
||||||
|
|
||||||
|
describe("newThread", () => {
|
||||||
|
it("uses the supplied key and has no chat id yet", () => {
|
||||||
|
expect(newThread("new-abc")).toEqual({ key: "new-abc", chatId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("switchThread", () => {
|
||||||
|
it("switches to an existing chat: key becomes the chat id", () => {
|
||||||
|
expect(switchThread("chat-1")).toEqual({
|
||||||
|
key: "chat-1",
|
||||||
|
chatId: "chat-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adoptThread", () => {
|
||||||
|
// Key UNCHANGED (no remount) + chatId moved null->realId. The unchanged key is
|
||||||
|
// what keeps the live useChat store alive; the matching chatId is what makes the
|
||||||
|
// window's render-phase reconciler (activeChatId !== thread.chatId) treat the
|
||||||
|
// adopted thread as already-in-sync rather than a switch.
|
||||||
|
it("adopts in place for a new chat: keeps the key, sets the chat id", () => {
|
||||||
|
const prev = newThread("new-abc");
|
||||||
|
expect(adoptThread(prev, "chat-1")).toEqual({
|
||||||
|
key: "new-abc",
|
||||||
|
chatId: "chat-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an already-persisted chat", () => {
|
||||||
|
const prev: { key: string; chatId: string | null } = {
|
||||||
|
key: "chat-1",
|
||||||
|
chatId: "chat-1",
|
||||||
|
};
|
||||||
|
expect(adoptThread(prev, "chat-2")).toBe(prev);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("threadSessionReducer", () => {
|
||||||
|
it("reconcile to an existing id switches (key becomes the id)", () => {
|
||||||
|
const next = threadSessionReducer(newThread("new-abc"), {
|
||||||
|
type: "reconcile",
|
||||||
|
chatId: "chat-1",
|
||||||
|
newKey: "new-xyz",
|
||||||
|
});
|
||||||
|
expect(next).toEqual({ key: "chat-1", chatId: "chat-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconcile to null starts a fresh new thread with the supplied key", () => {
|
||||||
|
const next = threadSessionReducer(switchThread("chat-1"), {
|
||||||
|
type: "reconcile",
|
||||||
|
chatId: null,
|
||||||
|
newKey: "new-xyz",
|
||||||
|
});
|
||||||
|
expect(next).toEqual({ key: "new-xyz", chatId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopt on a new thread keeps the key and sets the id", () => {
|
||||||
|
const next = threadSessionReducer(newThread("new-abc"), {
|
||||||
|
type: "adopt",
|
||||||
|
chatId: "chat-1",
|
||||||
|
});
|
||||||
|
expect(next).toEqual({ key: "new-abc", chatId: "chat-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adopt on a persisted thread is a no-op", () => {
|
||||||
|
const prev = switchThread("chat-1");
|
||||||
|
expect(threadSessionReducer(prev, { type: "adopt", chatId: "chat-2" })).toBe(
|
||||||
|
prev,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/client/src/features/ai-chat/utils/thread-identity.ts
Normal file
73
apps/client/src/features/ai-chat/utils/thread-identity.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Pure transitions for the AI-chat thread's identity: the single source of
|
||||||
|
* truth tying ChatThread's mount key to the chat id that mounted thread holds.
|
||||||
|
*
|
||||||
|
* The window keeps exactly ONE of these in state. Consolidating the mount key
|
||||||
|
* and the live thread's chat id into one atomic value makes the "stale chat id
|
||||||
|
* vs key" state unrepresentable: every change goes through one of the explicit
|
||||||
|
* transitions below, so the key and chatId can never silently diverge.
|
||||||
|
*
|
||||||
|
* - `newThread`/`switchThread` produce a key that forces a remount (+ reseed):
|
||||||
|
* `newThread` for a brand-new (id-less) chat, `switchThread` for an existing
|
||||||
|
* one. The caller picks which based on whether there is a chat id.
|
||||||
|
* - `adoptThread` keeps the SAME key so a brand-new chat learns its real id
|
||||||
|
* WITHOUT remounting (the live useChat store, holding the just-finished turn,
|
||||||
|
* is preserved and the next turn sends the real chatId).
|
||||||
|
*
|
||||||
|
* `newThread` takes the session key from the impure `generateId()` at the call
|
||||||
|
* site so these stay pure and unit-testable.
|
||||||
|
*/
|
||||||
|
export type ThreadIdentity = { key: string; chatId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A brand-new chat: a fresh session key and no chat id yet. `newKey` is
|
||||||
|
* supplied by the caller (generateId() is impure) so this stays pure/testable.
|
||||||
|
*/
|
||||||
|
export function newThread(newKey: string): ThreadIdentity {
|
||||||
|
return { key: newKey, chatId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to an EXISTING chat: the mount key becomes the chat id, forcing a
|
||||||
|
* remount + reseed from the persisted history. (A switch to a brand-new chat
|
||||||
|
* goes through `newThread` instead — there is no id to key on.)
|
||||||
|
*/
|
||||||
|
export function switchThread(chatId: string): ThreadIdentity {
|
||||||
|
return { key: chatId, chatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place adoption: a brand-new chat (`prev.chatId === null`) learns its real
|
||||||
|
* id WITHOUT remounting — keep the SAME key, set the chat id. If `prev` already
|
||||||
|
* has a chatId (not a new chat), this is a no-op (returns `prev`): adoption only
|
||||||
|
* applies to an as-yet-unadopted new thread.
|
||||||
|
*/
|
||||||
|
export function adoptThread(prev: ThreadIdentity, chatId: string): ThreadIdentity {
|
||||||
|
return prev.chatId === null ? { key: prev.key, chatId } : prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-identity transitions as a reducer action. See `threadSessionReducer`.
|
||||||
|
*/
|
||||||
|
export type ThreadSessionAction =
|
||||||
|
| { type: "reconcile"; chatId: string | null; newKey: string }
|
||||||
|
| { type: "adopt"; chatId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for thread-identity transitions. `reconcile` handles a
|
||||||
|
* genuine switch (user OR external atom write) -> remount; `adopt` moves a brand-
|
||||||
|
* new chat to its real id in place (no remount).
|
||||||
|
*/
|
||||||
|
export function threadSessionReducer(
|
||||||
|
state: ThreadIdentity,
|
||||||
|
action: ThreadSessionAction,
|
||||||
|
): ThreadIdentity {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reconcile":
|
||||||
|
return action.chatId === null
|
||||||
|
? newThread(action.newKey)
|
||||||
|
: switchThread(action.chatId);
|
||||||
|
case "adopt":
|
||||||
|
return adoptThread(state, action.chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
100
apps/client/src/features/ai-chat/utils/tool-parts.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
toolCitations,
|
||||||
|
toolRunState,
|
||||||
|
type ToolUiPart,
|
||||||
|
} from "./tool-parts";
|
||||||
|
|
||||||
|
describe("toolCitations", () => {
|
||||||
|
it("emits one citation per searchPages item with a /p/{id} href", () => {
|
||||||
|
const part: ToolUiPart = {
|
||||||
|
type: "tool-searchPages",
|
||||||
|
state: "output-available",
|
||||||
|
output: [
|
||||||
|
{ id: "p1", title: "First" },
|
||||||
|
{ id: "p2", title: "Second" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(toolCitations(part)).toEqual([
|
||||||
|
{ pageId: "p1", title: "First", href: "/p/p1" },
|
||||||
|
{ pageId: "p2", title: "Second", href: "/p/p2" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops searchPages items missing an id", () => {
|
||||||
|
const part: ToolUiPart = {
|
||||||
|
type: "tool-searchPages",
|
||||||
|
state: "output-available",
|
||||||
|
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
|
||||||
|
};
|
||||||
|
expect(toolCitations(part)).toEqual([
|
||||||
|
{ pageId: "p2", title: "Kept", href: "/p/p2" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
|
||||||
|
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
|
||||||
|
const part: ToolUiPart = {
|
||||||
|
type: "tool-updatePageContent",
|
||||||
|
state: "output-available",
|
||||||
|
input: { pageId: "host-1", title: "From input" },
|
||||||
|
output: { pageId: "host-1" },
|
||||||
|
};
|
||||||
|
expect(toolCitations(part)).toEqual([
|
||||||
|
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers output.id over input.pageId when both exist", () => {
|
||||||
|
const part: ToolUiPart = {
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-available",
|
||||||
|
input: { pageId: "input-id", title: "Input title" },
|
||||||
|
output: { id: "output-id", title: "Output title" },
|
||||||
|
};
|
||||||
|
expect(toolCitations(part)).toEqual([
|
||||||
|
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] when the state is not output-available", () => {
|
||||||
|
const part: ToolUiPart = {
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "input-available",
|
||||||
|
output: { id: "p1", title: "Pending" },
|
||||||
|
};
|
||||||
|
expect(toolCitations(part)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for a page-op output with no resolvable id", () => {
|
||||||
|
const part: ToolUiPart = {
|
||||||
|
type: "tool-getPage",
|
||||||
|
state: "output-available",
|
||||||
|
input: {},
|
||||||
|
output: { title: "Only a title" },
|
||||||
|
};
|
||||||
|
expect(toolCitations(part)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toolRunState", () => {
|
||||||
|
it('maps "output-error" to error', () => {
|
||||||
|
expect(toolRunState("output-error")).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps "output-denied" to error', () => {
|
||||||
|
expect(toolRunState("output-denied")).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps "output-available" to done', () => {
|
||||||
|
expect(toolRunState("output-available")).toBe("done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps "input-available" to running', () => {
|
||||||
|
expect(toolRunState("input-available")).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps undefined to running", () => {
|
||||||
|
expect(toolRunState(undefined)).toBe("running");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
*
|
*
|
||||||
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
|
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
|
||||||
* its `state` is one of input-streaming / input-available / output-available /
|
* its `state` is one of input-streaming / input-available / output-available /
|
||||||
* output-error (we only surface running / done / error). The server tools are:
|
* output-error (we only surface running / done / error). The full toolset the
|
||||||
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
|
* server exposes lives in `ai-chat-tools.service.ts` (the agent now exposes the
|
||||||
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
|
* complete Docmost toolset); friendly action-log labels exist ONLY for the
|
||||||
|
* tools listed in `toolLabelKey` below — every other tool falls through to the
|
||||||
|
* generic "Ran tool {{name}}" label.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A tool UI part as it arrives from `useChat` / persisted history. */
|
/** A tool UI part as it arrives from `useChat` / persisted history. */
|
||||||
@@ -38,6 +40,11 @@ export interface ToolCitation {
|
|||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||||
|
export function isToolPart(type: string): boolean {
|
||||||
|
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
|
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
|
||||||
export function getToolName(part: ToolUiPart): string {
|
export function getToolName(part: ToolUiPart): string {
|
||||||
if (part.type === "dynamic-tool") return part.toolName ?? "";
|
if (part.type === "dynamic-tool") return part.toolName ?? "";
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <div>{t("invalid invitation link")}</div>;
|
// Styled error with a CTA to login, mirroring the password-reset
|
||||||
|
// error page and the 404 page (issue #133)
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<Container my={40}>
|
||||||
|
<Text size="lg" ta="center">
|
||||||
|
{t("Invalid invitation link")}
|
||||||
|
</Text>
|
||||||
|
<Group justify="center">
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={APP_ROUTE.AUTH.LOGIN}
|
||||||
|
variant="subtle"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{t("Go to login page")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import CommentListItem from "./comment-list-item";
|
||||||
|
|
||||||
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
content: JSON.stringify({ type: "doc", content: [] }),
|
||||||
|
creatorId: "user-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
creator: { id: "user-1", name: "Service Bot", avatarUrl: null } as any,
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
function renderItem(comment: IComment) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentListItem — AI badge", () => {
|
||||||
|
it('renders the AI-agent badge when createdSource === "agent"', () => {
|
||||||
|
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||||
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
|
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
|
||||||
|
renderItem(baseComment({ createdSource: "user" }));
|
||||||
|
expect(screen.queryByText("AI-agent")).toBeNull();
|
||||||
|
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||||
|
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||||
|
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
|
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
@@ -116,8 +117,8 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} pb="xs">
|
<Box ref={ref} pb={6}>
|
||||||
<Group>
|
<Group gap="xs">
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
size="sm"
|
size="sm"
|
||||||
avatarUrl={comment.creator.avatarUrl}
|
avatarUrl={comment.creator.avatarUrl}
|
||||||
@@ -126,9 +127,18 @@ function CommentListItem({
|
|||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{comment.creator.name}
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||||
</Text>
|
{comment.creator.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{comment.createdSource === "agent" && (
|
||||||
|
<AiAgentBadge
|
||||||
|
authorName={comment.creator?.name}
|
||||||
|
aiChatId={comment.aiChatId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
||||||
{!comment.parentCommentId && canComment && (
|
{!comment.parentCommentId && canComment && (
|
||||||
@@ -155,7 +165,7 @@ function CommentListItem({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed" lh={1.1}>
|
||||||
{createdAtAgo}
|
{createdAtAgo}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -177,7 +187,7 @@ function CommentListItem({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={t("Jump to comment selection")}
|
aria-label={t("Jump to comment selection")}
|
||||||
>
|
>
|
||||||
<Text size="sm">{comment?.selection}</Text>
|
<Text size="xs">{comment?.selection}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Text,
|
Text,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import CommentListItem from "@/features/comment/components/comment-list-item";
|
import CommentListItem from "@/features/comment/components/comment-list-item";
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +27,16 @@ import { IPagination } from "@/lib/types.ts";
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
|
import { IconArrowUp, IconMessageOff, IconX } from "@tabler/icons-react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
|
||||||
function CommentListWithTabs() {
|
interface CommentListWithTabsProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
@@ -121,8 +126,8 @@ function CommentListWithTabs() {
|
|||||||
<Paper
|
<Paper
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
p="sm"
|
p="xs"
|
||||||
mb="sm"
|
mb="xs"
|
||||||
withBorder
|
withBorder
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
@@ -145,7 +150,7 @@ function CommentListWithTabs() {
|
|||||||
|
|
||||||
{!comment.resolvedAt && canComment && (
|
{!comment.resolvedAt && canComment && (
|
||||||
<>
|
<>
|
||||||
<Divider my={4} />
|
<Divider my={2} />
|
||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
@@ -194,28 +199,50 @@ function CommentListWithTabs() {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.List justify="center">
|
{/* Header row: full-width centered tab list with the close button overlaid on the right. */}
|
||||||
<Tabs.Tab
|
<div style={{ position: "relative" }}>
|
||||||
value="open"
|
<Tabs.List justify="center">
|
||||||
leftSection={
|
<Tabs.Tab
|
||||||
<Badge size="sm" variant="light" color="blue">
|
value="open"
|
||||||
{activeComments.length}
|
leftSection={
|
||||||
</Badge>
|
<Badge size="sm" variant="light" color="blue">
|
||||||
}
|
{activeComments.length}
|
||||||
>
|
</Badge>
|
||||||
{t("Open")}
|
}
|
||||||
</Tabs.Tab>
|
>
|
||||||
<Tabs.Tab
|
{t("Open")}
|
||||||
value="resolved"
|
</Tabs.Tab>
|
||||||
leftSection={
|
<Tabs.Tab
|
||||||
<Badge size="sm" variant="light" color="green">
|
value="resolved"
|
||||||
{resolvedComments.length}
|
leftSection={
|
||||||
</Badge>
|
<Badge size="sm" variant="light" color="green">
|
||||||
}
|
{resolvedComments.length}
|
||||||
>
|
</Badge>
|
||||||
{t("Resolved")}
|
}
|
||||||
</Tabs.Tab>
|
>
|
||||||
</Tabs.List>
|
{t("Resolved")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
{onClose && (
|
||||||
|
<Tooltip label={t("Close")} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("Close")}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: "50%",
|
||||||
|
// Nudge the close button slightly up to align with the tab labels.
|
||||||
|
transform: "translateY(calc(-50% - 4px))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
style={{ flex: "1 1 auto" }}
|
style={{ flex: "1 1 auto" }}
|
||||||
@@ -365,7 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
|||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||||
paddingTop: "var(--mantine-spacing-sm)",
|
paddingTop: "var(--mantine-spacing-sm)",
|
||||||
paddingBottom: 25,
|
paddingBottom: 10,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -374,7 +401,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
avatarUrl={currentUser?.user?.avatarUrl}
|
avatarUrl={currentUser?.user?.avatarUrl}
|
||||||
name={currentUser?.user?.name}
|
name={currentUser?.user?.name}
|
||||||
style={{ flexShrink: 0, marginTop: 10 }}
|
style={{ flexShrink: 0, marginTop: 2 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -396,7 +423,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
style={{ position: "absolute", right: 8, bottom: 30 }}
|
style={{ position: "absolute", right: 8, bottom: 15 }}
|
||||||
>
|
>
|
||||||
<IconArrowUp size={16} />
|
<IconArrowUp size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
.wrapper {
|
|
||||||
padding: var(--mantine-spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.focused-thread {
|
.focused-thread {
|
||||||
border: 2px solid #8d7249;
|
border: 2px solid #8d7249;
|
||||||
}
|
}
|
||||||
|
|
||||||
.textSelection {
|
.textSelection {
|
||||||
margin-top: 4px;
|
/* Breathing room below the comment header (author + timestamp) so the
|
||||||
|
quote does not stick to the timestamp when it is the first block. */
|
||||||
|
margin-top: 8px;
|
||||||
|
/* Align the quote's left bar with the comment body text left edge
|
||||||
|
(the comment editor insets its text by 6px). */
|
||||||
|
margin-left: 6px;
|
||||||
border-left: 2px solid var(--mantine-color-gray-6);
|
border-left: 2px solid var(--mantine-color-gray-6);
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
background: var(--mantine-color-gray-light);
|
background: var(--mantine-color-gray-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -32,6 +33,9 @@
|
|||||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Denser comments: override the global 16px ProseMirror body size with 14px
|
||||||
|
and tighten the rhythm vs. the comment header. Scoped to the comment
|
||||||
|
editor only - the page editor is unaffected. */
|
||||||
.ProseMirror :global(.ProseMirror){
|
.ProseMirror :global(.ProseMirror){
|
||||||
border-radius: var(--mantine-radius-sm);
|
border-radius: var(--mantine-radius-sm);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -39,7 +43,9 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
margin-top: 10px;
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 4px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export interface IComment {
|
|||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
resolvedBy?: IUser;
|
resolvedBy?: IUser;
|
||||||
|
// Agent-edit provenance (returned by the backend via selectAll('comments')).
|
||||||
|
// createdSource === "agent" marks a comment authored via an AI agent (MCP /
|
||||||
|
// internal AI chat); aiChatId deep-links to the internal chat when present
|
||||||
|
// (null for an external MCP agent); resolvedSource marks an AI-resolved thread.
|
||||||
|
createdSource?: string;
|
||||||
|
aiChatId?: string | null;
|
||||||
|
resolvedSource?: string | null;
|
||||||
yjsSelection?: {
|
yjsSelection?: {
|
||||||
anchor: any;
|
anchor: any;
|
||||||
head: any;
|
head: any;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.recordingWrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Translucent red halo that sits behind the stop button and scales with the
|
||||||
|
live microphone level (scale set inline from audioLevel). Radius follows the
|
||||||
|
ActionIcon's own radius so the halo matches the button's rounded-square
|
||||||
|
outline instead of being a circle. */
|
||||||
|
.pulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--mantine-radius-default);
|
||||||
|
background-color: var(--mantine-color-red-5);
|
||||||
|
opacity: 0.35;
|
||||||
|
transform-origin: center;
|
||||||
|
transform: scale(1);
|
||||||
|
transition: transform 90ms linear;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { ActionIcon, Loader, Tooltip } from "@mantine/core";
|
|||||||
import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
||||||
|
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
||||||
|
import classes from "./mic-button.module.css";
|
||||||
|
|
||||||
interface MicButtonProps {
|
interface MicButtonProps {
|
||||||
onText: (text: string) => void;
|
onText: (text: string) => void;
|
||||||
@@ -11,6 +13,14 @@ interface MicButtonProps {
|
|||||||
// Mantine ActionIcon size token; "lg" matches the chat composer, "md" the
|
// Mantine ActionIcon size token; "lg" matches the chat composer, "md" the
|
||||||
// editor toolbar.
|
// editor toolbar.
|
||||||
size?: "md" | "lg";
|
size?: "md" | "lg";
|
||||||
|
// Optional Mantine color override for the idle/transcribing states (the
|
||||||
|
// recording state stays red). Defaults to the theme primary when omitted.
|
||||||
|
color?: string;
|
||||||
|
// Optional explicit glyph size override; defaults to the size-token value.
|
||||||
|
iconSize?: number;
|
||||||
|
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||||
|
// text progressively as the user pauses; otherwise use the batch controller.
|
||||||
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,35 +34,64 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
onStart,
|
onStart,
|
||||||
disabled,
|
disabled,
|
||||||
size = "lg",
|
size = "lg",
|
||||||
|
color,
|
||||||
|
iconSize,
|
||||||
|
streaming = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { status, start, stop } = useDictation({ onText, onStart });
|
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
||||||
const iconSize = size === "lg" ? 18 : 16;
|
// active is a render-time choice, but both must be invoked every render. This
|
||||||
|
// is safe because both controllers are inert until start() is called — neither
|
||||||
|
// opens the mic on mount — so the unused one costs nothing.
|
||||||
|
const batchCtl = useDictation({ onText, onStart });
|
||||||
|
const streamingCtl = useStreamingDictation({ onText, onStart });
|
||||||
|
const ctl = streaming ? streamingCtl : batchCtl;
|
||||||
|
const { status, start, stop, audioLevel } = ctl;
|
||||||
|
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
||||||
|
|
||||||
if (status === "recording") {
|
if (status === "recording") {
|
||||||
|
// Live volume-driven halo: the scale follows the current mic level.
|
||||||
|
const haloScale = 1 + Math.min(1, audioLevel) * 0.9;
|
||||||
return (
|
return (
|
||||||
<Tooltip label={t("Stop recording")} withArrow>
|
<Tooltip label={t("Stop recording")} withArrow>
|
||||||
<ActionIcon
|
<span className={classes.recordingWrap}>
|
||||||
size={size}
|
<span
|
||||||
color="red"
|
className={classes.pulse}
|
||||||
variant="light"
|
style={{ transform: `scale(${haloScale})` }}
|
||||||
onClick={stop}
|
aria-hidden="true"
|
||||||
aria-label={t("Stop recording")}
|
/>
|
||||||
>
|
<ActionIcon
|
||||||
<IconPlayerStopFilled size={iconSize} />
|
size={size}
|
||||||
</ActionIcon>
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
onClick={stop}
|
||||||
|
aria-label={t("Stop recording")}
|
||||||
|
style={{ position: "relative", zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<IconPlayerStopFilled size={resolvedIconSize} />
|
||||||
|
</ActionIcon>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "transcribing" || status === "error") {
|
if (
|
||||||
|
status === "loading" ||
|
||||||
|
status === "transcribing" ||
|
||||||
|
status === "error"
|
||||||
|
) {
|
||||||
|
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||||
|
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||||
|
// a confusing second click can't fire while the model loads.
|
||||||
|
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
||||||
return (
|
return (
|
||||||
<Tooltip label={t("Transcribing…")} withArrow>
|
<Tooltip label={label} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={size}
|
size={size}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
color={color}
|
||||||
disabled
|
disabled
|
||||||
aria-label={t("Transcribing…")}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -65,11 +104,12 @@ export const MicButton: FC<MicButtonProps> = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={size}
|
size={size}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
color={color}
|
||||||
onClick={() => void start()}
|
onClick={() => void start()}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={t("Start dictation")}
|
aria-label={t("Start dictation")}
|
||||||
>
|
>
|
||||||
<IconMicrophone size={iconSize} />
|
<IconMicrophone size={resolvedIconSize} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
|
|
||||||
export type DictationStatus = "idle" | "recording" | "transcribing" | "error";
|
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
||||||
|
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||||
|
// hook and the mic button can show immediate feedback during that load.
|
||||||
|
export type DictationStatus =
|
||||||
|
| "idle"
|
||||||
|
| "recording"
|
||||||
|
| "transcribing"
|
||||||
|
| "error"
|
||||||
|
| "loading";
|
||||||
|
|
||||||
interface UseDictationOptions {
|
interface UseDictationOptions {
|
||||||
onText: (text: string) => void;
|
onText: (text: string) => void;
|
||||||
@@ -16,6 +24,8 @@ interface UseDictationResult {
|
|||||||
start: () => Promise<void>;
|
start: () => Promise<void>;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
|
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||||
|
audioLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate container/codec combinations in preference order. The first one the
|
// Candidate container/codec combinations in preference order. The first one the
|
||||||
@@ -56,6 +66,7 @@ export function useDictation(
|
|||||||
): UseDictationResult {
|
): UseDictationResult {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
|
||||||
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
||||||
// calls the current handlers without re-creating the recorder.
|
// calls the current handlers without re-creating the recorder.
|
||||||
@@ -70,6 +81,15 @@ export function useDictation(
|
|||||||
const canceledRef = useRef(false);
|
const canceledRef = useRef(false);
|
||||||
const startingRef = useRef(false);
|
const startingRef = useRef(false);
|
||||||
|
|
||||||
|
// Web Audio metering: derives a live input level from the captured stream.
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
// Exponentially smoothed level, and the last value pushed to React state.
|
||||||
|
const smoothedLevelRef = useRef(0);
|
||||||
|
const emittedLevelRef = useRef(0);
|
||||||
|
|
||||||
const clearTimer = useCallback(() => {
|
const clearTimer = useCallback(() => {
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
@@ -82,6 +102,91 @@ export function useDictation(
|
|||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Tear the audio meter down fully. Safe to call multiple times and on any exit
|
||||||
|
// path; defensive try/catch so cleanup never throws.
|
||||||
|
const stopMeter = useCallback(() => {
|
||||||
|
// Cancel the rAF first so getByteTimeDomainData can't run on a closed context.
|
||||||
|
if (rafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sourceRef.current?.disconnect();
|
||||||
|
sourceRef.current = null;
|
||||||
|
analyserRef.current = null;
|
||||||
|
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
|
||||||
|
void audioContextRef.current.close();
|
||||||
|
}
|
||||||
|
audioContextRef.current = null;
|
||||||
|
} catch (err) {
|
||||||
|
// Cleanup must never throw; just log for diagnosis.
|
||||||
|
console.warn("[dictation] audio meter teardown failed", err);
|
||||||
|
}
|
||||||
|
smoothedLevelRef.current = 0;
|
||||||
|
emittedLevelRef.current = 0;
|
||||||
|
setAudioLevel(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set up Web Audio metering on the already-captured stream. Reuses the existing
|
||||||
|
// MediaStream — never requests a second mic. Failure here must not break
|
||||||
|
// recording: on any error we warn and return, leaving the recorder running.
|
||||||
|
const startMeter = useCallback((stream: MediaStream) => {
|
||||||
|
try {
|
||||||
|
const Ctor =
|
||||||
|
window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext;
|
||||||
|
if (!Ctor) return;
|
||||||
|
|
||||||
|
const audioContext = new Ctor();
|
||||||
|
// Some browsers start the context suspended; resume so the loop produces
|
||||||
|
// data. Swallow rejection (e.g. context already closed by a fast
|
||||||
|
// start/stop race) to avoid an unhandled promise rejection.
|
||||||
|
audioContext.resume().catch(() => {});
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 512;
|
||||||
|
analyser.smoothingTimeConstant = 0.5;
|
||||||
|
// Connect ONLY to the analyser — never to destination, which would echo the
|
||||||
|
// mic back to the speakers.
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
sourceRef.current = source;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
|
||||||
|
// Allocate the time-domain buffer once and reuse it on every tick.
|
||||||
|
const data = new Uint8Array(analyser.fftSize);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const a = analyserRef.current;
|
||||||
|
if (!a) return;
|
||||||
|
a.getByteTimeDomainData(data);
|
||||||
|
// RMS of the centered waveform (samples are 0..255, midpoint 128).
|
||||||
|
let sumSquares = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const v = (data[i] - 128) / 128;
|
||||||
|
sumSquares += v * v;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sumSquares / data.length);
|
||||||
|
// Boost + clamp so normal speech maps to a visible 0..1 range.
|
||||||
|
const level = Math.min(1, rms * 3);
|
||||||
|
// Exponential smoothing to avoid jitter.
|
||||||
|
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
|
||||||
|
// Throttle React re-renders: only push when it changed meaningfully.
|
||||||
|
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
|
||||||
|
emittedLevelRef.current = smoothedLevelRef.current;
|
||||||
|
setAudioLevel(smoothedLevelRef.current);
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
} catch (err) {
|
||||||
|
// Web Audio unavailable or threw: recording continues without the meter.
|
||||||
|
console.warn("[dictation] audio meter unavailable", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const start = useCallback(async (): Promise<void> => {
|
const start = useCallback(async (): Promise<void> => {
|
||||||
// Synchronous live guard: status is stale between renders, so also block on
|
// Synchronous live guard: status is stale between renders, so also block on
|
||||||
// refs to prevent a double-click from opening two MediaStreams (the first
|
// refs to prevent a double-click from opening two MediaStreams (the first
|
||||||
@@ -163,8 +268,9 @@ export function useDictation(
|
|||||||
const recordedMime = recorder.mimeType || mimeType || "audio/webm";
|
const recordedMime = recorder.mimeType || mimeType || "audio/webm";
|
||||||
const wasCanceled = canceledRef.current;
|
const wasCanceled = canceledRef.current;
|
||||||
|
|
||||||
// Stop the mic tracks regardless of how we got here.
|
// Stop the mic tracks and the audio meter regardless of how we got here.
|
||||||
stopTracks();
|
stopTracks();
|
||||||
|
stopMeter();
|
||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
|
|
||||||
if (wasCanceled) {
|
if (wasCanceled) {
|
||||||
@@ -237,34 +343,49 @@ export function useDictation(
|
|||||||
// Recording has truly begun; release the synchronous start guard.
|
// Recording has truly begun; release the synchronous start guard.
|
||||||
startingRef.current = false;
|
startingRef.current = false;
|
||||||
|
|
||||||
|
// Start the live audio meter on the stream we already acquired.
|
||||||
|
startMeter(stream);
|
||||||
|
|
||||||
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
|
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
if (recorderRef.current?.state === "recording") {
|
if (recorderRef.current?.state === "recording") {
|
||||||
recorderRef.current.stop();
|
recorderRef.current.stop();
|
||||||
}
|
}
|
||||||
}, maxDurationMs);
|
}, maxDurationMs);
|
||||||
}, [status, t, clearTimer, stopTracks]);
|
}, [status, t, clearTimer, stopTracks, startMeter, stopMeter]);
|
||||||
|
|
||||||
const stop = useCallback((): void => {
|
const stop = useCallback((): void => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
const recorder = recorderRef.current;
|
const recorder = recorderRef.current;
|
||||||
if (recorder && recorder.state === "recording") {
|
if (recorder && recorder.state === "recording") {
|
||||||
|
// Normal path: onstop tears down tracks + meter and runs transcription.
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
|
} else {
|
||||||
|
// No live recorder (e.g. the track ended on its own): tear everything
|
||||||
|
// down directly so the meter/AudioContext and stream don't leak, and
|
||||||
|
// recover the UI to idle.
|
||||||
|
stopTracks();
|
||||||
|
stopMeter();
|
||||||
|
recorderRef.current = null;
|
||||||
|
chunksRef.current = [];
|
||||||
|
setStatus("idle");
|
||||||
}
|
}
|
||||||
}, [clearTimer]);
|
}, [clearTimer, stopTracks, stopMeter]);
|
||||||
|
|
||||||
const cancel = useCallback((): void => {
|
const cancel = useCallback((): void => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
canceledRef.current = true;
|
canceledRef.current = true;
|
||||||
const recorder = recorderRef.current;
|
const recorder = recorderRef.current;
|
||||||
if (recorder && recorder.state === "recording") {
|
if (recorder && recorder.state === "recording") {
|
||||||
// onstop sees canceledRef and skips transcription; it also stops tracks.
|
// onstop sees canceledRef and skips transcription; it also stops tracks
|
||||||
|
// and the meter.
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
} else {
|
} else {
|
||||||
stopTracks();
|
stopTracks();
|
||||||
|
stopMeter();
|
||||||
}
|
}
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}, [clearTimer, stopTracks]);
|
}, [clearTimer, stopTracks, stopMeter]);
|
||||||
|
|
||||||
// Clean up on unmount: stop any live recorder/stream and clear the timers.
|
// Clean up on unmount: stop any live recorder/stream and clear the timers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -280,8 +401,9 @@ export function useDictation(
|
|||||||
recorder.stop();
|
recorder.stop();
|
||||||
}
|
}
|
||||||
stopTracks();
|
stopTracks();
|
||||||
|
stopMeter();
|
||||||
};
|
};
|
||||||
}, [clearTimer, stopTracks]);
|
}, [clearTimer, stopTracks, stopMeter]);
|
||||||
|
|
||||||
return { status, start, stop, cancel };
|
return { status, start, stop, cancel, audioLevel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
|
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||||
|
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
||||||
|
|
||||||
|
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
||||||
|
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||||
|
// only fetched when the user actually begins dictation.
|
||||||
|
type MicVADInstance = {
|
||||||
|
start: () => Promise<void>;
|
||||||
|
pause: () => Promise<void>;
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseStreamingDictationOptions {
|
||||||
|
onText: (text: string) => void;
|
||||||
|
onStart?: () => void;
|
||||||
|
maxDurationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseStreamingDictationResult {
|
||||||
|
status: DictationStatus;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||||
|
audioLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
||||||
|
const VAD_SAMPLE_RATE = 16000;
|
||||||
|
|
||||||
|
// Asset paths for the VAD worklet/Silero model and the onnxruntime-web WASM
|
||||||
|
// binaries. vad-web 0.0.30's default asset path is "./" (relative to the current
|
||||||
|
// page URL), NOT a CDN — in this SPA that request hits the client-side catch-all
|
||||||
|
// route and returns index.html (text/html), so the onnxruntime ESM/wasm backend
|
||||||
|
// fails to initialize. We instead self-host the four needed files (the vad-web
|
||||||
|
// worklet + `silero_vad_v5.onnx` model and the onnxruntime-web `*.jsep.mjs`/
|
||||||
|
// `*.jsep.wasm`) under `apps/client/public/vad/` — populated by
|
||||||
|
// `scripts/copy-vad-assets.mjs`, which runs before `dev`/`build` — and point both
|
||||||
|
// paths at the fixed absolute "/vad/".
|
||||||
|
const VAD_BASE_ASSET_PATH: string | undefined = "/vad/";
|
||||||
|
const VAD_ONNX_WASM_BASE_PATH: string | undefined = "/vad/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming variant of useDictation. Detects speech with a real (Silero) VAD and,
|
||||||
|
* each time the speaker pauses, cuts that speech segment and POSTs it to the same
|
||||||
|
* batch transcription endpoint, so text appears progressively as the user speaks.
|
||||||
|
*
|
||||||
|
* Returns the SAME shape as useDictation ({ status, start, stop, cancel,
|
||||||
|
* audioLevel }) so MicButton can use either interchangeably. Refs hold the live
|
||||||
|
* VAD instance / counters / timer so component re-renders never lose them, and
|
||||||
|
* every exit path destroys the VAD and stops the MediaStream.
|
||||||
|
*/
|
||||||
|
export function useStreamingDictation(
|
||||||
|
options: UseStreamingDictationOptions,
|
||||||
|
): UseStreamingDictationResult {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||||
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
|
||||||
|
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
||||||
|
// current handlers without re-creating the VAD.
|
||||||
|
const optionsRef = useRef(options);
|
||||||
|
optionsRef.current = options;
|
||||||
|
|
||||||
|
const vadRef = useRef<MicVADInstance | null>(null);
|
||||||
|
// AudioContext we create+resume inside the click gesture and inject into
|
||||||
|
// MicVAD (see start()). We own it; MicVAD does not close an injected context.
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const canceledRef = useRef(false);
|
||||||
|
const startingRef = useRef(false);
|
||||||
|
// True while a recording session is active (VAD listening). Used to ignore late
|
||||||
|
// VAD callbacks that fire after stop()/cancel().
|
||||||
|
const activeRef = useRef(false);
|
||||||
|
|
||||||
|
// In-order emission: each segment gets a monotonically increasing seq when its
|
||||||
|
// speech ends; completed transcriptions are buffered by seq and flushed in
|
||||||
|
// order so out-of-order HTTP responses can't scramble the text.
|
||||||
|
const nextSeqRef = useRef(0);
|
||||||
|
const nextEmitSeqRef = useRef(0);
|
||||||
|
const resultsRef = useRef<Map<number, string>>(new Map());
|
||||||
|
// Number of transcription requests still in flight.
|
||||||
|
const inFlightRef = useRef(0);
|
||||||
|
// Session epoch: bumped when a NEW session starts (start) or everything is
|
||||||
|
// hard-discarded (cancel). Each in-flight request captures the epoch at send
|
||||||
|
// time; if the epoch has since changed, the request is stale and its
|
||||||
|
// then/catch/finally are skipped so old text can't leak into a new session and
|
||||||
|
// the in-flight counter can't be driven negative across sessions.
|
||||||
|
const epochRef = useRef(0);
|
||||||
|
|
||||||
|
// Exponentially smoothed speech level, and the last value pushed to React state.
|
||||||
|
const smoothedLevelRef = useRef(0);
|
||||||
|
const emittedLevelRef = useRef(0);
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset the level meter back to zero (refs + React state).
|
||||||
|
const resetLevel = useCallback(() => {
|
||||||
|
smoothedLevelRef.current = 0;
|
||||||
|
emittedLevelRef.current = 0;
|
||||||
|
setAudioLevel(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Destroy the live VAD instance (which also releases the mic stream and audio
|
||||||
|
// context it created). Safe to call multiple times and on any exit path;
|
||||||
|
// defensive try/catch so teardown never throws.
|
||||||
|
const destroyVad = useCallback(() => {
|
||||||
|
const vad = vadRef.current;
|
||||||
|
vadRef.current = null;
|
||||||
|
if (vad) {
|
||||||
|
try {
|
||||||
|
// destroy() pauses + tears down the worklet/stream/context internally.
|
||||||
|
// It returns a promise, so attach a .catch too: the surrounding
|
||||||
|
// try/catch only catches synchronous throws, and a rejected destroy()
|
||||||
|
// would otherwise surface as an unhandled rejection.
|
||||||
|
void vad
|
||||||
|
.destroy()
|
||||||
|
.catch((err) =>
|
||||||
|
console.warn("[dictation] VAD teardown failed", err),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Cleanup must never throw; just log for diagnosis.
|
||||||
|
console.warn("[dictation] VAD teardown failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Decide the status once recording has ended: stay "transcribing" while
|
||||||
|
// requests are in flight, otherwise return to "idle".
|
||||||
|
const settleAfterStop = useCallback(() => {
|
||||||
|
if (inFlightRef.current > 0) {
|
||||||
|
setStatus("transcribing");
|
||||||
|
} else {
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Drain the in-order result buffer: while the next expected seq is ready, trim
|
||||||
|
// it, emit it if non-empty, and advance. Called after every resolved request.
|
||||||
|
const drainResults = useCallback(() => {
|
||||||
|
const results = resultsRef.current;
|
||||||
|
while (results.has(nextEmitSeqRef.current)) {
|
||||||
|
const text = results.get(nextEmitSeqRef.current)!;
|
||||||
|
results.delete(nextEmitSeqRef.current);
|
||||||
|
nextEmitSeqRef.current += 1;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
// Whisper often returns a leading space; emit the trimmed value.
|
||||||
|
if (trimmed.length > 0) optionsRef.current.onText(trimmed);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Map a transcription error to a user-facing message, mirroring the batch hook.
|
||||||
|
const transcriptionErrorMessage = useCallback(
|
||||||
|
(err: unknown): string => {
|
||||||
|
const resp = (
|
||||||
|
err as { response?: { status?: number; data?: { message?: string } } }
|
||||||
|
)?.response;
|
||||||
|
const serverMsg = resp?.data?.message;
|
||||||
|
if (serverMsg && serverMsg.trim().length > 0) {
|
||||||
|
// The server already explains the cause (e.g. provider 404, bad format,
|
||||||
|
// STT not configured) — show it verbatim.
|
||||||
|
return serverMsg;
|
||||||
|
}
|
||||||
|
if (resp?.status === 503 || resp?.status === 403) {
|
||||||
|
return t("Voice dictation is not configured");
|
||||||
|
}
|
||||||
|
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
||||||
|
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
||||||
|
// the session: log + one notification, then advance past that seq so later
|
||||||
|
// segments still flush.
|
||||||
|
const handleSegment = useCallback(
|
||||||
|
(audio: Float32Array) => {
|
||||||
|
const seq = nextSeqRef.current;
|
||||||
|
nextSeqRef.current += 1;
|
||||||
|
inFlightRef.current += 1;
|
||||||
|
// Capture the epoch for this request synchronously at send time.
|
||||||
|
const epoch = epochRef.current;
|
||||||
|
|
||||||
|
const wavBlob = encodeWavPcm16(audio, VAD_SAMPLE_RATE);
|
||||||
|
void transcribeAudio(wavBlob, "speech.wav")
|
||||||
|
.then((text) => {
|
||||||
|
// Stale request from a previous session: drop it without touching any
|
||||||
|
// current-session state.
|
||||||
|
if (epoch !== epochRef.current) return;
|
||||||
|
// Defend against a non-string server value before drainResults trims.
|
||||||
|
resultsRef.current.set(seq, typeof text === "string" ? text : "");
|
||||||
|
drainResults();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (epoch !== epochRef.current) return;
|
||||||
|
// Log the full error for diagnosis (status + body + stack).
|
||||||
|
console.error("[dictation] segment transcription failed", err);
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: transcriptionErrorMessage(err),
|
||||||
|
});
|
||||||
|
// Skip this seq so later segments can still flush in order.
|
||||||
|
if (nextEmitSeqRef.current === seq) {
|
||||||
|
nextEmitSeqRef.current += 1;
|
||||||
|
drainResults();
|
||||||
|
} else {
|
||||||
|
resultsRef.current.set(seq, "");
|
||||||
|
drainResults();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (epoch !== epochRef.current) return;
|
||||||
|
inFlightRef.current -= 1;
|
||||||
|
// If recording already stopped, flip to idle once everything drained.
|
||||||
|
if (!activeRef.current && inFlightRef.current === 0) {
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[drainResults, transcriptionErrorMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = useCallback(async (): Promise<void> => {
|
||||||
|
// Synchronous live guard: status is stale between renders, so also block on
|
||||||
|
// refs to prevent a double-click from creating two VAD instances (the first
|
||||||
|
// would leak its mic stream).
|
||||||
|
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||||
|
if (status !== "idle") return;
|
||||||
|
startingRef.current = true;
|
||||||
|
|
||||||
|
// Notify the caller right when dictation begins (before any async work) so the
|
||||||
|
// editor can snapshot the caret position.
|
||||||
|
optionsRef.current.onStart?.();
|
||||||
|
|
||||||
|
// Reset per-session in-order emission state. Bump the epoch so any request
|
||||||
|
// still in flight from a previous (stopped) session becomes stale and its
|
||||||
|
// then/catch/finally are skipped — it can neither emit old text into this
|
||||||
|
// new session nor decrement this session's freshly-zeroed in-flight counter.
|
||||||
|
epochRef.current += 1;
|
||||||
|
canceledRef.current = false;
|
||||||
|
nextSeqRef.current = 0;
|
||||||
|
nextEmitSeqRef.current = 0;
|
||||||
|
resultsRef.current = new Map();
|
||||||
|
inFlightRef.current = 0;
|
||||||
|
resetLevel();
|
||||||
|
|
||||||
|
// Create and resume the AudioContext NOW, inside the click gesture, before
|
||||||
|
// the (first-time-slow) model load below. A context first touched outside a
|
||||||
|
// user gesture stays "suspended" and the VAD audio worklet never runs — that
|
||||||
|
// is exactly why the first click did nothing and only the second (model
|
||||||
|
// already cached, so MicVAD.new was fast enough to create the context inside
|
||||||
|
// the gesture) started recording. We own this context and inject it into
|
||||||
|
// MicVAD (which then will NOT close it); it is reused across start/stop and
|
||||||
|
// closed only on unmount.
|
||||||
|
const AudioCtor =
|
||||||
|
window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext;
|
||||||
|
if (AudioCtor && !audioContextRef.current) {
|
||||||
|
audioContextRef.current = new AudioCtor();
|
||||||
|
}
|
||||||
|
// Resume within the gesture; swallow rejection (e.g. already running/closed).
|
||||||
|
void audioContextRef.current?.resume().catch(() => {});
|
||||||
|
// Show immediate feedback while the model loads (see Part B).
|
||||||
|
setStatus("loading");
|
||||||
|
|
||||||
|
let vad: MicVADInstance;
|
||||||
|
try {
|
||||||
|
// Lazy import so the heavy onnx model/worklet are only fetched on first use
|
||||||
|
// and code-split out of the main bundle.
|
||||||
|
const { MicVAD } = await import("@ricky0123/vad-web");
|
||||||
|
|
||||||
|
vad = await MicVAD.new({
|
||||||
|
// Silero v5 model (smaller/faster than the legacy model).
|
||||||
|
model: "v5",
|
||||||
|
// vad-web 0.0.30 defaults startOnLoad:true, which opens the mic (calls
|
||||||
|
// getUserMedia) inside new() and leaves the later vad.start() a no-op —
|
||||||
|
// making its mic-permission error handling dead code. Force it off so the
|
||||||
|
// mic is opened only by the explicit vad.start() below, where the real
|
||||||
|
// getUserMedia errors are caught and mapped.
|
||||||
|
startOnLoad: false,
|
||||||
|
// Inject the AudioContext we created+resumed inside the click gesture so
|
||||||
|
// the VAD worklet runs on a "running" context. When provided, the library
|
||||||
|
// uses it and does NOT take ownership/close it.
|
||||||
|
...(audioContextRef.current
|
||||||
|
? { audioContext: audioContextRef.current }
|
||||||
|
: {}),
|
||||||
|
// Only pass asset paths when defined; otherwise the library uses its
|
||||||
|
// bundled CDN defaults.
|
||||||
|
...(VAD_BASE_ASSET_PATH !== undefined
|
||||||
|
? { baseAssetPath: VAD_BASE_ASSET_PATH }
|
||||||
|
: {}),
|
||||||
|
...(VAD_ONNX_WASM_BASE_PATH !== undefined
|
||||||
|
? { onnxWASMBasePath: VAD_ONNX_WASM_BASE_PATH }
|
||||||
|
: {}),
|
||||||
|
// --- VAD tuning (all tunable) ---
|
||||||
|
// Probability over which a frame counts as speech.
|
||||||
|
positiveSpeechThreshold: 0.5,
|
||||||
|
// Probability under which a frame counts as non-speech (~0.15 below the
|
||||||
|
// positive threshold, per Silero guidance).
|
||||||
|
negativeSpeechThreshold: 0.35,
|
||||||
|
// Silence to wait through before ending a segment (the "don't cut
|
||||||
|
// immediately" delay). Each ended segment is ONE transcription request, so
|
||||||
|
// cutting on short gaps over-fragments normal speech into a flood of tiny
|
||||||
|
// requests (and trips the server's per-user rate limit). Wait ~1.5s — a
|
||||||
|
// real sentence/thought boundary — so request count tracks actual pauses,
|
||||||
|
// not every inter-word gap. Higher = fewer requests but more latency
|
||||||
|
// before text appears. NOTE: vad-web 0.0.30 takes this in ms, not frames
|
||||||
|
// (one Silero frame is ~32ms at 16k).
|
||||||
|
redemptionMs: 1500,
|
||||||
|
// Audio kept before speech start (left padding so the first word isn't
|
||||||
|
// clipped) — ~0.3s.
|
||||||
|
preSpeechPadMs: 320,
|
||||||
|
// Ignore sub-100ms blips like clicks.
|
||||||
|
minSpeechMs: 96,
|
||||||
|
onFrameProcessed: (probabilities: { isSpeech: number }) => {
|
||||||
|
// Drive the level meter from the speech probability. Light exponential
|
||||||
|
// smoothing + a throttle so React state isn't updated every frame; this
|
||||||
|
// powers the existing button halo. Reuses the VAD's own frame
|
||||||
|
// probabilities — no second AudioContext/AnalyserNode.
|
||||||
|
if (!activeRef.current) return;
|
||||||
|
const level = Math.min(1, Math.max(0, probabilities.isSpeech));
|
||||||
|
smoothedLevelRef.current = smoothedLevelRef.current * 0.8 + level * 0.2;
|
||||||
|
if (Math.abs(smoothedLevelRef.current - emittedLevelRef.current) > 0.01) {
|
||||||
|
emittedLevelRef.current = smoothedLevelRef.current;
|
||||||
|
setAudioLevel(smoothedLevelRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSpeechStart: () => {
|
||||||
|
// No-op: the segment is only handled once it ends.
|
||||||
|
},
|
||||||
|
onSpeechEnd: (audio: Float32Array) => {
|
||||||
|
// A pause was detected — cut this segment and transcribe it. Ignore late
|
||||||
|
// callbacks that fire after stop()/cancel().
|
||||||
|
if (!activeRef.current || canceledRef.current) return;
|
||||||
|
handleSegment(audio);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// With startOnLoad:false, new() loads the model/worklet/wasm but does NOT
|
||||||
|
// open the mic, so a throw here is an asset/init failure (model fetch,
|
||||||
|
// worklet, onnxruntime wasm), not a mic-permission error. Map it as a
|
||||||
|
// generic "could not start" with the underlying detail. (The mic-permission
|
||||||
|
// name checks are kept in the vad.start() catch below, where getUserMedia
|
||||||
|
// actually runs.)
|
||||||
|
console.error("[dictation] VAD init failed", err);
|
||||||
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: `${t("Could not start recording")}: ${detail}`,
|
||||||
|
});
|
||||||
|
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||||
|
// don't leak it.
|
||||||
|
destroyVad();
|
||||||
|
setStatus("idle");
|
||||||
|
startingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vadRef.current = vad;
|
||||||
|
// Accept frames once start() resolves; the VAD callbacks already guard on
|
||||||
|
// activeRef, so setting it before start() is safe.
|
||||||
|
activeRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// With startOnLoad:false this is where getUserMedia actually runs, so map
|
||||||
|
// mic-permission errors here the same way the batch hook does; otherwise
|
||||||
|
// fall back to a generic "could not start" message.
|
||||||
|
await vad.start();
|
||||||
|
} catch (err) {
|
||||||
|
// Always log the full error for diagnosis (name, message, stack).
|
||||||
|
console.error("[dictation] VAD.start failed", err);
|
||||||
|
const name = (err as { name?: string })?.name;
|
||||||
|
const detail = (err as { message?: string })?.message ?? String(err);
|
||||||
|
let message: string;
|
||||||
|
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||||
|
message = t("Microphone access denied");
|
||||||
|
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||||
|
message = t("No microphone found");
|
||||||
|
} else if (name === "NotReadableError" || name === "AbortError") {
|
||||||
|
message = t("Microphone is unavailable or already in use");
|
||||||
|
} else {
|
||||||
|
message = `${t("Could not start recording")}: ${detail}`;
|
||||||
|
}
|
||||||
|
notifications.show({ color: "red", message });
|
||||||
|
activeRef.current = false;
|
||||||
|
destroyVad();
|
||||||
|
setStatus("idle");
|
||||||
|
startingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("recording");
|
||||||
|
// Recording has truly begun; release the synchronous start guard.
|
||||||
|
startingRef.current = false;
|
||||||
|
|
||||||
|
// Optional overall safety cap: auto-stop after maxDurationMs like the batch
|
||||||
|
// hook does.
|
||||||
|
const maxDurationMs = optionsRef.current.maxDurationMs ?? 120000;
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
if (activeRef.current) stopRef.current();
|
||||||
|
}, maxDurationMs);
|
||||||
|
}, [status, t, resetLevel, destroyVad, handleSegment]);
|
||||||
|
|
||||||
|
const stop = useCallback((): void => {
|
||||||
|
clearTimer();
|
||||||
|
if (!activeRef.current && !vadRef.current) {
|
||||||
|
// Nothing is running; make sure the UI is idle.
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mark inactive first so late onSpeechEnd/onFrameProcessed callbacks are
|
||||||
|
// ignored. Any speech segment that has NOT yet ended (user clicks Stop
|
||||||
|
// mid-utterance) is dropped — acceptable for v1; users normally pause before
|
||||||
|
// stopping.
|
||||||
|
activeRef.current = false;
|
||||||
|
destroyVad();
|
||||||
|
resetLevel();
|
||||||
|
settleAfterStop();
|
||||||
|
}, [clearTimer, destroyVad, resetLevel, settleAfterStop]);
|
||||||
|
|
||||||
|
// Keep stop() reachable from the maxDuration timer closure (which is created
|
||||||
|
// before stop is defined) without re-creating the VAD.
|
||||||
|
const stopRef = useRef(stop);
|
||||||
|
stopRef.current = stop;
|
||||||
|
|
||||||
|
const cancel = useCallback((): void => {
|
||||||
|
clearTimer();
|
||||||
|
canceledRef.current = true;
|
||||||
|
activeRef.current = false;
|
||||||
|
// Hard discard: bump the epoch so any in-flight request becomes stale and is
|
||||||
|
// ignored the moment it resolves (no emit, no counter touch).
|
||||||
|
epochRef.current += 1;
|
||||||
|
// Drop pending results / queue; in-flight requests will resolve into a now-
|
||||||
|
// empty buffer and be ignored.
|
||||||
|
resultsRef.current = new Map();
|
||||||
|
nextSeqRef.current = 0;
|
||||||
|
nextEmitSeqRef.current = 0;
|
||||||
|
inFlightRef.current = 0;
|
||||||
|
destroyVad();
|
||||||
|
resetLevel();
|
||||||
|
setStatus("idle");
|
||||||
|
}, [clearTimer, destroyVad, resetLevel]);
|
||||||
|
|
||||||
|
// Clean up on unmount: destroy the VAD, stop the mic stream, clear the timer.
|
||||||
|
// Defensive try/catch lives inside destroyVad so teardown never throws.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimer();
|
||||||
|
activeRef.current = false;
|
||||||
|
canceledRef.current = true;
|
||||||
|
destroyVad();
|
||||||
|
// Close the AudioContext we own (MicVAD never closes an injected one).
|
||||||
|
if (
|
||||||
|
audioContextRef.current &&
|
||||||
|
audioContextRef.current.state !== "closed"
|
||||||
|
) {
|
||||||
|
void audioContextRef.current.close().catch(() => {});
|
||||||
|
}
|
||||||
|
audioContextRef.current = null;
|
||||||
|
};
|
||||||
|
}, [clearTimer, destroyVad]);
|
||||||
|
|
||||||
|
return { status, start, stop, cancel, audioLevel };
|
||||||
|
}
|
||||||
87
apps/client/src/features/dictation/utils/encode-wav.test.ts
Normal file
87
apps/client/src/features/dictation/utils/encode-wav.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { encodeWavPcm16 } from "./encode-wav";
|
||||||
|
|
||||||
|
// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature
|
||||||
|
// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which
|
||||||
|
// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would
|
||||||
|
// produce audio the server cannot decode (silence / garbled transcripts), so we
|
||||||
|
// assert the canonical 44-byte header layout and the sample quantisation rails.
|
||||||
|
|
||||||
|
// Read a DataView back out of a Blob. jsdom's Blob does not implement
|
||||||
|
// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead.
|
||||||
|
function readView(blob: Blob): Promise<DataView> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStr(view: DataView, offset: number, length: number): string {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("encodeWavPcm16", () => {
|
||||||
|
it("writes the canonical RIFF/WAVE/fmt /data tags", async () => {
|
||||||
|
const view = await readView(encodeWavPcm16(new Float32Array(4)));
|
||||||
|
expect(readStr(view, 0, 4)).toBe("RIFF");
|
||||||
|
expect(readStr(view, 8, 4)).toBe("WAVE");
|
||||||
|
expect(readStr(view, 12, 4)).toBe("fmt ");
|
||||||
|
expect(readStr(view, 36, 4)).toBe("data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => {
|
||||||
|
const samples = new Float32Array(10);
|
||||||
|
const view = await readView(encodeWavPcm16(samples));
|
||||||
|
expect(view.getUint32(16, true)).toBe(16); // fmt chunk size
|
||||||
|
expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM
|
||||||
|
expect(view.getUint16(22, true)).toBe(1); // channels = mono
|
||||||
|
expect(view.getUint16(34, true)).toBe(16); // bits per sample
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => {
|
||||||
|
const sampleRate = 16000;
|
||||||
|
const samples = new Float32Array(10);
|
||||||
|
const view = await readView(encodeWavPcm16(samples, sampleRate));
|
||||||
|
expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2
|
||||||
|
expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit)
|
||||||
|
expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize
|
||||||
|
expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the sample rate to 16000 at offset 24", async () => {
|
||||||
|
const view = await readView(encodeWavPcm16(new Float32Array(2)));
|
||||||
|
expect(view.getUint32(24, true)).toBe(16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => {
|
||||||
|
const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000));
|
||||||
|
expect(view8.getUint32(24, true)).toBe(8000);
|
||||||
|
expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override
|
||||||
|
|
||||||
|
const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000));
|
||||||
|
expect(view48.getUint32(24, true)).toBe(48000);
|
||||||
|
expect(view48.getUint32(28, true)).toBe(48000 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps and quantises PCM16 samples to the asymmetric rails", async () => {
|
||||||
|
// +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000),
|
||||||
|
// 0 -> 0, and out-of-range values are clamped to the rails first.
|
||||||
|
const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]);
|
||||||
|
const view = await readView(encodeWavPcm16(samples));
|
||||||
|
expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0
|
||||||
|
expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0
|
||||||
|
expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0
|
||||||
|
expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0
|
||||||
|
expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a mono blob of length 44 + samples.length * 2", () => {
|
||||||
|
expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44);
|
||||||
|
expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2);
|
||||||
|
expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav");
|
||||||
|
});
|
||||||
|
});
|
||||||
32
apps/client/src/features/dictation/utils/encode-wav.ts
Normal file
32
apps/client/src/features/dictation/utils/encode-wav.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Encode mono Float32 PCM samples into a 16-bit PCM WAV blob (audio/wav).
|
||||||
|
// The server STT endpoint whitelists audio/wav, so this is sent as-is.
|
||||||
|
export function encodeWavPcm16(samples: Float32Array, sampleRate = 16000): Blob {
|
||||||
|
const bytesPerSample = 2;
|
||||||
|
const blockAlign = bytesPerSample; // mono
|
||||||
|
const dataSize = samples.length * bytesPerSample;
|
||||||
|
const buffer = new ArrayBuffer(44 + dataSize);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const writeStr = (offset: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
||||||
|
};
|
||||||
|
writeStr(0, "RIFF");
|
||||||
|
view.setUint32(4, 36 + dataSize, true);
|
||||||
|
writeStr(8, "WAVE");
|
||||||
|
writeStr(12, "fmt ");
|
||||||
|
view.setUint32(16, 16, true); // PCM fmt chunk size
|
||||||
|
view.setUint16(20, 1, true); // audio format = PCM
|
||||||
|
view.setUint16(22, 1, true); // channels = mono
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, 16, true); // bits per sample
|
||||||
|
writeStr(36, "data");
|
||||||
|
view.setUint32(40, dataSize, true);
|
||||||
|
let offset = 44;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
return new Blob([buffer], { type: "audio/wav" });
|
||||||
|
}
|
||||||
@@ -1,23 +1,43 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Loader, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconDownload,
|
IconDownload,
|
||||||
|
IconFileText,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
// STT-accepted audio MIME types (mirror of the server whitelist). If the
|
||||||
|
// fetched blob's type is not one of these, we infer it from the file
|
||||||
|
// extension so the upload's content-type is something the endpoint accepts.
|
||||||
|
const RECOGNIZED_AUDIO_MIME = new Set([
|
||||||
|
"audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg",
|
||||||
|
"audio/wav", "audio/x-wav", "audio/wave", "audio/m4a", "audio/x-m4a",
|
||||||
|
]);
|
||||||
|
const AUDIO_MIME_BY_EXT: Record<string, string> = {
|
||||||
|
mp3: "audio/mpeg", m4a: "audio/mp4", mp4: "audio/mp4",
|
||||||
|
wav: "audio/wav", ogg: "audio/ogg", oga: "audio/ogg", webm: "audio/webm",
|
||||||
|
};
|
||||||
|
|
||||||
export function AudioMenu({ editor }: EditorMenuProps) {
|
export function AudioMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
|
const dictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -68,6 +88,100 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleTranscribe = useCallback(async () => {
|
||||||
|
const src = editorState?.src;
|
||||||
|
if (!src || isTranscribing) return;
|
||||||
|
|
||||||
|
// The bubble menu shows for the selected audio node, so selection.from is
|
||||||
|
// that node's start position. Capture it now to disambiguate duplicate-src
|
||||||
|
// blocks after the async transcription completes.
|
||||||
|
const selectedPos = editor.state.selection.from;
|
||||||
|
|
||||||
|
setIsTranscribing(true);
|
||||||
|
try {
|
||||||
|
const fileUrl = getFileUrl(src);
|
||||||
|
// Derive a filename from the internal src for the multipart part name and
|
||||||
|
// for MIME inference when the fetched blob has no usable type.
|
||||||
|
const filename = decodeURIComponent(
|
||||||
|
src.split("?")[0].split("/").pop() || "audio",
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(fileUrl, { credentials: "include" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch audio file (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
|
||||||
|
// Ensure the upload's content-type is one the STT endpoint accepts; the
|
||||||
|
// server keys off the blob's MIME type.
|
||||||
|
let uploadBlob = blob;
|
||||||
|
const baseType = (blob.type || "").split(";")[0].trim().toLowerCase();
|
||||||
|
if (!RECOGNIZED_AUDIO_MIME.has(baseType)) {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const inferred = AUDIO_MIME_BY_EXT[ext];
|
||||||
|
if (inferred) {
|
||||||
|
// Rebuild the blob with an accepted content-type; the server keys off it.
|
||||||
|
uploadBlob = new Blob([blob], { type: inferred });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (await transcribeAudio(uploadBlob, filename)).trim();
|
||||||
|
if (text.length === 0) {
|
||||||
|
notifications.show({ message: t("No speech detected") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-scan the doc at insert time so a collaborative edit during the async
|
||||||
|
// transcription can't misplace the text. Among audio nodes with this src
|
||||||
|
// (the same file may be embedded more than once), pick the occurrence
|
||||||
|
// closest to the originally-selected block.
|
||||||
|
let insertPos: number | null = null;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === "audio" && node.attrs.src === src) {
|
||||||
|
const delta = Math.abs(pos - selectedPos);
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
bestDelta = delta;
|
||||||
|
insertPos = pos + node.nodeSize; // position just after the audio block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // visit all nodes to find the closest match
|
||||||
|
});
|
||||||
|
|
||||||
|
const paragraph = { type: "paragraph", content: [{ type: "text", text }] };
|
||||||
|
try {
|
||||||
|
if (insertPos !== null) {
|
||||||
|
editor.chain().focus().insertContentAt(insertPos, paragraph).run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().insertContent(paragraph).run();
|
||||||
|
}
|
||||||
|
} catch (insertErr) {
|
||||||
|
// A destroyed editor or out-of-bounds position must not throw; log and
|
||||||
|
// ignore so the transcription itself is not reported as a failure.
|
||||||
|
console.error("[audio-transcribe] insert failed", insertErr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[audio-transcribe] failed", err);
|
||||||
|
const resp = (
|
||||||
|
err as { response?: { status?: number; data?: { message?: string } } }
|
||||||
|
)?.response;
|
||||||
|
const serverMsg = resp?.data?.message;
|
||||||
|
let message: string;
|
||||||
|
if (serverMsg && serverMsg.trim().length > 0) {
|
||||||
|
// The server already explains the cause (e.g. provider error, bad
|
||||||
|
// format, STT not configured) — show it verbatim.
|
||||||
|
message = serverMsg;
|
||||||
|
} else if (resp?.status === 503 || resp?.status === 403) {
|
||||||
|
message = t("Voice dictation is not configured");
|
||||||
|
} else {
|
||||||
|
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||||
|
}
|
||||||
|
notifications.show({ color: "red", message });
|
||||||
|
} finally {
|
||||||
|
setIsTranscribing(false);
|
||||||
|
}
|
||||||
|
}, [editor, editorState?.src, isTranscribing, t]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!editorState?.src) return;
|
if (!editorState?.src) return;
|
||||||
const url = getFileUrl(editorState.src);
|
const url = getFileUrl(editorState.src);
|
||||||
@@ -95,6 +209,20 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<div className={classes.toolbar}>
|
<div className={classes.toolbar}>
|
||||||
|
{dictationEnabled && (
|
||||||
|
<Tooltip position="top" label={isTranscribing ? t("Transcribing…") : t("Transcribe")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Transcribe")}
|
||||||
|
variant="subtle"
|
||||||
|
disabled={isTranscribing}
|
||||||
|
>
|
||||||
|
{isTranscribing ? <Loader size={18} /> : <IconFileText size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user