Commit Graph

944 Commits

Author SHA1 Message Date
claude code agent 227
3299bde929 feat(ai-settings): replace useless API-key eye with in-field Clear
The API-key PasswordInput is a write-only buffer — the saved key never
loads back, so the built-in visibility toggle reveals nothing. When a key
is saved and the buffer is empty, render a red Clear (X) ActionIcon in the
field's rightSection (which suppresses the Mantine eye); while typing a new
key the default eye returns. Applied to all three AI endpoints (Chat,
Embeddings, Voice/STT). Handlers and payload logic unchanged.

Implements docs/backlog/api-key-field-clear-in-place-of-eye.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 05:05:34 +03:00
claude_code
732aaf54f8 refactor(import): remove non-functional DOCX/PDF/Confluence import stubs
These import paths relied on the private EE module that was deleted from
the repo. In the community build they either threw 'enterprise license'
(DOCX/PDF) or silently no-op'd (Confluence). The frontend buttons were
already removed in 38064064; this cleans up the dead backend stubs.

- import.service.ts: drop processDocx/processPdf methods, their dispatcher
  branches, the pageId computation + insertPage spread, and the now-unused
  moduleRef param/ModuleRef import
- file-import-task.service.ts: drop the Confluence branch and the now-unused
  moduleRef param/ModuleRef import
- import.controller.ts: restrict file extensions to .md/.html and zip
  sources to generic/notion; update the error message accordingly
- file.utils.ts: remove Confluence from the FileImportSource enum
- features.ts: remove the unused CONFLUENCE_IMPORT/DOCX_IMPORT/PDF_IMPORT
  feature keys

The isConfluenceImport logic in import-attachment.service.ts is intentionally
left in place (real shared attachment-parsing code, not a stub); its removal
is a separate, riskier refactor.
2026-06-20 04:05:29 +03:00
vvzvlad
3d03417c73 fix(import): surface real error cause in /pages/import instead of generic 400
The two catch blocks in importPage() threw an opaque "Error processing file
content" / "Failed to create imported page" BadRequest, hiding the real cause
from the HTTP response. This made a production 400 regression impossible to
diagnose without server log access, and violated the project convention that
errors must never be swallowed.

Extract `${err.name}: ${err.message}` into both the log (full err object kept
for the stack) and the thrown BadRequestException. Inner processMarkdown/
processHTML rethrowing catches and the EE processDocx/processPdf license
catches are left unchanged.

Local reproduction of the happy-dom 14->20 theory failed (full import chain
+ 22 edge cases pass on happy-dom@20.8.9), so the root cause is still pending
the now-visible reason from a recurring 400. Diagnostic script test-import.tsx
added; backlog doc updated with findings.
2026-06-19 16:25:12 +03:00
vvzvlad
11d789cdea feat(comment): don't auto-open comments panel when adding new comment
- Remove automatic panel opening in handleAddComment
- Remove automatic panel opening in handleAddReadOnlyComment
- Keep panel open on click for existing comments in editor
2026-06-19 16:10:23 +03:00
vvzvlad
b1d48d9d9a fix(client): compact page tree + fix selection highlight alignment
Reduce DocTree row stride from 32px to 26px for a denser sidebar tree,
and fix the selection/hover highlight that looked unbalanced at the
tighter spacing.

Root causes:
- The virtualized <li> had no explicit height, so `.node`'s height:100%
  collapsed to content height; combined with the asymmetric
  `[role="treeitem"] { padding-bottom: 2px }` rule, row content was
  pushed to the top of the highlight pill (icon glued to the top edge).
- NodeMenu / CreateNode action icons used the default Mantine ActionIcon
  size (md = 28px), overflowing the tighter 26px row stride onto
  neighbouring rows.

Changes:
- doc-tree.tsx: rowHeight 32 -> 26; give each row <li> a definite
  height = rowHeight.
- tree.module.css: rowWrapper fills the slot (height:100%); node pill is
  inset and vertically centered (height: calc(100% - 4px)); drop the
  asymmetric [role="treeitem"] padding-bottom.
- space-tree-node-menu.tsx / space-tree-row.tsx: action icons size={20}.
- share.module.css: drop now-dead .treeNode padding-bottom override.

Verified in an isolated browser harness: highlight content is centered
(2.8/2.8px) and nothing overflows the row stride.
2026-06-18 23:08:42 +03:00
vvzvlad
20867b0689 fix(client): tighten home page list vertical density
Reduce the recent/favorites/created-by-me list tables from
verticalSpacing="sm" (12px Td padding) to a numeric 6px, removing
~12px of extra height per row so the home page lists pack closer
together. The shared RecentChanges table also drives the space home
view, so both stay consistent.
2026-06-18 22:47:05 +03:00
vvzvlad
0457ba817a feat(client): remove Overview item from space sidebar
Drop the Overview home link that sat between the space switcher and
the Pages section in the authenticated space sidebar. Remove the JSX
block and clean up the now-unused imports (UnstyledButton, IconHome,
useLocation, getSpaceUrl) and the local `location` variable.
2026-06-18 22:41:45 +03:00
vvzvlad
66bb0813a8 fix(client): tighten page tree vertical density
Reduce DocTree default rowHeight from 32px to 26px so sidebar page
tree rows pack closer together. The virtualizer uses rowHeight as the
row stride (estimateSize + translateY), and row content is only ~22px
tall, so the previous 32px left an ~8px gap between nodes. Both the
space tree and the shared (public) tree inherit the default, so both
become denser and stay consistent.
2026-06-18 20:46:23 +03:00
vvzvlad
a0a7d62b59 feat(client): replace space switcher popover with always-visible space grid
Rework the space sidebar:
- remove the "New page" and "Search" menu items (search stays in the app
  header; page creation stays via the "+" button in the Pages section)
- move "Space settings" into a gear icon next to the current space name
- drop the searchable space popover and render all spaces as an
  always-visible grid of fixed-width cards (icon + name), several per row,
  sorted alphabetically, with the active space highlighted
- always inject the active space into the grid so it stays highlightable
  even when the user has more than the 100-space API page limit

The shared SpaceSelect component is left untouched (still used by the
move/copy page modals).
2026-06-18 20:43:07 +03:00
vvzvlad
1e7a306f96 feat(mcp): add hierarchical tree mode to list_pages
list_pages gains an opt-in `tree` parameter on both surfaces (the
@docmost/mcp server tool and the AI-chat agent tool), which share the
same DocmostClient.listPages. Default behavior (recent-by-updatedAt flat
list) is unchanged.

- client.ts: listPages(spaceId?, limit=50, tree=false); when tree is
  true it requires spaceId (throws a specific error otherwise), walks the
  sidebar tree via the existing bounded/cycle-safe enumerateSpacePages,
  and returns a nested tree; limit is ignored in tree mode.
- lib/tree.ts: new pure buildPageTree() — lean nodes { id, slugId, title,
  children? }, children sorted by position (code-unit order), orphans
  promoted to roots, cycle-safe.
- index.ts + ai-chat-tools.service.ts: expose `tree` in the tool schemas
  and descriptions; docmost-client.loader.ts: mirror the new signature.
- tests: add packages/mcp/test/unit/tree.test.mjs (nesting, ordering,
  lean shape, orphan promotion, cycle/self-reference safety).
- rebuild @docmost/mcp (build/ is tracked and loaded at runtime).
2026-06-18 20:30:00 +03:00
vvzvlad
8178d21c00 fix(client): glue version string to logo on the app header baseline
Wrap the logo link and the APP_VERSION text into a single bottom-aligned
Group so they read as one lockup ("gitmost v0.9..."). Move the version
styling into a new .brandVersion CSS class: shrink it from 12px to 10px,
keep the dimmed color and selectability, and lift it via margin-bottom so
its text baseline sits on the wordmark baseline of the 30px desktop logo
(derived from the logo SVG geometry). Drop the redundant lh prop.
2026-06-18 20:13:46 +03:00
vvzvlad
9b8ac430b2 fix(ai-chat): preserve scroll position during agent message streaming
The transcript force-scrolled to the bottom on every streamed delta because
the auto-scroll effect ran unconditionally whenever the messages array identity
changed. Scrolling up to read earlier messages was impossible — each token
yanked the view back down.

Implement a "stick to bottom" pattern in MessageList:
- track whether the viewport is pinned to the bottom via a scroll listener
  (pinnedToBottomRef, BOTTOM_THRESHOLD = 40px);
- only auto-scroll while pinned; a freshly sent user message always re-pins;
- attach the scroll listener via a [hasScrollArea] dependency so a brand-new
  empty chat (whose ScrollArea mounts only after the first message) wires it up;
- guard the effect's own scrollTop write (programmaticScrollRef) so it is not
  misread as a user scroll.
2026-06-18 19:54:40 +03:00
vvzvlad
f96df1c540 feat(ai-chat): show current context size instead of total tokens spent
The floating AI-chat header badge summed metadata.usage (AI SDK
totalUsage, all steps) across every assistant row, showing the
cumulative tokens SPENT — which grows each turn as history is re-sent.
Replace it with the conversation's CURRENT context size.

- server: persist metadata.contextTokens in streamText onFinish from the
  final-step `usage` (inputTokens + outputTokens ≈ current context
  window occupancy); keep usage: totalUsage for back-compat/fallback
- client: derive the badge from the most recent assistant row's
  contextTokens (fallback to that row's usage total for older chats)
  instead of summing all rows
- types: add metadata.contextTokens to IAiChatMessageRow
- i18n: rename badge label "Tokens used in this chat" -> "Current
  context size" (en-US)

No DB migration needed (metadata is a JSON column).
2026-06-18 19:54:34 +03:00
vvzvlad
01a5a4b5d2 refactor(ai): explicit STT request format instead of OpenRouter host-sniffing
Replace the implicit `hostname endsWith openrouter.ai` detection with an
explicit, admin-chosen provider field `sttApiStyle` ('multipart' = OpenAI-
compatible multipart /audio/transcriptions; 'json' = OpenRouter-style JSON +
base64 input_audio). The transcription path now branches on the stored field,
not on the URL — nothing hidden from the admin.

- ai.types: add SttApiStyle + STT_API_STYLES; field on AiProviderSettings and
  MaskedAiSettings (resolved via ResolvedAiConfig).
- update-ai-settings.dto: validate sttApiStyle with @IsIn(STT_API_STYLES).
- ai-settings.service: plumb sttApiStyle through resolve()/getMasked() and the
  non-secret update whitelist; workspace.repo: add it to the ALLOWED array so it
  persists.
- ai.service: drop isOpenRouter(); transcribe() branches on cfg.sttApiStyle;
  rename helper to transcribeJsonBase64 with provider-neutral error text and a
  BadRequestException (400) when the base URL is missing for the JSON style.
- client: SttApiStyle type on IAiSettings/IAiSettingsUpdate; "Request format"
  Select on the Voice/STT settings card; i18n.
2026-06-18 19:40:05 +03:00
vvzvlad
77249d59c6 feat(ai): OpenRouter STT support + real error surfacing + STT endpoint test
- ai.service: route *.openrouter.ai STT to its JSON+base64
  /audio/transcriptions API; keep the OpenAI multipart path (AI SDK) for
  OpenAI/self-hosted whisper. Unify transcription behind transcribe().
- /transcribe controller: surface the real provider/transport reason
  (describeProviderError) instead of an opaque 500; preserve HttpException.
- testConnection: add an 'stt' capability (silent-WAV probe) + DTO; client
  gets a Test endpoint button and status dot on the Voice/STT card.
- useDictation: log full errors to the console and show the real reason
  (mic start + transcription paths); handle NotReadable/Abort and missing
  mediaDevices.
- docs(CLAUDE.md): require full error logging + specific user-facing messages.
2026-06-18 19:26:35 +03:00
vvzvlad
5af40e0ee5 refactor(db): replace STT credentials migration
Remove the outdated 20260618T130000 migration file and add the updated
20260618T160000 version to correct the timestamp and ensure proper ordering.
2026-06-18 18:54:24 +03:00
vvzvlad
874bdd021c feat(ai): server-side voice dictation (STT) with mic in chat and editor
Add push-to-talk voice dictation that transcribes recorded audio on the
server via the workspace's OpenAI-compatible AI provider (Whisper /
gpt-4o-transcribe / self-hosted whisper), then inserts the text.

Backend:
- New `stt_api_key_enc` column + migration; STT creds parity with chat/
  embeddings (sttModel/sttBaseUrl/sttApiKey, write-only key, fallbacks to
  chat baseUrl/key). Both provider whitelists updated (service + repo).
- AiService.getTranscriptionModel + AiTranscriptionService.
- Gated POST /ai-chat/transcribe (dictation flag → 403, JWT + workspace
  scope + throttle, 25MB cap, MIME whitelist, never logs audio/key).
- New `settings.ai.dictation` workspace flag (DTO + service + audit).

Frontend:
- Wire up the Voice/STT settings card (model/base URL/key) and the
  Voice-dictation toggle.
- New `features/dictation`: useDictation (MediaRecorder state machine),
  MicButton, transcribe service; integrated into the chat composer and a
  new editor-toolbar dictation group, both gated by ai.dictation.
2026-06-18 18:45:33 +03:00
vvzvlad
c6b878c514 0.91.0
Bump root, client and server package versions 0.90.1 -> 0.91.0 to match the
v0.91.0 release tag. packages/mcp keeps its independent 1.0.0 version.
2026-06-18 18:07:54 +03:00
vvzvlad
0ceea15e87 feat(ai-chat): wider default window, larger max size, smaller font
Make the floating AI chat window open at a larger default size and
allow stretching it further, plus shrink the fonts.

- ai-chat-window.tsx: DEFAULT_WIDTH 362->540, DEFAULT_HEIGHT 602->680;
  clamp the default width to the viewport in computeInitialGeom()
  (symmetric with the existing height clamp) to avoid overflow on
  narrow screens.
- ai-chat-window.module.css: raise resize caps (max-width 560->900px,
  max-height 880->1100px); base font-size 12->11px.
- ai-chat.module.css: chat content font .messages sm->xs.
2026-06-18 18:06:48 +03:00
vvzvlad
06648d91bb feat(ai-chat): copy agent chat as Markdown to clipboard
Add a header button to the AI agent chat window that copies the active
conversation to the clipboard as Markdown, including the request
internals already persisted client-side — tool calls with their
input/output, per-message token usage, and finish/error info. No new
network call and no server/DB change: it serializes the already-loaded
persisted message rows.

- New util chat-markdown.ts (renamed from export-chat.ts): pure
  buildChatMarkdown() serializer reusing the tool-parts helpers so tool
  labels match the on-screen labels; fence() escapes embedded code
  fences.
- ai-chat-window.tsx: Copy button (shown only for a saved chat with
  loaded rows) using the project useClipboard hook; toggles a check
  icon on success and shows the standard "Copied" notification. Drag is
  unaffected (startDrag ignores button clicks).
- en-US: add "Copy chat" key, drop the obsolete "Export chat".
2026-06-18 17:53:18 +03:00
vvzvlad
8f5be58f9b feat(ai-chat): move AI chat icon into the global top bar
Move the AI-chat toggle icon (IconSparkles) from the page header menu
into the global top bar, placed next to the notifications icon. The
"AI chat enabled" gate (workspace.settings.ai.chat) is preserved, and
the icon style is aligned with the neighbouring notifications icon
(subtle, size sm). As a result the entry point is now available on all
routes instead of only on page routes.

- app-header.tsx: render the gated AI-chat ActionIcon before
  NotificationPopover; wire it to aiChatWindowOpenAtom.
- page-header-menu.tsx: remove the old AI icon block and its now-unused
  imports/locals.
2026-06-18 17:50:42 +03:00
vvzvlad
3d9c9daf98 feat(ai-chat): focus composer on chat creation
Add autoFocus to the chat composer Textarea so a freshly created chat
(window open, "New chat", chat switch — all remount ChatThread via key)
lands with the cursor ready in the input field, letting the user type
immediately without clicking into it.
2026-06-18 05:51:24 +03:00
vvzvlad
a945b47749 fix(mcp): verifiable mutation results + refuse formatting edits in edit_page_text
edit_page_text reported "success" when asked to change formatting (e.g. remove
strikethrough): the markdown-strip fallback matched the bare text, the replace
preserved marks, and the tool returned success — so the agent believed it had
fixed something that never changed.

Two fixes, both in the shared @docmost/mcp DocmostClient so they reach BOTH the
standalone MCP server and the in-app AI chat (which loads @docmost/mcp):

- Verifiable result for every content mutator: mutatePageContent now computes a
  `verify` change-report (text inserted/deleted, blocks changed, per-mark-type
  delta, integrity/structure delta) via summarizeChange() and returns it on all
  mutators (incl. replaceImage via mutateLiveContentUnlocked). diffDocs is
  text-only, so the mark/structure delta is what surfaces formatting changes.
- edit_page_text hard-refuses formatting edits: applyTextEdits rejects an edit
  whose find/replace differ only in markdown markers (via stripBalancedWrappers,
  which strips balanced wrappers/links without trimming whitespace/emoji, so
  plain-text edits like trailing-space trims, snake_case, math are NOT refused).
  A fully-refused batch errors instead of silently succeeding.

Also updated the model-facing edit_page_text descriptions in BOTH tool layers
(packages/mcp/src/index.ts and ai-chat-tools.service.ts) to drop the misleading
"strip-and-retry tolerated" wording and point formatting changes to patch_node.

New unit tests: test/unit/diff-verify.test.mjs, test/unit/json-edit-refuse.test.mjs.
2026-06-18 05:46:13 +03:00
vvzvlad
ca0622ef01 feat(client): persist page-tree open/closed state per workspace+user
The sidebar page tree previously kept its expanded/collapsed state in an
in-memory jotai atom, so it reset on every reload. Persist it in
localStorage instead, scoped by `${workspaceId}:${userId}` so multiple
accounts sharing a browser origin don't leak tree state into each other.

- open-tree-nodes-atom: replace the in-memory atom with a facade over an
  atomFamily of atomWithStorage, scoped via currentUserAtom; keep the same
  [OpenMap, functional-updater setter] public API so space-tree needs no
  change.
- Use an explicit synchronous createJSONStorage + getOnInit: true so the
  saved state is read at atom init — no collapse-then-expand flicker on
  reload and no write against an un-hydrated empty map.
- README / README.ru: document persistent page-tree state in the
  "What's different from Docmost" table.
2026-06-18 05:42:12 +03:00
vvzvlad
1968879fe5 feat(ai-chat): add Markdown export button for agent chat
Add an "Export chat" button to the AI agent chat window header that
downloads the active conversation as a Markdown file. The export is
client-only: it serializes the already-loaded persisted message rows
(no new network call, no server/DB change) and includes the request
internals the chat already holds — tool calls with their input/output,
per-message token usage, finish reason and error info.

- New util apps/client/src/features/ai-chat/utils/export-chat.ts:
  buildChatMarkdown() + exportChatAsMarkdown(); reuses tool-parts
  helpers so tool labels match the on-screen labels; fence() escapes
  embedded code fences; slugify() yields a safe filename with a chatId
  fallback; downloads via file-saver's saveAs.
- ai-chat-window.tsx: IconFileExport button in the header, shown only
  for a saved chat with loaded rows (canExport); drag is unaffected.
- en-US: add "Export chat" and "You" i18n keys.
2026-06-18 05:12:15 +03:00
vvzvlad
a0a8d3c97f feat(client): show git-describe version next to the brand logo
Display the app version (output of `git describe --tags`) in the header
beside the gitmost logo: a clean tag renders as `vX.Y.Z`, otherwise the
tag plus commits-since and short hash (e.g. v0.90.1-56-g25975acd).

- vite.config.ts: resolve APP_VERSION from env (Docker/CI) -> git describe
  (local) -> package.json version fallback
- app-header.tsx: render APP_VERSION after the brand block (ml="md"),
  nudge the Home nav group (ml={50} -> "xl")
- Dockerfile: accept APP_VERSION build-arg in the builder stage (.git is
  excluded from the build context)
- CI: pass APP_VERSION build-arg — release.yml uses the tag, develop.yml
  computes git describe with fetch-depth: 0
- nx.json: add APP_VERSION to the build target inputs so the cache
  invalidates when the version changes
2026-06-18 04:51:21 +03:00
vvzvlad
ea56985efd refactor(ai): move AI settings to a dedicated /settings/ai page
Extract the AI provider/endpoints settings and the MCP server section out
of the Workspace "General" settings page into their own "AI" settings page,
reachable from a new sidebar entry.

- add page apps/client/.../settings/workspace/ai-settings.tsx (AiProviderSettings
  admin-gated + McpSettings), with its own Helmet title
- register the /settings/ai route in App.tsx and add SETTINGS.WORKSPACE.AI
  to app-route.ts
- add an "AI" item (IconSparkles) to the Workspace group in settings-sidebar
- trim workspace-settings.tsx back to the General section and drop the
  now-unused imports
2026-06-18 04:34:57 +03:00
vvzvlad
87d6bdfbd9 feat(ai): redesign AI settings page with per-endpoint test buttons
Rebuild the workspace AI settings page into card-based "Endpoints"
(Chat / Embeddings / Voice) matching the new design, and split the
single connection test into independent per-endpoint Test buttons.

- server: testConnection(workspaceId, capability) probes only the
  requested capability ('chat' | 'embeddings'); add TestAiConnectionDto
  and wire it through the /workspace/ai-settings/test controller
- client: testAiConnection(capability) + capability-typed mutation; two
  independent test mutation instances so Chat/Embeddings results are isolated
- client: full rewrite of ai-provider-settings into Endpoints section —
  drop the provider dropdown (driver is always openai, base URL + key
  always shown), move the "AI chat" and surface the "Semantic search"
  feature toggles into card headers, system message behind an Edit modal,
  pgvector/reindex footer, and a disabled Voice/STT stub
- client: restyle external MCP tools and the MCP server section; collapse
  the AI sections in workspace-settings; remove the standalone
  ai-chat-settings component
- toggles now surface the server error message (e.g. missing pgvector)
- i18n: add new English strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 04:20:33 +03:00
vvzvlad
3806406413 refactor(import): remove non-functional DOCX/PDF/Confluence import buttons
DOCX, PDF and Confluence import relied on a private EE module that was
dropped from this build, so those code paths only threw "enterprise
license" errors (DOCX/PDF) or silently did nothing (Confluence) while the
UI still presented them as working options.

- page-import-modal: drop the Word (DOCX), PDF and Confluence FileButtons
- remove the now-dead icon imports (IconFileTypeDocx, IconFileTypePdf,
  ConfluenceIcon), the docx/pdf/confluence file refs and their input-reset
  branches in handleFileUpload/handleZipUpload
- delete the orphaned confluence-icon.tsx component (no remaining importers)

Markdown, HTML, Notion and the generic zip upload remain unchanged.
2026-06-18 03:50:34 +03:00
vvzvlad
c8e41e8916 feat(ai): hybrid RRF retrieval, heading-breadcrumb chunks, merged search tool
Improve agent RAG quality with three changes, plus a roadmap doc for the rest.

- Indexer: prefix each chunk with its heading path ("Page > H1 > H2"), built by
  walking the ProseMirror JSON (heading nodes) so a `#` inside a fenced code block
  is never mistaken for a heading. Falls back to plain-text chunking on any error.
  buildChunkRows: drop indexOf-against-source offsets (breadcrumb prefixes break
  verbatim matching) for a cumulative cursor — offsets are provenance-only.
- Hybrid search: new migration adds a generated `fts` tsvector column + GIN index
  to page_embeddings (same english+f_unaccent config as pages.tsv). New
  PageEmbeddingRepo.hybridSearch fuses cosine + full-text rankings via Reciprocal
  Rank Fusion (k=60, equal weights) in one SQL query at chunk granularity.
- Tools: collapse semanticSearch + searchPages into one hybrid `searchPages` tool
  with a query-rewrite-oriented description; gracefully falls back to the REST
  full-text path when embeddings are unconfigured. Access control (space scope +
  page-permission post-filter) preserved. Add a query-rewrite hint to the default
  system prompt.
- docs/rag-improvements-plan.md: record what shipped and the deferred backlog
  (reranker, attachment indexing, eval harness, tuning).

Note: requires a corpus reindex to populate breadcrumbs on existing pages.
2026-06-18 03:43:01 +03:00
vvzvlad
91a63f0b2c fix(ai): stop RAG coverage bar sticking below 100% on empty pages
"Indexed N of M pages" stayed at e.g. "27 of 34" forever even after a
successful full reindex. The numerator counted pages that have embeddings
while the denominator counted ALL non-deleted pages, so empty / text-less
pages (which legitimately store zero embeddings) could never be reached.

Add PageRepo.countEmbeddablePages: counts non-deleted pages that have
non-empty textContent OR already have a stored embedding row, and use it as
the totalPages denominator in AiSettingsService.getMasked. The "has
embeddings" clause covers pages indexed from the content JSON (null
textContent) and guarantees indexedPages <= totalPages. No DB migration.
2026-06-18 03:33:38 +03:00
vvzvlad
4be849f8a1 style(ai-chat): reduce chat window corner radius to match design
The floating AI chat window used a hard-coded border-radius of 14px,
larger than any other element and out of line with the rest of the UI.
Switch to the Mantine md radius token (8px) so the window corners blend
with the inner cards and message bubbles.
2026-06-18 03:15:21 +03:00
vvzvlad
80c900eb54 fix(ai): make RAG indexer observable and bound hung embedding calls
The bulk embedding reindex could hang on a single page forever
("Indexed 27 of 34 pages") with zero log output:
- all progress logs were debug-level, suppressed in production (pino info);
- embedMany() had no timeout, so a slow/hung embeddings endpoint blocked
  the sequential per-page loop indefinitely.

Changes:
- ai.service.embedTexts: bound embedMany with AbortSignal.timeout
  (configurable via AI_EMBEDDING_TIMEOUT_MS, default 120000ms); on timeout
  throw a clear, greppable message, classified by both signal.aborted and
  the error name (TimeoutError/AbortError/ResponseAborted) so a real
  provider error racing the timer keeps its diagnostics.
- embedding-indexer.reindexWorkspace: promote lifecycle/progress logs to
  info; log "[i/N] indexing page <id>" BEFORE the await so a hang names the
  stuck page; warn on slow pages (>30s); add timing + final summary.
- .env.example: document AI_EMBEDDING_TIMEOUT_MS.
2026-06-18 03:07:02 +03:00
vvzvlad
b46aed53e3 feat(ai): surface provider error bodies + probe embeddings in test connection
A misconfigured embeddings endpoint failed the RAG indexer with an opaque
"Invalid JSON response" and was not caught by "Test connection" (which only
probed the chat model), so it only surfaced silently during background
indexing.

- add describeProviderError(): formats AI SDK errors as
  "<statusCode>: <message> | response body: <truncated one-line snippet>"
  (statusCode/message/responseBody never carry the API key)
- use it in the bulk-reindex catch and the embedding processor's formatter so
  the real cause (e.g. an HTML 404 from a wrong base URL) is visible in logs
- testConnection now probes chat AND embeddings independently: skips a probe
  when that capability is unconfigured, returns ok:false with a Chat:/Embeddings:
  prefix on real failure, "not configured" when neither is set
2026-06-18 02:35:01 +03:00
vvzvlad
52e19fe678 feat(ai): wire up workspace RAG bulk reindex + manual "Reindex now"
The WORKSPACE_CREATE_EMBEDDINGS / WORKSPACE_DELETE_EMBEDDINGS jobs were
enqueued (on AI Search enable/disable) but had no AI_QUEUE handler, so
existing pages were never indexed ("Indexed 0 of N pages") and disabling
never purged embeddings.

- EmbeddingProcessor: handle WORKSPACE_CREATE_EMBEDDINGS (bulk reindex all
  live pages) and WORKSPACE_DELETE_EMBEDDINGS (purge workspace embeddings)
- EmbeddingIndexerService: add reindexWorkspace() (skips when embeddings
  unconfigured; per-page error isolation) and removeWorkspace()
- PageRepo.getIdsByWorkspace(), PageEmbeddingRepo.deleteByWorkspace()
- AiSettingsService.reindex() + admin-only POST /workspace/ai-settings/reindex
- Frontend: "Reindex now" button, service call and mutation
- Stable per-workspace jobId with remove-before-add so a stale job can't
  block future reindexes; cancel the delayed purge on enable/reindex so it
  can't wipe freshly-built embeddings
2026-06-18 02:15:18 +03:00
vvzvlad
a7f244053b feat(ai): separate base URL and API key for chat vs embedding model
Per-workspace AI provider config previously shared a single base URL and
a single API key between the chat model and the embedding model. Add
dedicated, optional embedding endpoint/token that fall back to the chat
values when empty, preserving backward compatibility.

- db: new migration adds nullable `embedding_api_key_enc` to
  `ai_provider_credentials`; chat key stays in `api_key_enc`
- repo: add `upsertEmbeddingKey` / `clearEmbeddingKey` (on-conflict
  touches only its own column, so chat/embedding keys never overwrite)
- ai-settings.service: store non-secret `embeddingBaseUrl`; resolve()
  applies fallback (embeddingBaseUrl || baseUrl; embedding key || chat
  key); getMasked() exposes raw `embeddingBaseUrl` + `hasEmbeddingApiKey`,
  never the key; update() handles the embedding key write-only
- ai.service: getEmbeddingModel() builds openai/gemini/ollama with the
  embedding-specific URL/key; chat path unchanged
- client: new "Embedding base URL" and "Embedding API key" fields with
  fallback hints and a clear-key action

Requires running the DB migration on deploy.
2026-06-18 01:33:45 +03:00
vvzvlad
060c14cc27 fix(ai-chat): restore workspace AI chat enable toggle for self-hosted
The EE-frontend removal (a88b3f77) deleted enable-ai-chat.tsx, the only UI
that set the workspace flag settings.ai.chat. The backend gates remained:
the header assistant icon (page-header-menu) and the /ai-chat/stream endpoint
both require settings.ai.chat === true, and the flag is auto-enabled only in
cloud mode. As a result, self-hosted community builds could configure an AI
provider but had no way to turn the chat on, so the assistant icon never
appeared.

Restore a single admin toggle wired to the already-existing backend `aiChat`
field, modeled on the adjacent MCP settings switch:
- add aiChat?: boolean to the client IWorkspace type
- add AiChatSettings component (optimistic update, revert-on-error,
  workspaceAtom sync so the icon updates without reload)
- render it (admin-only) in workspace settings between AI / Models and
  AI / External tools (MCP)

Generative AI and AI search toggles, removed in the same commit, are left
out of scope.
2026-06-18 01:17:07 +03:00
vvzvlad
4f6b667cf7 feat(brand): roll out Gitmost logo, favicon and app name
Replace the bare brand text on pages with the Gitmost logo lockup
(mark + "gitmost" wordmark) and use the mark as the favicon.

- add generated logo lockups (text outlined from Space Grotesk SemiBold)
  in dark/light ink variants; add reusable theme-aware <BrandLogo> component
- use BrandLogo in the global header (mark-only on mobile, full lockup on
  desktop) and on auth pages, dropping the old Docmost icon + plain text
- point favicon to /brand/gitmost-favicon.svg (SVG primary + PNG fallbacks);
  regenerate favicon/app-icon PNGs from the brand SVGs
- rename app name Docmost -> Gitmost (getAppName, index.html title/apple
  title, manifest name); use getAppName() in the 404 title
- align theme/background colors to the brand tile (#0E1117)
- move brand guide and logos into docs/brand/ (canonical home) with a README,
  and serve runtime copies from apps/client/public/brand/
2026-06-18 00:12:26 +03:00
vvzvlad
41dfeeb77a perf(ai-chat): compact large tool outputs before persisting them
Read tools (getPage, getPageJson, getNode, diffPageVersions,
exportPageMarkdown) return whole pages with no size cap. Their outputs
were stored verbatim in metadata.parts and the tool_calls column, and
metadata.parts is replayed to the provider on every later turn via
convertToModelMessages. After reading a couple of large pages the prompt
grew by full page bodies each turn — rising token cost, latency and DB
row size.

Add compactToolOutput(): a pure, recursive, size-bounded compactor used
in assistantParts() and serializeSteps(). It preserves the value's kind
and small scalar fields (id/title/pageId, which the client reads to build
citations on reload) while truncating long strings, capping long arrays
with a marker, and collapsing subtrees past a depth limit. Small outputs
are returned unchanged by identity. Tool inputs are left intact so
replayed tool_use arguments keep their object shape.

Compaction runs only at persistence time (onFinish/onAbort), so the live
stream and the current turn's multi-step reasoning still see full bodies.

Add unit tests for compactToolOutput.
2026-06-17 23:44:51 +03:00
vvzvlad
4379163c21 fix(ai-chat): keep composer draft across new-chat id adoption remount
Typing into the composer while the agent was streaming lost the draft once
the turn finished: on a brand-new chat, adopting the freshly created chat id
changes ChatThread's key and remounts it, wiping ChatInput's local state.

Lift the composer draft into a module-level jotai atom (aiChatDraftAtom) so it
survives the remount. Reset it only on deliberate chat switches — startNewChat,
selectChat, and the page-history "AI agent" badge deep-link — so a draft never
leaks between conversations, while adoption (which goes through a useEffect)
preserves it.
2026-06-17 23:44:20 +03:00
vvzvlad
1f2d20244e feat(ai-chat): show RAG indexing coverage in AI settings
Display "Indexed N of M pages" on the AI provider settings page so admins
can see how much of the wiki is covered by vector-RAG semantic search.

- page-embedding.repo: add countIndexedPages() — distinct non-deleted pages
  that have stored embeddings in the workspace
- page.repo: add countByWorkspace() — total non-deleted pages
- ai-settings.service: compute both counts in getMasked() (Promise.all) and
  return them with the masked settings; inject PageEmbeddingRepo + PageRepo
- MaskedAiSettings / IAiSettings: add indexedPages + totalPages
- ai-provider-settings: render a dimmed coverage line under "Embedding model"
- i18n: add the "Indexed {{indexed}} of {{total}} pages" key (en-US, ru-RU)
2026-06-17 23:18:51 +03:00
vvzvlad
cb2b7a9851 fix(ai-chat): rebrand default agent persona to Gitmost
The default system prompt introduced the assistant as embedded in
"Docmost". Update DEFAULT_PROMPT to say "Gitmost" so the agent
presents itself with the correct product name.
2026-06-17 23:12:34 +03:00
vvzvlad
51c1830383 fix(ai-chat): keep provider errors visible after a new-chat remount
Surfacing the stream error via useChat().error alone was not enough: on a
brand-new chat the errored turn still fires onFinish -> onTurnFinished, which
adopts the freshly-created chat id and changes the <ChatThread> key, remounting
it with a fresh useChat whose transient `error` is gone. The thread re-seeds
from persisted history, where the assistant row has empty parts and the error
lives only in metadata.error — which was never rendered. Result: an empty
"AI agent" row and no visible error.

- Render the persisted metadata.error inline in MessageItem, so the error
  survives the remount and is also shown in reopened chat history.
- Carry metadata.error onto the rebuilt UIMessage in rowToUiMessage.
- Extract the error formatter into utils/error-message.ts (describeChatError)
  and reuse it for both the live Alert and the persisted error.
- Add metadata.error to the IAiChatMessageRow type.

Client-only; the server already persists metadata.error. No new i18n keys.
2026-06-17 19:41:54 +03:00
vvzvlad
022a1687a5 fix(ai-chat): surface real provider stream errors in the agent UI
The AI chat UI previously collapsed every non-403/503 failure into a
generic "could not respond" message, hiding real provider errors such as
OpenRouter HTTP 402 "requires more credits". The backend already forwards
the real "<status>: <message>" via pipeUIMessageStreamToResponse onError,
so the fix is client-side.

- describeError now returns the provider message verbatim for any error
  that is not one of our own gating responses, so 402 (credits), 429
  (rate limit) and similar causes are visible to the user.
- Match gating responses by the NestJS JSON "statusCode" field instead of
  loose substring/word checks, so a provider message that merely contains
  "403"/"503"/"disabled" is no longer misclassified and hidden.
- Add a providerDetail() helper that filters empty text and the opaque
  "An error occurred." / "Internal server error" placeholders, falling
  back to the generic message only then.

No backend changes; no new i18n keys.
2026-06-17 18:33:44 +03:00
vvzvlad
551f975886 fix(collab): use '-' instead of ':' in agent page-history jobId
BullMQ rejects custom job IDs containing ':' (Redis key separator),
throwing "Custom Id cannot contain :" inside the onStoreDocument hook
for every agent edit. This broke agent-driven page saves (MCP
create_page runs as actor='agent') with HTTP 400.

Switch the agent dedup suffix from `${page.id}:agent` to
`${page.id}-agent`. The jobId is only used as a BullMQ dedup key and is
never parsed by the history processor; page.id is a UUID, so the
hyphenated id cannot collide with a human job whose id is a bare page.id.
2026-06-17 17:38:32 +03:00
vvzvlad
7609538f9c feat(ai-chat): convert AI chat from aside panel to floating window
Replace the docked right-aside AI chat with a floating, draggable,
resizable, minimizable window per the GitmostAgent design. The "AI chat"
entry points (page header menu, page-history item) now open the window
instead of the aside tab.

- Add ai-chat-window.tsx + ai-chat-window.module.css: fixed-position
  window with viewport-clamped drag, CSS resize, minimize (hides body
  via CSS so ChatThread/useChat stays mounted and streaming is not
  aborted), and geometry kept in state (survives close/reopen, re-clamped
  on open via useLayoutEffect, size tracked with a ResizeObserver).
- Reuse ChatThread, ConversationList and the transcript components
  unchanged; move all orchestration (active chat, adopt-new-chat,
  openPage, queries) into the window.
- Header shows a tokens-only badge: sum of persisted metadata.usage for
  the active chat (no cost/context-total data available), hidden at 0.
- Add aiChatWindowOpenAtom; mount the window once in global-app-shell.
- Remove the aside "ai-chat" tab handling and delete ai-chat-panel.tsx.
- Type IAiChatMessageRow.metadata.usage; add "Minimize" and
  "Tokens used in this chat" i18n keys.
2026-06-17 17:11:01 +03:00
vvzvlad
afd2248a75 feat(ai-chat): tolerate markdown in edit_page_text/insert_node locators
Locators (edit_page_text `find`, insert_node `anchorText`) are matched
against the document's plain text, so a model-supplied locator carrying
markdown wrappers (**bold**, *italic*, `code`, [t](url)) or trailing emoji
never matched and the edit/insert failed. Add stripInlineMarkdown() and a
fallback: try the locator verbatim first (exact match wins, so literal
asterisks/underscores still work), and only on zero matches retry with a
markdown-stripped form. The ambiguity guard runs on the post-fallback count,
and `replace` / inserted node content are never stripped, so no formatting is
lost. Failed edits gain an atom-aware reason plus a bounded "closest block
text" hint; the insert_node "anchor not found" error now points at plain-text
anchors / anchorNodeId.

New packages/mcp/src/lib/text-normalize.ts (+ unit tests); wired into
json-edit.ts and node-ops.ts; tool descriptions updated. Tests: 212 pass.
2026-06-17 15:44:19 +03:00
vvzvlad
fc9088b74d fix(ai-chat): cross-mark text edits, partial batches, JSON-string node parity
edit_page_text (applyTextEdits) now matches at the inline-block level instead of
per text node, so a find/replace may cross bold/italic/link boundaries; the
replacement inherits marks from the unchanged common prefix/suffix via a diff
splice. Atom (non-text inline) slots can never be part of a match, making the
U+FFFC placeholder collision-safe, and inserted text never inherits an atom's
marks.

The edit batch is no longer all-or-nothing: applyTextEdits returns
{ doc, results, failed } and applies what it can; editPageText writes only on a
real change (no spurious history version for a no-op) and throws an aggregated,
actionable error only when nothing applied.

The AI-chat insert_node / patch_node / update_page_json tools now JSON.parse a
node/content argument that arrives as a string, matching the standalone MCP
server (this is what made insert_node fail under OpenAI tool calls).

Tool descriptions gain concrete ProseMirror examples and reflect the new
edit_page_text behavior. Adds/updates json-edit unit tests (183 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:57:58 +03:00
vvzvlad
0a9788e89a feat(collab): separate agent edits from human edits in page history
Page-history snapshots are debounced/coalesced (one per 1–5 min window,
jobId=page.id). A human edit followed by an agent edit in the same window
collapsed into a single snapshot, losing both the pre-agent human state and
a deterministic record of the agent's result.

Two provenance-aware boundaries now bracket an agent intervention:
- Before: on a user->agent transition, onStoreDocument synchronously pins the
  current (pre-agent) human content as its own history version tagged 'user',
  inside the page-write transaction, before the agent overwrites it.
- After: agent stores enqueue an immediate (delay 0), source-keyed history job
  (jobId=`${pageId}:agent`) so the agent's result snapshots deterministically
  as 'agent' and a later human edit (jobId=page.id) cannot coalesce/retag it.

Also add an `id desc` tie-break to findPageLastHistory so "last history" stays
deterministic when two snapshots share a created_at, consistent with
findPageHistoryByPageId.

Known trade-offs (Variant 1): the delay-0 worker re-reads the row, leaving a
millisecond mis-tag window; multiple agent edits in one turn may yield multiple
versions. The reverse agent->human boundary is intentionally out of scope.
2026-06-17 06:40:28 +03:00
vvzvlad
b0997cb749 feat(ai-chat)!: drop updateComment from the agent toolset
Editing an existing comment's text is irreversible (not version-tracked),
which breaks the agent's "only reversible operations" invariant. Remove the
updateComment tool that was added in the toolset-expansion change, leaving the
agent at 40 tools (comments: create/resolve only).

- Remove the updateComment tool from forUser().
- Remove updateComment from the DocmostClientLike interface.
- Reword SAFETY_FRAMEWORK: comments are create/resolve only; drop the
  comment-text-edit exception (keep the public-sharing one); keep the
  no-permanent-deletion guarantee and anti-prompt-injection rules.
- Tests: assert updateComment is NOT exposed (mirrors the deleteComment guard).
- docs(ai-agent-chat-plan): move updateComment to the "not exposed" list.
2026-06-17 06:03:19 +03:00