Compare commits

...
Sign in to create a new pull request.

126 commits

Author SHA1 Message Date
Roland Tannous
1eab078311 RAG: silence external prefetch model when chunks off-topic 2026-05-30 13:45:19 +04:00
Roland Tannous
d676cb8600 RAG: tighten local tool-loop nudge to prevent self-asked follow-ups 2026-05-30 13:12:15 +04:00
Roland Tannous
1923cb4eca
Merge branch 'main' into feature/rag 2026-05-29 21:56:55 +04:00
Roland Tannous
bab409ef84 Studio: fix KB files view widening dialog (WebKit overflow-x), use grid doc rows 2026-05-29 20:37:40 +04:00
Roland Tannous
8cc0b80704 Studio: clip KB detail slot so files view can't widen the dialog 2026-05-29 20:13:51 +04:00
Roland Tannous
978028bf82 Studio: fix KB files view overflow by using native scroll for the document list 2026-05-29 20:03:49 +04:00
Roland Tannous
1a7c1cccba Studio: stop KB preview from overflowing the settings dialog; inset counts, add header spacing 2026-05-29 19:55:53 +04:00
Roland Tannous
aa68568ff5 Studio: remove KB embedding override; prevent KB tab horizontal overflow 2026-05-29 19:39:37 +04:00
Roland Tannous
187a5853ba Studio: KB panel — embedder on its own line, uniform full-width doc pills 2026-05-29 19:20:46 +04:00
Roland Tannous
80e7ad4daf Studio: redesign KB management — per-base upload/files panels, doc pills, shared upload toast 2026-05-29 19:02:14 +04:00
Roland Tannous
6388f3f349 Studio: full-width KB list when no base is selected 2026-05-29 18:33:09 +04:00
Roland Tannous
71b5214ca7 Studio: restructure Knowledge bases tab layout; hint under header, compact when empty 2026-05-29 18:24:16 +04:00
Roland Tannous
b17b3ad727 Studio: fix overlapping sections in Knowledge bases settings tab 2026-05-29 16:25:18 +04:00
Roland Tannous
1844ec0776 Studio: clarify caption toggle text searchability 2026-05-29 16:11:17 +04:00
Roland Tannous
fc7fa0b5a7 Studio: tweak caption toggle helper text 2026-05-29 15:58:41 +04:00
Roland Tannous
1a31aa5b27 Studio: keep local RAG answers grounded; cap tools and steer off web when RAG is on 2026-05-29 15:42:32 +04:00
Roland Tannous
7335dc07a9 Studio: make figure captioning optional with a Retrieval toggle 2026-05-29 15:05:29 +04:00
Roland Tannous
c149cf58d1 Studio: place Cancel left of dismiss X, vertically centered 2026-05-29 14:11:29 +04:00
Roland Tannous
1112fc891b Studio: center Cancel in indexing toast, start file counter at 1 2026-05-29 13:18:11 +04:00
Roland Tannous
4635b0bc36 Studio: add dismiss X alongside Cancel on the indexing toast 2026-05-29 13:04:00 +04:00
Roland Tannous
2b0c6cf220 Studio: fix scopeKey type in RAG upload cancel wiring 2026-05-29 12:42:21 +04:00
Roland Tannous
68646a7abf Studio: cancel RAG indexing from the toast and reset the batch 2026-05-29 11:02:54 +04:00
Roland Tannous
05944a1e9b Studio: suppress RAG sources for uncited external prefetch 2026-05-28 21:06:55 +04:00
Roland Tannous
c34dd5f19a Studio: point OpenEnv dep at huggingface/ org (repo moved)
meta-pytorch/OpenEnv was transferred to huggingface/OpenEnv. The old
URL still works via GitHub's redirect from a clean clone, but uv's git
cache can fail to follow it on some machines — surfacing as a
'could not read Username for github.com' credential prompt mid-install.
Point the requirement at the canonical huggingface/OpenEnv.git (same
HEAD), which also sidesteps any stale cache entry keyed on the old URL.
2026-05-28 20:34:57 +04:00
Roland Tannous
cbe4b0866a Studio: show total chunks in the aggregate indexing toast
The completion toast reported only document count. Capture each job's
num_chunks from its complete event into the index-progress store and sum
across the batch, so the toast reads 'N documents and M chunks indexed'.
Already-indexed (deduped) files contribute 0 new chunks.
2026-05-28 20:24:15 +04:00
Roland Tannous
8c6acdc6a3 Studio: aggregate RAG indexing into one toast for multi-doc uploads
Uploading several documents (or a folder) produced a separate toast per
file, which piled up. Replace the per-job toast stack with a single
aggregate toast driven by a new index-progress-store that's populated at
addDoc time — so it counts queued files (held by the concurrency
semaphore) in the denominator, which the per-job rag-store map can't see.

- index-progress-store: one entry per file in the batch
  (queued/indexing/ready/error + 0..1 progress).
- Both addDoc paths register each file on entry and update it through
  the lifecycle (setIndexing after acquiring a slot; setProgress on job
  progress events; setReady/setError on terminal).
- ingestion-toast-stack: renders ONE toast — 'Indexing document(s) · X/Y
  · Z%' with a progress bar while in flight (overall = completed files +
  in-flight fractions, over total), 'RAG index ready · N documents
  indexed' (+ failures) when the last finishes, then auto-dismisses.
  Single-file uploads still read naturally ('Indexing document' / '1
  document indexed').

IngestionProgress / rag-store jobs are untouched (still used by the
KB detail panel). Not build/UI verified here (no bun).
2026-05-28 20:22:09 +04:00
Roland Tannous
08cee7cc0a Studio: bounded parallel RAG indexing + folder upload
Uploading many docs (or a folder) previously spawned an ingestion
subprocess per file all at once, thrashing the GPU/CPU. Add a
configurable concurrency limit and a folder picker.

- ragIndexConcurrency setting (default 1) in the chat runtime store,
  persisted like the other RAG scalar settings; exposed as a 'Parallel
  indexing' slider (1-8) at the bottom of the sidebar Retrieval section.
- New rag-index-queue.ts semaphore: each document upload acquires a slot
  before it starts and releases it once its ingestion job finishes
  (complete / error / already-indexed), so bulk uploads drain at the
  configured rate. Wired into both composer upload paths
  (use-thread-doc-uploads + shared-composer).
- Folder upload: a second 'Attach a folder' button on the RAG attach
  control uses a webkitdirectory input; every compatible file is routed
  through the same queue. Multi-file select already worked (the input has
  'multiple' and loops addDoc).
- Content-hash dedup (shipped earlier) means re-scanning a folder skips
  already-indexed files.

Not build/UI verified here (no bun); needs bun typecheck + a browser
check of bulk/folder upload draining at the set concurrency.
2026-05-28 19:52:49 +04:00
pre-commit-ci[bot]
dafc7092ad [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-28 13:18:30 +00:00
Roland Tannous
b836c3c76b Studio: RAG for external model providers via prefetch
External providers (OpenAI/Anthropic/Gemini) can't run the local
search_knowledge_base tool loop, so give them RAG by prefetching:
studio retrieves before calling the provider, injects the chunks into
the user prompt, and surfaces it as a synthetic tool call. Local models
are untouched (they keep tool-based RAG + decomposition).

Backend:
- New POST /api/rag/prefetch: momentarily loads the pre-cached helper
  (gemma-4-E2B-it-GGUF) via LlamaCppBackend(kill_orphans=False) to
  decompose the question into up to 3 queries, retrieves+merges+dedups
  per query, unloads the helper. Raw single-query fallback if the helper
  can't load. New core/rag/query_decompose.py owns the helper lifecycle.
- Factored the retrieval body of /search into _execute_search, reused by
  both endpoints.

Frontend:
- prefetchRag() client.
- chat-adapter external branch: gated on isExternalRequest + ragToolEnabled
  + scope!=off + ragScopeHasDocs (no docs -> no prefetch, prior behavior
  preserved). Formats hits as <chunk id=N> (parseChunks shape), injects
  into the last user message (send-only; not shown in the user bubble),
  seeds a synthetic search_knowledge_base tool-call part so the existing
  chunk-card UI + [N] citations + source badges all work unchanged.
- Extends PR #5674's disabled-tool guard: when RAG is off, reinforce
  'no document search (RAG) capabilities'; when prefetch ran, point the
  model at the injected excerpts instead.
- RAG pill enabled for external providers regardless of supports_tools.

Not build/UI verified here (no bun/GPU/keys); needs bun typecheck+test
and a browser round-trip with real provider keys.
2026-05-28 17:18:08 +04:00
Roland Tannous
cf7dec1f13 Studio: grey out RAG pill when the model can't call tools
RAG retrieval runs entirely through the local search_knowledge_base
tool. If the loaded model doesn't support tool calling (e.g. a
safetensors model whose template advertises tools in an unparsable
emission format, so supports_tools is suppressed), enabling RAG does
nothing — the model never calls the tool. The pill stayed lit and
clickable, which was misleading.

Gate the RAG pill on supportsTools (in addition to modelLoaded), the
same condition web/code use when there's no provider builtin. Applied
to both composer surfaces (shared-composer and the in-thread
RagToggle), with a 'RAG needs a model that supports tool calling'
tooltip on the disabled state.
2026-05-28 16:02:50 +04:00
Roland Tannous
f30c0a48dd Studio: don't duplicate composer chip when re-uploading an indexed doc
The backend dedups re-uploads and the sidepanel shows the doc once, but
the composer's pending-doc chips are created per addDoc call, so each
re-upload of an already-indexed file appended another 'Ready' chip for
the same document. In the already_indexed branch, if a chip with the
returned documentId already exists, drop the chip we just added instead
of marking it ready — so the composer shows each document only once.
2026-05-28 15:33:17 +04:00
Roland Tannous
266342a64e Studio: skip re-indexing an already-indexed document (content-hash dedup)
Re-uploading the same file into the same scope (KB or thread) used to
parse, chunk, caption and embed it all over again, creating a duplicate
set of chunks. Dedup by content hash instead:

  - schema: add rag_documents.content_hash (sha256 of the bytes) via the
    standard PRAGMA/ALTER migration, plus (scope, content_hash) indexes.
  - upload: _save_upload now streams the bytes through sha256 and returns
    the digest alongside path/name/size.
  - _start_ingestion: before inserting, look for a COMPLETED row in the
    same scope with the same hash. If found, delete the redundant upload
    from disk and return the existing document_id with already_indexed=
    true and an empty job_id — no ingestion job is started. Only
    'completed' rows dedup, so a failed/in-flight prior attempt can still
    retry. Scope-local: the same file in two KBs is indexed in each.
  - frontend: UploadResponse.already_indexed flows through the rag-store
    (skips job subscription) into both upload paths, which mark the chip
    ready immediately and toast '<file> is already indexed'.

Pre-existing rows have NULL content_hash and won't dedup until
re-uploaded once under the new path. Not build/UI-verified here (no bun
in this env); needs typecheck + browser check.
2026-05-28 15:19:06 +04:00
Roland Tannous
d55e5d1474 Revert "Studio: inline DOCX preview via docx-preview + DOMPurify"
This reverts commit ba78141ac5.
2026-05-28 15:08:19 +04:00
Roland Tannous
32e57fa1c5 Revert "Studio: render embedded images in DOCX preview"
This reverts commit f4b34f71c5.
2026-05-28 15:08:19 +04:00
Roland Tannous
f4b34f71c5 Studio: render embedded images in DOCX preview
docx-preview defaults to blob: URLs for embedded images, which DOMPurify
strips from img src (blob: isn't in its default allowed-URI list), so
figures vanished after sanitize. Switch docx-preview to useBase64URL so
images inline as data: URIs, and add ADD_DATA_URI_TAGS: ['img'] to the
DOMPurify config so those data: URIs survive sanitization. Script /
handler / javascript: stripping is unchanged.
2026-05-28 14:59:52 +04:00
Roland Tannous
ba78141ac5 Studio: inline DOCX preview via docx-preview + DOMPurify
Previously a DOCX citation only showed the extracted snippet + a
Download button (Risk #3: never render a user-supplied .docx inline).
Add a faithful inline render that keeps that guarantee:

  - New PreviewDocxView renders the .docx with docx-preview into an
    off-screen element, then injects DOMPurify-sanitized HTML into the
    live DOM (keeping <style> for docx-preview's scoped layout CSS).
    Script tags, event handlers and javascript: URLs are stripped, so
    a malicious .docx can't execute in the app origin.
  - preview-store now fetches the raw bytes for docx and exposes them
    via previewBlob, but deliberately keeps previewBlobUrl = null — no
    object URL is created, so the 'open raw original inline' path stays
    disabled (Risk #3) and Download remains the only raw-file path.
  - preview-panel routes docx -> PreviewDocxView when a blob is present,
    falling back to the text-view snippet otherwise. isInlineBlobAllowed
    still returns false for docx, so html/unknown behaviour is unchanged.
  - Deps: docx-preview + dompurify added to package.json.
  - Tests updated: docx now asserts bytes-fetched-without-object-URL.

Not build/UI-verified in this environment (deps not installed here);
needs bun install + browser check.
2026-05-28 14:53:06 +04:00
Roland Tannous
3a0c774795 Studio: humanize RAG ingest stage labels and completion toast
- Add human-readable stage labels for caption_images ('Captioning
  images') and extract_images ('Extracting images') so the raw
  underscore stage names no longer leak into the progress toast.
- On completion, the toast title is now 'RAG index ready' (was
  'Indexed') and the body reads '1 document and N chunk(s) indexed'
  (was 'Indexed N chunks'), with chunk pluralization.
2026-05-28 14:06:27 +04:00
Roland Tannous
3173689b59
Merge branch 'main' into feature/rag 2026-05-28 13:46:11 +04:00
Roland Tannous
95622dc405 Studio: don't leak exception details in RAG warmup/precache responses
CodeQL flagged information exposure through an exception in the /warmup
and /reranker/precache endpoints: both returned str(exc) in the JSON
body, exposing internal paths and stack details to the client. Keep
the full exception in the server-side warning log and return a generic
error message ('Failed to load embedder' / 'Failed to download
reranker') to the caller instead. The frontend only surfaces the
message in a toast, so a generic string is sufficient.
2026-05-28 13:42:46 +04:00
Roland Tannous
290201f62e Studio: trim captioner logs to invoked+complete, render subprocess logs as JSON
Two changes to the RAG captioning log output:

  - Drop the noisy per-image and path-selection info lines
    (using-chat-VLM, loading-helper, per-image done). Only the
    'caption_images: invoked' and 'caption_images: complete' lines
    remain; warnings for genuine failures (helper load, per-image
    request, helper unload) are kept.
  - Configure structlog at the top of the ingestion subprocess worker
    with the same env the parent uses. The worker runs in a spawned
    process where structlog was never set up, so its logs fell back to
    structlog's dev ConsoleRenderer ([info] ...) instead of the JSON
    renderer the rest of the app uses. Now captioner/parser logs from
    the subprocess match the parent's JSON format.
2026-05-28 13:39:44 +04:00
pre-commit-ci[bot]
d6a7c9f8c7 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-28 07:50:36 +00:00
Etherll
f25ea25570 Merge branch 'feature/rag' of https://github.com/unslothai/unsloth into feature/rag 2026-05-28 10:49:59 +03:00
Roland Tannous
7e3e69db3f Studio: raise ingestion toast stack above the settings sheet
The toast stack and the chat settings sheet were both z-50, so an open
side panel (rendered later in the DOM) covered the indexing toast. Bump
the stack to z-[9999] — comfortably above the sheet's z-50 — so the
ingestion toast stays visible like the Sonner reranker toast does.
2026-05-28 11:41:39 +04:00
Etherll
c0f8d486a4 Studio: fix RAG PDF main page rendering as a thin white strip
The thumbnail-rail refactor hoisted <Document> to wrap both the rail and
the main page so the PDF loads once. That moved the width-measuring scroll
container INSIDE <Document>, which only renders its children after the PDF
finishes loading. The old `useEffect(..., [])` ran on component mount —
when the container was still absent — so the ResizeObserver never attached,
`width` stayed null, and the main <Page> collapsed to width 0.

Replace the mount-effect measurement with a callback ref: the
ResizeObserver now attaches the instant the container node mounts,
regardless of when that happens relative to PDF load. Disconnects cleanly
on unmount / re-attach.

Verified: tsc clean, vite build succeeds, preview-pdf-smoke (incl. the
resize/debounce case) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:36:20 +03:00
Roland Tannous
3117cb5f99 Revert "Studio: restore draft thread (and its RAG docs) across page reloads"
This reverts commit d652f03b6e.
2026-05-28 11:32:22 +04:00
Roland Tannous
d652f03b6e Studio: restore draft thread (and its RAG docs) across page reloads
assistant-ui mints a fresh __LOCALID_* draft id on every page load, so
RAG docs uploaded under the previous draft id were orphaned after a
refresh — the doc panel queries useThreadDocuments(activeThreadId) and
the new id had nothing.

Persist activeThreadId in localStorage and, on the first settled render
after load, have ActiveThreadSync ask aui to switchToThread(persisted)
when it differs from the freshly-minted draft. Because the draft was
already persisted to the backend by initialize()/ensureThreadRecord
when its first doc was uploaded, the adapter's fetch() resolves it and
aui adopts it as mainThreadId. That keeps aui's mainThreadId and our
activeThreadId unified, so the earlier divergence (uploads under the
persisted id vs chat-completion reading aui's fresh id) can't recur —
unlike the reverted localStorage-only attempt, the chat-adapter's
unstable_threadId now equals the persisted id after the switch.

A one-shot ref ensures we only re-adopt on initial load; user-driven
new-chat / thread switches still flow through normally. If the
persisted draft was never initialized (no doc/message, not in the
backend), switchToThread rejects and we fall back to the fresh draft.
2026-05-28 10:26:12 +04:00
Etherll
79fc69741c Studio: post-merge build fixes — drop dead score handling, dedupe activeThreadId, add knowledgeBases i18n key
Follow-ups after merging origin/main into feature/rag:

* chat-adapter.ts: drop `score` field from DocumentSourcePart and the
  `chunk.score` copy — the remote "hide RAG retrieval scores from chunks,
  citations, and side panel" commit removed `score` from ParsedChunk.
* chat-settings-sheet.tsx: remove the duplicate `const activeThreadId =`
  introduced by the merge (kept the HEAD-side declaration at line 497).
* chat-settings-sheet.tsx: drop the "Min relevance" Slider that referenced
  `ragMinScore` / `setRagMinScore` — same intent as the hide-scores commit
  (these are still on the runtime store but the side-panel UI is gone).
* i18n locales (en, zh-CN): add `settings.tabs.knowledgeBases` translation
  key so the new TabDef entry passes the TranslationKey union check.

Verified: `tsc --noEmit` clean, `vite build` succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:20:22 +03:00
Etherll
9eb0778628 Merge remote-tracking branch 'origin/main' into feature/rag
# Conflicts:
#	studio/backend/core/inference/llama_cpp.py
#	studio/backend/routes/__init__.py
#	studio/backend/routes/inference.py
#	studio/frontend/package.json
#	studio/frontend/src/components/assistant-ui/sources.tsx
#	studio/frontend/src/components/assistant-ui/thread.tsx
#	studio/frontend/src/features/chat/api/chat-adapter.ts
#	studio/frontend/src/features/chat/chat-settings-sheet.tsx
#	studio/frontend/src/features/chat/shared-composer.tsx
#	studio/frontend/src/features/chat/stores/chat-runtime-store.ts
#	studio/frontend/src/features/settings/settings-dialog.tsx
2026-05-28 00:38:58 +03:00
Etherll
27b0a50a84 Studio: WIP — RAG preview UI, locator/auth refactor, tests, fixtures (pre-merge snapshot)
Snapshot taken before fast-forwarding feature/rag to origin and merging main.
Bundles in-flight work so the merge has a clean tree:

Frontend
- PDF preview panel (preview-panel, preview-pdf-view, preview-text-view,
  preview-unavailable) with lazy-rendered page thumbnail rail
- Resizable preview slot via useResizablePanelWidth hook (drag handle,
  localStorage persistence, viewport clamping)
- Neutral scrollbar + Source Excerpt card restyle (no brand-coloured rail)
- Preview-store + chat-adapter / rag-api / kb-detail wiring
- Frontend test harness (vitest.config, setupTests, biome update) and the
  paired __tests__ suites for preview, sources, document-row, chat-adapter,
  rag-api, knowledge-bases-tab, search-knowledge-base-tool-ui

Backend
- RAG locator + authorization modules with chunking / retrieval / tool /
  vector_store / studio_db updates
- Paired test_rag_* suites (authorization, locators, locator_backfill,
  locator_migration, preview_routes, preview_target_locators, source_identity)

Other
- tests/fixtures/rag-preview for preview route fixtures (sample.pdf,
  sample.txt, make_fixture_pdf.py)
- .gitignore + package(-lock).json adjustments for the new test runner

Will be squashed/reworked via interactive rebase after main is merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:13:43 +03:00
Roland Tannous
dd3ee02648 Studio: revert activeThreadId persistence (caused fresh-chat doc loss)
The two previous commits (41e43b6c7 and 3d3a00c2b) persisted
activeThreadId in localStorage so RAG docs would survive a page
reload. That broke fresh chats: stale localStorage values from a
prior session pinned activeThreadId to an old draft id, but the
chat-completion path reads aui's current mainThreadId via
unstable_threadId. The two diverged — uploads went under the stale
persisted id, the chat-completion turn looked up docs under the new
aui id, and nothing matched.

Revert the persistence + ActiveThreadSync guard. We're back to the
pre-fix behaviour where uploads-in-the-same-session work, and a
proper fix for the reload case (promote drafts to real chat_threads
rows on first doc upload so the id never changes) will land next.
2026-05-27 22:31:05 +04:00
Roland Tannous
3d3a00c2be Studio: stop ActiveThreadSync clearing persisted draft on reload
ActiveThreadSync was reacting to aui's mainThreadId === null on mount
(aui hasn't booted yet) by calling setActiveThreadId(null), which
wiped the just-restored persisted draft id from localStorage and
emptied the doc panel for the user's thread. The previous fix only
covered the 'aui minted a different LOCALID' branch; it missed the
'mainThreadId is null while aui boots' branch.

Treat a null mainThreadId as a no-op for the sync. Explicit clears
(new chat, sidebar delete) keep going through setActiveThreadId(null)
directly, so this guard doesn't trap stale state — it just gives the
persisted draft id a chance to survive until aui finishes booting.
2026-05-27 21:46:52 +04:00
Roland Tannous
41e43b6c7b Studio: persist activeThreadId across reloads so RAG docs survive
When the user uploads a doc to a brand-new chat (a draft thread with
an assistant-ui __LOCALID_* id), the backend stores rag_documents
rows scoped to that id. On page reload assistant-ui mints a fresh
__LOCALID_* for the new mainThreadId, so useThreadDocuments asks the
backend for docs under the NEW id and gets nothing, even though the
original rows are still on disk under the OLD id.

  - chat-runtime-store: persist activeThreadId in localStorage via
    a new CHAT_ACTIVE_THREAD_KEY, restore on init, save on every
    setActiveThreadId call (including clears, which write '').
  - ActiveThreadSync: when aui's freshly-minted mainThreadId is a
    __LOCALID_* and we already have a persisted __LOCALID_* draft,
    keep ours instead of overwriting. This only affects RAG/doc
    lookup; aui's chat history for the new draft starts empty
    either way, so there's no regression for users who don't have
    attached docs.

User-initiated thread switches (new chat, switching to a saved
thread, deleting the current thread) all go through setActiveThreadId
with the new id (or null), so they correctly replace/clear the
persisted value.
2026-05-27 21:33:51 +04:00
Roland Tannous
6c1761f16c Studio: force RAG pill off on model load; drop auto-enable migration
RAG is opt-in but the chat store had a hydration-time migration that
silently flipped ragToolEnabled to true whenever a persisted ragSource
was anything other than 'off'. Plus there was no logic to reset the
pill across model loads, so the pill stayed on across sessions even
after the user explicitly disabled and re-enabled it.

  - Remove the migration block in chat-runtime-store.hydrate — the
    embedder warmup still runs when ragToolEnabled is genuinely
    persisted true.
  - In use-chat-model-runtime's load-success handler, call
    setRagToolEnabled(false) (via the setter, so localStorage stays
    in sync) immediately after the loaded-state setState batch. Every
    fresh model load now starts with the pill off and the user must
    toggle it explicitly.
2026-05-27 21:18:04 +04:00
Roland Tannous
29e8cad8b8 Studio: fix RAG reranker deadlock on first load (Lock -> RLock)
get_reranker() acquires the module-level _lock and then, on first load,
calls unload() to clear any stale state before _load() instantiates the
CrossEncoder. unload() acquires the same _lock — but threading.Lock is
non-reentrant, so the second acquisition by the holding thread blocked
forever. Symptom: rerank=True hung the search_knowledge_base tool with
no further log output past 'rerank entered'.

Switch to threading.RLock so the same thread can re-enter without
blocking. unload()'s independent callers still work the same way; the
only behaviour change is that re-entrant acquisition from one thread
now succeeds.
2026-05-27 21:10:38 +04:00
Roland Tannous
1db654abb1 Studio: print reranker stage milestones to stderr for diagnostic visibility
When the reranker hung on rerank=True there were zero log lines after
'retrieved=N (no threshold)', which made it impossible to tell whether
the hang was in _load (CrossEncoder construction), in get_reranker's
lock acquisition, or in predict. Structlog routing may also be the
culprit since we never saw the 'Loading RAG reranker' info line.

Add unconditional stderr prints at each milestone — entered, device
resolved, before CrossEncoder, after CrossEncoder, rerank entered,
predict starting, predict done. These bypass any logger config and
show up directly in /tmp/studio.log next to the rest of the captured
stdout/stderr. Leaving structlog logger.info calls in place too so
the structured stream still gets the same data when routing works.
2026-05-27 21:05:22 +04:00
Roland Tannous
8fb2fb9e2a Studio: precache RAG reranker on toggle-on, not at app startup
Auto-downloading a 1.1 GB cross-encoder at every studio start is
wrong for users who never use rerank — reranker is opt-in by design.
Move the precache from a startup daemon thread to an explicit
POST /api/rag/reranker/precache endpoint, and have the chat settings
sheet call it the moment the 'Use reranker' switch is flipped on.

  - Backend: drop the startup _precache_reranker thread; add the
    /api/rag/reranker/precache route that calls precache_reranker().
  - Frontend: new precacheRagReranker() in rag-api, wired into the
    Switch's onCheckedChange so the download runs synchronously
    with a loading toast. On success: 'Reranker ready'. On failure:
    error toast + auto-flip the switch back off so the next query
    doesn't trigger another long hang.

First toggle-on pays the 1.1 GB download once; subsequent toggles
hit the HF cache and return ~instantly.
2026-05-27 20:45:17 +04:00
Roland Tannous
3f6a390df6 Studio: precache RAG reranker on startup; instrument loader + predict
The reranker model (BAAI/bge-reranker-base by default, ~1.1 GB) was
never precached, so the first user-facing rerank call paid the full
download cost — which on slow connections looked like a hang and got
retried by upstream timeouts. The deprecation warning that surfaced
during the hang was actually from sentence-transformers internals
firing while the download was still in flight.

Mirror the precache_helper_gguf pattern: add precache_reranker() that
calls snapshot_download in a daemon thread at FastAPI startup. The
first opt-in rerank now finds the weights already on disk and only
pays the in-process model load.

Also tighten the loader:
  - explicit device selection (cuda when torch.cuda.is_available,
    else cpu) so we don't rely on sentence-transformers auto-detect
    behaviour that has historically picked cpu under odd
    CUDA_VISIBLE_DEVICES configs;
  - structlog-shaped logs with elapsed_seconds around load + predict
    so a real runtime hang is visible in /tmp/studio.log with
    'RAG reranker predict starting' / 'RAG reranker predict done'.
2026-05-27 20:36:19 +04:00
Roland Tannous
8198459597 Studio: anchor toasts to top-right, fix tight spacing in ingest stack
The RAG ingestion toast stack was pinned to bottom-right and rendered
its title and progress text with gap-0.5, so the stage label and
percentage rendered close enough to look concatenated (e.g.
'indexing5%') on narrow widths. The default Sonner toaster also
defaulted to bottom-right, so other notifications were inconsistent
with the new layout.

  - Move the ingestion toast stack to top-right.
  - Set Sonner's default position to top-right.
  - Bump vertical gap between title and progress (gap-1.5).
  - Add horizontal gap-3 between stage label and percentage, plus
    shrink-0 + tabular-nums on the percent so it never collides with
    the (truncatable) stage label.
2026-05-27 17:56:28 +04:00
Roland Tannous
5edffa3feb Studio: skip RAG tool + system prompt nudge when scope has no docs
If RAG is toggled on but the active scope (thread or KB) has zero
indexed documents, exposing search_knowledge_base to the model just
wastes a tool-call turn — the model calls the tool, gets back 'no
matching chunks', and has to re-plan. The system prompt nudge that
instructs the model to call the tool before answering is similarly
counterproductive.

Frontend: before computing ragToolPathTaken in chat-adapter, fetch the
document list for the current scope (KB or thread) and require docs to
exist. The flag now gates both the system prompt injection and the
enabled_tools list. Defensive fallback: if the docs lookup fails the
flag stays true so we don't silently swallow RAG.

Backend: add _drop_rag_tool_if_scope_empty in routes/inference.py that
counts rag_documents for the request's rag_scope and strips
search_knowledge_base from the tool list when 0. Applied at both
chat-completion tool-filter sites so the protection works regardless
of which streaming path serves the request.
2026-05-27 17:48:21 +04:00
Roland Tannous
336ad815b3 Studio: hide RAG retrieval scores from chunks, citations, and side panel
Scores were only ever useful for debugging; surfacing them in chunk
cards (score X.XXX · dense Y.YYY) and citation hovers made the UI
noisy without giving the user anything actionable. Drop them in three
places:

  - Backend search_knowledge_base no longer emits score / dense_score
    attributes on the <chunk> tags fed to the LLM; the tool description
    is updated to match.
  - Chunk-card metadata in the assistant-ui tool result strips score /
    dense lines.
  - Source-badge hover tooltips drop the 'score N' meta line.

Also remove the 'Min relevance' slider from the chat settings sheet.
The backend min_score field stays plumbed (default 0 = no filter) so
the threshold can be re-exposed later or driven programmatically.
2026-05-27 17:15:18 +04:00
Roland Tannous
8145f1d527 Studio: splice VLM figure captions next to their 'Figure N:' line
Captions were appended at the bottom of the page text, so the chunk
containing 'Figure 1: Asymmetries ...' got chunked separately from
'**Figure**: Flowchart with ...' on the same page. Retrieval surfaced
the caption-text chunk but the VLM description landed in a different
chunk, leaving the LLM without the visual content right next to the
figure label.

Splice each VLM caption right after the matching 'Figure N:' (or
'Table N:') line as '**Figure N description**: ...', so:

  - The figure-boundary chunker now keeps both the original in-PDF
    caption AND the VLM description in the same chunk (which starts
    with 'Figure N:').
  - Multi-figure pages get per-figure attribution — the prefix
    'Figure N description' lets the LLM tell two figures on the same
    page apart, even though the bbox renderer still emits one image
    per page today (multi-figure clustering is a follow-up).
  - When the page text has no figure lines (DOCX/HTML/TXT or rare
    PDF layouts) the old end-of-page appendix is kept as a fallback.
2026-05-27 17:02:36 +04:00
Roland Tannous
0be7ca39a4 Studio: render figure regions (vector + raster) for RAG captioning
page.get_images() only returns raster blobs embedded in the PDF's
resource dictionary, so vector schematics like Figure 1 — drawn purely
with paths/lines — were never extracted, and the VLM only ever saw
incidental embedded photos that happened to live near figures.

Replace the xref-based extraction with bbox rendering: union the
bounding rects of all vector drawings and raster image_info entries on
each page, expand a few points, and render the region with
get_pixmap(clip=bbox, matrix=2x). The captioner now receives the
actual figure — schematic arrows, box labels, legend text, and any
inset photos — and produces a caption that describes the figure as a
whole, not just one embedded sub-image.

Also sharpen the captioner prompt: explicitly tell the VLM the image
is a single figure cropped from a PDF page, and not to describe page
chrome or body paragraphs.
2026-05-27 16:36:54 +04:00
Roland Tannous
6659bdf152 Studio: add figure-reference retrieval source to RAG hybrid search
Dense vectors don't preserve numbers (BGE-small treats 'Figure 1' and
'Figure 10' as nearly identical), so a query like 'what does Figure 1
show' got out-ranked by chunks describing other figures that share more
vocabulary with the question — even after the figure-boundary chunker
ensured Figure 1's chunk started with the literal caption.

Detect 'Figure N' / 'Table N' (numbered, decimal, appendix-style)
references in the query, look up chunks that start with those captions
directly, and feed the result as a third RRF source. RRF gives them
rank-0 in the third ranking and the fused score lifts them above the
dense-vocabulary noise. No-ops when the query has no figure ref.
2026-05-27 16:22:08 +04:00
Roland Tannous
ba0fd85e8b Studio: break RAG chunks at figure/table caption boundaries
Dense embedders mean-pool over a whole chunk, so a 'Figure 1:' caption
buried at the end of a 500-token body chunk gets washed out by the
surrounding theory text and never surfaces for queries about that
figure. Pre-split each page's markdown at the start of every
Figure/Table caption line so the caption anchors its own chunk, which
gives both BM25 and the dense vector a focused, figure-dominated
target. Handles numbered, decimal, and appendix-style labels
(Figure 1, Figure 1.2, Figure B.1, Table 4, Fig./Tab. abbreviations).
2026-05-27 16:09:23 +04:00
Roland Tannous
0481ac30c6 Studio: disable thinking for RAG captioner requests
Reasoning models (gemma-4, qwen3-thinking) burn the entire max_tokens
budget on <thinking> output and return empty visible content, so the
captioner produced zero captions for every image. Pass
chat_template_kwargs={enable_thinking: false} per-request to skip the
reasoning phase, and bump max_tokens 120 -> 200 as headroom.
2026-05-27 15:33:48 +04:00
Roland Tannous
3372a79043 Studio: route RAG ingestion + captioner loggers through structlog
Both modules used stdlib logging.getLogger which is not bridged to the
project's structlog config, so every probe / captioner log was silently
dropped. Switch to loggers.get_logger and convert %-format calls to
structlog kwargs so the captioning path becomes observable.
2026-05-27 15:20:22 +04:00
Roland Tannous
6debd0ab19 Studio: fix RAG VLM probe — import singleton from routes.inference, not core.inference.llama_cpp 2026-05-27 13:21:58 +04:00
Roland Tannous
af7917c45a Studio: don't kill chat-model llama-server when spawning helper backends 2026-05-27 12:44:44 +04:00
Roland Tannous
aedede2f2e Studio: text-mode default with VLM-captioned figure splicing; helper VLM fallback 2026-05-27 11:16:56 +04:00
Roland Tannous
26d2421b6c Studio: pass BytesIO (not PIL Image) to BGE-VL encode so model.data_process can re-open 2026-05-27 05:44:40 +04:00
Roland Tannous
bdbf47a341 Studio: default multimodal RAG embedder to BAAI/BGE-VL-large (Qwen3-VL kept as alt) 2026-05-27 00:11:40 +04:00
Roland Tannous
c6a1935dd2 Studio: caption RAG figures via loaded chat VLM only; drop separate captioning model 2026-05-26 23:53:23 +04:00
Roland Tannous
f522545b65 Studio: strip pymupdf4llm picture-text markers; fail ingest cleanly on FK error 2026-05-26 22:07:21 +04:00
Roland Tannous
c1bc79bc84 Studio: point RAG captioner at the pre-quantized Unsloth bnb-4bit repo 2026-05-26 21:52:17 +04:00
Roland Tannous
5883a2d6c6 Studio: load RAG captioner via Unsloth FastVisionModel (4-bit, native path) 2026-05-26 21:49:36 +04:00
Roland Tannous
9d6e893ed0 Studio: load RAG captioner in 4-bit via BitsAndBytesConfig 2026-05-26 21:47:38 +04:00
Roland Tannous
d0e894726b Studio: swap RAG captioner to Qwen3-VL-2B-Instruct (free-form, Vision2Seq-compatible) 2026-05-26 21:44:56 +04:00
Roland Tannous
38e22c15d7 Studio: bump llama-server prefill timeout to 300s + warm RAG embedder when RAG turns on 2026-05-26 21:24:31 +04:00
Roland Tannous
5351822ff8 Studio: default RAG mode to multimodal everywhere 2026-05-26 21:03:51 +04:00
Roland Tannous
c22fb88b4f Studio: composer + button uploads to the currently-selected RAG source (KB or thread) 2026-05-26 20:51:26 +04:00
Roland Tannous
7c1a09b350 Studio: VLM-caption figures at ingest + pass image hits to LLM + render in card 2026-05-26 20:17:52 +04:00
Roland Tannous
9d5719a475 Studio: widen doc source parts past SDK SourceMessagePart type 2026-05-26 17:58:03 +04:00
Roland Tannous
7c1f8efe99 Studio: render only LLM-cited RAG chunks as Source badges; globally unique chunk IDs 2026-05-26 17:52:58 +04:00
Roland Tannous
d6a5abb4ba Studio: render search_knowledge_base tool results as chunk cards 2026-05-26 16:23:54 +04:00
Roland Tannous
719bd38ddf Studio: cap RAG tool calls at 3 focused sub-queries per user turn 2026-05-26 16:04:04 +04:00
Roland Tannous
1f3a92cab9 Studio: force RAG tool path — disable prefetch, RAG-first tool order, must-call directive 2026-05-26 15:52:40 +04:00
Roland Tannous
3248052269 Studio: show RAG Mode/Chunking on fresh chats; lazy-init thread on change 2026-05-26 15:30:49 +04:00
Roland Tannous
ae013e3383 Studio: declare aui in SharedComposer for RAG attach flow 2026-05-26 15:24:56 +04:00
Roland Tannous
1b45656bf5 Studio: let RAG attach create the backend thread on first upload 2026-05-26 15:22:02 +04:00
Roland Tannous
04d4909ed6 Studio: format search_knowledge_base hits as fenced <chunk> blocks with score/page/tokens 2026-05-26 15:09:11 +04:00
Roland Tannous
86b52503dd Studio: trim verbose comments/docstrings across RAG code 2026-05-26 13:51:11 +04:00
Roland Tannous
3b477a816c Studio: add BM25/semantic/hybrid search-mode toggle to RAG settings 2026-05-26 12:39:50 +04:00
Roland Tannous
e48d836ffc Studio: default RAG source to thread documents in sidepanel 2026-05-26 12:11:38 +04:00
Roland Tannous
7b3a13fea4 Studio: address gemini-code-assist PR review
Four valid review comments from gemini-code-assist[bot] on #5759:

1. core/rag/bm25.py:_load — wrap bm25s.BM25.load + json.loads with
   specific exception handlers (FileNotFoundError, OSError,
   JSONDecodeError, ValueError) and log a warning instead of
   propagating a 500. Corrupt/partial bm25 dirs now degrade to
   empty-search rather than crashing the request.

2. core/rag/tool.py was importing _resolve_scope_embedder from
   routes/rag.py — a layering violation (core depending on
   routes). Move the resolver into a new core/rag/scope.py module
   along with the chat-settings key constants; routes/rag.py
   now re-imports it under the same name. Same behaviour, no
   cycle, one source of truth for the resolution logic.

3. core/rag/bm25.py:rebuild_index — call delete_scope before saving
   the new index so stale files from a previous build (or a
   bm25s naming change) never coexist with current files. The
   library's save() doesn't unlink files it doesn't write.

4. routes/rag.py:_save_upload was running f.write() synchronously
   inside an async def. Switch to anyio.open_file() so each chunk
   write runs in a worker thread instead of blocking the event
   loop on multi-MB uploads. Cleanup unlink happens after the
   async-with closes the handle so Windows is happy.

Skipped one (vector_store.py:133 'hasattr query_points' redundancy)
— that comment was on the pre-rewrite Qdrant code; the file is
now sqlite-vec backed and the hasattr check is gone.
2026-05-25 16:43:39 +04:00
Roland Tannous
005234c953 Studio: route RAG parent-process loggers through structlog
core/rag/db.py, vector_store.py, tool.py, bm25.py, and reranker.py
all run only in the FastAPI parent process. Switch their loggers
from Python stdlib to studio's structlog get_logger so their output
shows up in the same JSON stream as the rest of the backend (the
request_completed / RAG search lines).

embeddings.py and ingestion.py stay on stdlib because they execute
inside the mp.spawn ingestion subprocess, which doesn't inherit the
parent's structlog configuration.
2026-05-25 15:25:11 +04:00
Roland Tannous
2093fb1608 Studio: swap RAG vector store from Qdrant to sqlite-vec
asg017/sqlite-vec is Apache-2.0 and OSI-approved. Replaces
qdrant-client (~30 MB) with a small SQLite extension loaded into a
dedicated rag.db file. Single file holds RAG vectors; bm25s indexes
and chat-side studio.db are unaffected.

- New core/rag/db.py owns the rag.db connection and sqlite-vec load.
  Extension load runs once at first open. Process-wide singleton
  protected by a lock; check_same_thread=False + WAL handles the
  FastAPI thread pool.
- core/rag/vector_store.py keeps the same public API
  (ensure_collection / upsert_chunks / search / collection_exists /
  delete_scope / delete_document) so callers in routes/rag.py,
  core/rag/ingestion.py, core/rag/tool.py, and core/rag/retrieval.py
  don't change. ensure_collection is now a no-op; collection_exists
  returns True iff the scope has at least one indexed vector.
- search uses sqlite-vec's vec_distance_cosine and converts distance
  to similarity in [0, 1] so the per-scope min_score threshold
  semantics stay identical.
- Mixed-dim scopes coexist behind WHERE scope = ? — the per-scope
  embedder resolver guarantees one embedder per scope.
- requirements/rag.txt swaps qdrant-client for sqlite-vec.
- utils/paths/storage_roots.py drops rag_vectordb_root() (the old
  qdrant directory); rag.db lives directly under rag_root().
- Rewritten tests/python/test_rag_vector_store.py for the new
  semantics (collection_exists tracks populated scopes; new tests
  for filtered search and upsert conflict resolution).

Python build requirement: connection.enable_load_extension(True)
must be available. install.sh creates the venv via uv-managed
python-build-standalone, which is compiled with
--enable-loadable-sqlite-extensions, so this works on standard
installs. core/rag/db.py raises an actionable error on the rare
custom-interpreter case.
2026-05-25 15:13:59 +04:00
pre-commit-ci[bot]
b931b0039b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 06:39:01 +00:00
Roland Tannous
0130c1d1ff Studio: query RAG with the same embedder that indexed the scope
The query was always going through the default text embedder
(bge-small, 384-d) regardless of how the scope was ingested. A
multimodal thread indexed by Qwen3-VL (2048-d) crashed at cosine
similarity with 'shapes (104,2048) and (384,) not aligned'.

Resolve the scope's embedder at search time:
  - kb_<id> -> rag_knowledge_bases.embedding_model column
  - thread_<id> -> thread settings (with fallback to defaults +
    RAG_EMBEDDER_MATRIX matrix lookup)

Pass it through retrieve_hybrid/retrieve_dense to embeddings.encode
so the query lands in the same vector space as the docs. Both the
/api/rag/search route and the search_knowledge_base tool use the
same resolver.
2026-05-24 22:06:14 +04:00
Roland Tannous
fe124f5b63 Studio: re-bump sentence-transformers pin for Qwen3-VL embedder
Qwen3-VL-Embedding-2B's modules.json references
sentence_transformers.base.modules, introduced after 5.2.0.
Unlike BGE-VL, Qwen3-VL has no fragile custom forward subclass to
break against the newer ST internals, so the bump should land
cleanly for the multimodal path.
2026-05-24 21:56:27 +04:00
Roland Tannous
8666ec9bd6 Studio: swap multimodal RAG embedder to Qwen3-VL-Embedding-2B
Built on Qwen3 (not CLIP), so the 77-token text cap that bit
BGE-VL-base is gone — long chunks embed losslessly. Loads via
vanilla SentenceTransformer with trust_remote_code, no custom
adapter needed. 2048-d shared text+image space.

Adds qwen-vl-utils>=0.0.14 to rag.txt for image preprocessing,
required by the Qwen3-VL embedder family.
2026-05-24 21:48:02 +04:00
Roland Tannous
810e3a80be Studio: truncate BGE-VL text inputs to CLIP's 77-token cap
BGE-VL inherits CLIP's 77-token text positional embedding table —
longer chunks crash inside the text model with a shape mismatch.
Pre-tokenize with truncation=True, max_length=77 and call
get_text_features directly so the high-level encode() (which does
not truncate) is bypassed. Log when truncation happens — text
chunks beyond the cap are silently cut, so multimodal mode is
lossy on the text channel. Image channel is unaffected.
2026-05-24 21:42:23 +04:00
Roland Tannous
ad6fb95dba Studio: load BGE-VL via transformers AutoModel, bypassing ST shim
BGE-VL's sentence-transformers shim (bge_vl_clip_transformer.py)
is coupled to specific ST internals and broke across both the 5.2
and 5.3 pin attempts. The canonical load path documented at
bge-model.com is transformers.AutoModel with trust_remote_code +
model.set_processor() + model.encode(text=/images=).

- Wrap that path in _BGEVLAdapter exposing the slice of
  SentenceTransformer API the ingester uses (encode for text or PIL
  images, get_sentence_embedding_dimension, best-effort tokenize).
- Restore BAAI/BGE-VL-base in RAG_EMBEDDER_MATRIX.
- Revert sentence_transformers pin to 5.2.0 — no longer relevant
  since the multimodal path no longer touches ST.
2026-05-24 21:35:09 +04:00
Roland Tannous
16c0367e84 Studio: swap multimodal RAG embedder to clip-ViT-B-32
BGE-VL-base ships a custom Transformer subclass that's tightly
coupled to a specific sentence-transformers internal API — it
imports a module path missing in older ST and overrides forward()
expecting a return shape that changed in newer ST. We can't pin ST
to BGE-VL's exact version without risking the training/inference
paths that use the same library, so swap the multimodal default to
the canonical clip-ViT-B-32 which sentence-transformers wraps
natively (no trust_remote_code, no shim, ST-version-independent).
Same 512-d shared text+image space.
2026-05-24 21:32:01 +04:00
Roland Tannous
d5e62df2b8 Studio: bump sentence-transformers pin for multimodal RAG (BGE-VL)
BGE-VL's custom modeling code imports
sentence_transformers.base.modules.transformer, which doesn't exist
in the 5.2.0 pin. Bump to >=5.3.0,<7 so the new Module package is
available.
2026-05-24 21:27:42 +04:00
Roland Tannous
da698cbdae Studio: pass trust_remote_code to RAG embedder loader
BGE-VL (multimodal) and nomic-embed-text-v1.5 (late chunking) both
ship custom modeling code in their model repos; sentence-transformers
refuses to import the referenced module (e.g. bge_vl_clip_transformer)
without trust_remote_code=True. Safe to enable because the embedder
matrix is config-pinned — users don't supply arbitrary names.
2026-05-24 21:14:22 +04:00
Roland Tannous
236ad37091 Studio: switch RAG dense search to qdrant query_points API
client.search() was removed/renamed to query_points() in
qdrant-client 1.10+. Use the new API when available and fall back
to search() for older pinned versions.
2026-05-24 18:27:49 +04:00
Roland Tannous
9ce194482b Studio: add ragMinScore to PersistedChatSettings type
tsc -b caught the missing key after the runtime-store addition.
2026-05-24 18:17:03 +04:00
Roland Tannous
ccebbed190 Studio: always pre-fetch RAG + min-score threshold + retrieval logging
- chat-adapter pre-fetches retrieval on every turn when RAG is on,
  regardless of provider. Users no longer have to phrase queries as
  'the document I attached' for retrieval to fire. Local tool models
  still get search_knowledge_base registered as a refinement path.
- New per-thread ragMinScore slider (Min relevance, 0..1) gates
  retrieved hits by dense cosine similarity. Hits below the floor
  (and BM25-only hits with no dense signal) are dropped server-side
  so unrelated docs don't get injected when the user's query is
  off-topic from what's indexed.
- Backend logs at search start (scope, top_k, min_score, query
  preview), after retrieval (retrieved vs met_threshold counts),
  and on return (final hit count) for both /api/rag/search and the
  search_knowledge_base tool path.
- System-prompt nudge prepended when pre-fetch returns hits so the
  model knows to cite [1], [2] rather than paraphrase silently.
2026-05-24 18:15:02 +04:00
Roland Tannous
e27c079e3d Studio: renumber install steps so RAG is independent step 9 2026-05-24 16:24:54 +04:00
Roland Tannous
6c694d2ef8 Studio: move RAG deps to dedicated rag.txt and install in normal path
no-torch-runtime.txt is only consumed in NO_TORCH (Intel Mac
GGUF-only) mode, so qdrant-client / bm25s / pymupdf etc. were never
installed in the normal install path. Split them into rag.txt and
add a step to install_python_stack.py that installs it after studio
deps, skipped only when NO_TORCH is set.
2026-05-24 16:23:26 +04:00
Roland Tannous
30856de739 Studio: route in-thread doc uploads through RAG ingest
When ragToolEnabled is on, the in-thread composer's + button now
opens a doc-only picker and uploads selected files to the per-thread
RAG pipeline (matching SharedComposer). Pending chips show
Uploading -> Indexing -> Ready status; Send is blocked while any
doc is in-flight. Previously these files were base64'd inline as
native attachments, which bypassed RAG entirely.
2026-05-24 16:13:05 +04:00
Roland Tannous
510509318a Studio: mirror RAG pill in in-thread composer
The Phase 4 RAG button only existed in shared-composer; the
assistant-ui in-thread composer renders its own pill set, so RAG
was missing once a thread had messages.
2026-05-24 15:59:57 +04:00
Roland Tannous
5edbc99916 Studio: stable empty-array sentinel in RAG document selectors
Fresh [] literals from useRagStore selectors triggered Zustand's
Object.is snapshot check on every render, causing React #185 on the
chat page when documentsByScope[key] was unpopulated.
2026-05-24 15:24:48 +04:00
Roland Tannous
810b2e27f3 Studio: fix React #185 update-depth loop in RAG additions
Two useEffects added in Phase 2C and Phase 4 followed the anti-pattern
of calling a state setter inside the effect and listing the setter's
output in the dep array. Both could re-fire indefinitely when the
selected slice changed shape on each render — which the chat page hit
on first load.

chat-settings-sheet.tsx (thread settings loader)
- Before: `useEffect(load, [..., threadSettings, ...])` with
  `if (!threadSettings) load()` inside. After load, the Zustand
  selector returned a freshly-constructed slice, dep changed,
  effect re-ran. If anything in between caused threadSettings to
  briefly flicker undefined (e.g. a race during initial hydration
  or a fast subsequent thread switch), the load fired again and the
  cycle repeated.
- After: ref-guarded by activeThreadId — `threadSettingsLoadedRef`
  tracks which threadId has been loaded; the effect deps shrink to
  `[ragSource.kind, activeThreadId, loadThreadSettings]`, all
  stable per-thread, removing the feedback loop.

ingestion-toast-stack.tsx (terminal-job auto-dismiss)
- Before: `useEffect(..., [jobs, dismissedJobs])` with
  `setDismissedJobs(prev => new Set(prev).add(jobId))` inside the
  scheduled setTimeout. Each setter creates a new Set reference;
  the dep change re-triggers the effect, which clears and
  reschedules timers. Under fast SSE event arrival or a strict-mode
  double-mount, the scheduler runs faster than its cleanup and
  React caps the depth.
- After: dismissedJobs is read via a ref (kept in sync at the top
  of the component); the effect only depends on `[jobs]`. A
  `scheduledJobsRef` prevents duplicate timer scheduling for the
  same job across multiple effect runs, and the setDismissedJobs
  updater no-ops when the job is already dismissed.

No behavior change for the happy path — toasts still auto-dismiss
after DISMISS_DELAY_MS; thread settings still load on first sight
of a thread.
2026-05-24 14:02:10 +04:00
Roland Tannous
a3ad6015bc Studio: fix tsc errors in Phase 4 frontend
Two errors surfaced by the frontend build (`tsc -b`) after the Phase 4
commit c74fc13eb landed:

  src/features/chat/chat-settings-sheet.tsx(449,9): TS2451 — cannot
  redeclare block-scoped 'activeThreadId'.
  src/features/rag/stores/rag-store.ts(326,9): TS2774 — condition will
  always return true since this function is always defined.

Fixes
- chat-settings-sheet.tsx: an `activeThreadId` declaration already
  existed near the code-exec section (line 636). Phase 2B's RAG
  retrieval-section block introduced a second declaration at line 449.
  The earlier one is needed for the Retrieval block; drop the later
  redeclaration — downstream code still resolves it via lexical scope.
- rag-store.ts subscribeJob: `get().jobUnsubscribers[jobId]` indexes a
  `Record<string, () => void>`. Without `noUncheckedIndexedAccess` TS
  infers the result as the function type (never undefined), so
  `if (existing)` is always-truthy. Replace with `if (jobId in
  get().jobUnsubscribers) return;` — same semantics, satisfies TS.
2026-05-24 13:47:34 +04:00
Roland Tannous
c74fc13ebc Studio: RAG-as-tool composer button (Phase 4)
Promotes RAG to a first-class composer toggle alongside Think / Web
Search / Code, with tool-use semantics on local models that support
tools and a pre-fetch fallback on external providers. The model
decides when to call `search_knowledge_base` on local inference; on
external providers retrieval still fires before each message (the
existing pre-fetch path), gated on the same button.

Backend
- core/rag/tool.py (new): search_knowledge_base handler + JSON-schema
  tool spec. Resolves scope (kb_id wins over thread_id) from the
  request's rag_scope, runs retrieve_hybrid + optional rerank, then
  hydrates filename / page_number / text from sqlite and formats as
  numbered Markdown citations ('[1] file.pdf (page 5): ...') for the
  LLM to cite. Empty scope returns a user-facing hint; empty results
  return a clear no-match message instead of an empty string.
- core/inference/tools.py: SEARCH_KNOWLEDGE_BASE_TOOL added to
  ALL_TOOLS (lazy import keeps tools.py importable on inference
  paths that never touch RAG). execute_tool() gains a tool_context
  parameter that carries per-request extras the LLM doesn't see
  (currently just rag_scope). The new 'search_knowledge_base' branch
  dispatches to the handler with scope unpacked from tool_context.
- core/inference/llama_cpp.py + safetensors_agentic.py +
  orchestrator.py: thread tool_context through generate_chat_completion_
  with_tools / run_safetensors_tool_loop / execute_tool. Both local
  backends (GGUF llama-server and safetensors agentic) carry the same
  context object.
- models/inference.py: ChatCompletionRequest gains optional
  rag_scope: dict ({kb_id?, thread_id?, enable_rerank?, default_top_k?,
  reranker_model?}). Ignored unless 'search_knowledge_base' is in
  enabled_tools.
- routes/inference.py: both the GGUF and safetensors call sites for
  generate_chat_completion_with_tools forward payload.rag_scope into
  tool_context.

Frontend
- chat-runtime-store.ts: global ragToolEnabled boolean + setter +
  CHAT_RAG_TOOL_ENABLED_KEY localStorage, mirroring toolsEnabled /
  codeToolsEnabled. Settings-hydration migration auto-flips
  ragToolEnabled=true for pre-Phase-4 users who already had ragSource
  set, so existing RAG users don't silently lose retrieval on upgrade.
- shared-composer.tsx: new 'RAG' pill button after Images (uses
  lucide BookOpenIcon, composer-pill-btn style, data-active toggle).
  Disabled when no model is loaded. Toggling on from ragSource='off'
  auto-flips source to 'thread' so the sidebar lands ready-to-go.
- chat-adapter.ts:
  * The existing pre-fetch block is now gated on ragToolEnabled AND
    only fires when the tool path isn't viable (external provider OR
    local model without tool-use support). Tool-capable local models
    skip pre-fetch and let the LLM decide.
  * The local-model body assembly adds 'search_knowledge_base' to
    enabled_tools and packs ragSource + enableRerank + ragTopK into a
    rag_scope object the backend tool handler consumes.
- chat-settings-sheet.tsx: entire Retrieval CollapsibleSection is
  wrapped in {ragToolEnabled && ...} so it hides when the button is
  off — the button is now the single on/off control. The 'Off'
  option is removed from the Source dropdown (the button handles
  that). Default open when shown so settings are one click away.

Tests
- test_rag_tool_handler.py: handler covers empty query, missing
  scope, kb_id > thread_id precedence, thread-only path, citation
  formatting (numbered + page numbers + unknown source); tool spec
  shape (function/name/required); execute_tool dispatch with and
  without tool_context; ALL_TOOLS includes the new spec without
  dropping the existing ones.

Verification scope
- Local GGUF with tools: toggle button on, upload doc, ask about
  doc content → assistant emits a search_knowledge_base tool call
  card (rendered by the existing ToolFallback component since no
  custom UI exists yet — that's a v2 nice-to-have).
- External provider (Anthropic / OpenAI / etc.): same button, same
  UX, but uses the pre-fetch path under the hood.
- Migration: pre-existing ragSource != off → button initializes ON
  so retrieval keeps working.
2026-05-24 13:41:45 +04:00
Roland Tannous
7e069816a1 Studio: end-to-end multimodal RAG integration test
@pytest.mark.server integration coverage that drives the ingestion
subprocess in-process and asserts the full multimodal pipeline
produces image + caption chunks with proper kind / image_path /
pair_group metadata. Default pytest runs skip; explicitly:

    pytest -m server tests/python/test_rag_multimodal_integration.py

Generates a small PDF (text + PNG figure + caption paragraph) via
pymupdf, points UNSLOTH_STUDIO_HOME at tmp_path, resets the embedder
singleton, and calls _subprocess_worker with mode='multimodal'.
Asserts:
- at least one chunk of each kind (text / image / caption) is emitted
- image chunks carry a real on-disk image_path under tmp_path
- image + caption chunks share a pair_group

A second test confirms BGE-VL produces text and image vectors of the
same dimension — sanity-check for the shared-space assumption that
the multimodal retrieval path relies on.

Downloads BGE-VL-base (~600 MB) on first run, so the test is gated
behind the existing server marker rather than the default suite.
2026-05-24 12:53:41 +04:00
Roland Tannous
ee1ff2bb50 Studio: per-thread RAG chunking/mode overrides
Threads can now opt into late chunking or multimodal mode independently
of the KBs they reference. Per-thread settings persist in chat_settings
under thread:<id>:rag and fall back to the app-level defaults when the
thread hasn't set anything explicitly.

Backend (routes/rag.py)
- ThreadRagSettings / UpdateThreadRagSettingsRequest Pydantic models.
- GET/PUT /api/rag/threads/{thread_id}/settings backed by
  chat_settings (upsert_chat_settings_merge). Same (multimodal, late)
  constraint enforcement as the create + defaults endpoints.
- POST /api/rag/threads/{thread_id}/reingest now accepts the same
  body shape — if any field is set, the new settings are persisted
  via set_thread_rag_settings BEFORE the reingest, so subsequent
  uploads pick up the change too.
- upload_thread_document reads the per-thread settings and passes
  them through to _start_ingestion, replacing the previous hard-coded
  ('standard', 'text', RAG_EMBEDDING_MODEL) defaults.

Frontend
- rag-api.ts: ThreadRagSettings type + getThreadRagSettings /
  setThreadRagSettings wrappers. reingestThreadDocuments now accepts
  optional UpdateThreadRagSettingsRequest opts.
- rag-store.ts: threadSettings map keyed by threadId, plus
  loadThreadSettings / updateThreadSettings actions. reingestThread
  refreshes the local settings copy when opts were supplied.
- chat-settings-sheet.tsx Retrieval section: when source = thread,
  shows side-by-side Mode + Chunking selects above the documents
  list. Selecting a different value:
    - persists immediately if the thread has no docs
    - prompts "Re-index N documents?" if docs exist; on Yes calls
      reingestThread with the new opts, on No reverts the select
  The (multimodal, late) constraint is enforced via per-option
  disabled + tooltip, matching the KB create dialog.
2026-05-24 12:53:28 +04:00
Roland Tannous
ca83bea538 Studio: app-level RAG defaults for new KBs
Power users can now set their preferred chunking strategy / mode /
embedder once in Settings → Knowledge Bases and have new KBs use those
values by default, instead of toggling on every create.

Backend
- routes/rag.py:
  - GET /api/rag/defaults returns the stored RagDefaults (or sensible
    fallbacks when nothing is set: standard / text / null embedder).
  - PUT /api/rag/defaults is PATCH-style — only fields present in the
    body overwrite. The (multimodal, late) constraint is enforced
    here too, so users can't poison the defaults with a combination
    the create path would reject.
- Persistence reuses the existing chat_settings store via
  upsert_chat_settings_merge; the values live under a single
  rag.defaults key as a nested JSON dict.

Frontend
- rag-api.ts: getRagDefaults / setRagDefaults wrappers + RagDefaults
  + UpdateRagDefaultsRequest types.
- rag-store.ts: defaults state, loadDefaults / updateDefaults
  actions. loadDefaults swallows errors so a missing endpoint just
  leaves defaults null.
- rag-defaults-section.tsx (new): self-contained mode + strategy +
  embedding-model controls, persists on change. Used in the Settings
  KB tab below the ThreadIndexList section.
- knowledge-bases-tab.tsx: mounts RagDefaultsSection below thread
  indexes with a separator.
- kb-create-dialog.tsx: loads defaults on open and prefills the form
  with them (falls back to hard-coded standard / text when defaults
  haven't loaded yet). reset() returns to the latest defaults rather
  than the hard-coded ones.
2026-05-24 12:47:16 +04:00
Roland Tannous
2b85a165e6 Studio: global RAG ingestion toast stack (Phase 2C)
Adds a floating progress-card stack mounted at the app root that
watches useRagStore.jobs and renders one card per in-flight ingestion
job, regardless of which page the user is on. Wraps the existing
IngestionProgress component so the progress UI stays consistent with
the per-doc chips in the KB detail panel and chat sidebar.

- Terminal cards (complete/error) linger for 4s then auto-dismiss.
- A manual dismiss button is always available.
- Reduced-motion preference is respected (no slide animation).
- Positioned bottom-right, z-50, pointer-events-none container so
  clicks pass through to the page underneath.

Mounted in app/routes/__root.tsx next to the existing SettingsDialog
so it's visible across every authenticated route. Closes the last
deferred item from Phase 2.
2026-05-24 12:45:00 +04:00
Roland Tannous
d9dfc7db80 Studio: re-ingest existing KBs / threads with new settings (Backfill UX)
Closes the upgrade-path gap from Phase 3: a KB or thread whose chunks
were ingested under one strategy can now be rebuilt under a different
one without losing the uploaded files.

Backend
- routes/rag.py:
  - POST /api/rag/knowledge-bases/{kb_id}/reingest takes optional
    chunking_strategy / mode / embedding_model in the body. Validates
    the (multimodal, late) constraint via _validate_mode_combo, updates
    the rag_knowledge_bases row, wipes scope artifacts (sqlite chunks
    via cascade, Qdrant collection, bm25), and re-INSERTs a fresh
    rag_documents row + ingestion job per stored file. Returns the new
    job IDs so callers can stream progress via the existing SSE.
  - POST /api/rag/threads/{thread_id}/reingest is the simpler thread
    variant — no body, rebuilds with current defaults.
  - Shared _reingest_scope helper strips the UUID upload prefix when
    re-naming docs so users see the original filenames again.

Frontend
- rag-api.ts: reingestKnowledgeBase(kbId, opts) and
  reingestThreadDocuments(threadId) wrappers + ReingestResponse type.
- rag-store.ts: reingestKB / reingestThread actions refresh the KB +
  doc lists and subscribe to every returned job so the existing
  IngestionProgress chips render without further wiring.
- kb-reconfigure-dialog.tsx (new): mirrors KBCreateDialog but
  pre-fills with the KB's current strategy / mode / embedder, enforces
  the same (multimodal + late) constraint with disabled options, and
  confirms before submitting. Submit label flips between "Re-index"
  (no settings change) and "Reconfigure & re-index".
- kb-detail-panel.tsx: header gains the chunking + mode summary and
  a "Reconfigure…" button that opens the dialog. Button is disabled
  when the KB has no documents.
- chat-settings-sheet.tsx Retrieval section: "Re-index" button beside
  the existing "Clear thread index" when the thread has documents.

Tests
- test_rag_reingest.py: ReingestKBRequest accepts optional fields,
  rejects unknown enum values via Pydantic, and the shared mode-combo
  guard still bites on the reingest path.
2026-05-24 12:41:55 +04:00
Roland Tannous
68114fd223 Studio: multimodal RAG mode (Phase 3B-multimodal)
When a KB has mode = 'multimodal', ingestion extracts images alongside
text and embeds both into a shared 512-d vector space via BGE-VL-base.
Image hits become first-class search results — useful for slides,
reports, and diagrams where text-only retrieval loses ~30-50% of the
content.

Backend
- embeddings.py: new encode_images(image_bytes_list) — opens bytes via
  PIL and routes to the SentenceTransformer (BGE-VL accepts PIL images
  in the same encode call as text).
- ingestion.py: _subprocess_worker gains document_id arg and a new
  _stream_image_chunks() helper. For multimodal KBs the standard text
  chunking runs first, then images are saved to
  rag_uploads_root() / 'images' / <document_id> / img-NNNN.<ext> and
  embedded; for each image with an adjacent caption, both an
  'image'-kind chunk (vector = encoded image) and a 'caption'-kind
  chunk (vector = encoded caption text) are streamed back with a
  shared pair_group field.
- ingestion.py parent: _insert_chunks_and_collect_for_bm25 now reads
  kind / image_path / pair_group from the subprocess message,
  populates the new rag_chunks columns, and runs a second pass that
  sets linked_chunk_id for each image ↔ caption pair. BM25 indexes
  text + caption chunks only — image chunks have no tokenisable body.
- retrieval.py: Hit gains a `kind` field plumbed through bm25, dense,
  RRF, and rerank paths.
- reranker.py: image-kind hits skip CrossEncoder rerank (text-only
  model) but are appended back in their original relative position
  rather than dropped.
- routes/rag.py: new GET /api/rag/images/{document_id}/{filename}
  static-file route with realpath containment check. SearchHit gains
  `kind` and `image_url` fields so the chat UI can render image
  thumbnails alongside text hits. KB-doc upload threads kind/mode
  through to ingestion.

Frontend
- rag-api.ts: SearchHit gains optional `kind` and `image_url`.
- kb-create-dialog.tsx: new Mode select (Text / Multimodal) alongside
  the existing Chunking strategy select. The forbidden
  (multimodal + late) combo is enforced in the UI — each side
  disables the conflicting option on the other side with a tooltip
  explaining why. Embedding-model placeholder cycles through the
  three valid defaults (bge-small / nomic / BGE-VL).
- kb-list.tsx + chat-settings-sheet.tsx: 🖼️ MM badge alongside the
   Late one so multimodal KBs are obvious at a glance.

Tests
- test_rag_multimodal.py: parser returns images when want_images=True
  and skips them when False; _validate_mode_combo rejects the
  forbidden (multimodal, late) pair with 400; RAG_EMBEDDER_MATRIX
  contains the three valid combos and excludes the forbidden one;
  image URL construction shape is verified. A server-marked test
  loads BGE-VL-base end-to-end and confirms image + text vectors
  share the same dimension.

Phase 3 of the plan is now feature-complete on the backend; the
remaining items (re-ingest UX for changing strategy on existing KBs)
are tracked under "Backfill UX" and can land separately.
2026-05-24 12:33:08 +04:00
Roland Tannous
673b7f86ba Studio: late chunking opt-in per KB (Phase 3B-late)
When a KB has chunking_strategy = 'late', ingestion takes a separate
code path that embeds the full document in a single forward pass and
mean-pools token embeddings per chunk span. Each chunk vector carries
full-document context via the encoder's bidirectional attention —
Jina's published technique, ~+6.5 nDCG@10 on long docs.

Backend
- chunking.py: new chunk_pages_with_spans() that joins all pages into a
  single full_doc, runs the existing recursive splitter, and returns
  per-chunk (char_start, char_end) offsets. Page-number metadata is
  recovered by overlap with the original page ranges so PDF citations
  still work. Existing chunk_pages() unchanged.
- embeddings.py: new late_chunk_encode(doc_text, char_spans). Tokenizes
  the doc with return_offsets_mapping, runs the underlying transformer
  to get per-token last_hidden_state, then mean-pools per chunk span.
  When the doc exceeds the embedder's context, falls back to windowed
  late chunking with a 512-token overlap so cross-window context is
  partially preserved.
- ingestion.py _subprocess_worker: branches on chunking_strategy.
  'late' path: chunk_pages_with_spans -> late_chunk_encode -> one big
  chunks_batch message. 'standard' path unchanged. Both reuse the same
  parent-side pump.
- ingestion.enqueue_ingestion: new chunking_strategy + mode kwargs;
  defaults to 'standard' / 'text' for legacy callers. embedder model
  resolved via resolve_embedder() from the (mode, strategy) matrix.
- routes/rag.py: KB-doc upload reads chunking_strategy + mode from the
  KB row (defensive .get for pre-Phase-3 schemas) and threads them
  through _start_ingestion.

Frontend
- kb-create-dialog.tsx: new "Chunking strategy" select with Standard /
  Late options. Embedding-model placeholder switches to nomic when
  Late is picked. createKB request now carries chunking_strategy.
- kb-list.tsx + chat-settings-sheet.tsx: small " Late" badge next to
  late-chunking KB names in the settings KB list and the chat sidebar
  dropdown so users see the mode at a glance.

Tests
- test_rag_late_chunking.py: pure-python tests for chunk_pages_with_spans
  (chunks index back into full_doc; page numbers inherited by overlap;
  pages joined with blank line). A server-marked test loads
  all-MiniLM-L6-v2 to exercise late_chunk_encode end-to-end.

No multimodal yet; that's Phase 3B-multimodal (next PR).
2026-05-24 12:18:09 +04:00
Roland Tannous
4c1ab745d6 Studio: schema + API plumbing for per-KB chunking strategy + mode
Phase 3 lays two orthogonal per-KB knobs in the data and API layers so
follow-up commits (Phase 3B-late, Phase 3B-multimodal) only need to add
their code path and UI selector, not schema or types.

Schema (studio/backend/storage/studio_db.py)
- rag_knowledge_bases gains chunking_strategy ('standard'|'late', default
  'standard') and mode ('text'|'multimodal', default 'text'). Both are
  immutable after KB creation — changing either invalidates existing
  chunks because they were ingested through a specific pipeline.
- rag_chunks gains kind ('text'|'image'|'caption', default 'text'),
  image_path (NULLABLE), linked_chunk_id (NULLABLE) — used by Phase
  3B-multimodal to pair image chunks with their captions.
- Idempotent ALTER TABLE additions for existing installs (mirrors the
  chat_threads display_name / *_code_exec_container_id pattern earlier
  in the file).

API (studio/backend/routes/rag.py)
- ChunkingStrategy + KBMode Literal aliases.
- CreateKBRequest accepts both fields with backward-compat defaults.
- KBResponse exposes both.
- _validate_mode_combo rejects (multimodal, late) with 400 — no public
  open-weight embedder supports both at once. Surface the constraint
  early rather than failing silently during ingestion.

Config (studio/backend/utils/rag/config.py)
- RAG_EMBEDDER_MATRIX dict keyed by (mode, strategy) → embedder name.
- resolve_embedder() helper falls back to RAG_EMBEDDING_MODEL for legacy
  KBs that pre-date the columns.
- (multimodal, late) intentionally absent.

Frontend (studio/frontend/src/features/rag/)
- api/rag-api.ts: ChunkingStrategy + KBMode types; KnowledgeBase
  interface and createKnowledgeBase request type updated.
- stores/rag-store.ts: createKB signature uses the shared request type.

No user-visible UI changes yet — the only currently-usable combination
is (text, standard), so adding one-option selectors would be UX noise.
Phase 3B-late and Phase 3B-multimodal each add the relevant selector
option as part of shipping the code path.
2026-05-24 11:54:38 +04:00
Roland Tannous
c4b5889e53 Studio: layout-aware RAG parsers + heading-aware chunking (Phase 3A)
Replace bare-pypdf/python-docx/BeautifulSoup extraction with Markdown-
preserving parsers so the chunker can split on real heading boundaries
instead of running paragraphs together.

Parsers
- pdf.py:  pymupdf + pymupdf4llm.to_markdown() per page; pypdf kept as
           fallback when pymupdf can't open the file.
- docx.py: mammoth.convert_to_html() + markdownify, with an explicit
           style_map so Title/Heading 1..6 become h1..h6 in the output.
- html.py: BeautifulSoup pre-scrub (drop script/style) then markdownify
           so <h*>, <table>, <ul> convert faithfully.
- text.py: signature update only; TXT/MD pass through unchanged.
- parsers/__init__.py: new ParsedImage + ParseResult dataclass; parse()
  signature is now parse(path, *, want_images=False) -> ParseResult.
  ParseResult is iterable over .pages for backward compat.

Chunker
- chunking.py: prepend Markdown heading separators ("\n# " .. "\n#### ")
  to the priority list so heading-aware splits happen for free once the
  parsers emit Markdown.

Ingestion
- ingestion.py: single call site updated to consume ParseResult.pages.

Deps (no-torch-runtime.txt)
+ pymupdf>=1.24, pymupdf4llm>=0.0.17, mammoth>=1.7, markdownify>=0.13
- pypdf kept as a fallback path.

Tests
- test_rag_parsers.py asserts Markdown headings survive PDF/DOCX/HTML
  extraction; also exercises ParseResult iteration backward-compat.
- test_rag_chunking.py: new case verifying chunks start at Markdown
  heading boundaries when the input is Markdown.

Foundation for Phase 3B-late (heading-aware spans for late chunking)
and Phase 3B-multimodal (want_images=True enables image extraction in
the same parser layer). No schema or opt-in flags in this commit.
2026-05-24 11:10:13 +04:00
Roland Tannous
92994e8b83 Studio: add RAG with hybrid search, reranker, chat integration
Backend (studio/backend/):
- core/rag/: parsers (PDF/TXT/MD/DOCX/HTML via pypdf/python-docx/bs4),
  recursive token-aware chunker, embeddings singleton via
  FastSentenceTransformer.from_pretrained(for_inference=True), Qdrant
  local vector store, bm25s lexical index, RRF hybrid retrieval,
  spawn-subprocess ingestion job with SSE progress, optional
  CrossEncoder reranker (off-by-default).
- routes/rag.py: KB CRUD, doc upload (KB + per-thread), doc list/delete,
  ingestion SSE, hybrid+rerank search, thread-index list/clear.
- routes/chat_history.py: purge thread RAG artifacts on thread delete
  and clear-all (rag_documents has no FK cascade to chat_threads so
  uploads work on un-persisted threads).
- studio.db gains 4 RAG tables; storage_roots gains rag_*() helpers.
- auth/authentication.py: get_current_subject_sse accepts ?token=... so
  EventSource can stream ingestion progress.

Frontend (studio/frontend/):
- features/rag/: api client, Zustand store, hooks, dropzone, KB list,
  doc rows, ingestion-progress, thread-index list components.
- Settings dialog gains a Knowledge Bases tab (master/detail + thread
  documents list); /knowledge-bases deep-links to it.
- features/chat/: per-thread ragSource/enableRerank/ragTopK state in
  chat-runtime-store; Retrieval section in chat-settings-sheet with KB
  DropdownMenu (active highlight + per-row trash), thread doc list with
  Clear-thread-index button, RAG Top K slider, reranker toggle;
  chat-adapter retrieves before /v1/chat/completions and injects hits
  as a system block; shared-composer + button routes documents into
  pendingDocs (auto-uploads, send blocked while indexing).
2026-05-23 18:46:15 +04:00
121 changed files with 21222 additions and 756 deletions

3
.gitignore vendored
View file

@ -235,3 +235,6 @@ package-lock.json
!studio/backend/core/data_recipe/oxc-validator/package-lock.json
!studio/package-lock.json
llama.cpp/
/.Codex
/.gemini
/.antigravitycli

View file

@ -147,6 +147,40 @@ async def get_current_subject(
)
async def get_current_subject_sse(
token: Optional[str] = None,
authorization: Optional[str] = None,
) -> str:
"""Auth dep for SSE endpoints.
EventSource cannot send custom headers, so callers pass the bearer
as a ``?token=`` query param. Falls back to the Authorization
header so curl / API clients keep working.
Wire with ``Query(None)`` and ``Header(None)`` at the route layer:
async def stream(
current_subject: str = Depends(
lambda token = Query(None), authorization = Header(None):
get_current_subject_sse(token, authorization)
),
): ...
"""
raw = token
if not raw and authorization and authorization.lower().startswith("bearer "):
raw = authorization[len("bearer ") :].strip()
if not raw:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing token",
)
credentials = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = raw)
return await _get_current_subject(
credentials,
allow_password_change = False,
)
async def get_current_subject_allow_password_change(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:

View file

@ -616,7 +616,19 @@ class LlamaCppBackend:
3. unload_model() terminates llama-server subprocess
"""
def __init__(self):
def __init__(self, kill_orphans: bool = True):
"""Construct a backend wrapper around llama-server.
``kill_orphans`` (default True): at construction time, reap any
llama-server processes lingering from a prior studio crash. Safe
for the global singleton (only one LlamaCppBackend exists at
startup). Pass ``False`` for short-lived secondary instances
spawned alongside an already-running chat-model server (e.g.
the RAG captioner helper, `_run_with_helper`) otherwise the
constructor will kill the parent's healthy chat model because
it can't distinguish "another instance's healthy server" from
"a stale process".
"""
self._process: Optional[subprocess.Popen] = None
self._port: Optional[int] = None
self._model_identifier: Optional[str] = None
@ -699,7 +711,8 @@ class LlamaCppBackend:
# to decide whether to wait for the VRAM reclaim to finish.
self._last_kill_monotonic: float = 0.0
self._kill_orphaned_servers()
if kill_orphans:
self._kill_orphaned_servers()
atexit.register(self._cleanup)
# ── Properties ────────────────────────────────────────────────
@ -4227,9 +4240,12 @@ class LlamaCppBackend:
# without triggering a retry storm. Cancel during both
# prefill and streaming is handled by the watcher thread
# which closes the response, unblocking any httpx read.
# 300 s headroom for large models (30B+) re-prefilling after
# a tool call that returned a long result (e.g. RAG chunks
# with images) — prior 120 s was tripping on Gemma-4-31B.
prefill_timeout = httpx.Timeout(
connect = 30,
read = 120.0,
read = 300.0,
write = 10,
pool = 10,
)
@ -4452,6 +4468,7 @@ class LlamaCppBackend:
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
tool_context: Optional[dict] = None,
) -> Generator[dict, None, None]:
"""
Agentic loop: let the model call tools, execute them, and continue.
@ -5121,6 +5138,7 @@ class LlamaCppBackend:
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
tool_context = tool_context,
)
yield {

View file

@ -838,6 +838,7 @@ class InferenceOrchestrator:
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
tool_context: Optional[dict] = None,
use_adapter: Optional[Union[bool, str]] = None,
**_unused,
):
@ -895,6 +896,7 @@ class InferenceOrchestrator:
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
tool_context = tool_context,
)
def generate_with_adapter_control(

View file

@ -105,6 +105,7 @@ def run_safetensors_tool_loop(
max_tool_iterations: int = 25,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
tool_context: Optional[dict] = None,
) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator.
@ -340,6 +341,7 @@ def run_safetensors_tool_loop(
cancel_event = cancel_event,
timeout = eff_timeout,
session_id = session_id,
tool_context = tool_context,
)
except Exception as exc:
logger.exception("Tool %s raised: %s", tool_name, exc)

View file

@ -511,7 +511,15 @@ TERMINAL_TOOL = {
},
}
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL]
# Lazy import: don't pull rag stack on inference paths that never see RAG.
def _get_rag_tool_spec():
from core.rag.tool import SEARCH_KNOWLEDGE_BASE_TOOL
return SEARCH_KNOWLEDGE_BASE_TOOL
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, _get_rag_tool_spec()]
# OpenAI's function.name regex: ^[a-zA-Z0-9_-]{1,64}$ -- enforced before
@ -609,12 +617,17 @@ def execute_tool(
cancel_event = None,
timeout: int | None = _TIMEOUT_UNSET,
session_id: str | None = None,
tool_context: dict | None = None,
) -> str:
"""Execute a tool by name with the given arguments. Returns result as a string.
``timeout``: int sets per-call limit in seconds, ``None`` means no limit,
unset (default) uses ``_EXEC_TIMEOUT`` (300 s).
``session_id``: optional thread/session ID for per-conversation sandbox isolation.
``tool_context``: optional per-request extras the LLM does not see (RAG scope,
future per-tool overrides). Keys consumed:
- ``rag_scope``: ``{kb_id?, thread_id?, enable_rerank?, default_top_k?,
reranker_model?, min_score?, mode?}`` consumed by ``search_knowledge_base``.
"""
logger.info(
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
@ -653,6 +666,23 @@ def execute_tool(
return _bash_exec(
arguments.get("command", ""), cancel_event, effective_timeout, session_id
)
if name == "search_knowledge_base":
from core.rag.tool import search_knowledge_base
scope = (tool_context or {}).get("rag_scope") or {}
raw_mode = scope.get("mode")
mode = raw_mode if raw_mode in ("bm25", "dense", "hybrid") else "hybrid"
return search_knowledge_base(
query = arguments.get("query", ""),
top_k = arguments.get("top_k"),
scope_kb_id = scope.get("kb_id"),
scope_thread_id = scope.get("thread_id"),
enable_rerank = bool(scope.get("enable_rerank")),
reranker_model = scope.get("reranker_model"),
default_top_k = int(scope.get("default_top_k") or 5),
min_score = float(scope.get("min_score") or 0.0),
mode = mode,
)
return f"Unknown tool: {name}"

View file

@ -0,0 +1,2 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

View file

@ -0,0 +1,121 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Subject-scoped authorization for RAG document preview routes.
Used by `/api/rag/documents/{document_id}/file` and
`/api/rag/documents/{document_id}/preview-target` to enforce that the
current authenticated subject is allowed to see a given document and
chunk. Existence and authorization failures collapse to a single 404
so the API does not leak document IDs to a non-owner.
"""
from __future__ import annotations
import sqlite3
from fastapi import HTTPException
from storage.studio_db import get_connection
_NOT_FOUND_DETAIL = "Document not found"
def document_for_subject_or_404(
document_id: str,
current_subject: str,
) -> sqlite3.Row:
"""Return the `rag_documents` row if `current_subject` may access it.
Authorization rules:
- KB documents: the document's KB must have
`rag_knowledge_bases.owner_user_id == current_subject`. A KB with a
NULL owner is not accessible through this helper (legacy pre-auth
rows must be migrated or accessed via admin tooling).
- Thread documents: thread-scoped RAG documents are gated by an
explicit single-user invariant for Studio's current release. The
`chat_threads` table does not yet carry an `owner_user_id` column,
so we cannot bind a thread to a specific subject in the schema.
The helper still requires (a) an authenticated subject (enforced
by the route's `Depends(get_current_subject)`) and (b) that the
referenced thread actually exists in `chat_threads`. A missing
thread row collapses to 404 so a non-existent thread cannot
silently grant access through a dangling `thread_id`.
# TODO(thread-owner): once `chat_threads.owner_user_id` exists,
# join through it the same way KB documents do and drop the
# single-user invariant. Update the test
# `tests/test_rag_authorization.py::test_thread_doc_other_user_404`
# to assert per-user isolation rather than thread existence.
Both not-found and not-authorized raise `HTTPException(404)` with the
same detail string. Callers must NOT distinguish the two cases in
their response, to avoid leaking document existence to a non-owner.
Returns the document row so the caller can read `stored_path`,
`filename`, `content_type`, etc. without re-querying.
"""
if not document_id or not current_subject:
raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL)
with get_connection() as conn:
row = conn.execute(
"SELECT * FROM rag_documents WHERE id = ?",
(document_id,),
).fetchone()
if row is None:
raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL)
kb_id = row["kb_id"]
thread_id = row["thread_id"]
if kb_id is not None:
owner_row = conn.execute(
"SELECT owner_user_id FROM rag_knowledge_bases WHERE id = ?",
(kb_id,),
).fetchone()
if owner_row is None:
raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL)
owner = owner_row["owner_user_id"]
if owner is None or owner != current_subject:
raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL)
return row
if thread_id is not None:
# Single-user invariant (see TODO above). We require the
# thread row to exist; an unknown thread_id is treated as
# not-found, not as silent grant.
thread_row = conn.execute(
"SELECT id FROM chat_threads WHERE id = ?",
(thread_id,),
).fetchone()
if thread_row is None:
raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL)
return row
# Documents must belong to either a KB or a thread (DB CHECK
# constraint enforces XOR on insert); a row that satisfies
# neither is corrupt — treat as 404.
raise HTTPException(status_code = 404, detail = _NOT_FOUND_DETAIL)
def chunk_belongs_to_document(chunk_id: str, document_id: str) -> bool:
"""True iff `chunk_id` exists in `rag_chunks` for `document_id`.
Used by `/preview-target?chunk_id=...` after the caller has
already established subject authorization for `document_id`. Does
NOT perform authorization itself: callers MUST call
`document_for_subject_or_404(document_id, ...)` first, otherwise a
valid `chunk_id` from another subject's document would leak via a
`True` return.
"""
if not chunk_id or not document_id:
return False
with get_connection() as conn:
row = conn.execute(
"SELECT 1 FROM rag_chunks WHERE id = ? AND document_id = ?",
(chunk_id, document_id),
).fetchone()
return row is not None

View file

@ -0,0 +1,111 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Per-scope BM25 index (rebuild on change; bm25s has no cheap incremental insert).
Each scope dir holds the bm25s files + ids.json mapping row index chunk_id.
"""
from __future__ import annotations
import json
import shutil
import threading
from pathlib import Path
from typing import Any
from loggers import get_logger
from utils.paths.storage_roots import ensure_dir, rag_bm25_root
logger = get_logger(__name__)
_load_lock = threading.Lock()
_cache: dict[str, tuple[Any, list[str]]] = {}
def _scope_dir(scope: str) -> Path:
return rag_bm25_root() / scope
def _ids_path(scope: str) -> Path:
return _scope_dir(scope) / "ids.json"
def _has_index(scope: str) -> bool:
return _ids_path(scope).is_file()
def _evict(scope: str) -> None:
_cache.pop(scope, None)
def rebuild_index(scope: str, chunks: list[dict]) -> None:
"""Rebuild scope's BM25 from full chunk list. Empty list deletes the index."""
import bm25s
base = _scope_dir(scope)
if not chunks:
delete_scope(scope)
return
texts = [c["text"] for c in chunks]
ids = [c["id"] for c in chunks]
tokens = bm25s.tokenize(texts, show_progress = False)
retriever = bm25s.BM25()
retriever.index(tokens, show_progress = False)
# bm25s.BM25.save does not unlink stale files; clear the dir first.
delete_scope(scope)
ensure_dir(base)
retriever.save(str(base))
_ids_path(scope).write_text(json.dumps(ids))
with _load_lock:
_cache[scope] = (retriever, ids)
def _load(scope: str) -> tuple[Any, list[str]] | None:
if not _has_index(scope):
return None
with _load_lock:
if scope in _cache:
return _cache[scope]
import bm25s
try:
retriever = bm25s.BM25.load(str(_scope_dir(scope)), load_corpus = False)
ids = json.loads(_ids_path(scope).read_text())
except (FileNotFoundError, OSError, json.JSONDecodeError, ValueError) as exc:
# Corrupt/partial index: treat as missing so re-ingest rebuilds cleanly.
logger.warning(
"bm25 index unreadable for scope %s (%s: %s); treating as missing",
scope,
type(exc).__name__,
exc,
)
return None
_cache[scope] = (retriever, ids)
return _cache[scope]
def search(scope: str, query: str, k: int) -> list[tuple[str, float]]:
import bm25s
loaded = _load(scope)
if loaded is None:
return []
retriever, ids = loaded
if not ids:
return []
k_actual = min(k, len(ids))
q_tokens = bm25s.tokenize([query], show_progress = False)
indices, scores = retriever.retrieve(q_tokens, k = k_actual, show_progress = False)
out: list[tuple[str, float]] = []
for pos in range(indices.shape[1]):
idx = int(indices[0][pos])
out.append((ids[idx], float(scores[0][pos])))
return out
def delete_scope(scope: str) -> None:
base = _scope_dir(scope)
if base.exists():
shutil.rmtree(base, ignore_errors = True)
_evict(scope)

View file

@ -0,0 +1,212 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Figure captioning for RAG ingestion.
Two captioning sources, tried in order:
1. The user's currently-loaded chat VLM — when ``vlm_url`` / ``vlm_model``
are provided by the parent process (it probes ``llama_cpp.is_vision``
at enqueue time). Captions go through that model's OpenAI-compatible
``/v1/chat/completions`` endpoint as base64 ``image_url``.
2. A helper llama-server fallback that loads the pre-cached
``unsloth/gemma-4-E2B-it-GGUF`` (gemma-3n family, multimodal) with
its mmproj for vision. Spawned for the lifetime of a
``caption_images`` call, unloaded before return so no llama-server
process leaks past ingestion.
Defensive: any per-image failure returns an empty string; total
captioner unavailability (no chat VLM + helper load failure) returns
empty strings for every image. The caller (``_stream_image_chunks``)
falls back to the parser's page-text caption in that case.
"""
from __future__ import annotations
import base64
from io import BytesIO
from typing import Any, Optional
from loggers import get_logger
logger = get_logger(__name__)
_PROMPT = (
"This image is a region cropped from a PDF page that contains a "
"single figure (schematic, chart, diagram, table, photo, or "
"their combination). Describe the figure's structure and content "
"in <=80 words. Focus on factual visible content: axes, labels, "
"arrow labels, box labels, legends, visible text in the figure, "
"and what entities are connected to what. Do not speculate beyond "
"what is visible and do not describe the page header/footer or "
"body paragraphs."
)
_MAX_NEW_TOKENS = 200
# Downscale large images so the base64 payload stays manageable; the chat
# model's prefill cost scales with image-tile count, not pixel count, but
# very large inputs still bloat the JSON body. 1600 px on the long side
# matches PR #5351's chat-composer extractor.
_MAX_IMAGE_SIZE = 1600
_REQUEST_TIMEOUT_SECONDS = 120.0
# Helper VLM (used when no vision-capable chat model is loaded).
# Matches the model pre-cached by precache_helper_gguf() at studio
# startup so the captioner doesn't have to wait on a fresh download.
_HELPER_REPO = "unsloth/gemma-4-E2B-it-GGUF"
_HELPER_VARIANT = "UD-Q4_K_XL"
_HELPER_MODEL_NAME = "helper"
def _image_to_data_url(blob: bytes) -> str:
from PIL import Image
img = Image.open(BytesIO(blob)).convert("RGB")
if max(img.size) > _MAX_IMAGE_SIZE:
img.thumbnail((_MAX_IMAGE_SIZE, _MAX_IMAGE_SIZE))
buf = BytesIO()
img.save(buf, format = "JPEG", quality = 88)
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/jpeg;base64,{encoded}"
def _load_helper_vlm() -> Optional[tuple[Any, str, str]]:
"""Spawn a private LlamaCppBackend with the helper VLM + mmproj.
Returns ``(backend, base_url, model_name)`` on success, ``None`` on
failure. The caller is responsible for unloading the backend when
done (so the helper doesn't outlive the ingestion subprocess).
"""
try:
from core.inference.llama_cpp import LlamaCppBackend
# kill_orphans=False is critical: the global singleton is
# already running the user's chat-model llama-server. Killing
# "orphans" here would reap that healthy chat process because
# the orphan-killer can't tell two LlamaCppBackend instances
# apart by PID ownership.
backend = LlamaCppBackend(kill_orphans = False)
logger.info(
"RAG captioner: loading helper VLM as fallback",
repo = _HELPER_REPO,
variant = _HELPER_VARIANT,
)
ok = backend.load_model(
hf_repo = _HELPER_REPO,
hf_variant = _HELPER_VARIANT,
model_identifier = f"rag-captioner:{_HELPER_REPO}:{_HELPER_VARIANT}",
is_vision = True,
n_ctx = 4096,
n_gpu_layers = -1,
)
if not ok:
logger.warning("RAG captioner: helper VLM failed to start")
return None
return backend, backend.base_url, _HELPER_MODEL_NAME
except Exception as exc: # noqa: BLE001
logger.warning("RAG captioner: helper VLM load raised", error = str(exc))
return None
def _post_one(client: Any, endpoint: str, model: str, blob: bytes) -> str:
"""POST one image to the OpenAI-compatible endpoint, return caption."""
data_url = _image_to_data_url(blob)
payload = {
"model": model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": _PROMPT},
{
"type": "image_url",
"image_url": {"url": data_url},
},
],
}
],
"max_tokens": _MAX_NEW_TOKENS,
"temperature": 0.0,
# Reasoning models (gemma-4, qwen3-thinking, etc.) burn the whole
# token budget on <thinking> output and emit empty visible content
# — useless for a short image caption. Disable thinking for this
# request only; the user's chat sessions stay unaffected.
"chat_template_kwargs": {"enable_thinking": False},
}
response = client.post(endpoint, json = payload)
response.raise_for_status()
data = response.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
return content.strip() if isinstance(content, str) else ""
def caption_images(
image_bytes_list: list[bytes],
*,
vlm_url: Optional[str] = None,
vlm_model: Optional[str] = None,
) -> list[str]:
"""Generate one short caption per image; same-length output.
Tries the loaded chat VLM first (``vlm_url`` + ``vlm_model``). If
those are missing, spawns the helper VLM, captions, and unloads it
before returning. On any failure returns ``""`` for the affected
image. Never raises.
"""
logger.info(
"caption_images: invoked",
n_images = len(image_bytes_list),
vlm_url = vlm_url,
vlm_model = vlm_model,
)
if not image_bytes_list:
return []
import httpx
helper_backend: Optional[Any] = None
try:
# Resolve endpoint + model: chat VLM if available, else helper.
if vlm_url and vlm_model:
endpoint = f"{vlm_url.rstrip('/')}/v1/chat/completions"
model_name = vlm_model
else:
loaded = _load_helper_vlm()
if loaded is None:
logger.warning(
"caption_images: helper load failed, returning empty captions"
)
return ["" for _ in image_bytes_list]
helper_backend, helper_base_url, helper_model_name = loaded
endpoint = f"{helper_base_url.rstrip('/')}/v1/chat/completions"
model_name = helper_model_name
out: list[str] = []
with httpx.Client(timeout = _REQUEST_TIMEOUT_SECONDS) as client:
for idx, blob in enumerate(image_bytes_list):
try:
out.append(_post_one(client, endpoint, model_name, blob))
except Exception as exc: # noqa: BLE001
logger.warning(
"caption_images: per-image request failed",
idx = idx,
endpoint = endpoint,
error = str(exc),
)
out.append("")
non_empty = sum(1 for c in out if c.strip())
logger.info(
"caption_images: complete",
total = len(out),
non_empty = non_empty,
)
return out
finally:
# Always tear down the helper if we spawned one. Chat VLM (when
# provided by the parent) is left alone — it's not ours to manage.
if helper_backend is not None:
try:
helper_backend.unload_model()
logger.info("RAG captioner: helper VLM unloaded")
except Exception as exc: # noqa: BLE001
logger.warning("RAG captioner: helper unload failed", error = str(exc))

View file

@ -0,0 +1,309 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable
from .parsers import ParsedPage
# Match "Figure 1:", "Figure 1.2:", "Fig. 3.", "Table 4:" etc. at line-start,
# tolerating leading bold markers. Used to break chunks BEFORE such captions
# so the caption ends up at the start of its own chunk — dense embeddings
# pool over the whole chunk, so figure references buried at the end get
# diluted by surrounding body text.
_FIGURE_BOUNDARY_RE = re.compile(
# Number forms covered: "1", "12", "1.2", "B.1" (appendix-style),
# tolerating bold wrappers around either the label or the number.
r"^\**(?:Figure|Fig\.|Table|Tab\.)\s+[A-Z]?\.?\d+(?:\.\d+)?\**[\.:]",
re.MULTILINE | re.IGNORECASE,
)
def _split_at_figure_boundaries(text: str) -> list[str]:
"""Split markdown at the start of each figure / table caption.
Each segment starts with either the original text head or a "Figure N:" /
"Table N:" line, so the caption anchors the embedding of the chunk it
lands in. Returns the original text as a single-element list when no
captions are found.
"""
matches = list(_FIGURE_BOUNDARY_RE.finditer(text))
if not matches:
return [text]
segments: list[str] = []
last = 0
for m in matches:
if m.start() > last:
segments.append(text[last : m.start()])
last = m.start()
segments.append(text[last:])
return [s for s in segments if s.strip()]
@dataclass(frozen = True)
class Chunk:
text: str
token_count: int
page_number: int | None = None
source_page_index: int | None = None
page_char_start: int | None = None
page_char_end: int | None = None
line_start: int | None = None
line_end: int | None = None
TokenCounter = Callable[[str], int]
def _char_token_estimate(text: str) -> int:
return max(1, (len(text) + 3) // 4)
def _split_on(text: str, separator: str) -> list[str]:
if separator == "":
return list(text)
parts = text.split(separator)
if len(parts) == 1:
return parts
glued: list[str] = []
for i, part in enumerate(parts):
if i < len(parts) - 1:
glued.append(part + separator)
else:
if part:
glued.append(part)
return [p for p in glued if p]
def _atomic_split(
text: str,
separators: tuple[str, ...],
max_tokens: int,
count: TokenCounter,
) -> list[str]:
if count(text) <= max_tokens:
return [text]
for sep in separators:
pieces = _split_on(text, sep)
if len(pieces) <= 1:
continue
out: list[str] = []
for piece in pieces:
if count(piece) <= max_tokens:
out.append(piece)
else:
tail = separators[separators.index(sep) + 1 :]
out.extend(_atomic_split(piece, tail, max_tokens, count))
return out
approx_chars = max(1, max_tokens * 4)
return [text[i : i + approx_chars] for i in range(0, len(text), approx_chars)]
def _merge(
pieces: list[str],
max_tokens: int,
overlap_tokens: int,
count: TokenCounter,
) -> list[str]:
"""Greedy-merge into <= max_tokens chunks with overlap."""
chunks: list[str] = []
buffer: list[str] = []
buffer_tokens = 0
for piece in pieces:
piece_tokens = count(piece)
if buffer and buffer_tokens + piece_tokens > max_tokens:
chunks.append("".join(buffer))
if overlap_tokens > 0:
overlap: list[str] = []
running = 0
for prev in reversed(buffer):
prev_tokens = count(prev)
if running + prev_tokens > overlap_tokens:
break
overlap.insert(0, prev)
running += prev_tokens
buffer = list(overlap)
buffer_tokens = running
else:
buffer = []
buffer_tokens = 0
buffer.append(piece)
buffer_tokens += piece_tokens
if buffer:
chunks.append("".join(buffer))
return [c.strip() for c in chunks if c.strip()]
def _line_bounds(text: str, start: int, end: int) -> tuple[int, int]:
"""Return 1-based inclusive line numbers for a page-local span."""
line_start = text.count("\n", 0, start) + 1
line_end = text.count("\n", 0, max(start, end - 1)) + 1
return line_start, line_end
def _locate_piece(
page_text: str,
piece: str,
search_cursor: int,
) -> tuple[int | None, int | None, int | None, int | None, int]:
idx = page_text.find(piece, search_cursor)
if idx < 0:
idx = page_text.find(piece)
if idx < 0:
return None, None, None, None, search_cursor
end = idx + len(piece)
line_start, line_end = _line_bounds(page_text, idx, end)
return idx, end, line_start, line_end, idx + 1
# Markdown headings first so layout-aware parser output splits at sections.
DEFAULT_SEPARATORS: tuple[str, ...] = (
"\n# ",
"\n## ",
"\n### ",
"\n#### ",
"\n\n",
"\n",
". ",
" ",
"",
)
def chunk_pages(
pages: list[ParsedPage],
*,
max_tokens: int,
overlap_tokens: int,
token_counter: TokenCounter | None = None,
separators: tuple[str, ...] = DEFAULT_SEPARATORS,
) -> list[Chunk]:
"""Split pages independently so page_number stays attached to chunks."""
count = token_counter or _char_token_estimate
out: list[Chunk] = []
for page_index, page in enumerate(pages):
search_cursor = 0
for segment in _split_at_figure_boundaries(page.text):
atomic = _atomic_split(segment, separators, max_tokens, count)
merged = _merge(atomic, max_tokens, overlap_tokens, count)
for piece in merged:
start, end, line_start, line_end, search_cursor = _locate_piece(
page.text,
piece,
search_cursor,
)
out.append(
Chunk(
text = piece,
token_count = count(piece),
page_number = page.page_number,
source_page_index = page_index,
page_char_start = start,
page_char_end = end,
line_start = line_start,
line_end = line_end,
)
)
return out
_PAGE_SEPARATOR = "\n\n"
def chunk_pages_with_spans(
pages: list[ParsedPage],
*,
max_tokens: int,
overlap_tokens: int,
token_counter: TokenCounter | None = None,
separators: tuple[str, ...] = DEFAULT_SEPARATORS,
) -> tuple[str, list[Chunk], list[tuple[int, int]]]:
"""Late-chunking variant: joins pages so the embedder sees the whole doc.
Returns ``(full_doc, chunks, char_spans)``; ``char_spans[i]`` is the
(start, end) char offset of ``chunks[i].text`` inside ``full_doc``.
Page numbers are recovered by overlap with the original page ranges.
"""
count = token_counter or _char_token_estimate
parts: list[str] = []
page_ranges: list[tuple[int, int, int, int | None]] = []
cursor = 0
for index, page in enumerate(pages):
parts.append(page.text)
start = cursor
end = cursor + len(page.text)
page_ranges.append((start, end, index, page.page_number))
cursor = end
if index < len(pages) - 1:
cursor += len(_PAGE_SEPARATOR)
full_doc = _PAGE_SEPARATOR.join(parts)
atomic: list[str] = []
for segment in _split_at_figure_boundaries(full_doc):
atomic.extend(_atomic_split(segment, separators, max_tokens, count))
merged = _merge(atomic, max_tokens, overlap_tokens, count)
chunks: list[Chunk] = []
char_spans: list[tuple[int, int]] = []
search_cursor = 0
for piece in merged:
text = piece.strip()
if not text:
continue
idx = full_doc.find(text, search_cursor)
if idx < 0:
# Overlap can push past a chunk's true start; restart from head.
idx = full_doc.find(text)
if idx < 0:
continue
end_idx = idx + len(text)
page_locator = _page_for_span(idx, end_idx, page_ranges)
source_page_index: int | None = None
page_number: int | None = None
page_char_start: int | None = None
page_char_end: int | None = None
line_start: int | None = None
line_end: int | None = None
if page_locator is not None:
page_start, page_end, page_idx, page_no = page_locator
source_page_index = page_idx
page_number = page_no
page_char_start = max(0, idx - page_start)
page_char_end = min(page_end, end_idx) - page_start
line_start, line_end = _line_bounds(
pages[page_idx].text,
page_char_start,
page_char_end,
)
chunks.append(
Chunk(
text = text,
token_count = count(text),
page_number = page_number,
source_page_index = source_page_index,
page_char_start = page_char_start,
page_char_end = page_char_end,
line_start = line_start,
line_end = line_end,
)
)
char_spans.append((idx, end_idx))
# Advance past start (not end) so overlapping next chunk is findable.
search_cursor = idx + 1
return full_doc, chunks, char_spans
def _page_for_span(
start: int,
end: int,
page_ranges: list[tuple[int, int, int, int | None]],
) -> tuple[int, int, int, int | None] | None:
for ps, pe, page_index, page_number in page_ranges:
if start < pe and end > ps:
return ps, pe, page_index, page_number
return None

View file

@ -0,0 +1,91 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Lazy process-wide rag.db connection with sqlite-vec loaded."""
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from loggers import get_logger
from utils.paths.storage_roots import ensure_dir, rag_root
logger = get_logger(__name__)
_conn: sqlite3.Connection | None = None
_conn_lock = threading.Lock()
def rag_db_path() -> Path:
return rag_root() / "rag.db"
def _load_sqlite_vec(conn: sqlite3.Connection) -> None:
try:
conn.enable_load_extension(True)
except AttributeError as exc:
raise RuntimeError(
"This Python build cannot load SQLite extensions "
"(connection.enable_load_extension is unavailable). RAG "
"requires sqlite-vec, which loads as a SQLite extension. "
"Re-install studio via install.sh so the venv uses uv's "
"managed Python (python-build-standalone), compiled with "
"--enable-loadable-sqlite-extensions."
) from exc
import sqlite_vec
sqlite_vec.load(conn)
conn.enable_load_extension(False)
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS rag_vectors (
chunk_id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
document_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'text',
dim INTEGER NOT NULL,
vector BLOB NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_rag_vectors_scope
ON rag_vectors(scope);
CREATE INDEX IF NOT EXISTS idx_rag_vectors_scope_doc
ON rag_vectors(scope, document_id);
"""
)
conn.commit()
def get_rag_connection() -> sqlite3.Connection:
global _conn
with _conn_lock:
if _conn is None:
ensure_dir(rag_root())
conn = sqlite3.connect(
str(rag_db_path()),
check_same_thread = False,
)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
_load_sqlite_vec(conn)
_ensure_schema(conn)
_conn = conn
logger.info("RAG vector store opened", path = str(rag_db_path()))
return _conn
def _reset_for_tests() -> None:
global _conn
with _conn_lock:
if _conn is not None:
try:
_conn.close()
except Exception:
pass
_conn = None

View file

@ -0,0 +1,456 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""RAG embedder singleton. Independent of the chat InferenceBackend."""
from __future__ import annotations
import logging
import threading
from typing import Any
from utils.rag.config import RAG_EMBED_BATCH_SIZE, RAG_EMBEDDING_MODEL
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_model: Any | None = None
_model_name: str | None = None
_embedding_dim: int | None = None
def _load(model_name: str) -> Any:
logger.info("Loading RAG embedder: %s", model_name)
# BGE-VL's ST shim breaks across ST versions; load via AutoModel.
if model_name.startswith("BAAI/BGE-VL"):
return _BGEVLAdapter(model_name)
from unsloth import FastSentenceTransformer
# trust_remote_code: nomic-embed-text-v1.5 needs custom modeling for 8K ctx.
return FastSentenceTransformer.from_pretrained(
model_name,
for_inference = True,
trust_remote_code = True,
)
class _BGEVLAdapter:
"""SentenceTransformer-shaped adapter over BGE-VL's AutoModel."""
def __init__(self, hf_model_name: str):
from transformers import AutoModel
import torch
self._model = AutoModel.from_pretrained(
hf_model_name,
trust_remote_code = True,
)
# Required: BGE-VL's encode() raises without an installed processor.
self._model.set_processor(hf_model_name)
device = "cuda" if torch.cuda.is_available() else "cpu"
self._model.to(device).eval()
self._device = device
self._dim: int | None = None
def _normalize(self, tensor):
import torch.nn.functional as F
return F.normalize(tensor, p = 2.0, dim = -1)
# CLIP positional embedding cap; longer text triggers shape mismatch.
_CLIP_TEXT_MAX_TOKENS = 77
def encode(
self,
inputs,
*,
batch_size: int = 32,
normalize_embeddings: bool = True,
convert_to_numpy: bool = True,
show_progress_bar: bool = False,
**_ignored,
):
import io
import numpy as np
import torch
from PIL import Image
if inputs is None or len(inputs) == 0:
return np.zeros(
(0, self.get_sentence_embedding_dimension()), dtype = np.float32
)
sample = inputs[0]
is_image = isinstance(sample, Image.Image) or isinstance(
sample, (bytes, bytearray)
)
chunks_out = []
for start in range(0, len(inputs), batch_size):
batch = list(inputs[start : start + batch_size])
if is_image:
# BGE-VL's internal data_process re-opens each item with
# Image.open(...), which needs a file-like (has .read())
# or a path — NOT a pre-opened PIL Image. Pass BytesIO so
# the model's own opener works. PIL Images get rebuffered
# via an in-memory PNG round-trip.
file_likes: list[Any] = []
for b in batch:
if isinstance(b, (bytes, bytearray)):
file_likes.append(io.BytesIO(b))
elif isinstance(b, Image.Image):
buf = io.BytesIO()
b.save(buf, format = "PNG")
buf.seek(0)
file_likes.append(buf)
else:
file_likes.append(b)
with torch.no_grad():
vecs = self._model.encode(images = file_likes)
else:
vecs = self._encode_text_truncated([str(t) for t in batch])
if normalize_embeddings:
vecs = self._normalize(vecs)
chunks_out.append(vecs.detach().cpu())
out = torch.cat(chunks_out, dim = 0)
return out.numpy() if convert_to_numpy else out
def _encode_text_truncated(self, texts: list[str]):
"""Truncate to CLIP's 77-token limit; long text in multimodal mode is lossy."""
import torch
tokenizer = self._get_text_tokenizer()
inputs = tokenizer(
texts,
return_tensors = "pt",
padding = True,
truncation = True,
max_length = self._CLIP_TEXT_MAX_TOKENS,
)
inputs = {k: v.to(self._device) for k, v in inputs.items()}
if any(len(t.split()) > 30 for t in texts):
logger.info(
"BGE-VL text encode: truncating chunks to %d tokens (CLIP cap)",
self._CLIP_TEXT_MAX_TOKENS,
)
with torch.no_grad():
return self._model.get_text_features(**inputs)
def _get_text_tokenizer(self):
processor = getattr(self._model, "processor", None)
if processor is not None:
tok = getattr(processor, "tokenizer", None)
if tok is not None:
return tok
tok = getattr(self._model, "tokenizer", None)
if tok is not None:
return tok
raise AttributeError("BGE-VL adapter could not locate a text tokenizer")
def get_sentence_embedding_dimension(self) -> int:
if self._dim is None:
v = self.encode(["dim-probe"], batch_size = 1)
self._dim = int(v.shape[-1])
return self._dim
def tokenize(self, texts):
return self._get_text_tokenizer()(
texts,
return_tensors = "pt",
padding = True,
)
def get_embedder(model_name: str | None = None) -> Any:
global _model, _model_name, _embedding_dim
target = model_name or RAG_EMBEDDING_MODEL
with _lock:
if _model is None or _model_name != target:
_model = _load(target)
_model_name = target
try:
_embedding_dim = int(_model.get_sentence_embedding_dimension())
except Exception:
_embedding_dim = None
return _model
def get_embedding_dim(model_name: str | None = None) -> int:
model = get_embedder(model_name)
global _embedding_dim
if _embedding_dim is None:
_embedding_dim = int(model.get_sentence_embedding_dimension())
return _embedding_dim
def get_active_model_name() -> str | None:
return _model_name
def encode(
texts: list[str],
*,
model_name: str | None = None,
batch_size: int | None = None,
normalize: bool = True,
):
model = get_embedder(model_name)
return model.encode(
texts,
batch_size = batch_size or RAG_EMBED_BATCH_SIZE,
normalize_embeddings = normalize,
convert_to_numpy = True,
show_progress_bar = False,
)
def encode_images(
image_bytes_list: list[bytes],
*,
model_name: str | None = None,
batch_size: int | None = None,
normalize: bool = True,
):
"""Embed image bytes via a CLIP-family multimodal encoder."""
from io import BytesIO
from PIL import Image
if not image_bytes_list:
return []
model = get_embedder(model_name)
images = [Image.open(BytesIO(b)).convert("RGB") for b in image_bytes_list]
return model.encode(
images,
batch_size = batch_size or RAG_EMBED_BATCH_SIZE,
normalize_embeddings = normalize,
convert_to_numpy = True,
show_progress_bar = False,
)
def token_counter(model_name: str | None = None):
"""Return a token-count callable backed by the embedder's tokenizer."""
model = get_embedder(model_name)
def _count(text: str) -> int:
try:
tokens = model.tokenize([text])
ids = tokens.get("input_ids")
if ids is None:
return max(1, len(text) // 4)
return int(ids.shape[1])
except Exception:
return max(1, len(text) // 4)
return _count
# --- Late chunking (Jina technique) ---
_LATE_WINDOW_OVERLAP_TOKENS = 512
def late_chunk_encode(
doc_text: str,
char_spans: list[tuple[int, int]],
*,
model_name: str | None = None,
normalize: bool = True,
):
"""Single forward pass over the doc, mean-pool token embeddings per chunk span."""
import numpy as np
if not char_spans:
return []
model = get_embedder(model_name)
tokenizer = model.tokenizer
max_length = int(getattr(model, "max_seq_length", None) or 8192)
encoded = tokenizer(
doc_text,
return_tensors = "pt",
return_offsets_mapping = True,
add_special_tokens = True,
truncation = False,
)
offsets = encoded.pop("offset_mapping")[0].tolist()
n_tokens = int(encoded["input_ids"].shape[1])
if n_tokens <= max_length:
token_embeddings = _encode_tokens(model, encoded)
return _pool_spans(
token_embeddings,
offsets,
char_spans,
normalize = normalize,
np_module = np,
model = model,
doc_text = doc_text,
)
logger.info(
"Late chunking: doc has %d tokens > model max %d; using windowed pass",
n_tokens,
max_length,
)
return _windowed_late_chunk_encode(
doc_text = doc_text,
char_spans = char_spans,
model = model,
max_length = max_length,
normalize = normalize,
np_module = np,
)
def _encode_tokens(model, encoded):
import torch
transformer = model[0].auto_model
device = next(transformer.parameters()).device
inputs_on_device = {k: v.to(device) for k, v in encoded.items()}
with torch.no_grad():
outputs = transformer(**inputs_on_device)
return outputs.last_hidden_state[0].detach().cpu().numpy()
def _pool_spans(
token_embeddings,
offsets,
char_spans,
*,
normalize: bool,
np_module,
model,
doc_text: str,
token_index_offset: int = 0,
):
"""Mean-pool token embeddings per (char_start, char_end) span."""
vectors = []
n_rows = token_embeddings.shape[0]
for char_start, char_end in char_spans:
# Skip special tokens whose offsets are (0, 0).
indices = [
i - token_index_offset
for i, (ts, te) in enumerate(offsets)
if te > ts and te > char_start and ts < char_end
]
indices = [i for i in indices if 0 <= i < n_rows]
if not indices:
vec = model.encode(
doc_text[char_start:char_end],
normalize_embeddings = normalize,
convert_to_numpy = True,
show_progress_bar = False,
)
vectors.append(vec)
continue
pooled = token_embeddings[indices].mean(axis = 0)
if normalize:
denom = float(np_module.linalg.norm(pooled))
if denom > 0:
pooled = pooled / denom
vectors.append(pooled)
return vectors
def _windowed_late_chunk_encode(
*,
doc_text: str,
char_spans: list[tuple[int, int]],
model,
max_length: int,
normalize: bool,
np_module,
):
"""Doc > ctx window: pool each chunk against the window containing most of its tokens."""
import torch
tokenizer = model.tokenizer
transformer = model[0].auto_model
device = next(transformer.parameters()).device
full = tokenizer(
doc_text,
return_tensors = "pt",
return_offsets_mapping = True,
add_special_tokens = False,
truncation = False,
)
all_input_ids = full["input_ids"][0]
all_offsets = full["offset_mapping"][0].tolist()
n_tokens = int(all_input_ids.shape[0])
stride = max(1, max_length - _LATE_WINDOW_OVERLAP_TOKENS)
windows: list[tuple[int, int]] = []
pos = 0
while pos < n_tokens:
end = min(pos + max_length, n_tokens)
windows.append((pos, end))
if end >= n_tokens:
break
pos += stride
window_embeddings: dict[int, "np_module.ndarray"] = {}
def _window_embeddings(window_index: int):
if window_index in window_embeddings:
return window_embeddings[window_index]
ws, we = windows[window_index]
win_ids = all_input_ids[ws:we].unsqueeze(0).to(device)
win_attn = torch.ones_like(win_ids)
with torch.no_grad():
outputs = transformer(input_ids = win_ids, attention_mask = win_attn)
emb = outputs.last_hidden_state[0].detach().cpu().numpy()
window_embeddings[window_index] = emb
return emb
vectors = []
for char_start, char_end in char_spans:
chunk_token_indices = [
i
for i, (ts, te) in enumerate(all_offsets)
if te > ts and te > char_start and ts < char_end
]
if not chunk_token_indices:
vec = model.encode(
doc_text[char_start:char_end],
normalize_embeddings = normalize,
convert_to_numpy = True,
show_progress_bar = False,
)
vectors.append(vec)
continue
best_window = 0
best_overlap = 0
for wi, (ws, we) in enumerate(windows):
overlap = sum(1 for ti in chunk_token_indices if ws <= ti < we)
if overlap > best_overlap:
best_overlap = overlap
best_window = wi
ws, _we = windows[best_window]
emb = _window_embeddings(best_window)
local_indices = [
ti - ws for ti in chunk_token_indices if ws <= ti < ws + emb.shape[0]
]
if not local_indices:
vec = model.encode(
doc_text[char_start:char_end],
normalize_embeddings = normalize,
convert_to_numpy = True,
show_progress_bar = False,
)
vectors.append(vec)
continue
pooled = emb[local_indices].mean(axis = 0)
if normalize:
denom = float(np_module.linalg.norm(pooled))
if denom > 0:
pooled = pooled / denom
vectors.append(pooled)
return vectors

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,500 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Backfill and PDF-region helpers for durable RAG chunk locators."""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loggers import get_logger
from storage.studio_db import get_connection
from . import vector_store
from .parsers import ParsedPage, parse
from .vector_store import kb_scope, thread_scope
logger = get_logger(__name__)
@dataclass(frozen = True)
class LocatorMatch:
page_index: int
page_number: int | None
start: int
end: int
line_start: int
line_end: int
@dataclass(frozen = True)
class BackfillResult:
document_id: str
total_chunks: int
matched: int
already_located: int
ambiguous: int
missing: int
skipped: int
regions_matched: int
pages_refreshed: int
def _line_bounds(text: str, start: int, end: int) -> tuple[int, int]:
line_start = text.count("\n", 0, start) + 1
line_end = text.count("\n", 0, max(start, end - 1)) + 1
return line_start, line_end
def _find_exact(page_text: str, needle: str) -> list[tuple[int, int]]:
if not needle:
return []
out: list[tuple[int, int]] = []
cursor = 0
while True:
idx = page_text.find(needle, cursor)
if idx < 0:
break
out.append((idx, idx + len(needle)))
cursor = idx + 1
return out
def _normalize_with_map(text: str) -> tuple[str, list[int], list[int]]:
chars: list[str] = []
starts: list[int] = []
ends: list[int] = []
last_space = False
for idx, ch in enumerate(text):
if ch.isspace():
if chars and not last_space:
chars.append(" ")
starts.append(idx)
ends.append(idx + 1)
elif chars and last_space:
ends[-1] = idx + 1
last_space = True
continue
chars.append(ch.casefold())
starts.append(idx)
ends.append(idx + 1)
last_space = False
first = 0
while first < len(chars) and chars[first] == " ":
first += 1
last = len(chars)
while last > first and chars[last - 1] == " ":
last -= 1
return "".join(chars[first:last]), starts[first:last], ends[first:last]
def _find_normalized(page_text: str, needle: str) -> list[tuple[int, int]]:
norm_page, starts, ends = _normalize_with_map(page_text)
norm_needle, _needle_starts, _needle_ends = _normalize_with_map(needle)
if not norm_page or not norm_needle:
return []
out: list[tuple[int, int]] = []
cursor = 0
while True:
idx = norm_page.find(norm_needle, cursor)
if idx < 0:
break
end_idx = idx + len(norm_needle) - 1
if 0 <= idx < len(starts) and 0 <= end_idx < len(ends):
out.append((starts[idx], ends[end_idx]))
cursor = idx + 1
return out
def _locate_unique(
text: str, pages: list[ParsedPage]
) -> tuple[LocatorMatch | None, str]:
text = (text or "").strip()
if not text:
return None, "missing"
matches: list[LocatorMatch] = []
for page_index, page in enumerate(pages):
for start, end in _find_exact(page.text, text):
line_start, line_end = _line_bounds(page.text, start, end)
matches.append(
LocatorMatch(
page_index = page_index,
page_number = page.page_number,
start = start,
end = end,
line_start = line_start,
line_end = line_end,
)
)
if len(matches) == 1:
return matches[0], "matched"
if len(matches) > 1:
return None, "ambiguous"
for page_index, page in enumerate(pages):
for start, end in _find_normalized(page.text, text):
line_start, line_end = _line_bounds(page.text, start, end)
matches.append(
LocatorMatch(
page_index = page_index,
page_number = page.page_number,
start = start,
end = end,
line_start = line_start,
line_end = line_end,
)
)
if len(matches) == 1:
return matches[0], "matched"
if len(matches) > 1:
return None, "ambiguous"
return None, "missing"
def _replace_document_pages(document_id: str, pages: list[ParsedPage]) -> None:
now = int(time.time())
rows = [
(
document_id,
index,
page.page_number,
page.text,
len(page.text),
len(page.text.splitlines()),
now,
)
for index, page in enumerate(pages)
]
with get_connection() as conn:
conn.execute(
"DELETE FROM rag_document_pages WHERE document_id = ?", (document_id,)
)
if rows:
conn.executemany(
"""
INSERT INTO rag_document_pages
(document_id, page_index, page_number, text, char_count,
line_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
conn.commit()
def _region_anchor(page_text: str, match: LocatorMatch) -> str | None:
segment = page_text[match.start : match.end]
words = [w.strip(" \t\r\n*#`[]()") for w in segment.split()]
words = [w for w in words if len(w) >= 2]
if len(words) < 3:
return None
anchor = " ".join(words[: min(16, len(words))])
return anchor if len(anchor) >= 12 else None
def _normalized_occurrences(haystack: str, needle: str) -> int:
norm_haystack, _starts, _ends = _normalize_with_map(haystack)
norm_needle, _needle_starts, _needle_ends = _normalize_with_map(needle)
if not norm_haystack or not norm_needle:
return 0
count = 0
cursor = 0
while True:
idx = norm_haystack.find(norm_needle, cursor)
if idx < 0:
return count
count += 1
cursor = idx + 1
def pdf_regions_for_match(
pdf_path: Path,
pages: list[ParsedPage],
match: LocatorMatch,
) -> list[dict[str, Any]]:
"""Return normalized PDF rectangles for a unique chunk match.
Regions are intentionally conservative: no PyMuPDF, no page, no
unique anchor, or no positive-area rectangles all produce an empty
list rather than guessed highlights.
"""
if pdf_path.suffix.lower() != ".pdf":
return []
if match.page_index < 0 or match.page_index >= len(pages):
return []
anchor = _region_anchor(pages[match.page_index].text, match)
if not anchor:
return []
try:
import pymupdf
except Exception:
return []
try:
doc = pymupdf.open(str(pdf_path))
except Exception:
return []
try:
return _pdf_regions_for_match_doc(doc, pages, match, anchor)
finally:
doc.close()
def _pdf_regions_for_match_doc(
doc: Any,
pages: list[ParsedPage],
match: LocatorMatch,
anchor: str,
) -> list[dict[str, Any]]:
try:
if match.page_index >= len(doc):
return []
page = doc[match.page_index]
raw_text = page.get_text("text") or ""
if _normalized_occurrences(raw_text, anchor) != 1:
return []
rects = page.search_for(anchor) or []
page_rect = page.rect
page_width = float(page_rect.width)
page_height = float(page_rect.height)
if page_width <= 0 or page_height <= 0:
return []
out: list[dict[str, Any]] = []
for rect in rects:
width = max(0.0, float(rect.x1 - rect.x0))
height = max(0.0, float(rect.y1 - rect.y0))
if width <= 0 or height <= 0:
continue
out.append(
{
"pageIndex": match.page_index,
"pageNumber": match.page_number,
"x": max(0.0, min(1.0, float(rect.x0) / page_width)),
"y": max(0.0, min(1.0, float(rect.y0) / page_height)),
"width": max(0.0, min(1.0, width / page_width)),
"height": max(0.0, min(1.0, height / page_height)),
"confidence": "exact",
"source": "pymupdf-search",
}
)
return out
except Exception:
return []
def pdf_regions_for_chunks(
pdf_path: Path,
pages: list[ParsedPage],
chunks: list[Any],
) -> list[list[dict[str, Any]]]:
if pdf_path.suffix.lower() != ".pdf":
return [[] for _ in chunks]
try:
import pymupdf
doc = pymupdf.open(str(pdf_path))
except Exception:
return [[] for _ in chunks]
regions: list[list[dict[str, Any]]] = []
try:
for chunk in chunks:
page_index = getattr(chunk, "source_page_index", None)
start = getattr(chunk, "page_char_start", None)
end = getattr(chunk, "page_char_end", None)
if page_index is None or start is None or end is None:
regions.append([])
continue
if page_index < 0 or page_index >= len(pages):
regions.append([])
continue
line_start, line_end = _line_bounds(pages[page_index].text, start, end)
match = LocatorMatch(
page_index = int(page_index),
page_number = getattr(chunk, "page_number", None),
start = int(start),
end = int(end),
line_start = line_start,
line_end = line_end,
)
anchor = _region_anchor(pages[match.page_index].text, match)
if not anchor:
regions.append([])
continue
regions.append(_pdf_regions_for_match_doc(doc, pages, match, anchor))
return regions
finally:
doc.close()
def _scope_for_document(kb_id: str | None, thread_id: str | None) -> str | None:
if kb_id:
return kb_scope(kb_id)
if thread_id:
return thread_scope(thread_id)
return None
def _update_vector_payloads(
scope: str | None, updates: dict[str, dict[str, Any]]
) -> None:
if not scope or not updates:
return
try:
vector_store.update_chunk_payload_fields(scope, updates)
except Exception as exc:
logger.warning(
"RAG locator backfill: vector payload update failed",
error = str(exc),
)
def backfill_document_locators(document_id: str, stored_path: Path) -> BackfillResult:
parsed = parse(stored_path, want_images = False)
pages = parsed.pages
_replace_document_pages(document_id, pages)
with get_connection() as conn:
doc_row = conn.execute(
"SELECT kb_id, thread_id FROM rag_documents WHERE id = ?",
(document_id,),
).fetchone()
if doc_row is None:
return BackfillResult(document_id, 0, 0, 0, 0, 0, 0, 0, len(pages))
rows = conn.execute(
"""
SELECT id, text, kind, page_number, source_page_index,
page_char_start, page_char_end, line_start, line_end,
pdf_regions_json
FROM rag_chunks
WHERE document_id = ?
ORDER BY chunk_index ASC
""",
(document_id,),
).fetchall()
scope = _scope_for_document(doc_row["kb_id"], doc_row["thread_id"])
total = len(rows)
matched = 0
already_located = 0
ambiguous = 0
missing = 0
skipped = 0
regions_matched = 0
sql_updates: list[tuple[Any, ...]] = []
vector_updates: dict[str, dict[str, Any]] = {}
for row in rows:
kind = row["kind"] or "text"
text = row["text"] or ""
if kind not in ("text", "caption") or not text.strip():
skipped += 1
continue
existing_complete = (
row["source_page_index"] is not None
and row["page_char_start"] is not None
and row["page_char_end"] is not None
and row["line_start"] is not None
and row["line_end"] is not None
)
match: LocatorMatch | None
status: str
if existing_complete:
already_located += 1
page_index = int(row["source_page_index"])
if 0 <= page_index < len(pages):
match = LocatorMatch(
page_index = page_index,
page_number = row["page_number"],
start = int(row["page_char_start"]),
end = int(row["page_char_end"]),
line_start = int(row["line_start"]),
line_end = int(row["line_end"]),
)
else:
match = None
status = "already_located"
else:
match, status = _locate_unique(text, pages)
if status == "matched" and match is not None:
matched += 1
elif status == "ambiguous":
ambiguous += 1
continue
else:
missing += 1
continue
if match is None:
continue
regions = pdf_regions_for_match(stored_path, pages, match)
regions_json = json.dumps(regions, separators = (",", ":")) if regions else None
if regions:
regions_matched += 1
if status == "matched" or (regions and not row["pdf_regions_json"]):
sql_updates.append(
(
match.page_number,
match.page_index,
match.start,
match.end,
match.line_start,
match.line_end,
regions_json,
row["id"],
)
)
vector_updates[row["id"]] = {
"page_number": match.page_number,
"source_page_index": match.page_index,
"page_char_start": match.start,
"page_char_end": match.end,
"line_start": match.line_start,
"line_end": match.line_end,
"pdf_regions": regions,
}
if sql_updates:
with get_connection() as conn:
conn.executemany(
"""
UPDATE rag_chunks
SET page_number = COALESCE(page_number, ?),
source_page_index = ?,
page_char_start = ?,
page_char_end = ?,
line_start = ?,
line_end = ?,
pdf_regions_json = COALESCE(?, pdf_regions_json)
WHERE id = ?
""",
sql_updates,
)
conn.commit()
_update_vector_payloads(scope, vector_updates)
return BackfillResult(
document_id = document_id,
total_chunks = total,
matched = matched,
already_located = already_located,
ambiguous = ambiguous,
missing = missing,
skipped = skipped,
regions_matched = regions_matched,
pages_refreshed = len(pages),
)

View file

@ -0,0 +1,181 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
# Same shape as the chunker's figure-boundary regex but captures the
# figure label (Figure / Fig. / Table / Tab.) AND the number so we can
# attribute a VLM caption back to a specific figure on a multi-figure
# page.
_FIGURE_LINE_RE = re.compile(
r"^(?P<lead>\**)(?P<label>Figure|Fig\.|Table|Tab\.)\s+"
r"(?P<num>[A-Z]?\.?\d+(?:\.\d+)?)(?P<trail>\**[\.:])",
re.MULTILINE | re.IGNORECASE,
)
@dataclass(frozen = True)
class ParsedPage:
"""Markdown text from one page (PDF) or whole doc (others)."""
text: str
page_number: int | None = None
@dataclass(frozen = True)
class ParsedImage:
"""Image extracted with want_images=True; nearest_caption may be empty."""
image_bytes: bytes
mime_type: str
page_number: int | None = None
nearest_caption: str = ""
@dataclass(frozen = True)
class ParseResult:
pages: list[ParsedPage] = field(default_factory = list)
images: list[ParsedImage] = field(default_factory = list)
def __iter__(self):
return iter(self.pages)
def __len__(self):
return len(self.pages)
def __bool__(self):
return bool(self.pages) or bool(self.images)
class UnsupportedFormatError(ValueError):
pass
def _normalize_label(raw: str) -> str:
head = raw.lower()
if head.startswith("fig"):
return "Figure"
if head.startswith("tab"):
return "Table"
return raw.capitalize()
def _splice_inline_at_figure_lines(text: str, captions: list[str]) -> str:
"""Splice each caption right after the matching 'Figure N:' line.
Captions are consumed in order against the figure caption lines
appearing in the page text. Each spliced block is prefixed with
"**Figure N description**:" so a retrieved chunk lets the LLM
distinguish between multiple figures on the same page.
Fallback when no figure caption lines exist (or fewer than we have
captions): leftover captions are appended at the end of the page
text as generic "**Figure**: ..." entries.
"""
if not captions:
return text
matches = list(_FIGURE_LINE_RE.finditer(text))
if not matches:
appendix = "\n\n".join(f"**Figure**: {c}" for c in captions)
return f"{text}\n\n{appendix}"
parts: list[str] = []
cursor = 0
caps = iter(captions)
used = 0
for m in matches:
# Insertion point = end of the line containing the figure label.
line_end = text.find("\n", m.end())
if line_end == -1:
line_end = len(text)
parts.append(text[cursor:line_end])
try:
cap = next(caps)
except StopIteration:
cursor = line_end
continue
used += 1
label = f"{_normalize_label(m.group('label'))} {m.group('num')}"
parts.append(f"\n\n**{label} description**: {cap}")
cursor = line_end
parts.append(text[cursor:])
leftover = list(caps)
body = "".join(parts)
if leftover:
appendix = "\n\n".join(f"**Figure**: {c}" for c in leftover)
body = f"{body}\n\n{appendix}"
return body
def inline_image_captions(
pages: list[ParsedPage],
images: list[ParsedImage],
captions: list[str],
) -> list[ParsedPage]:
"""Splice per-image captions into the markdown of the pages they came from.
Each caption lands right after the page's matching ``Figure N:`` or
``Table N:`` line as ``**Figure N description**: `` so the chunker
keeps the VLM description adjacent to the figure's existing in-PDF
caption text. When a page has more figure caption lines than we have
captioned images, the extra figure lines are left alone; when there
are more captions than figure lines (or no figure lines at all),
leftovers fall back to an end-of-page ``**Figure**: `` appendix.
``captions`` is parallel to ``images`` (same length, same order).
Empty or whitespace-only captions are skipped. Images without a
page_number are bucketed onto the single-page documents (DOCX/HTML/
TXT all collapse to one page).
"""
if not images or not captions:
return list(pages)
if len(captions) != len(images):
return list(pages)
per_page: dict[int | None, list[str]] = {}
for img, cap in zip(images, captions):
cleaned = (cap or "").strip()
if not cleaned:
continue
per_page.setdefault(img.page_number, []).append(cleaned)
if not per_page:
return list(pages)
out: list[ParsedPage] = []
null_bucket = per_page.get(None, [])
for page in pages:
captions_for_this = per_page.get(page.page_number, [])
if page.page_number is None and null_bucket:
captions_for_this = captions_for_this + null_bucket
if not captions_for_this:
out.append(page)
continue
new_text = _splice_inline_at_figure_lines(page.text, captions_for_this)
out.append(
ParsedPage(
text = new_text,
page_number = page.page_number,
)
)
return out
def parse(path: Path, *, want_images: bool = False) -> ParseResult:
suffix = path.suffix.lower()
if suffix == ".pdf":
from .pdf import extract
elif suffix in (".txt", ".md", ".markdown"):
from .text import extract
elif suffix == ".docx":
from .docx import extract
elif suffix in (".html", ".htm"):
from .html import extract
else:
raise UnsupportedFormatError(f"Unsupported file type: {suffix}")
return extract(path, want_images = want_images)

View file

@ -0,0 +1,90 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""DOCX → Markdown via mammoth (preserves headings, lists, tables)."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from . import ParsedImage, ParsedPage, ParseResult
logger = logging.getLogger(__name__)
# Force common DOCX heading style names to Markdown headings.
_STYLE_MAP = """
p[style-name='Title'] => h1.title:fresh
p[style-name='Subtitle'] => h2.subtitle:fresh
p[style-name='Heading 1'] => h1:fresh
p[style-name='Heading 2'] => h2:fresh
p[style-name='Heading 3'] => h3:fresh
p[style-name='Heading 4'] => h4:fresh
p[style-name='Heading 5'] => h5:fresh
p[style-name='Heading 6'] => h6:fresh
"""
def _html_to_markdown(html: str) -> str:
from markdownify import markdownify
md = markdownify(html, heading_style = "ATX", strip = ["script", "style"])
md = re.sub(r"\n{3,}", "\n\n", md)
return md.strip()
def extract(path: Path, *, want_images: bool = False) -> ParseResult:
import mammoth
images: list[ParsedImage] = []
if want_images:
# Capture bytes; suppress src so base64 doesn't land in Markdown.
def _convert(image):
with image.open() as image_bytes:
blob = image_bytes.read()
mime = (image.content_type or "application/octet-stream").lower()
images.append(
ParsedImage(
image_bytes = blob,
mime_type = mime,
page_number = None,
nearest_caption = "",
)
)
return {"src": ""}
convert_image = mammoth.images.img_element(_convert)
else:
convert_image = mammoth.images.img_element(lambda _image: {"src": ""})
with open(path, "rb") as fp:
result = mammoth.convert_to_html(
fp,
convert_image = convert_image,
style_map = _STYLE_MAP,
)
for message in result.messages:
logger.debug("mammoth %s: %s", getattr(message, "type", "msg"), message.message)
markdown = _html_to_markdown(result.value)
if want_images and images:
# Approximate caption: first 1500 chars of doc.
caption_pool = markdown[:1500]
images = [
ParsedImage(
image_bytes = img.image_bytes,
mime_type = img.mime_type,
page_number = img.page_number,
nearest_caption = caption_pool,
)
for img in images
]
if not markdown:
return ParseResult(pages = [], images = images)
return ParseResult(
pages = [ParsedPage(text = markdown, page_number = None)],
images = images,
)

View file

@ -0,0 +1,86 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HTML → Markdown via markdownify. Images only resolve local file refs."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from urllib.parse import unquote, urlparse
from . import ParsedImage, ParsedPage, ParseResult
logger = logging.getLogger(__name__)
_SKIP_TAGS = ("script", "style", "noscript", "template")
def _collect_local_images(soup, html_path: Path) -> list[ParsedImage]:
images: list[ParsedImage] = []
base_dir = html_path.parent
for tag in soup.find_all("img"):
src = tag.get("src") or ""
parsed = urlparse(src)
if parsed.scheme and parsed.scheme not in ("file", ""):
continue
local_path = (base_dir / unquote(parsed.path or src)).resolve()
try:
local_path.relative_to(base_dir.resolve())
except ValueError:
# Path traversal: refuse to read outside the source's directory.
continue
if not local_path.is_file():
continue
try:
blob = local_path.read_bytes()
except OSError:
continue
suffix = local_path.suffix.lower().lstrip(".")
mime = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
}.get(suffix, f"image/{suffix or 'octet-stream'}")
caption = tag.get("alt") or tag.get("title") or ""
images.append(
ParsedImage(
image_bytes = blob,
mime_type = mime,
page_number = None,
nearest_caption = caption,
)
)
return images
def extract(path: Path, *, want_images: bool = False) -> ParseResult:
from bs4 import BeautifulSoup
from markdownify import markdownify
raw = path.read_bytes()
soup = BeautifulSoup(raw, "lxml")
for tag_name in _SKIP_TAGS:
for tag in soup.find_all(tag_name):
tag.decompose()
images: list[ParsedImage] = []
if want_images:
images = _collect_local_images(soup, path)
md = markdownify(
str(soup),
heading_style = "ATX",
strip = list(_SKIP_TAGS),
)
md = re.sub(r"\n{3,}", "\n\n", md).strip()
if not md:
return ParseResult(pages = [], images = images)
return ParseResult(
pages = [ParsedPage(text = md, page_number = None)],
images = images,
)

View file

@ -0,0 +1,163 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""PDF → Markdown via pymupdf4llm; pypdf fallback for malformed files."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from . import ParsedImage, ParsedPage, ParseResult
logger = logging.getLogger(__name__)
# pymupdf4llm wraps OCR'd vector-graphics text with these markers even when
# `ignore_images=True`. Strip the whole block — the VLM captioner produces
# a proper description for the figure, and the marker text just pollutes
# the chunked body / shows up verbatim in citations.
_PICTURE_TEXT_BLOCK_RE = re.compile(
r"-{3,}\s*Start of picture text\s*-{3,}.*?-{3,}\s*End of picture text\s*-{3,}",
re.DOTALL | re.IGNORECASE,
)
def _strip_picture_text_markers(md: str) -> str:
return _PICTURE_TEXT_BLOCK_RE.sub("", md)
def _extract_with_pymupdf(path: Path, want_images: bool) -> ParseResult:
import pymupdf
import pymupdf4llm
doc = pymupdf.open(str(path))
try:
pages: list[ParsedPage] = []
for page_index in range(len(doc)):
try:
md = pymupdf4llm.to_markdown(
doc,
pages = [page_index],
write_images = False,
ignore_images = True,
show_progress = False,
)
except Exception:
# pymupdf4llm can choke on a single page; fall back to plain text.
md = doc[page_index].get_text("text") or ""
md = _strip_picture_text_markers(md).strip()
if md:
pages.append(ParsedPage(text = md, page_number = page_index + 1))
images: list[ParsedImage] = []
if want_images:
images = _extract_images_pymupdf(doc, pages)
return ParseResult(pages = pages, images = images)
finally:
doc.close()
# Pages smaller than this (in PDF points) are ignored as figure regions —
# bigger than a typical icon/glyph, smaller than a banner.
_MIN_FIGURE_PT = 60
# 2× scale renders at 144 dpi (PDF default is 72 dpi). Enough resolution for
# the captioner to read axis labels, arrow text, and inset photos.
_RENDER_SCALE = 2.0
# Expand the union bbox a few points so caption baselines / borders survive.
_FIGURE_MARGIN_PT = 8.0
def _extract_images_pymupdf(doc, pages: list[ParsedPage]) -> list[ParsedImage]:
"""Render each page's figure region (vector drawings + raster sub-images)
as a single PNG. Vector schematics like Figure 1 (no embedded raster)
are visible to the captioner only via rendering ``page.get_images``
misses them entirely. We union all non-text geometry on a page into
one bbox; for academic papers this typically maps 1:1 to "the figure
on this page".
"""
import pymupdf
captions_by_page: dict[int, str] = {
p.page_number: p.text for p in pages if p.page_number
}
out: list[ParsedImage] = []
for page_index in range(len(doc)):
page = doc[page_index]
page_number = page_index + 1
try:
rects: list[pymupdf.Rect] = []
for drawing in page.get_drawings() or []:
rect = drawing.get("rect")
if rect is not None:
rects.append(pymupdf.Rect(rect))
for info in page.get_image_info(xrefs = True) or []:
bbox = info.get("bbox")
if bbox is not None:
rects.append(pymupdf.Rect(bbox))
except Exception:
continue
if not rects:
continue
union = rects[0]
for r in rects[1:]:
union |= r
if union.width < _MIN_FIGURE_PT or union.height < _MIN_FIGURE_PT:
continue
# Expand and clip to page rect so we don't render past page edges.
union = (
pymupdf.Rect(
union.x0 - _FIGURE_MARGIN_PT,
union.y0 - _FIGURE_MARGIN_PT,
union.x1 + _FIGURE_MARGIN_PT,
union.y1 + _FIGURE_MARGIN_PT,
)
& page.rect
)
try:
matrix = pymupdf.Matrix(_RENDER_SCALE, _RENDER_SCALE)
pix = page.get_pixmap(clip = union, matrix = matrix, alpha = False)
png_bytes = pix.tobytes("png")
except Exception:
continue
if not png_bytes:
continue
caption = (captions_by_page.get(page_number, "") or "")[:1500]
out.append(
ParsedImage(
image_bytes = png_bytes,
mime_type = "image/png",
page_number = page_number,
nearest_caption = caption,
)
)
return out
def _extract_with_pypdf_fallback(path: Path) -> ParseResult:
from pypdf import PdfReader
reader = PdfReader(str(path))
pages: list[ParsedPage] = []
for index, page in enumerate(reader.pages):
try:
text = page.extract_text() or ""
except Exception:
text = ""
text = text.strip()
if text:
pages.append(ParsedPage(text = text, page_number = index + 1))
return ParseResult(pages = pages, images = [])
def extract(path: Path, *, want_images: bool = False) -> ParseResult:
try:
return _extract_with_pymupdf(path, want_images)
except Exception as exc:
logger.warning(
"pymupdf failed for %s (%s: %s); falling back to pypdf",
path,
type(exc).__name__,
exc,
)
return _extract_with_pypdf_fallback(path)

View file

@ -0,0 +1,30 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
from pathlib import Path
from . import ParsedPage, ParseResult
def extract(path: Path, *, want_images: bool = False) -> ParseResult:
raw = path.read_bytes()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
try:
import chardet
detected = chardet.detect(raw)
encoding = detected.get("encoding") or "latin-1"
except ImportError:
encoding = "latin-1"
text = raw.decode(encoding, errors = "replace")
text = text.strip()
if not text:
return ParseResult(pages = [], images = [])
return ParseResult(
pages = [ParsedPage(text = text, page_number = None)],
images = [],
)

View file

@ -0,0 +1,122 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Query decomposition for external-provider RAG prefetch.
External providers can't run studio's `search_knowledge_base` tool loop, so
for the prefetch path we retrieve up-front. To match the multi-query
behaviour local models get from the tool-loop system prompt, we spin up the
pre-cached helper GGUF (``unsloth/gemma-4-E2B-it-GGUF``, the same model the
captioner uses) momentarily, ask it to split the user's question into up to
three focused search queries, then unload it.
The helper llama-server is its own subprocess (llama.cpp), spawned with
``kill_orphans=False`` so it can't reap a resident chat model, and always
unloaded in a ``finally``. Any failure (helper can't load, request errors,
empty output) falls back to ``[query]`` a single raw retrieval so RAG
prefetch never hard-fails on decomposition.
"""
from __future__ import annotations
from typing import Any, Optional
from loggers import get_logger
# Reuse the exact model the captioner / precache path already downloads.
from core.rag.captioner import _HELPER_REPO, _HELPER_VARIANT, _HELPER_MODEL_NAME
logger = get_logger(__name__)
_MAX_QUERIES = 3
_REQUEST_TIMEOUT_SECONDS = 60.0
_PROMPT = (
"Split the user's question into up to 3 focused search queries for "
"retrieving relevant passages from their documents. Prefer fewer when "
"the question is narrow — one is fine. Output ONLY the queries, one per "
"line, no numbering, no preamble."
)
def _load_helper() -> Optional[tuple[Any, str, str]]:
"""Spawn a private text-only helper llama-server. Caller unloads it."""
try:
from core.inference.llama_cpp import LlamaCppBackend
# kill_orphans=False: a resident chat-model llama-server (if any)
# must not be reaped by this transient instance.
backend = LlamaCppBackend(kill_orphans = False)
ok = backend.load_model(
hf_repo = _HELPER_REPO,
hf_variant = _HELPER_VARIANT,
model_identifier = f"rag-querygen:{_HELPER_REPO}:{_HELPER_VARIANT}",
is_vision = False,
n_ctx = 4096,
n_gpu_layers = -1,
)
if not ok:
logger.warning("RAG query-decompose: helper failed to start")
return None
return backend, backend.base_url, _HELPER_MODEL_NAME
except Exception as exc: # noqa: BLE001
logger.warning("RAG query-decompose: helper load raised", error = str(exc))
return None
def _parse_queries(raw: str, fallback: str) -> list[str]:
out: list[str] = []
for line in (raw or "").splitlines():
# Strip common list markers the model might emit despite the prompt.
cleaned = line.strip().lstrip("-*0123456789.) ").strip()
if cleaned:
out.append(cleaned)
if len(out) >= _MAX_QUERIES:
break
return out or [fallback]
def decompose_query(query: str) -> list[str]:
"""Return up to 3 focused search queries; ``[query]`` on any failure.
Loads the helper, asks for the decomposition, unloads. Never raises.
"""
q = (query or "").strip()
if not q:
return []
import httpx
loaded = _load_helper()
if loaded is None:
return [q]
backend, base_url, model_name = loaded
try:
endpoint = f"{base_url.rstrip('/')}/v1/chat/completions"
payload = {
"model": model_name,
"messages": [
{"role": "system", "content": _PROMPT},
{"role": "user", "content": q},
],
"max_tokens": 160,
"temperature": 0.0,
# gemma-4 is a reasoning model; thinking would eat the budget and
# emit no visible queries (same issue the captioner hit).
"chat_template_kwargs": {"enable_thinking": False},
}
with httpx.Client(timeout = _REQUEST_TIMEOUT_SECONDS) as client:
response = client.post(endpoint, json = payload)
response.raise_for_status()
data = response.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
queries = _parse_queries(content if isinstance(content, str) else "", q)
logger.info("RAG query-decompose: produced queries", n = len(queries))
return queries
except Exception as exc: # noqa: BLE001
logger.warning("RAG query-decompose: request failed", error = str(exc))
return [q]
finally:
try:
backend.unload_model()
except Exception as exc: # noqa: BLE001
logger.warning("RAG query-decompose: helper unload failed", error = str(exc))

View file

@ -0,0 +1,221 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Opt-in CrossEncoder reranker (off by default; shares GPU with chat model)."""
from __future__ import annotations
import gc
import sys
import threading
import time
from typing import Any
from loggers import get_logger
from utils.rag.config import RAG_RERANK_BATCH_SIZE, RAG_RERANKER_MODEL
from .retrieval import Hit
logger = get_logger(__name__)
# Reentrant: get_reranker() holds the lock while calling unload(), which
# also enters `with _lock`. A plain Lock would deadlock the same thread on
# the second acquisition; RLock allows re-entry from the holding thread.
_lock = threading.RLock()
_model: Any | None = None
_model_name: str | None = None
def _resolve_device() -> str:
"""Prefer CUDA when available; otherwise CPU. Explicit so we don't rely
on sentence-transformers' auto-detect (which historically picks CPU when
CUDA_VISIBLE_DEVICES is set funny)."""
try:
import torch
if torch.cuda.is_available():
return "cuda"
except Exception: # noqa: BLE001
pass
return "cpu"
def _load(model_name: str) -> Any:
# Stderr print is unconditional so we can see this line even when
# structlog routing is misbehaving — diagnostics for a previously
# invisible hang.
print(
f"[rag.reranker] _load entered: model={model_name}",
file = sys.stderr,
flush = True,
)
from sentence_transformers import CrossEncoder
device = _resolve_device()
print(
f"[rag.reranker] device resolved: {device}",
file = sys.stderr,
flush = True,
)
logger.info(
"Loading RAG reranker",
model = model_name,
device = device,
)
started = time.perf_counter()
print(
f"[rag.reranker] calling CrossEncoder(...) on {device}",
file = sys.stderr,
flush = True,
)
model = CrossEncoder(model_name, device = device)
elapsed = round(time.perf_counter() - started, 2)
print(
f"[rag.reranker] CrossEncoder returned in {elapsed}s",
file = sys.stderr,
flush = True,
)
logger.info(
"RAG reranker loaded",
model = model_name,
device = device,
elapsed_seconds = elapsed,
)
return model
def precache_reranker(model_name: str | None = None) -> None:
"""Download reranker weights into the HF cache (no instantiation).
Mirrors ``precache_helper_gguf``: runs in a background thread on
FastAPI startup so the first user-facing rerank doesn't pay the
~1.1 GB download. Safe to call when the model is already cached
(huggingface_hub no-ops on existing files).
"""
target = model_name or RAG_RERANKER_MODEL
try:
from huggingface_hub import snapshot_download
from huggingface_hub.utils import disable_progress_bars
disable_progress_bars()
logger.info("Pre-caching RAG reranker", model = target)
started = time.perf_counter()
snapshot_download(repo_id = target, repo_type = "model")
logger.info(
"RAG reranker cached",
model = target,
elapsed_seconds = round(time.perf_counter() - started, 2),
)
except Exception as exc: # noqa: BLE001
# Non-critical: the lazy loader will retry the download on first
# use. We log so the user can see what happened.
logger.warning(
"RAG reranker precache failed; will download lazily",
model = target,
error = str(exc),
)
def get_reranker(model_name: str | None = None) -> Any:
global _model, _model_name
target = model_name or RAG_RERANKER_MODEL
with _lock:
if _model is None or _model_name != target:
unload()
_model = _load(target)
_model_name = target
return _model
def unload() -> None:
"""Drop the reranker; next call lazy-loads again."""
global _model, _model_name
with _lock:
if _model is not None:
_model = None
_model_name = None
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
pass
def rerank(
query: str,
pairs: list[tuple[Hit, str]],
*,
model_name: str | None = None,
top_k: int | None = None,
) -> list[Hit]:
"""Re-order (Hit, text) pairs by CrossEncoder score; image hits are appended last."""
if not pairs:
return []
text_pairs = [(h, t) for h, t in pairs if h.kind != "image"]
image_hits = [h for h, _t in pairs if h.kind == "image"]
print(
f"[rag.reranker] rerank entered: n_pairs={len(text_pairs)}",
file = sys.stderr,
flush = True,
)
model = get_reranker(model_name)
print(
"[rag.reranker] reranker model in hand",
file = sys.stderr,
flush = True,
)
if text_pairs:
inputs = [(query, text) for _, text in text_pairs]
print(
f"[rag.reranker] predict starting: n_inputs={len(inputs)} "
f"batch_size={RAG_RERANK_BATCH_SIZE}",
file = sys.stderr,
flush = True,
)
logger.info(
"RAG reranker predict starting",
n_inputs = len(inputs),
batch_size = RAG_RERANK_BATCH_SIZE,
)
started = time.perf_counter()
scores = model.predict(
inputs,
batch_size = RAG_RERANK_BATCH_SIZE,
show_progress_bar = False,
)
elapsed = round(time.perf_counter() - started, 2)
print(
f"[rag.reranker] predict done in {elapsed}s",
file = sys.stderr,
flush = True,
)
logger.info(
"RAG reranker predict done",
n_inputs = len(inputs),
elapsed_seconds = elapsed,
)
ranked = sorted(
zip(text_pairs, scores),
key = lambda item: float(item[1]),
reverse = True,
)
reranked_text = [
Hit(
chunk_id = h.chunk_id,
score = float(s),
document_id = h.document_id,
chunk_index = h.chunk_index,
kind = h.kind,
)
for (h, _t), s in ranked
]
else:
reranked_text = []
out: list[Hit] = reranked_text + image_hits
if top_k is not None:
out = out[:top_k]
return out

View file

@ -0,0 +1,239 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""RAG retrieval: BM25, dense, and RRF hybrid. Hits carry dense_score for thresholding."""
from __future__ import annotations
import re
from dataclasses import dataclass
from utils.rag.config import (
RAG_RRF_K,
RAG_TOP_K_BM25,
RAG_TOP_K_DENSE,
RAG_TOP_K_HYBRID,
)
from . import bm25, embeddings, vector_store
# Match "Figure 1", "Figure 1.2", "Figure B.1", "Table 4", "Fig. 5" anywhere
# in the query. Used to inject a third retrieval source that directly looks
# up chunks anchored by these references — dense vectors don't preserve
# figure numbers, so without this an exact-numbered query gets out-ranked
# by chunks describing other figures that share more vocabulary with the
# question.
_FIGURE_REF_RE = re.compile(
r"\b(Figure|Fig\.|Table|Tab\.)\s+([A-Z]?\.?\d+(?:\.\d+)?)\b",
re.IGNORECASE,
)
def _extract_figure_refs(query: str) -> list[str]:
"""Return normalized 'Figure N' / 'Table N' references found in query."""
refs: list[str] = []
seen: set[str] = set()
for m in _FIGURE_REF_RE.finditer(query):
head = m.group(1).lower()
label = "Figure" if head.startswith("fig") else "Table"
ref = f"{label} {m.group(2)}"
if ref not in seen:
seen.add(ref)
refs.append(ref)
return refs
@dataclass(frozen = True)
class Hit:
chunk_id: str
score: float
document_id: str | None = None
chunk_index: int | None = None
kind: str = "text"
source_page_index: int | None = None
page_char_start: int | None = None
page_char_end: int | None = None
line_start: int | None = None
line_end: int | None = None
# Raw cosine; None for BM25-only hits.
dense_score: float | None = None
def retrieve_bm25(scope: str, query: str, k: int | None = None) -> list[Hit]:
limit = k or RAG_TOP_K_BM25
return [Hit(chunk_id = cid, score = s) for cid, s in bm25.search(scope, query, limit)]
def retrieve_figure_refs(
scope: str,
query: str,
*,
k: int = 5,
document_ids: list[str] | None = None,
) -> list[Hit]:
"""Look up chunks anchored at a 'Figure N:' / 'Table N:' caption that
the query references. Returns at most ``k`` hits usually 0 or 1.
Chunks produced by the figure-boundary chunker start with the literal
caption, so a SQL prefix match is enough; we don't need full-text
search here.
"""
refs = _extract_figure_refs(query)
if not refs:
return []
from .db import get_rag_connection
placeholders_docs = ""
params: list = [scope]
if document_ids:
placeholders_docs = f" AND document_id IN ({','.join('?' * len(document_ids))})"
params.extend(document_ids)
like_clauses: list[str] = []
for ref in refs:
# Match "Figure 1:" and "Figure 1." (period-terminated captions).
like_clauses.append(
"json_extract(payload_json, '$.text') LIKE ?"
" OR json_extract(payload_json, '$.text') LIKE ?"
)
params.extend([f"{ref}:%", f"{ref}.%"])
sql = (
"SELECT chunk_id, document_id, chunk_index, kind"
" FROM rag_vectors"
f" WHERE scope = ?{placeholders_docs}"
" AND kind = 'text'"
f" AND ({' OR '.join(like_clauses)})"
f" LIMIT {int(k)}"
)
out: list[Hit] = []
with get_rag_connection() as conn:
for row in conn.execute(sql, params):
out.append(
Hit(
chunk_id = row[0],
score = 1.0,
document_id = row[1],
chunk_index = row[2],
kind = row[3] or "text",
)
)
return out
def retrieve_dense(
scope: str,
query: str,
k: int | None = None,
*,
document_ids: list[str] | None = None,
embedder_model: str | None = None,
) -> list[Hit]:
"""Dense retrieval. embedder_model MUST match the model that populated this scope."""
limit = k or RAG_TOP_K_DENSE
vector = embeddings.encode(
[query],
normalize = True,
model_name = embedder_model,
)[0].tolist()
raw = vector_store.search(
scope,
query_vector = vector,
top_k = limit,
document_ids = document_ids,
)
out: list[Hit] = []
for r in raw:
payload = r["payload"]
out.append(
Hit(
chunk_id = r["chunk_id"],
score = r["score"],
document_id = payload.get("document_id"),
chunk_index = payload.get("chunk_index"),
kind = payload.get("kind", "text"),
source_page_index = payload.get("source_page_index"),
page_char_start = payload.get("page_char_start"),
page_char_end = payload.get("page_char_end"),
line_start = payload.get("line_start"),
line_end = payload.get("line_end"),
dense_score = r["score"],
)
)
return out
def _rrf_fuse(
rankings: list[list[Hit]],
*,
rrf_k: int,
top_k: int,
) -> list[Hit]:
fused: dict[str, float] = {}
seen: dict[str, Hit] = {}
# Preserve dense_score through fusion for downstream thresholding.
dense_scores: dict[str, float] = {}
for ranking in rankings:
for rank, hit in enumerate(ranking):
fused[hit.chunk_id] = fused.get(hit.chunk_id, 0.0) + 1.0 / (
rrf_k + rank + 1
)
if hit.chunk_id not in seen:
seen[hit.chunk_id] = hit
if hit.dense_score is not None:
dense_scores[hit.chunk_id] = hit.dense_score
ordered = sorted(fused.items(), key = lambda kv: kv[1], reverse = True)[:top_k]
return [
Hit(
chunk_id = cid,
score = score,
document_id = seen[cid].document_id,
chunk_index = seen[cid].chunk_index,
kind = seen[cid].kind,
source_page_index = seen[cid].source_page_index,
page_char_start = seen[cid].page_char_start,
page_char_end = seen[cid].page_char_end,
line_start = seen[cid].line_start,
line_end = seen[cid].line_end,
dense_score = dense_scores.get(cid),
)
for cid, score in ordered
]
def retrieve_hybrid(
scope: str,
query: str,
*,
k: int | None = None,
k_bm25: int | None = None,
k_dense: int | None = None,
document_ids: list[str] | None = None,
embedder_model: str | None = None,
) -> list[Hit]:
bm25_hits = retrieve_bm25(scope, query, k_bm25 or RAG_TOP_K_BM25)
dense_hits = retrieve_dense(
scope,
query,
k_dense or RAG_TOP_K_DENSE,
document_ids = document_ids,
embedder_model = embedder_model,
)
rankings = [bm25_hits, dense_hits]
fig_hits = retrieve_figure_refs(scope, query, document_ids = document_ids)
if fig_hits:
rankings.append(fig_hits)
return _rrf_fuse(
rankings,
rrf_k = RAG_RRF_K,
top_k = k or RAG_TOP_K_HYBRID,
)
def filter_by_min_score(hits: list[Hit], min_score: float) -> list[Hit]:
"""Drop hits whose dense_score < min_score; BM25-only hits dropped too."""
if min_score <= 0.0:
return hits
return [h for h in hits if h.dense_score is not None and h.dense_score >= min_score]

View file

@ -0,0 +1,49 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Scope identifiers (kb_<id> / thread_<id>) + per-scope embedder resolver."""
from __future__ import annotations
from storage.studio_db import get_connection, list_chat_settings
from utils.rag.config import resolve_embedder
RAG_DEFAULTS_KEY = "rag.defaults"
def thread_settings_key(thread_id: str) -> str:
return f"thread:{thread_id}:rag"
def resolve_scope_embedder(scope: str) -> str | None:
"""KB → kb.embedding_model; thread → per-thread/defaults/matrix. None = use default."""
if scope.startswith("kb_"):
kb_id = scope[len("kb_") :]
with get_connection() as conn:
row = conn.execute(
"SELECT embedding_model FROM rag_knowledge_bases WHERE id = ?",
(kb_id,),
).fetchone()
return row["embedding_model"] if row else None
if scope.startswith("thread_"):
thread_id = scope[len("thread_") :]
all_settings = list_chat_settings()
defaults = all_settings.get(RAG_DEFAULTS_KEY) or {}
if not isinstance(defaults, dict):
defaults = {}
per_thread = all_settings.get(thread_settings_key(thread_id)) or {}
if not isinstance(per_thread, dict):
per_thread = {}
explicit = per_thread.get("embedding_model") or defaults.get("embedding_model")
if explicit:
return explicit
mode = per_thread.get("mode") or defaults.get("mode") or "text"
chunking_strategy = (
per_thread.get("chunking_strategy")
or defaults.get("chunking_strategy")
or "standard"
)
return resolve_embedder(mode, chunking_strategy)
return None

View file

@ -0,0 +1,289 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""`search_knowledge_base` tool — RAG retrieval surfaced to the LLM.
Scope comes from the request body (`rag_scope`), not from the tool args,
so the model never sees KB UUIDs.
"""
from __future__ import annotations
from contextvars import ContextVar
from pathlib import Path
from typing import Any, Literal
from loggers import get_logger
logger = get_logger(__name__)
# Per-request chunk-id counter. Task-local (FastAPI runs each request in
# its own asyncio task → its own Context). Lets the model cite chunks
# unambiguously when multiple search_knowledge_base calls run in the
# same chat turn: call 1 returns ids 1..N, call 2 returns N+1..N+M, etc.
_chunk_id_counter: ContextVar[int] = ContextVar("rag_chunk_id_counter", default = 0)
SEARCH_KNOWLEDGE_BASE_TOOL = {
"type": "function",
"function": {
"name": "search_knowledge_base",
"description": (
"ALWAYS CALL THIS TOOL FIRST before answering any user question. "
"It searches the user's attached documents and returns the chunks "
"you must ground your reply in. Do not answer from your own "
"knowledge until you have called this tool with a focused query "
"derived from the user's latest message. Returns chunks wrapped in "
'<chunk id="N" source="..." page="...">...</chunk> '
"tags. CITE each chunk you use with its LITERAL id attribute, "
'e.g. `<chunk id="7">` is cited as `[7]`. IDs are unique across '
"all calls in this turn — never renumber, never reuse."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"A focused search query — phrase it as the question "
"you want answered, not as a keyword list."
),
},
"top_k": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": (
"How many chunks to retrieve (default 5). Higher = "
"more grounding, more tokens."
),
},
},
"required": ["query"],
},
},
}
def _xml_attr(value: Any) -> str:
return (
str(value)
.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _format_hits_for_llm(hits: list[dict], start_id: int = 0) -> str:
"""Render hits as fenced <chunk> blocks with metadata.
``start_id`` offsets the citation id so multiple calls in the same
request produce globally unique ids (call 1: 1..N, call 2: N+1..N+M).
"""
if not hits:
return (
"No matching chunks were found in the attached documents. "
"Either nothing in this scope is relevant, or no documents "
"have been ingested yet."
)
blocks: list[str] = []
for index, hit in enumerate(hits, start = start_id + 1):
attrs = [
f'id="{index}"',
f'source="{_xml_attr(hit.get("filename") or "unknown")}"',
]
# Durable backend ids — additive per contracts.md §3.1 (T3).
# ``id`` above stays as the visible citation id (used by the model
# as `[N]`); ``document_id`` + ``chunk_id`` are what the preview
# route consumes. Old XML without these attrs still parses on
# the frontend (hover-only), per contracts §3.2.
document_id = hit.get("document_id")
if document_id:
attrs.append(f'document_id="{_xml_attr(document_id)}"')
backend_chunk_id = hit.get("chunk_id")
if backend_chunk_id:
attrs.append(f'chunk_id="{_xml_attr(backend_chunk_id)}"')
page = hit.get("page_number")
if page is not None:
attrs.append(f'page="{page}"')
chunk_index = hit.get("chunk_index")
if chunk_index is not None:
attrs.append(f'chunk_index="{chunk_index}"')
for attr_name in (
"source_page_index",
"page_char_start",
"page_char_end",
"line_start",
"line_end",
):
value = hit.get(attr_name)
if value is not None:
attrs.append(f'{attr_name}="{value}"')
tokens = hit.get("token_count")
if tokens:
attrs.append(f'tokens="{tokens}"')
kind = hit.get("kind")
if kind and kind != "text":
attrs.append(f'kind="{_xml_attr(kind)}"')
image_path = hit.get("image_path")
if kind == "image" and image_path and document_id:
# Mirror routes/rag.py search-response shape so the frontend
# tool card can render the image inline via the same route.
image_url = f"/api/rag/images/{document_id}/{Path(image_path).name}"
attrs.append(f'image_url="{_xml_attr(image_url)}"')
text = (hit.get("text") or "").strip()
blocks.append(f"<chunk {' '.join(attrs)}>\n{text}\n</chunk>")
return "\n\n".join(blocks)
def search_knowledge_base(
*,
query: str,
top_k: int | None = None,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
enable_rerank: bool = False,
reranker_model: str | None = None,
default_top_k: int = 5,
min_score: float = 0.0,
mode: Literal["bm25", "dense", "hybrid"] = "hybrid",
) -> str:
"""Run RAG and return a tool-result string. kb_id takes precedence over thread_id."""
if not query or not query.strip():
return "Error: empty query."
if not scope_kb_id and not scope_thread_id:
return (
"No knowledge base or thread documents are configured for "
"retrieval. Ask the user to upload a document or select a "
"knowledge base in the chat settings."
)
from core.rag import retrieval
from core.rag.vector_store import kb_scope, thread_scope
from storage.studio_db import get_connection
scope = kb_scope(scope_kb_id) if scope_kb_id else thread_scope(scope_thread_id)
k = top_k if top_k is not None else default_top_k
if enable_rerank:
from utils.rag.config import RAG_RERANK_CANDIDATE_K
candidate_k = max(k, RAG_RERANK_CANDIDATE_K)
else:
candidate_k = k
from core.rag.scope import resolve_scope_embedder
scope_embedder = resolve_scope_embedder(scope)
logger.info(
"search_knowledge_base: scope=%s embedder=%s mode=%s top_k=%d min_score=%.3f rerank=%s query=%r",
scope,
scope_embedder or "<default>",
mode,
k,
min_score,
enable_rerank,
query[:120],
)
try:
if mode == "bm25":
hits = retrieval.retrieve_bm25(scope, query.strip(), candidate_k)
elif mode == "dense":
hits = retrieval.retrieve_dense(
scope,
query.strip(),
candidate_k,
embedder_model = scope_embedder,
)
else:
hits = retrieval.retrieve_hybrid(
scope,
query.strip(),
k = candidate_k,
embedder_model = scope_embedder,
)
except Exception as exc: # noqa: BLE001
logger.exception("search_knowledge_base retrieval failed")
return f"Error: retrieval failed ({type(exc).__name__})."
retrieved_count = len(hits)
if min_score > 0.0:
hits = retrieval.filter_by_min_score(hits, min_score)
logger.info(
"search_knowledge_base: retrieved=%d met_threshold=%d (min_score=%.3f)",
retrieved_count,
len(hits),
min_score,
)
else:
logger.info(
"search_knowledge_base: retrieved=%d (no threshold)", retrieved_count
)
chunk_ids = [h.chunk_id for h in hits]
lookup: dict[str, dict] = {}
if chunk_ids:
placeholders = ",".join("?" for _ in chunk_ids)
with get_connection() as conn:
rows = conn.execute(
f"""
SELECT c.id AS chunk_id, c.text, c.page_number,
c.token_count, c.kind, c.image_path,
c.source_page_index, c.page_char_start,
c.page_char_end, c.line_start, c.line_end,
c.document_id, d.filename
FROM rag_chunks c
JOIN rag_documents d ON d.id = c.document_id
WHERE c.id IN ({placeholders})
""",
chunk_ids,
).fetchall()
for row in rows:
lookup[row["chunk_id"]] = dict(row)
if enable_rerank and hits:
from core.rag import reranker
pairs = [
(hit, lookup[hit.chunk_id]["text"])
for hit in hits
if hit.chunk_id in lookup
]
try:
hits = reranker.rerank(
query.strip(),
pairs,
model_name = reranker_model,
top_k = k,
)
except Exception as exc: # noqa: BLE001
logger.warning("rerank failed in search_knowledge_base: %s", exc)
hits = hits[:k]
else:
hits = hits[:k]
# Merge Hit-side metadata (score, dense_score, chunk_index) into the
# sqlite-side row so the formatter sees one flat dict per chunk.
# Image-kind hits flow through so the multimodal embedder's match
# can reach the LLM; their image_url lets the UI render the picture.
formatted: list[dict] = []
for hit in hits:
row = lookup.get(hit.chunk_id)
if row is None:
continue
formatted.append(
{
**row,
"score": hit.score,
"dense_score": hit.dense_score,
"chunk_index": hit.chunk_index,
}
)
start_id = _chunk_id_counter.get()
rendered = _format_hits_for_llm(formatted, start_id = start_id)
_chunk_id_counter.set(start_id + len(formatted))
return rendered

View file

@ -0,0 +1,189 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""sqlite-vec vector store. Scope filter (kb_<id>/thread_<id>) keeps mixed-dim
scopes safe per-scope embedder resolver guarantees one dim per scope."""
from __future__ import annotations
import json
from typing import Iterable
from loggers import get_logger
logger = get_logger(__name__)
def kb_scope(kb_id: str) -> str:
return f"kb_{kb_id}"
def thread_scope(thread_id: str) -> str:
return f"thread_{thread_id}"
def collection_exists(scope: str) -> bool:
from core.rag.db import get_rag_connection
conn = get_rag_connection()
row = conn.execute(
"SELECT 1 FROM rag_vectors WHERE scope = ? LIMIT 1",
(scope,),
).fetchone()
return row is not None
def ensure_collection(scope: str, dim: int) -> None:
"""No-op; kept for API parity. Vectors go straight into the shared table."""
_ = scope, dim
def upsert_chunks(scope: str, points: Iterable[dict]) -> None:
"""Insert/update vectors. Each point: {id, vector, payload}."""
import sqlite_vec
from core.rag.db import get_rag_connection
rows = []
for p in points:
payload = p.get("payload") or {}
vec = list(p["vector"])
rows.append(
(
p["id"],
scope,
str(payload.get("document_id") or ""),
int(payload.get("chunk_index") or 0),
str(payload.get("kind") or "text"),
len(vec),
sqlite_vec.serialize_float32(vec),
json.dumps(payload, default = str),
)
)
if not rows:
return
conn = get_rag_connection()
conn.executemany(
"""
INSERT INTO rag_vectors
(chunk_id, scope, document_id, chunk_index, kind, dim, vector, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chunk_id) DO UPDATE SET
scope = excluded.scope,
document_id = excluded.document_id,
chunk_index = excluded.chunk_index,
kind = excluded.kind,
dim = excluded.dim,
vector = excluded.vector,
payload_json = excluded.payload_json
""",
rows,
)
conn.commit()
def search(
scope: str,
query_vector: list[float],
*,
top_k: int,
document_ids: list[str] | None = None,
) -> list[dict]:
"""Cosine search; returns {chunk_id, score, payload} with score = 1 - distance."""
import sqlite_vec
from core.rag.db import get_rag_connection
if not collection_exists(scope):
return []
serialized = sqlite_vec.serialize_float32(list(query_vector))
sql = (
"SELECT chunk_id, payload_json, "
" vec_distance_cosine(vector, ?) AS distance "
"FROM rag_vectors WHERE scope = ?"
)
params: list = [serialized, scope]
if document_ids:
placeholders = ",".join("?" for _ in document_ids)
sql += f" AND document_id IN ({placeholders})"
params.extend(document_ids)
sql += " ORDER BY distance ASC LIMIT ?"
params.append(int(top_k))
conn = get_rag_connection()
rows = conn.execute(sql, params).fetchall()
out: list[dict] = []
for row in rows:
score = 1.0 - float(row["distance"])
try:
payload = json.loads(row["payload_json"] or "{}")
except json.JSONDecodeError:
payload = {}
out.append(
{
"chunk_id": row["chunk_id"],
"score": score,
"payload": payload,
}
)
return out
def update_chunk_payload_fields(
scope: str,
updates: dict[str, dict],
) -> None:
"""Merge locator fields into existing vector payload JSON by chunk id."""
from core.rag.db import get_rag_connection
if not updates:
return
conn = get_rag_connection()
rows = conn.execute(
f"""
SELECT chunk_id, payload_json
FROM rag_vectors
WHERE scope = ? AND chunk_id IN ({",".join("?" for _ in updates)})
""",
[scope, *updates.keys()],
).fetchall()
payload_rows: list[tuple[str, str]] = []
for row in rows:
try:
payload = json.loads(row["payload_json"] or "{}")
except json.JSONDecodeError:
payload = {}
payload.update(updates.get(row["chunk_id"], {}))
payload_rows.append((json.dumps(payload, default = str), row["chunk_id"], scope))
if not payload_rows:
return
conn.executemany(
"""
UPDATE rag_vectors
SET payload_json = ?
WHERE chunk_id = ? AND scope = ?
""",
payload_rows,
)
conn.commit()
def delete_scope(scope: str) -> None:
from core.rag.db import get_rag_connection
conn = get_rag_connection()
conn.execute("DELETE FROM rag_vectors WHERE scope = ?", (scope,))
conn.commit()
def delete_document(scope: str, document_id: str) -> None:
from core.rag.db import get_rag_connection
conn = get_rag_connection()
conn.execute(
"DELETE FROM rag_vectors WHERE scope = ? AND document_id = ?",
(scope, document_id),
)
conn.commit()

View file

@ -136,6 +136,7 @@ from routes import (
mcp_servers_router,
models_router,
providers_router,
rag_router,
training_history_router,
training_router,
)
@ -584,6 +585,7 @@ app.include_router(export_router, prefix = "/api/export", tags = ["export"])
app.include_router(
training_history_router, prefix = "/api/train", tags = ["training-history"]
)
app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
# ============ Health and System Endpoints ============

View file

@ -738,6 +738,17 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
)
rag_scope: Optional[dict] = Field(
None,
description = (
"[x-unsloth] Per-request context the `search_knowledge_base` tool "
"consumes when the LLM invokes it. Shape: "
"{kb_id?: str, thread_id?: str, enable_rerank?: bool, "
"default_top_k?: int, reranker_model?: str, min_score?: float, "
"mode?: 'bm25'|'dense'|'hybrid'}. Ignored unless "
"'search_knowledge_base' is in enabled_tools."
),
)
cancel_id: Optional[str] = Field(
None,
description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.",

View file

@ -11,10 +11,18 @@ peft==0.18.1
# TRL and related packages
trl==0.23.1
git+https://github.com/meta-pytorch/OpenEnv.git
# OpenEnv moved from meta-pytorch/ to the huggingface/ org; the old URL
# relies on a GitHub redirect that uv's git cache can fail to follow
# (manifests as a credential prompt). Point at the canonical repo.
git+https://github.com/huggingface/OpenEnv.git
# executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio
torch-c-dlpack-ext
sentence_transformers==5.2.0
# Bumped from 5.2.0 to expose the `sentence_transformers.base.modules`
# package that Qwen3-VL-Embedding-2B references in its modules.json
# (still supported as an alternative multimodal RAG embedder; the
# current default is BAAI/BGE-VL-large via our in-process adapter).
# Upper bound at <7 to avoid jumps across a future major rewrite.
sentence_transformers>=5.3.0,<7
transformers==4.57.6
pytorch_tokenizers
kernels==0.12.1

View file

@ -0,0 +1,35 @@
# Studio RAG dependencies.
# Installed by studio/install_python_stack.py in the normal (with-torch)
# path. Skipped in NO_TORCH (Intel Mac GGUF-only) mode because RAG
# embedding relies on sentence-transformers, which requires torch.
# Vector store + lexical index.
#
# sqlite-vec is an Apache-2.0 SQLite extension (asg017/sqlite-vec) that
# adds vector functions (vec_distance_cosine, serialize_float32, vec0
# virtual tables). The studio loads it into a dedicated rag.db file and
# stores vectors as BLOB columns alongside the RAG metadata — no separate
# vector server, no second client library. bm25s persists per-scope
# lexical indexes to disk.
sqlite-vec>=0.1.5
bm25s>=0.2
# Image preprocessing helpers required by Qwen3-VL-Embedding-2B (the
# multimodal embedder). Not used in text-only mode.
qwen-vl-utils>=0.0.14
# Layout-aware Markdown extraction (Phase 3A) so the chunker can split
# on real headings instead of running paragraphs together. pymupdf4llm
# preserves headings + pipe-tables; mammoth handles DOCX Heading styles;
# markdownify converts HTML <h*>/<table>/<ul> faithfully.
pymupdf>=1.24
pymupdf4llm>=0.0.17
mammoth>=1.7
markdownify>=0.13
# pypdf is kept as a fallback for malformed PDFs that defeat pymupdf.
pypdf>=4.0
python-docx>=1.1
beautifulsoup4>=4.12
lxml>=5.0
chardet>=5.2

View file

@ -16,6 +16,7 @@ from routes.export import router as export_router
from routes.training_history import router as training_history_router
from routes.chat_history import router as chat_history_router
from routes.providers import router as providers_router
from routes.rag import router as rag_router
from routes.mcp_servers import router as mcp_servers_router
__all__ = [
@ -30,5 +31,6 @@ __all__ = [
"training_history_router",
"chat_history_router",
"providers_router",
"rag_router",
"mcp_servers_router",
]

View file

@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from auth.authentication import get_current_subject
from core.rag.ingestion import purge_all_thread_documents, purge_thread_documents
from storage.studio_db import (
ChatMessageConflictError,
CorruptSettingsError,
@ -229,6 +230,9 @@ async def delete_threads(
payload: ChatDeleteRequest,
current_subject: str = Depends(get_current_subject),
):
# rag_documents has no FK cascade to chat_threads, so purge their
# files + vectors + bm25 explicitly before deleting the threads.
purge_thread_documents(payload.ids)
delete_chat_threads(payload.ids)
return {"status": "deleted"}
@ -354,6 +358,7 @@ async def record_import_ledger(
@router.delete("")
async def clear_history(current_subject: str = Depends(get_current_subject)):
purge_all_thread_documents()
clear_chat_history()
return {"status": "deleted"}

View file

@ -302,6 +302,45 @@ def _effective_enable_tools(payload) -> Optional[bool]:
return policy if policy is not None else payload.enable_tools
def _drop_rag_tool_if_scope_empty(tools: list, rag_scope: Optional[dict]) -> list:
"""Strip ``search_knowledge_base`` when the request's rag_scope has no docs.
Exposing the tool to the LLM when there's nothing to retrieve wastes a
tool-call turn the model hits the tool, gets back "no chunks", and
has to re-plan. We do the doc count here as defence-in-depth; the
frontend also avoids requesting the tool in this case.
"""
if not rag_scope:
return tools
kb_id = rag_scope.get("kb_id")
thread_id = rag_scope.get("thread_id")
if not kb_id and not thread_id:
return tools
try:
from storage.studio_db import get_connection
with get_connection() as conn:
if kb_id:
row = conn.execute(
"SELECT COUNT(*) FROM rag_documents WHERE kb_id = ?",
(kb_id,),
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) FROM rag_documents WHERE thread_id = ?",
(thread_id,),
).fetchone()
doc_count = row[0] if row else 0
except Exception as exc: # noqa: BLE001
logger.warning("RAG scope-has-docs check failed", error = str(exc))
return tools
if doc_count > 0:
return tools
return [
t for t in tools if t.get("function", {}).get("name") != "search_knowledge_base"
]
# Cancel registry. Proxies (e.g. Colab) can swallow client fetch aborts
# so is_disconnected() never fires. POST /inference/cancel looks up
# in-flight cancel_events here by cancel_id (per-run) or session_id /
@ -2673,13 +2712,17 @@ async def openai_chat_completions(
# MCP-only request: skip built-ins, leave room for MCP tools.
tools_to_use = []
elif payload.enabled_tools is not None:
# Preserve client-supplied order so prioritised tools
# (e.g. search_knowledge_base when RAG is on) appear first.
_by_name = {t["function"]["name"]: t for t in ALL_TOOLS}
tools_to_use = [
t
for t in ALL_TOOLS
if t["function"]["name"] in payload.enabled_tools
_by_name[name] for name in payload.enabled_tools if name in _by_name
]
else:
tools_to_use = ALL_TOOLS
tools_to_use = _drop_rag_tool_if_scope_empty(
tools_to_use, payload.rag_scope
)
if _mcp_allowed:
tools_to_use = tools_to_use + await get_enabled_mcp_tools()
@ -2790,6 +2833,9 @@ async def openai_chat_completions(
if payload.tool_call_timeout is not None
else 300,
session_id = payload.session_id,
tool_context = (
{"rag_scope": payload.rag_scope} if payload.rag_scope else None
),
)
_tool_sentinel = object()
@ -3202,11 +3248,15 @@ async def openai_chat_completions(
if not _sf_tools_on:
_sf_tools_to_use = []
elif payload.enabled_tools is not None:
_by_name = {t["function"]["name"]: t for t in ALL_TOOLS}
_sf_tools_to_use = [
t for t in ALL_TOOLS if t["function"]["name"] in payload.enabled_tools
_by_name[name] for name in payload.enabled_tools if name in _by_name
]
else:
_sf_tools_to_use = ALL_TOOLS
_sf_tools_to_use = _drop_rag_tool_if_scope_empty(
_sf_tools_to_use, payload.rag_scope
)
if _sf_mcp_allowed:
_sf_tools_to_use = _sf_tools_to_use + await get_enabled_mcp_tools()
@ -3288,6 +3338,9 @@ async def openai_chat_completions(
def sf_generate_with_tools():
return backend.generate_chat_completion_with_tools(
tool_context = (
{"rag_scope": payload.rag_scope} if payload.rag_scope else None
),
messages = _sf_chat_messages,
tools = _sf_tools_to_use,
system_prompt = _sf_system_prompt or "",

1862
studio/backend/routes/rag.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -205,6 +205,161 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
) WITHOUT ROWID
"""
)
# RAG schema. rag_documents enforces XOR on (kb_id, thread_id).
# chunking_strategy/mode are immutable post-create (invalidates chunks).
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rag_knowledge_bases (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
owner_user_id TEXT,
embedding_model TEXT NOT NULL,
chunking_strategy TEXT NOT NULL DEFAULT 'standard',
mode TEXT NOT NULL DEFAULT 'text',
created_at INTEGER NOT NULL
)
"""
)
# Idempotent ALTER for pre-existing installs.
kb_cols = {
row[1]
for row in conn.execute("PRAGMA table_info(rag_knowledge_bases)").fetchall()
}
if "chunking_strategy" not in kb_cols:
conn.execute(
"ALTER TABLE rag_knowledge_bases "
"ADD COLUMN chunking_strategy TEXT NOT NULL DEFAULT 'standard'"
)
if "mode" not in kb_cols:
conn.execute(
"ALTER TABLE rag_knowledge_bases "
"ADD COLUMN mode TEXT NOT NULL DEFAULT 'text'"
)
# thread_id has no FK: docs can attach before the thread is persisted.
# chat_history DELETE handlers purge matching rag_documents explicitly.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rag_documents (
id TEXT NOT NULL PRIMARY KEY,
kb_id TEXT REFERENCES rag_knowledge_bases(id) ON DELETE CASCADE,
thread_id TEXT,
filename TEXT NOT NULL,
content_type TEXT,
stored_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
num_chunks INTEGER NOT NULL DEFAULT 0,
byte_size INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at INTEGER NOT NULL,
CHECK ((kb_id IS NOT NULL) <> (thread_id IS NOT NULL))
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_documents_kb_id ON rag_documents(kb_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_documents_thread_id ON rag_documents(thread_id)"
)
# content_hash: sha256 of the uploaded bytes, used to skip re-indexing a
# file that already exists in the same scope (kb_id / thread_id).
rag_documents_columns = {
row[1] for row in conn.execute("PRAGMA table_info(rag_documents)").fetchall()
}
if "content_hash" not in rag_documents_columns:
conn.execute("ALTER TABLE rag_documents ADD COLUMN content_hash TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_documents_kb_hash "
"ON rag_documents(kb_id, content_hash)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_documents_thread_hash "
"ON rag_documents(thread_id, content_hash)"
)
# kind: text|image|caption. linked_chunk_id pairs image↔caption (both null for text).
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rag_chunks (
id TEXT NOT NULL PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES rag_documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
text TEXT NOT NULL,
token_count INTEGER NOT NULL DEFAULT 0,
page_number INTEGER,
kind TEXT NOT NULL DEFAULT 'text',
image_path TEXT,
linked_chunk_id TEXT,
source_page_index INTEGER,
page_char_start INTEGER,
page_char_end INTEGER,
line_start INTEGER,
line_end INTEGER,
pdf_regions_json TEXT,
UNIQUE(document_id, chunk_index)
)
"""
)
chunk_cols = {
row[1] for row in conn.execute("PRAGMA table_info(rag_chunks)").fetchall()
}
if "kind" not in chunk_cols:
conn.execute(
"ALTER TABLE rag_chunks ADD COLUMN kind TEXT NOT NULL DEFAULT 'text'"
)
if "image_path" not in chunk_cols:
conn.execute("ALTER TABLE rag_chunks ADD COLUMN image_path TEXT")
if "linked_chunk_id" not in chunk_cols:
conn.execute("ALTER TABLE rag_chunks ADD COLUMN linked_chunk_id TEXT")
for column in (
"source_page_index",
"page_char_start",
"page_char_end",
"line_start",
"line_end",
):
if column not in chunk_cols:
conn.execute(f"ALTER TABLE rag_chunks ADD COLUMN {column} INTEGER")
if "pdf_regions_json" not in chunk_cols:
conn.execute("ALTER TABLE rag_chunks ADD COLUMN pdf_regions_json TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rag_document_pages (
document_id TEXT NOT NULL REFERENCES rag_documents(id) ON DELETE CASCADE,
page_index INTEGER NOT NULL,
page_number INTEGER,
text TEXT NOT NULL,
char_count INTEGER NOT NULL DEFAULT 0,
line_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
PRIMARY KEY(document_id, page_index)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_document_pages_document_id "
"ON rag_document_pages(document_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rag_ingestion_jobs (
id TEXT NOT NULL PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES rag_documents(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
progress REAL NOT NULL DEFAULT 0.0,
stage TEXT,
error TEXT,
started_at INTEGER,
finished_at INTEGER
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_jobs_document_id ON rag_ingestion_jobs(document_id)"
)
def get_connection() -> sqlite3.Connection:

View file

@ -0,0 +1,272 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for document_for_subject_or_404 and chunk_belongs_to_document.
Authorization rules under test (contracts.md §1 / §2, Risk #1):
- KB documents: KB must exist and KB.owner_user_id must equal current_subject.
- Thread documents: thread must exist in chat_threads; current-Studio single-user
invariant means any authenticated subject can access, BUT the thread row must
exist (a missing thread is 404, not silent grant).
- Missing document or missing KB both collapse to 404.
- KB with NULL owner_user_id is NOT accessible (legacy row guard).
- Both not-found and not-authorized return HTTP 404 with identical detail to
prevent document-existence leaking.
- chunk_belongs_to_document only returns True when chunk.document_id matches.
"""
from __future__ import annotations
import uuid
import pytest
from fastapi import HTTPException
import storage.studio_db as studio_db
from core.rag.authorization import (
chunk_belongs_to_document,
document_for_subject_or_404,
)
# ── Fixtures ──────────────────────────────────────────────────────────
def _reset_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
def _uid() -> str:
return str(uuid.uuid4())
def _insert_kb(conn, kb_id: str, owner: str | None = "user-alice") -> None:
conn.execute(
"""
INSERT INTO rag_knowledge_bases (id, name, embedding_model, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(kb_id, f"KB-{kb_id[:8]}", "bge-small", owner, 1_700_000_000),
)
def _insert_thread(conn, thread_id: str) -> None:
conn.execute(
"""
INSERT INTO chat_threads (id, title, model_type, model_id, archived, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(thread_id, "Test Thread", "base", "llama3", 0, 1_700_000_000),
)
def _insert_kb_doc(conn, doc_id: str, kb_id: str, stored_path: str = "doc.pdf") -> None:
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, ?, NULL, ?, ?, ?, 'completed', 0, 1024, ?)
""",
(doc_id, kb_id, "report.pdf", "application/pdf", stored_path, 1_700_000_000),
)
def _insert_thread_doc(
conn, doc_id: str, thread_id: str, stored_path: str = "doc.txt"
) -> None:
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, NULL, ?, ?, ?, ?, 'completed', 0, 512, ?)
""",
(doc_id, thread_id, "note.txt", "text/plain", stored_path, 1_700_000_000),
)
def _insert_chunk(conn, chunk_id: str, doc_id: str, chunk_index: int = 0) -> None:
conn.execute(
"""
INSERT INTO rag_chunks (id, document_id, chunk_index, text, token_count)
VALUES (?, ?, ?, ?, ?)
""",
(chunk_id, doc_id, chunk_index, "some chunk text", 20),
)
# ── KB-document authorization ─────────────────────────────────────────
def test_kb_doc_correct_owner_returns_row(tmp_path, monkeypatch):
"""KB doc authorized when KB.owner_user_id == current_subject."""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_id, kb_id)
row = document_for_subject_or_404(doc_id, "alice")
assert row["id"] == doc_id
def test_kb_doc_wrong_owner_raises_404(tmp_path, monkeypatch):
"""KB doc returns 404 when current_subject != KB.owner_user_id."""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_id, kb_id)
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404(doc_id, "mallory")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Document not found"
def test_kb_doc_null_owner_raises_404(tmp_path, monkeypatch):
"""KB with NULL owner_user_id is not accessible through the helper (legacy guard)."""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = None)
_insert_kb_doc(conn, doc_id, kb_id)
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404(doc_id, "alice")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Document not found"
def test_kb_doc_missing_kb_raises_404(tmp_path, monkeypatch):
"""Document rows whose KB was deleted collapse to 404.
Insert both KB and doc, then delete the KB (ON DELETE CASCADE removes the doc
too). A subsequent lookup for the doc id must return 404, not 500.
If for some reason the doc row survives (e.g. FK off), the helper must
still 404 because the KB is gone.
"""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_id, kb_id)
# Delete the KB — ON DELETE CASCADE should also drop the doc.
conn.execute("DELETE FROM rag_knowledge_bases WHERE id = ?", (kb_id,))
# After cascade deletion the doc_id no longer exists → 404.
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404(doc_id, "alice")
assert exc_info.value.status_code == 404
# ── Thread-document authorization (single-user invariant) ─────────────
def test_thread_doc_existing_thread_grants_access(tmp_path, monkeypatch):
"""Thread doc is accessible when thread exists (single-user Studio invariant)."""
_reset_db(tmp_path, monkeypatch)
doc_id, thread_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_thread(conn, thread_id)
_insert_thread_doc(conn, doc_id, thread_id)
row = document_for_subject_or_404(doc_id, "any-authenticated-user")
assert row["id"] == doc_id
def test_thread_doc_nonexistent_thread_raises_404(tmp_path, monkeypatch):
"""A missing thread_id does NOT silently grant access — it must be 404."""
_reset_db(tmp_path, monkeypatch)
doc_id, thread_id = _uid(), _uid()
# Insert doc with a thread_id that has no matching chat_threads row.
with studio_db.get_connection() as conn:
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, NULL, ?, 'x.txt', 'text/plain', 'x.txt', 'completed', 0, 1, ?)
""",
(doc_id, thread_id, 1_700_000_000),
)
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404(doc_id, "alice")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Document not found"
# ── Missing document ──────────────────────────────────────────────────
def test_missing_document_raises_404(tmp_path, monkeypatch):
"""Completely absent document_id returns 404 with canonical detail."""
_reset_db(tmp_path, monkeypatch)
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404("nonexistent-id", "alice")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Document not found"
def test_empty_document_id_raises_404(tmp_path, monkeypatch):
"""Empty string document_id raises 404 rather than hitting the DB."""
_reset_db(tmp_path, monkeypatch)
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404("", "alice")
assert exc_info.value.status_code == 404
def test_empty_subject_raises_404(tmp_path, monkeypatch):
"""Empty subject raises 404 — cannot authorize without a subject."""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_id, kb_id)
with pytest.raises(HTTPException) as exc_info:
document_for_subject_or_404(doc_id, "")
assert exc_info.value.status_code == 404
# ── chunk_belongs_to_document ─────────────────────────────────────────
def test_chunk_belongs_returns_true_for_matching_doc(tmp_path, monkeypatch):
"""chunk_belongs_to_document returns True when chunk.document_id matches."""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_id, kb_id)
_insert_chunk(conn, chunk_id, doc_id)
assert chunk_belongs_to_document(chunk_id, doc_id) is True
def test_chunk_belongs_returns_false_for_wrong_doc(tmp_path, monkeypatch):
"""chunk_belongs_to_document returns False when chunk belongs to a different document."""
_reset_db(tmp_path, monkeypatch)
kb_id = _uid()
doc_a, doc_b, chunk_id = _uid(), _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_a, kb_id, "a.pdf")
_insert_kb_doc(conn, doc_b, kb_id, "b.pdf")
_insert_chunk(conn, chunk_id, doc_a)
# chunk belongs to doc_a — probing with doc_b must return False
assert chunk_belongs_to_document(chunk_id, doc_b) is False
def test_chunk_belongs_returns_false_for_missing_chunk(tmp_path, monkeypatch):
"""chunk_belongs_to_document returns False for a nonexistent chunk_id."""
_reset_db(tmp_path, monkeypatch)
doc_id, kb_id = _uid(), _uid()
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_kb_doc(conn, doc_id, kb_id)
assert chunk_belongs_to_document("ghost-chunk-id", doc_id) is False
def test_chunk_belongs_returns_false_for_empty_inputs(tmp_path, monkeypatch):
"""chunk_belongs_to_document returns False for empty inputs without DB access."""
_reset_db(tmp_path, monkeypatch)
assert chunk_belongs_to_document("", "some-doc") is False
assert chunk_belongs_to_document("some-chunk", "") is False
assert chunk_belongs_to_document("", "") is False

View file

@ -0,0 +1,279 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import queue as queue_module
import uuid
import pytest
import storage.studio_db as studio_db
from core.rag.chunking import chunk_pages, chunk_pages_with_spans
from core.rag.ingestion import (
_JobState,
_insert_chunks_and_collect_for_bm25,
_pump,
_replace_document_pages,
)
from core.rag.parsers import ParsedPage
def _uid() -> str:
return str(uuid.uuid4())
def _token_count(text: str) -> int:
return max(1, len(text.split()))
def test_standard_chunking_records_page_local_char_and_line_spans():
pages = [
ParsedPage(
text = "alpha first line\nbeta target line\ngamma final line",
page_number = 7,
)
]
chunks = chunk_pages(
pages,
max_tokens = 3,
overlap_tokens = 0,
token_counter = _token_count,
separators = ("\n", " ", ""),
)
target = next(chunk for chunk in chunks if "beta" in chunk.text)
assert target.page_number == 7
assert target.source_page_index == 0
assert target.page_char_start == pages[0].text.index("beta target line")
assert target.page_char_end == target.page_char_start + len("beta target line")
assert target.line_start == 2
assert target.line_end == 2
def test_late_chunking_maps_global_span_back_to_source_page():
pages = [
ParsedPage(text = "page one alpha", page_number = 1),
ParsedPage(text = "page two beta target", page_number = 2),
]
_full_doc, chunks, spans = chunk_pages_with_spans(
pages,
max_tokens = 4,
overlap_tokens = 0,
token_counter = _token_count,
separators = ("\n\n", " ", ""),
)
target = next(chunk for chunk in chunks if "beta" in chunk.text)
assert spans[chunks.index(target)][0] >= len(pages[0].text)
assert target.page_number == 2
assert target.source_page_index == 1
assert target.page_char_start is not None
assert target.page_char_end is not None
assert pages[1].text[target.page_char_start : target.page_char_end].strip()
def test_image_chunk_persistence_keeps_page_focus_and_null_text_locators(
tmp_path,
monkeypatch,
):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
from core.rag import ingestion
captured_points: list[dict] = []
monkeypatch.setattr(
ingestion.vector_store,
"upsert_chunks",
lambda _scope, points: captured_points.extend(points),
)
kb_id = _uid()
doc_id = _uid()
with studio_db.get_connection() as conn:
conn.execute(
"""
INSERT INTO rag_knowledge_bases
(id, name, embedding_model, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(kb_id, "KB", "embedder", "alice", 1_700_000_000),
)
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, ?, NULL, ?, ?, ?, 'completed', 1, 10, ?)
""",
(doc_id, kb_id, "image.pdf", "application/pdf", "image.pdf", 1_700_000_001),
)
_insert_chunks_and_collect_for_bm25(
doc_id,
"kb_scope",
0,
[
{
"text": "",
"token_count": 0,
"page_number": 3,
"kind": "image",
"image_path": str(tmp_path / "img.png"),
}
],
[[0.1, 0.2]],
)
with studio_db.get_connection() as conn:
row = conn.execute(
"""
SELECT page_number, source_page_index, page_char_start,
page_char_end, line_start, line_end
FROM rag_chunks WHERE document_id = ?
""",
(doc_id,),
).fetchone()
assert row["page_number"] == 3
assert row["source_page_index"] is None
assert row["page_char_start"] is None
assert row["page_char_end"] is None
assert row["line_start"] is None
assert row["line_end"] is None
assert captured_points[0]["payload"]["page_number"] == 3
assert captured_points[0]["payload"]["page_char_start"] is None
def test_replace_document_pages_replaces_existing_rows(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
kb_id = _uid()
doc_id = _uid()
with studio_db.get_connection() as conn:
conn.execute(
"""
INSERT INTO rag_knowledge_bases
(id, name, embedding_model, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(kb_id, "KB", "embedder", "alice", 1_700_000_000),
)
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, ?, NULL, ?, ?, ?, 'completed', 1, 10, ?)
""",
(doc_id, kb_id, "doc.pdf", "application/pdf", "doc.pdf", 1_700_000_001),
)
_replace_document_pages(
doc_id,
[
{
"page_index": 0,
"page_number": 1,
"text": "old page",
"char_count": 8,
"line_count": 1,
}
],
)
_replace_document_pages(
doc_id,
[
{
"page_index": 1,
"page_number": 2,
"text": "new\npage",
"char_count": 8,
"line_count": 2,
}
],
)
with studio_db.get_connection() as conn:
rows = conn.execute(
"""
SELECT page_index, page_number, text, char_count, line_count
FROM rag_document_pages WHERE document_id = ?
""",
(doc_id,),
).fetchall()
assert [dict(row) for row in rows] == [
{
"page_index": 1,
"page_number": 2,
"text": "new\npage",
"char_count": 8,
"line_count": 2,
}
]
class _OneMessageQueue:
def __init__(self, message: dict) -> None:
self.message = message
self.used = False
def get(self, timeout: float) -> dict:
if self.used:
raise queue_module.Empty
self.used = True
return self.message
class _FinishedProcess:
def join(self, timeout: float | None = None) -> None:
return None
def is_alive(self) -> bool:
return False
def terminate(self) -> None:
return None
@pytest.mark.parametrize(
"pages",
[
[
{
"page_index": 0,
"page_number": 1,
"text": "orphan page",
"char_count": 11,
"line_count": 1,
}
],
[],
],
)
def test_document_pages_missing_document_fails_pump_cleanly(
tmp_path,
monkeypatch,
pages,
):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
with studio_db.get_connection():
pass
state = _JobState("job-missing-doc", "missing-doc", "kb_scope")
queue = _OneMessageQueue(
{
"type": "document_pages",
"pages": pages,
}
)
_pump(state, _FinishedProcess(), queue)
assert state.status == "failed"
assert state.error is not None
assert "document was removed before ingestion finished" in state.error

View file

@ -0,0 +1,140 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import storage.studio_db as studio_db
from auth.authentication import get_current_subject
@pytest.fixture(scope = "module")
def app():
import sys
backend_dir = str(Path(__file__).resolve().parent.parent)
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
from main import app as _app
return _app
@pytest.fixture
def db_env(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
return tmp_path
def _uid() -> str:
return str(uuid.uuid4())
def _make_client(app, subject: str = "alice"):
app.dependency_overrides[get_current_subject] = lambda: subject
return TestClient(app, raise_server_exceptions = True)
def _clear_overrides(app):
app.dependency_overrides.clear()
def _insert_kb(conn, kb_id: str, owner: str = "alice") -> None:
conn.execute(
"INSERT INTO rag_knowledge_bases "
"(id, name, embedding_model, owner_user_id, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(kb_id, f"KB-{kb_id[:6]}", "bge-small", owner, 1_700_000_000),
)
def _insert_doc(conn, doc_id: str, kb_id: str, stored_path: str, filename: str) -> None:
conn.execute(
"INSERT INTO rag_documents "
"(id, kb_id, thread_id, filename, content_type, stored_path, status, "
"num_chunks, byte_size, created_at) "
"VALUES (?, ?, NULL, ?, 'text/plain', ?, 'completed', 1, 64, ?)",
(doc_id, kb_id, filename, stored_path, 1_700_000_000),
)
def _insert_chunk(conn, chunk_id: str, doc_id: str, text: str) -> None:
conn.execute(
"INSERT INTO rag_chunks "
"(id, document_id, chunk_index, text, token_count, page_number) "
"VALUES (?, ?, 0, ?, 5, NULL)",
(chunk_id, doc_id, text),
)
def test_backfill_preserves_ids_and_updates_unique_locator(app, db_env, monkeypatch):
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "paper.txt"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_text("Intro line\nUnique quote here.\nEnd.", encoding = "utf-8")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored), "paper.txt")
_insert_chunk(conn, chunk_id, doc_id, "Unique quote here.")
client = _make_client(app, "alice")
try:
resp = client.post(f"/api/rag/documents/{doc_id}/locators/backfill")
target_resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["documentId"] == doc_id
assert body["matched"] == 1
assert body["ambiguous"] == 0
target = target_resp.json()
assert target["documentId"] == doc_id
assert target["chunkId"] == chunk_id
assert target["sourcePageIndex"] == 0
assert target["lineStart"] == 2
assert target["pageCharStart"] in (len("Intro line\n"), len("Intro line\r\n"))
def test_backfill_leaves_ambiguous_matches_null(app, db_env, monkeypatch):
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "paper.txt"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_text("Repeat me.\nOther text.\nRepeat me.", encoding = "utf-8")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored), "paper.txt")
_insert_chunk(conn, chunk_id, doc_id, "Repeat me.")
client = _make_client(app, "alice")
try:
resp = client.post(f"/api/rag/documents/{doc_id}/locators/backfill")
target_resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["matched"] == 0
assert body["ambiguous"] == 1
target = target_resp.json()
assert target["sourcePageIndex"] is None
assert target["pageCharStart"] is None
assert target["lineStart"] is None

View file

@ -0,0 +1,86 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import uuid
import storage.studio_db as studio_db
def _uid() -> str:
return str(uuid.uuid4())
def test_locator_schema_is_additive_and_nullable(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
with studio_db.get_connection() as conn:
chunk_cols = {
row["name"] for row in conn.execute("PRAGMA table_info(rag_chunks)")
}
assert {
"source_page_index",
"page_char_start",
"page_char_end",
"line_start",
"line_end",
}.issubset(chunk_cols)
page_cols = {
row["name"] for row in conn.execute("PRAGMA table_info(rag_document_pages)")
}
assert {
"document_id",
"page_index",
"page_number",
"text",
"char_count",
"line_count",
}.issubset(page_cols)
kb_id = _uid()
doc_id = _uid()
chunk_id = _uid()
conn.execute(
"""
INSERT INTO rag_knowledge_bases
(id, name, embedding_model, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(kb_id, "KB", "embedder", "alice", 1_700_000_000),
)
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, ?, NULL, ?, ?, ?, 'completed', 1, 10, ?)
""",
(doc_id, kb_id, "old.pdf", "application/pdf", "old.pdf", 1_700_000_001),
)
conn.execute(
"""
INSERT INTO rag_chunks
(id, document_id, chunk_index, text, token_count, page_number)
VALUES (?, ?, 0, ?, 3, 1)
""",
(chunk_id, doc_id, "legacy chunk"),
)
row = conn.execute(
"""
SELECT source_page_index, page_char_start, page_char_end,
line_start, line_end
FROM rag_chunks WHERE id = ?
""",
(chunk_id,),
).fetchone()
assert dict(row) == {
"source_page_index": None,
"page_char_start": None,
"page_char_end": None,
"line_start": None,
"line_end": None,
}

View file

@ -0,0 +1,626 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for GET /api/rag/documents/{id}/preview-target and /file.
Acceptance criteria covered (contracts.md §1, §2, PLAN.md T1/T2, Risk #1-3):
/preview-target:
- 200 with chunk data when chunk_id present and belongs to doc.
- 200 with all-null chunk fields when chunk_id absent (document-row preview).
- 404 when document missing (collapsed existence + auth).
- 404 when chunk_id does not belong to document_id (cross-doc probe collapsed).
- 401 when no bearer token.
/file:
- 200 with correct Content-Type and nosniff header.
- X-Content-Type-Options: nosniff present on every 200.
- Cache-Control: private present on every 200.
- HTML extension served as text/plain + attachment (Risk #3).
- DOCX extension served with attachment disposition.
- 404 when document missing or wrong subject.
- 404 when file deleted from disk (DB row exists, subject authorized).
- Outside-root stored_path returns 404 (path containment, Risk #2).
Auth is injected via dependency override (mock at the boundary, not the
implementation target). We do NOT mock document_for_subject_or_404 itself.
"""
from __future__ import annotations
import os
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import storage.studio_db as studio_db
from auth.authentication import get_current_subject
# ── App import (deferred to avoid import-time side-effects) ───────────
@pytest.fixture(scope = "module")
def app():
import sys
backend_dir = str(Path(__file__).resolve().parent.parent)
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
from main import app as _app
return _app
# ── Test-level DB + auth fixtures ─────────────────────────────────────
@pytest.fixture
def db_env(tmp_path, monkeypatch):
"""Point studio_db at a fresh temp DB for each test."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
return tmp_path
def _uid() -> str:
return str(uuid.uuid4())
def _make_client(app, subject: str = "alice"):
"""Return a TestClient with get_current_subject overridden to return subject."""
app.dependency_overrides[get_current_subject] = lambda: subject
client = TestClient(app, raise_server_exceptions = True)
return client
def _clear_overrides(app):
app.dependency_overrides.clear()
def _insert_kb(conn, kb_id: str, owner: str = "alice") -> None:
conn.execute(
"INSERT INTO rag_knowledge_bases (id, name, embedding_model, owner_user_id, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(kb_id, f"KB-{kb_id[:6]}", "bge-small", owner, 1_700_000_000),
)
def _insert_doc(
conn,
doc_id: str,
kb_id: str,
stored_path: str,
filename: str = "report.pdf",
content_type: str | None = "application/pdf",
status: str = "completed",
) -> None:
conn.execute(
"INSERT INTO rag_documents "
"(id, kb_id, thread_id, filename, content_type, stored_path, status, "
"num_chunks, byte_size, created_at) "
"VALUES (?, ?, NULL, ?, ?, ?, ?, 0, 1024, ?)",
(doc_id, kb_id, filename, content_type, stored_path, status, 1_700_000_000),
)
def _insert_chunk(
conn,
chunk_id: str,
doc_id: str,
text: str = "The margin rose to 18.2% in Q3.",
page_number: int | None = 7,
chunk_index: int = 14,
) -> None:
conn.execute(
"INSERT INTO rag_chunks "
"(id, document_id, chunk_index, text, token_count, page_number) "
"VALUES (?, ?, ?, ?, ?, ?)",
(chunk_id, doc_id, chunk_index, text, 30, page_number),
)
# ── /preview-target tests ─────────────────────────────────────────────
class TestPreviewTarget:
def test_with_chunk_id_returns_full_metadata(self, app, db_env, monkeypatch):
"""GET /preview-target?chunk_id=<id> returns page + snippet when chunk valid."""
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "report.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4 dummy")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
_insert_chunk(conn, chunk_id, doc_id, page_number = 7, chunk_index = 14)
client = _make_client(app, "alice")
try:
resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["documentId"] == doc_id
assert body["chunkId"] == chunk_id
assert body["targetPage"] == 7
assert body["chunkIndex"] == 14
assert body["snippet"] is not None and len(body["snippet"]) > 0
assert body["mediaKind"] == "pdf"
def test_preview_target_returns_pdf_regions_when_present(
self, app, db_env, monkeypatch
):
"""Chunk preview includes only stored confident PDF regions."""
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "report.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4 dummy")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
_insert_chunk(conn, chunk_id, doc_id, page_number = 7, chunk_index = 14)
conn.execute(
"""
UPDATE rag_chunks
SET pdf_regions_json = ?
WHERE id = ?
""",
(
'[{"pageIndex":6,"pageNumber":7,"x":0.1,"y":0.2,'
'"width":0.3,"height":0.04,"confidence":"exact",'
'"source":"pymupdf-search"}]',
chunk_id,
),
)
client = _make_client(app, "alice")
try:
resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["pdfRegions"] == [
{
"pageIndex": 6,
"pageNumber": 7,
"x": 0.1,
"y": 0.2,
"width": 0.3,
"height": 0.04,
"confidence": "exact",
"source": "pymupdf-search",
}
]
def test_without_chunk_id_returns_all_null_chunk_fields(
self, app, db_env, monkeypatch
):
"""GET /preview-target without chunk_id returns metadata-only (decision Q2)."""
doc_id, kb_id = _uid(), _uid()
stored = db_env / "rag" / "uploads" / "annual.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4 dummy")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/preview-target")
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
# All chunk fields MUST be null — UI must not guess a first chunk.
assert body["chunkId"] is None
assert body["chunkIndex"] is None
assert body["targetPage"] is None
assert body["snippet"] is None
assert body["kind"] is None
assert body["imageUrl"] is None
assert body["documentId"] == doc_id
def test_missing_document_returns_404(self, app, db_env, monkeypatch):
"""Nonexistent document_id returns 404 to both existence and auth probes."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{_uid()}/preview-target")
finally:
_clear_overrides(app)
assert resp.status_code == 404
assert resp.json()["detail"] == "Document not found"
def test_wrong_subject_returns_404(self, app, db_env, monkeypatch):
"""Document owned by alice returns 404 when accessed by mallory."""
doc_id, kb_id = _uid(), _uid()
stored = db_env / "rag" / "uploads" / "secret.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored))
client = _make_client(app, "mallory")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/preview-target")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_cross_doc_chunk_id_returns_404(self, app, db_env, monkeypatch):
"""chunk_id from a different document returns 404 — not 400 (opaque)."""
kb_id = _uid()
doc_a, doc_b = _uid(), _uid()
chunk_a = _uid()
stored_a = db_env / "rag" / "uploads" / "a.pdf"
stored_b = db_env / "rag" / "uploads" / "b.pdf"
stored_a.parent.mkdir(parents = True, exist_ok = True)
stored_a.write_bytes(b"data")
stored_b.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_a, kb_id, str(stored_a), "a.pdf")
_insert_doc(conn, doc_b, kb_id, str(stored_b), "b.pdf")
_insert_chunk(conn, chunk_a, doc_a)
client = _make_client(app, "alice")
try:
# Probe doc_b with chunk_a (which belongs to doc_a)
resp = client.get(
f"/api/rag/documents/{doc_b}/preview-target?chunk_id={chunk_a}"
)
finally:
_clear_overrides(app)
# Must be 404, NOT 200 with doc_a's chunk data
assert resp.status_code == 404
def test_unauthenticated_returns_401(self, app, db_env, monkeypatch):
"""No bearer token → 401."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
# No override — let the real dependency raise
client = TestClient(app, raise_server_exceptions = False)
resp = client.get(f"/api/rag/documents/{_uid()}/preview-target")
assert resp.status_code == 401
# ── /file tests ───────────────────────────────────────────────────────
class TestFileRoute:
def test_pdf_200_with_correct_headers(self, app, db_env, monkeypatch):
"""GET /file for a PDF returns 200 with nosniff, Cache-Control, inline disposition."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "annual.pdf"
stored.write_bytes(b"%PDF-1.4\n%%EOF")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn, doc_id, kb_id, str(stored), "annual.pdf", "application/pdf"
)
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
assert resp.headers.get("x-content-type-options") == "nosniff"
assert "private" in (resp.headers.get("cache-control") or "")
ct = resp.headers.get("content-type", "")
assert "pdf" in ct.lower()
def test_signed_file_url_supports_range_without_bearer_query(
self, app, db_env, monkeypatch
):
"""Short-lived signed URL is redeemable without Authorization and supports ranges."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "annual.pdf"
stored.write_bytes(b"%PDF-1.4\n%%EOF")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
monkeypatch.setattr("routes.rag.get_jwt_secret", lambda subject: "test-secret")
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn, doc_id, kb_id, str(stored), "annual.pdf", "application/pdf"
)
client = _make_client(app, "alice")
try:
url_resp = client.get(f"/api/rag/documents/{doc_id}/file-url")
assert url_resp.status_code == 200
signed_url = url_resp.json()["url"]
assert "Bearer" not in signed_url
assert "Authorization" not in signed_url
file_resp = client.get(signed_url, headers = {"Range": "bytes=0-3"})
finally:
_clear_overrides(app)
assert file_resp.status_code == 206
assert file_resp.content == b"%PDF"
assert (
file_resp.headers.get("content-range")
== f"bytes 0-3/{stored.stat().st_size}"
)
assert file_resp.headers.get("accept-ranges") == "bytes"
assert file_resp.headers.get("x-content-type-options") == "nosniff"
def test_signed_file_route_rejects_forged_token(self, app, db_env, monkeypatch):
"""Signed file route is not public without a valid preview token."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "annual.pdf"
stored.write_bytes(b"%PDF-1.4\n%%EOF")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
monkeypatch.setattr("routes.rag.get_jwt_secret", lambda subject: "test-secret")
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn, doc_id, kb_id, str(stored), "annual.pdf", "application/pdf"
)
client = TestClient(app, raise_server_exceptions = False)
resp = client.get(f"/api/rag/documents/{doc_id}/file-signed?token=bogus")
assert resp.status_code == 401
def test_html_file_served_as_text_plain_with_attachment(
self, app, db_env, monkeypatch
):
"""HTML uploads must be served as text/plain + attachment (Risk #3 — no XSS)."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "malicious.html"
stored.write_bytes(b"<script>alert(1)</script>")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored), "malicious.html", "text/html")
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
ct = resp.headers.get("content-type", "").lower()
# MUST NOT be text/html — must be text/plain
assert "text/html" not in ct, f"HTML executed inline! content-type={ct}"
assert "text/plain" in ct
disp = resp.headers.get("content-disposition", "").lower()
assert "attachment" in disp, f"HTML not forced to attachment: {disp}"
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_docx_served_as_attachment(self, app, db_env, monkeypatch):
"""DOCX files must be served with Content-Disposition: attachment."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "report.docx"
stored.write_bytes(b"PK\x03\x04fake-docx")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn,
doc_id,
kb_id,
str(stored),
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
disp = resp.headers.get("content-disposition", "").lower()
assert "attachment" in disp
def test_missing_document_returns_404(self, app, db_env, monkeypatch):
"""Nonexistent document returns 404."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{_uid()}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_wrong_subject_returns_404(self, app, db_env, monkeypatch):
"""Document accessible to alice is 404 for mallory (auth-collapse)."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "private.pdf"
stored.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored))
client = _make_client(app, "mallory")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_deleted_file_returns_404_with_doc_file_not_found(
self, app, db_env, monkeypatch
):
"""File gone from disk returns 404 with 'Document file not found' detail."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "gone.pdf"
stored.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
# Delete the file after inserting the row
stored.unlink()
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 404
detail = resp.json().get("detail", "")
assert "file not found" in detail.lower() or "not found" in detail.lower()
def test_outside_root_stored_path_returns_404(
self, app, db_env, monkeypatch, tmp_path
):
"""stored_path outside rag_uploads_root returns 404 — path containment (Risk #2)."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
# A legitimate-looking path that is outside the RAG uploads root
outside = tmp_path / "etc" / "passwd"
outside.parent.mkdir(parents = True, exist_ok = True)
outside.write_bytes(b"root:x:0:0")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
# Insert with stored_path pointing outside root
conn.execute(
"INSERT INTO rag_documents "
"(id, kb_id, thread_id, filename, content_type, stored_path, status, "
"num_chunks, byte_size, created_at) "
"VALUES (?, ?, NULL, 'passwd', 'text/plain', ?, 'completed', 0, 10, ?)",
(doc_id, kb_id, str(outside), 1_700_000_000),
)
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
# Must NOT serve the file — containment violation must return 404
assert resp.status_code == 404
def test_nosniff_and_cache_headers_on_txt_file(self, app, db_env, monkeypatch):
"""Safety headers present on every 200 response, including plain text."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "notes.txt"
stored.write_bytes(b"hello world")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored), "notes.txt", "text/plain")
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
assert resp.headers.get("x-content-type-options") == "nosniff"
cc = resp.headers.get("cache-control", "")
assert "private" in cc
# ── /images tests ─────────────────────────────────────────────────────
class TestImageRoute:
def test_image_route_wrong_subject_returns_404(self, app, db_env, monkeypatch):
"""Extracted images require the same document authorization as /file."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
images = uploads / "images" / doc_id
images.mkdir(parents = True, exist_ok = True)
image = images / "figure.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
stored = uploads / "report.pdf"
stored.write_bytes(b"%PDF-1.4")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored), "report.pdf")
client = _make_client(app, "mallory")
try:
resp = client.get(f"/api/rag/images/{doc_id}/figure.png")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_image_route_authorized_subject_gets_image(self, app, db_env, monkeypatch):
"""Authorized subject can still fetch an extracted image."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
images = uploads / "images" / doc_id
images.mkdir(parents = True, exist_ok = True)
image = images / "figure.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
stored = uploads / "report.pdf"
stored.write_bytes(b"%PDF-1.4")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored), "report.pdf")
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/images/{doc_id}/figure.png")
finally:
_clear_overrides(app)
assert resp.status_code == 200
assert resp.content.startswith(b"\x89PNG")

View file

@ -0,0 +1,136 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import storage.studio_db as studio_db
from auth.authentication import get_current_subject
@pytest.fixture(scope = "module")
def app():
import sys
backend_dir = str(Path(__file__).resolve().parent.parent)
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
from main import app as _app
return _app
@pytest.fixture
def db_env(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
return tmp_path
def _uid() -> str:
return str(uuid.uuid4())
def _make_client(app, subject: str = "alice"):
app.dependency_overrides[get_current_subject] = lambda: subject
return TestClient(app, raise_server_exceptions = True)
def _clear_overrides(app):
app.dependency_overrides.clear()
def _seed_doc(conn, doc_id: str, kb_id: str, stored_path: str) -> None:
conn.execute(
"""
INSERT INTO rag_knowledge_bases
(id, name, embedding_model, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(kb_id, "KB", "embedder", "alice", 1_700_000_000),
)
conn.execute(
"""
INSERT INTO rag_documents
(id, kb_id, thread_id, filename, content_type, stored_path, status,
num_chunks, byte_size, created_at)
VALUES (?, ?, NULL, ?, ?, ?, 'completed', 1, 10, ?)
""",
(doc_id, kb_id, "report.pdf", "application/pdf", stored_path, 1_700_000_001),
)
def test_preview_target_returns_nullable_locator_fields(app, db_env):
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "report.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4")
with studio_db.get_connection() as conn:
_seed_doc(conn, doc_id, kb_id, str(stored))
conn.execute(
"""
INSERT INTO rag_chunks
(id, document_id, chunk_index, text, token_count, page_number,
source_page_index, page_char_start, page_char_end, line_start,
line_end)
VALUES (?, ?, 2, ?, 8, 4, 3, 20, 52, 6, 7)
""",
(chunk_id, doc_id, "highlight me"),
)
client = _make_client(app)
try:
resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["sourcePageIndex"] == 3
assert body["pageCharStart"] == 20
assert body["pageCharEnd"] == 52
assert body["lineStart"] == 6
assert body["lineEnd"] == 7
def test_preview_target_old_null_locator_rows_still_work(app, db_env):
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "legacy.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4")
with studio_db.get_connection() as conn:
_seed_doc(conn, doc_id, kb_id, str(stored))
conn.execute(
"""
INSERT INTO rag_chunks
(id, document_id, chunk_index, text, token_count, page_number)
VALUES (?, ?, 0, ?, 4, 1)
""",
(chunk_id, doc_id, "legacy"),
)
client = _make_client(app)
try:
resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["snippet"] == "legacy"
assert body["sourcePageIndex"] is None
assert body["pageCharStart"] is None
assert body["pageCharEnd"] is None
assert body["lineStart"] is None
assert body["lineEnd"] is None

View file

@ -0,0 +1,243 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for durable source identity in tool XML output (PLAN.md T3, contracts §3).
Acceptance criteria:
- _format_hits_for_llm emits document_id and chunk_id attributes on <chunk> elements.
- The visible citation id (id="N") is a per-call counter, NOT the backend chunk UUID.
- Same-filename documents in different KB slots remain distinguishable by document_id.
- Hits without a matching DB row (lookup miss) are silently dropped not emitted
with wrong IDs.
- Legacy hits (no document_id in hit dict) still render without crashing.
"""
from __future__ import annotations
import re
import uuid
from xml.etree import ElementTree
import pytest
from core.rag.tool import _format_hits_for_llm
# ── Helpers ───────────────────────────────────────────────────────────
def _uid() -> str:
return str(uuid.uuid4())
def _hit(
*,
chunk_id: str,
document_id: str,
filename: str = "report.pdf",
text: str = "some text",
page_number: int | None = 3,
chunk_index: int = 0,
score: float = 0.85,
) -> dict:
"""Build a flat hit dict as _format_hits_for_llm expects."""
return {
"chunk_id": chunk_id,
"document_id": document_id,
"filename": filename,
"text": text,
"page_number": page_number,
"chunk_index": chunk_index,
"score": score,
"dense_score": score,
"token_count": 20,
"kind": "text",
"image_path": None,
}
def _parse_chunks(xml_output: str) -> list[dict]:
"""Parse <chunk ...> elements from the multi-block tool output."""
chunks = []
# Each block is wrapped in <chunk ...>\n...\n</chunk>; parse directly.
for match in re.finditer(r"<chunk\s([^>]*)>", xml_output):
attrs_raw = match.group(1)
# Quick attribute parser for "key="value"" pairs.
attrs: dict = {}
for m in re.finditer(r'(\w+)="([^"]*)"', attrs_raw):
attrs[m.group(1)] = m.group(2)
chunks.append(attrs)
return chunks
# ── Tests: durable IDs present in XML ────────────────────────────────
def test_format_hits_emits_document_id_and_chunk_id():
"""T3: tool XML <chunk> must carry document_id and chunk_id attributes."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id = chunk_id, document_id = doc_id)]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert len(chunks) == 1, output
assert chunks[0]["document_id"] == doc_id
assert chunks[0]["chunk_id"] == chunk_id
def test_citation_id_is_sequential_counter_not_uuid():
"""Visible id='N' is a 1-based counter — never equal to the backend chunk UUID."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id = chunk_id, document_id = doc_id)]
output = _format_hits_for_llm(hits, start_id = 0)
chunks = _parse_chunks(output)
visible_id = chunks[0]["id"]
# Must be a small integer string, NOT the UUID
assert visible_id == "1", f"expected '1' got {visible_id!r}"
assert visible_id != chunk_id
def test_citation_ids_are_globally_sequential_across_calls():
"""start_id offset ensures IDs stay unique across multiple tool calls per turn."""
hits_call1 = [_hit(chunk_id = _uid(), document_id = _uid(), filename = "a.pdf")]
hits_call2 = [
_hit(chunk_id = _uid(), document_id = _uid(), filename = "b.pdf"),
_hit(chunk_id = _uid(), document_id = _uid(), filename = "c.pdf"),
]
out1 = _format_hits_for_llm(hits_call1, start_id = 0)
out2 = _format_hits_for_llm(hits_call2, start_id = 1)
chunks1 = _parse_chunks(out1)
chunks2 = _parse_chunks(out2)
assert chunks1[0]["id"] == "1"
assert chunks2[0]["id"] == "2"
assert chunks2[1]["id"] == "3"
# No id overlap
all_ids = {c["id"] for c in chunks1 + chunks2}
assert len(all_ids) == 3
def test_same_filename_docs_have_distinct_document_ids():
"""Two docs with the same filename route to distinct document_id values (Risk #4)."""
filename = "annual-report.pdf"
chunk_a, doc_a = _uid(), _uid()
chunk_b, doc_b = _uid(), _uid()
hits = [
_hit(chunk_id = chunk_a, document_id = doc_a, filename = filename),
_hit(chunk_id = chunk_b, document_id = doc_b, filename = filename),
]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert len(chunks) == 2
# Both use the same filename but MUST have distinct document_id values
assert chunks[0]["document_id"] != chunks[1]["document_id"]
assert chunks[0]["document_id"] == doc_a
assert chunks[1]["document_id"] == doc_b
def test_same_filename_docs_have_distinct_citation_ids():
"""Same-filename docs in the same turn still get distinct visible [N] ids."""
filename = "notes.pdf"
chunk_a, doc_a = _uid(), _uid()
chunk_b, doc_b = _uid(), _uid()
hits = [
_hit(chunk_id = chunk_a, document_id = doc_a, filename = filename),
_hit(chunk_id = chunk_b, document_id = doc_b, filename = filename),
]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
citation_ids = {c["id"] for c in chunks}
assert len(citation_ids) == 2, f"citation IDs not unique: {chunks}"
def test_empty_hits_returns_no_chunks_message():
"""Empty hit list returns the 'no matching chunks' message, not broken XML."""
output = _format_hits_for_llm([])
chunks = _parse_chunks(output)
assert len(chunks) == 0
assert "no matching chunks" in output.lower() or "no matching" in output.lower()
def test_page_number_attribute_present_when_page_exists():
"""page attribute is emitted when page_number is not None."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id = chunk_id, document_id = doc_id, page_number = 5)]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert chunks[0].get("page") == "5"
def test_page_number_attribute_absent_when_null():
"""page attribute is omitted when page_number is None."""
chunk_id, doc_id = _uid(), _uid()
hits = [_hit(chunk_id = chunk_id, document_id = doc_id, page_number = None)]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert "page" not in chunks[0], f"unexpected page attr: {chunks[0]}"
def test_locator_attributes_are_additive_when_present():
"""T10: tool XML carries nullable locator metadata without changing visible ids."""
chunk_id, doc_id = _uid(), _uid()
hit = _hit(chunk_id = chunk_id, document_id = doc_id, page_number = 5)
hit.update(
{
"source_page_index": 4,
"page_char_start": 11,
"page_char_end": 42,
"line_start": 2,
"line_end": 3,
}
)
output = _format_hits_for_llm([hit])
chunk = _parse_chunks(output)[0]
assert chunk["id"] == "1"
assert chunk["chunk_id"] == chunk_id
assert chunk["source_page_index"] == "4"
assert chunk["page_char_start"] == "11"
assert chunk["page_char_end"] == "42"
assert chunk["line_start"] == "2"
assert chunk["line_end"] == "3"
def test_xml_special_chars_in_filename_escaped():
"""Filename with XML special chars does not break the chunk element."""
chunk_id, doc_id = _uid(), _uid()
hits = [
_hit(
chunk_id = chunk_id,
document_id = doc_id,
filename = 'report <2025> "final" & draft.pdf',
)
]
output = _format_hits_for_llm(hits)
# The output must parse cleanly (no unescaped < or " in attrs)
chunks = _parse_chunks(output)
assert len(chunks) == 1
# source attribute should have the filename escaped
source_attr = chunks[0].get("source", "")
assert "<" not in source_attr and '"' not in source_attr
def test_multiple_hits_carry_independent_ids():
"""Three hits each carry their own distinct chunk_id and document_id."""
hit_data = [
(_uid(), _uid()),
(_uid(), _uid()),
(_uid(), _uid()),
]
hits = [
_hit(chunk_id = cid, document_id = did, filename = f"doc{i}.pdf")
for i, (cid, did) in enumerate(hit_data)
]
output = _format_hits_for_llm(hits)
chunks = _parse_chunks(output)
assert len(chunks) == 3
emitted_chunk_ids = {c["chunk_id"] for c in chunks}
emitted_doc_ids = {c["document_id"] for c in chunks}
expected_chunk_ids = {cid for cid, _ in hit_data}
expected_doc_ids = {did for _, did in hit_data}
assert emitted_chunk_ids == expected_chunk_ids
assert emitted_doc_ids == expected_doc_ids

View file

@ -100,6 +100,34 @@ def precache_helper_gguf():
logger.info(f"Helper GGUF cached: {len(matching)} file(s)")
else:
logger.warning(f"No GGUF matching variant '{variant}' in {repo}")
# If the repo also ships an mmproj (vision projection), grab it
# so the helper can be used as a vision-language model by the
# RAG captioner path. Preference order: F16 → BF16 → F32 → any.
# Best-effort — the LLM-assist path doesn't need vision, so a
# missing mmproj is fine and only logged.
mmproj_files = [
f for f in files if "mmproj" in f.lower() and f.endswith(".gguf")
]
if mmproj_files:
mmproj_target: Optional[str] = None
for pref in ("mmproj-f16.gguf", "mmproj-bf16.gguf", "mmproj-f32.gguf"):
for cand in mmproj_files:
if cand.lower() == pref:
mmproj_target = cand
break
if mmproj_target:
break
if mmproj_target is None:
mmproj_target = mmproj_files[0]
try:
logger.info(f"Pre-caching helper mmproj: {repo}/{mmproj_target}")
hf_hub_download(repo_id = repo, filename = mmproj_target)
except Exception as mmproj_exc:
logger.warning(
f"Helper mmproj download failed (vision fallback "
f"will be unavailable until cached): {mmproj_exc}"
)
except Exception as e:
logger.warning(f"Failed to pre-cache helper GGUF: {e}")
finally:
@ -127,7 +155,9 @@ def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
try:
from core.inference.llama_cpp import LlamaCppBackend
backend = LlamaCppBackend()
# kill_orphans=False so the helper backend doesn't reap the
# parent's chat-model llama-server while loading itself.
backend = LlamaCppBackend(kill_orphans = False)
logger.info(f"Loading helper model: {repo} ({variant})")
ok = backend.load_model(

View file

@ -120,6 +120,18 @@ def tensorboard_root() -> Path:
return studio_root() / "runs"
def rag_root() -> Path:
return studio_root() / "rag"
def rag_uploads_root() -> Path:
return rag_root() / "uploads"
def rag_bm25_root() -> Path:
return rag_root() / "bm25"
def ensure_dir(path: Path) -> Path:
path.mkdir(parents = True, exist_ok = True)
return path
@ -261,6 +273,9 @@ def ensure_studio_directories() -> None:
exports_root,
auth_root,
tensorboard_root,
rag_root,
rag_uploads_root,
rag_bm25_root,
):
ensure_dir(dir_fn())
_setup_cache_env()

View file

@ -0,0 +1,2 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

View file

@ -0,0 +1,84 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from __future__ import annotations
import os
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
return default
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return float(raw)
except ValueError:
return default
RAG_EMBEDDING_MODEL: str = (
os.environ.get("UNSLOTH_RAG_EMBEDDING_MODEL", "").strip()
or "BAAI/bge-small-en-v1.5"
)
# Default embedder per (mode, chunking). (multimodal, late) is unsupported
# and rejected at KB-create time in routes/rag.py.
#
# Text mode is the default; figures from PDFs are captioned at ingest by
# the loaded chat VLM (or a helper gemma-3n fallback) and spliced into
# the page markdown before chunking, so a single 384-d text embedder
# handles all retrieval. Multimodal mode adds image-vector rows on top,
# embedded by Qwen3-VL-Embedding-2B (2 B params, 2048-d, no CLIP text
# cap — full 512-token chunks embed losslessly).
#
# Alternative multimodal embedders left in tree for manual override:
# - "BAAI/BGE-VL-large" — smaller (~400 M / 768-d) but CLIP-family
# with a 77-token text cap; routed via `_BGEVLAdapter` in
# core/rag/embeddings.py.
RAG_EMBEDDER_MATRIX: dict[tuple[str, str], str] = {
("text", "standard"): "BAAI/bge-small-en-v1.5",
("text", "late"): "nomic-ai/nomic-embed-text-v1.5",
("multimodal", "standard"): "Qwen/Qwen3-VL-Embedding-2B",
}
def resolve_embedder(mode: str, chunking_strategy: str) -> str:
"""Embedder for (mode, chunking); unknown combos fall back to RAG_EMBEDDING_MODEL."""
return RAG_EMBEDDER_MATRIX.get(
(mode, chunking_strategy),
RAG_EMBEDDING_MODEL,
)
RAG_CHUNK_SIZE: int = _env_int("UNSLOTH_RAG_CHUNK_SIZE", 512)
RAG_CHUNK_OVERLAP: int = _env_int("UNSLOTH_RAG_CHUNK_OVERLAP", 64)
RAG_TOP_K_BM25: int = _env_int("UNSLOTH_RAG_TOP_K_BM25", 30)
RAG_TOP_K_DENSE: int = _env_int("UNSLOTH_RAG_TOP_K_DENSE", 30)
RAG_TOP_K_HYBRID: int = _env_int("UNSLOTH_RAG_TOP_K_HYBRID", 10)
RAG_RRF_K: int = _env_int("UNSLOTH_RAG_RRF_K", 60)
RAG_MAX_UPLOAD_MB: int = _env_int("UNSLOTH_RAG_MAX_UPLOAD_MB", 50)
RAG_EMBED_BATCH_SIZE: int = _env_int("UNSLOTH_RAG_EMBED_BATCH_SIZE", 32)
RAG_RERANKER_MODEL: str = (
os.environ.get("UNSLOTH_RAG_RERANKER_MODEL", "").strip() or "BAAI/bge-reranker-base"
)
RAG_RERANK_CANDIDATE_K: int = _env_int("UNSLOTH_RAG_RERANK_CANDIDATE_K", 50)
RAG_RERANK_BATCH_SIZE: int = _env_int("UNSLOTH_RAG_RERANK_BATCH_SIZE", 16)
RAG_UPLOAD_EXTS: frozenset[str] = frozenset(
{".pdf", ".txt", ".md", ".markdown", ".docx", ".html", ".htm"}
)

View file

@ -38,7 +38,7 @@
},
"overrides": [
{
"include": ["vite.config.ts", "eslint.config.js"],
"include": ["vite.config.ts", "vitest.config.ts", "eslint.config.js"],
"linter": {
"rules": {
"correctness": { "noNodejsModules": "off" },

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,8 @@
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"test:watch": "vitest",
"i18n:check": "node --experimental-strip-types --no-warnings src/i18n/check-parity.ts",
"biome:check": "biome check",
"biome:fix": "biome check --write"
@ -47,6 +49,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@toolwind/corner-shape": "^0.0.8-3",
"@types/event-source-polyfill": "1.0.5",
"@xyflow/react": "^12.10.0",
"assistant-stream": "0.3.12",
"canvas-confetti": "^1.9.4",
@ -54,6 +57,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dexie": "^4.3.0",
"event-source-polyfill": "1.0.31",
"fflate": "0.8.3",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
@ -66,6 +70,7 @@
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-pdf": "^10.4.1",
"react-resizable-panels": "^4.6.4",
"recharts": "3.7.0",
"shadcn": "^4.2.0",
@ -86,10 +91,14 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.39.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/canvas-confetti": "^1.9.0",
"@types/js-yaml": "^4.0.9",
"@types/node-forge": "^1.3.14",
"@types/node": "^25.5.2",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@ -97,8 +106,10 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.1.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vitest": "^4.1.7"
}
}

View file

@ -0,0 +1,125 @@
/**
* Tests for chat-adapter XML parsing contracts §3 / §4, T3.
*
* Coverage:
* - New XML with document_id + chunk_id attributes citationId, documentId, backendChunkId populated.
* - Legacy XML without durable IDs citationId populated, documentId/backendChunkId absent.
* - Same visible [N] across turns does NOT imply same backendChunkId.
* - Same filename in two chunks distinct documentId values preserved.
* - Missing attributes degrade gracefully no throw.
* - citationId is always the visible "N" counter, never the UUID.
*/
import {
type ParsedChunk,
parseChunks,
} from "@/components/assistant-ui/tool-ui-search-knowledge-base";
import { describe, expect, it } from "vitest";
// ── Tests ─────────────────────────────────────────────────────────────
describe("parseChunks — durable IDs (contracts §3/§4)", () => {
it("new XML with document_id + chunk_id populates all three identity fields", () => {
const xml = `
<chunk id="1" source="report.pdf" page="7" document_id="doc-abc" chunk_id="chunk-xyz">
The margin rose to 18%.
</chunk>
`.trim();
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts).toHaveLength(1);
expect(parts[0].id).toBe("1");
expect(parts[0].documentId).toBe("doc-abc");
expect(parts[0].backendChunkId).toBe("chunk-xyz");
expect(parts[0].source).toBe("report.pdf");
expect(parts[0].page).toBe("7");
});
it("legacy XML without durable IDs leaves documentId and backendChunkId absent", () => {
// Old XML: no document_id, no chunk_id — hover-only, NOT preview-clickable (Q3).
const xml = `
<chunk id="2" source="old-doc.pdf" page="3">
Legacy chunk text.
</chunk>
`.trim();
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts).toHaveLength(1);
expect(parts[0].id).toBe("2");
expect(parts[0].documentId).toBeUndefined();
expect(parts[0].backendChunkId).toBeUndefined();
});
it("citationId (id) is the visible counter string, never the backend UUID", () => {
const docId = "550e8400-e29b-41d4-a716-446655440000";
const chunkId = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
const xml = `<chunk id="5" source="paper.pdf" document_id="${docId}" chunk_id="${chunkId}">text</chunk>`;
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts[0].id).toBe("5");
expect(parts[0].id).not.toBe(docId);
expect(parts[0].id).not.toBe(chunkId);
});
it("same filename in two chunks preserves distinct documentId values", () => {
const xml = `
<chunk id="1" source="annual.pdf" document_id="doc-001" chunk_id="chunk-001">First excerpt.</chunk>
<chunk id="2" source="annual.pdf" document_id="doc-002" chunk_id="chunk-002">Second excerpt.</chunk>
`.trim();
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts).toHaveLength(2);
expect(parts[0].documentId).toBe("doc-001");
expect(parts[1].documentId).toBe("doc-002");
expect(parts[0].documentId).not.toBe(parts[1].documentId);
});
it("same visible id in different turns does not imply same backendChunkId", () => {
// Turn 1 and turn 2 both have id="1" but different backend identities.
const turn1 = `<chunk id="1" source="a.pdf" document_id="doc-A" chunk_id="chunk-A">Turn 1.</chunk>`;
const turn2 = `<chunk id="1" source="b.pdf" document_id="doc-B" chunk_id="chunk-B">Turn 2.</chunk>`;
const p1: ParsedChunk[] = parseChunks(turn1);
const p2: ParsedChunk[] = parseChunks(turn2);
expect(p1[0].id).toBe(p2[0].id); // both "1"
expect(p1[0].backendChunkId).not.toBe(p2[0].backendChunkId);
expect(p1[0].documentId).not.toBe(p2[0].documentId);
});
it("missing chunk_id only (partial durable attrs) → backendChunkId absent", () => {
const xml = `<chunk id="3" source="x.pdf" document_id="doc-XYZ">text</chunk>`;
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts[0].documentId).toBe("doc-XYZ");
expect(parts[0].backendChunkId).toBeUndefined();
});
it("multiple new-format chunks all carry independent IDs", () => {
const xml = `
<chunk id="1" source="a.pdf" document_id="doc-1" chunk_id="ck-1">A</chunk>
<chunk id="2" source="b.pdf" document_id="doc-2" chunk_id="ck-2">B</chunk>
<chunk id="3" source="c.pdf" document_id="doc-3" chunk_id="ck-3">C</chunk>
`.trim();
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts).toHaveLength(3);
const docIds = new Set(parts.map((p) => p.documentId));
const backendChunkIds = new Set(parts.map((p) => p.backendChunkId));
const citationIds = new Set(parts.map((p) => p.id));
expect(docIds.size).toBe(3);
expect(backendChunkIds.size).toBe(3);
expect(citationIds.size).toBe(3);
});
it("empty XML returns empty array without throwing", () => {
expect(parseChunks("")).toHaveLength(0);
expect(parseChunks("No chunks here.")).toHaveLength(0);
});
it("XML entity encoding in source attribute is decoded", () => {
// &amp; should decode to & in the source attribute (decodeXml in parseChunks)
const xml = `<chunk id="1" source="report &amp; summary.pdf" document_id="doc-1" chunk_id="ck-1">text</chunk>`;
const parts: ParsedChunk[] = parseChunks(xml);
expect(parts).toHaveLength(1);
expect(parts[0].id).toBe("1");
expect(parts[0].source).toBe("report & summary.pdf");
});
});

View file

@ -0,0 +1,92 @@
import type { RagDocument } from "@/features/rag/api/rag-api";
import { DocumentRow } from "@/features/rag/components/document-row";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
function makeDoc(overrides: Partial<RagDocument> = {}): RagDocument {
return {
id: "doc-abc",
kb_id: "kb-1",
thread_id: null,
filename: "report.pdf",
content_type: "application/pdf",
status: "completed",
num_chunks: 5,
byte_size: 10240,
error: null,
created_at: 1_700_000_000,
...overrides,
};
}
let onPreview: ReturnType<typeof vi.fn>;
let onDelete: ReturnType<typeof vi.fn>;
beforeEach(() => {
onPreview = vi.fn();
onDelete = vi.fn();
});
describe("DocumentRow preview event propagation", () => {
it("clicking a previewable row opens document-level preview", async () => {
render(
React.createElement(DocumentRow, {
doc: makeDoc(),
onPreview: onPreview as () => void,
onDelete: onDelete as () => void,
}),
);
await userEvent.click(
screen.getByRole("button", { name: /open preview of report.pdf/i }),
);
expect(onPreview).toHaveBeenCalledTimes(1);
});
it("Enter and Space open a previewable row", () => {
render(
React.createElement(DocumentRow, {
doc: makeDoc(),
onPreview: onPreview as () => void,
onDelete: onDelete as () => void,
}),
);
const row = screen.getByRole("button", {
name: /open preview of report.pdf/i,
});
fireEvent.keyDown(row, { key: "Enter" });
fireEvent.keyDown(row, { key: " " });
expect(onPreview).toHaveBeenCalledTimes(2);
});
it("clicking delete does not open preview", async () => {
render(
React.createElement(DocumentRow, {
doc: makeDoc(),
onPreview: onPreview as () => void,
onDelete: onDelete as () => void,
}),
);
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onPreview).not.toHaveBeenCalled();
});
it("non-previewable rows have no row button semantics", () => {
render(
React.createElement(DocumentRow, {
doc: makeDoc({ status: "pending" }),
onDelete: onDelete as () => void,
}),
);
expect(screen.queryByRole("button", { name: /open preview/i })).toBeNull();
});
});

View file

@ -0,0 +1,82 @@
import { KnowledgeBasesTab } from "@/features/settings/tabs/knowledge-bases-tab";
import { render, screen } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockUsePreviewStore } = vi.hoisted(() => {
const state = {
target: null as unknown,
status: "idle",
close: vi.fn(),
};
const fn = vi.fn((selector?: (s: typeof state) => unknown) => {
if (typeof selector === "function") return selector(state);
return state;
}) as ReturnType<typeof vi.fn> & { __state: typeof state };
fn.__state = state;
return { mockUsePreviewStore: fn };
});
vi.mock("@/features/rag/stores/preview-store", () => ({
usePreviewStore: mockUsePreviewStore,
}));
vi.mock("@/features/rag/components/kb-list", () => ({
KBList: () => React.createElement("div", { "data-testid": "kb-list" }),
}));
vi.mock("@/features/rag/components/kb-create-dialog", () => ({
KBCreateDialog: () =>
React.createElement("div", { "data-testid": "kb-create-dialog" }),
}));
vi.mock("@/features/rag/components/kb-detail-panel", () => ({
KBDetailPanel: () =>
React.createElement("div", { "data-testid": "kb-detail-panel" }),
}));
vi.mock("@/features/rag/components/preview-panel", () => ({
PreviewPanel: ({ open }: { open: boolean }) =>
React.createElement("div", {
"data-testid": "settings-preview-panel",
"data-open": String(open),
}),
}));
vi.mock("@/features/rag/components/thread-index-list", () => ({
ThreadIndexList: () =>
React.createElement("div", { "data-testid": "thread-index-list" }),
}));
vi.mock("@/features/rag/components/rag-defaults-section", () => ({
RagDefaultsSection: () =>
React.createElement("div", { "data-testid": "rag-defaults-section" }),
}));
beforeEach(() => {
mockUsePreviewStore.__state.target = null;
mockUsePreviewStore.__state.status = "idle";
mockUsePreviewStore.mockImplementation(
(selector?: (s: typeof mockUsePreviewStore.__state) => unknown) => {
if (typeof selector === "function") {
return selector(mockUsePreviewStore.__state);
}
return mockUsePreviewStore.__state;
},
);
});
describe("KnowledgeBasesTab preview host", () => {
it("renders a preview panel when the preview store is active", () => {
mockUsePreviewStore.__state.target = {
documentId: "doc-abc",
filename: "report.pdf",
};
mockUsePreviewStore.__state.status = "ready";
render(React.createElement(KnowledgeBasesTab));
const panel = screen.getByTestId("settings-preview-panel");
expect(panel.getAttribute("data-open")).toBe("true");
});
});

View file

@ -0,0 +1,93 @@
import type { PreviewTarget } from "@/features/rag/api/rag-api";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockFetchPreviewTarget, mockFetchPreviewFileBlob } = vi.hoisted(() => ({
mockFetchPreviewTarget:
vi.fn<
(documentId: string, chunkId?: string | null) => Promise<PreviewTarget>
>(),
mockFetchPreviewFileBlob:
vi.fn<(documentId: string, signal?: AbortSignal) => Promise<Blob>>(),
}));
vi.mock("@/features/rag/api/rag-api", async (importOriginal) => {
const original =
await importOriginal<typeof import("@/features/rag/api/rag-api")>();
return {
...original,
fetchPreviewTarget: mockFetchPreviewTarget,
fetchPreviewFileBlob: mockFetchPreviewFileBlob,
};
});
vi.mock("react-pdf", () => ({
Document: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", { "data-testid": "pdf-document" }, children),
Page: () => React.createElement("div", { "data-testid": "pdf-page" }),
pdfjs: { GlobalWorkerOptions: { workerSrc: "" } },
}));
import { PreviewPanel } from "@/features/rag/components/preview-panel";
import { usePreviewStore } from "@/features/rag/stores/preview-store";
function target(overrides: Partial<PreviewTarget> = {}): PreviewTarget {
return {
documentId: "doc-abc",
filename: "report.html",
contentType: "text/plain",
mediaKind: "html",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: "chunk-1",
chunkIndex: 0,
targetPage: 1,
snippet: "safe extracted text",
kind: "text",
imageUrl: null,
sourcePageIndex: null,
pageCharStart: null,
pageCharEnd: null,
lineStart: null,
lineEnd: null,
pdfRegions: [],
...overrides,
};
}
beforeEach(() => {
mockFetchPreviewTarget.mockReset();
mockFetchPreviewFileBlob.mockReset();
usePreviewStore.getState().close();
});
afterEach(() => {
usePreviewStore.getState().close();
});
describe("preview a11y hardening", () => {
it("Escape closes the preview and restores focus to the opener", async () => {
const opener = document.createElement("button");
opener.textContent = "Open preview";
document.body.appendChild(opener);
opener.focus();
mockFetchPreviewTarget.mockResolvedValue(target());
await usePreviewStore.getState().open({ documentId: "doc-abc" });
render(React.createElement(PreviewPanel, { open: true }));
expect(
screen.getByRole("region", { name: /document preview/i }),
).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(usePreviewStore.getState().status).toBe("idle");
});
expect(document.activeElement).toBe(opener);
opener.remove();
});
});

View file

@ -0,0 +1,521 @@
/**
* Tests for preview-panel HTML/DOCX/unknown must NEVER render inline (T5 / Risk #3).
*
* Acceptance criteria (contracts §5.4, PLAN.md T5, decisions Q7):
* - mediaKind === "pdf" react-pdf view is mounted (or loading indicator shown).
* - mediaKind === "html" text-view fallback shown, NO object/embed/iframe with blob URL.
* - mediaKind === "docx" text-view fallback shown, NO inline rendering.
* - mediaKind === "unknown" unavailable/download state, NOT inline.
* - mediaKind === "text" text/snippet view shown.
* - Panel without a target renders nothing or unavailable state.
*/
import {
type PreviewMediaKind,
type PreviewTarget,
} from "@/features/rag/api/rag-api";
import type { PreviewLoadStatus } from "@/features/rag/stores/preview-store";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import {
type MockInstance,
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const DOWNLOAD_BUTTON_NAME = /download/i;
const LONG_CONTENT_TEXT = /Some long content/;
// ── Mock preview store ────────────────────────────────────────────────
// The real component uses per-field selectors: usePreviewStore((s) => s.target)
// so the mock must handle the selector pattern.
// vi.hoisted ensures the mock fn is initialised before vi.mock factory runs.
interface MockStoreState {
target: PreviewTarget | null;
previewBlobUrl: string | null;
previewBlob: Blob | null;
previewFileUrl: string | null;
previewFileUrlExpiresAt: number | null;
status: PreviewLoadStatus;
error: string | null;
close: () => void;
open: () => void;
}
let mockState: MockStoreState = {
target: null,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "idle",
error: null,
close: vi.fn(),
open: vi.fn(),
};
const { mockAuthFetch, mockUsePreviewStore } = vi.hoisted(() => {
// usePreviewStore is called two ways:
// usePreviewStore((s) => s.field) — selector form (React hook)
// usePreviewStore.getState().close() — outside React (cleanup effect)
const fn = vi.fn((selector?: (s: MockStoreState) => unknown) => {
if (typeof selector === "function") {
return selector(mockState);
}
return mockState;
}) as ReturnType<typeof vi.fn> & { getState: () => MockStoreState };
fn.getState = () => mockState;
return { mockAuthFetch: vi.fn(), mockUsePreviewStore: fn };
});
vi.mock("@/features/auth", () => ({
authFetch: mockAuthFetch,
getAuthToken: () => "mock-token-123",
}));
vi.mock("@/features/rag/stores/preview-store", async (importOriginal) => {
const real =
await importOriginal<
typeof import("@/features/rag/stores/preview-store")
>();
return {
...real,
usePreviewStore: mockUsePreviewStore,
// isInlineBlobAllowed passes through from the real module so assertions
// use the production allowlist, not a test-local copy (D1.5 fix).
};
});
// react-pdf requires a browser worker URL that doesn't exist in jsdom.
vi.mock("react-pdf", () => ({
Document: ({ children }: { children: React.ReactNode }) =>
React.createElement("div", { "data-testid": "pdf-document" }, children),
Page: () => React.createElement("div", { "data-testid": "pdf-page" }),
pdfjs: { GlobalWorkerOptions: { workerSrc: "" } },
}));
beforeEach(() => {
mockAuthFetch.mockReset();
mockAuthFetch.mockResolvedValue(
new Response(new Blob(["download bytes"], { type: "text/plain" }), {
status: 200,
}),
);
mockState = {
target: null,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "idle",
error: null,
close: vi.fn(),
open: vi.fn(),
};
mockUsePreviewStore.mockImplementation(
(selector?: (s: MockStoreState) => unknown) => {
if (typeof selector === "function") {
return selector(mockState);
}
return mockState;
},
);
// Restore getState after mockImplementation replaces the fn internals
mockUsePreviewStore.getState = () => mockState;
// Mock window.matchMedia globally for tests
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
// ── Import panel + production allowlist predicate AFTER mocks ─────────
import { PreviewPanel } from "@/features/rag/components/preview-panel";
import { isInlineBlobAllowed } from "@/features/rag/stores/preview-store";
// ── Helpers ───────────────────────────────────────────────────────────
function makeTarget(overrides: Partial<PreviewTarget> = {}): PreviewTarget {
return {
documentId: "doc-abc",
filename: "report.pdf",
contentType: "application/pdf",
mediaKind: "pdf",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: null,
chunkIndex: null,
targetPage: null,
snippet: null,
kind: null,
imageUrl: null,
sourcePageIndex: null,
pageCharStart: null,
pageCharEnd: null,
lineStart: null,
lineEnd: null,
pdfRegions: [],
...overrides,
};
}
function setReady(overrides: Partial<PreviewTarget> = {}): void {
mockState.target = makeTarget(overrides);
mockState.status = "ready";
}
// ── Tests against real panel component ───────────────────────────────
describe("preview-panel inline rendering safety (contracts §5.4 / Risk #3)", () => {
it("panel with no target (idle) renders without crashing", () => {
const { container } = render(
React.createElement(PreviewPanel, { open: true }),
);
expect(container).toBeDefined();
expect(document.querySelector("iframe")).toBeNull();
});
it("html mediaKind does not render an iframe, object, or embed element", () => {
setReady({ mediaKind: "html", filename: "malicious.html" });
render(React.createElement(PreviewPanel, { open: true }));
expect(document.querySelector("iframe")).toBeNull();
expect(document.querySelector("object")).toBeNull();
expect(document.querySelector("embed")).toBeNull();
});
it("docx mediaKind does not render an iframe, object, or embed element", () => {
setReady({ mediaKind: "docx", filename: "report.docx" });
render(React.createElement(PreviewPanel, { open: true }));
expect(document.querySelector("iframe")).toBeNull();
expect(document.querySelector("object")).toBeNull();
expect(document.querySelector("embed")).toBeNull();
});
it("unknown mediaKind does not render inline blob content", () => {
setReady({ mediaKind: "unknown", filename: "data.bin" });
render(React.createElement(PreviewPanel, { open: true }));
expect(document.querySelector("iframe")).toBeNull();
expect(document.querySelector("object")).toBeNull();
expect(document.querySelector("embed")).toBeNull();
});
it.each<PreviewMediaKind>(["html", "docx", "unknown"])(
"%s download creates only a download object URL, never inline preview content",
async (mediaKind) => {
const createObjectUrl = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:unsafe");
const revokeObjectUrl = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => undefined);
setReady({
mediaKind,
filename: `unsafe.${mediaKind}`,
contentType: "text/plain",
snippet: "Extracted text only.",
});
render(React.createElement(PreviewPanel, { open: true }));
fireEvent.click(
screen.getByRole("button", { name: DOWNLOAD_BUTTON_NAME }),
);
await waitFor(() => {
expect(mockAuthFetch).toHaveBeenCalledWith(
"/api/rag/documents/doc-abc/file",
);
});
expect(createObjectUrl).toHaveBeenCalledWith(expect.any(Blob));
await waitFor(() => {
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:unsafe");
});
expect(document.querySelector("iframe")).toBeNull();
expect(document.querySelector("object")).toBeNull();
expect(document.querySelector("embed")).toBeNull();
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
},
);
it("text mediaKind renders without iframe (text fallback path)", () => {
setReady({
mediaKind: "text",
filename: "notes.txt",
snippet: "This is the extracted text content.",
});
render(React.createElement(PreviewPanel, { open: true }));
expect(document.querySelector("iframe")).toBeNull();
});
it("open=false triggers close side-effect on the store", () => {
const closeFn = vi.fn();
mockState.close = closeFn;
mockState.target = makeTarget();
mockState.status = "ready";
const { rerender } = render(
React.createElement(PreviewPanel, { open: true }),
);
rerender(React.createElement(PreviewPanel, { open: false }));
// The useEffect for open=false should have called close()
expect(closeFn).toHaveBeenCalled();
});
it("closes the preview panel on Escape key down (Escape key closures)", () => {
const closeFn = vi.fn();
mockState.close = closeFn;
mockState.target = makeTarget();
mockState.status = "ready";
render(React.createElement(PreviewPanel, { open: true }));
fireEvent.keyDown(document, { key: "Escape" });
expect(closeFn).toHaveBeenCalled();
});
it("renders with premium glassmorphic visual details and a pulsing green indicator dot", () => {
setReady({ mediaKind: "text", filename: "notes.txt" });
render(React.createElement(PreviewPanel, { open: true }));
const section = screen.getByLabelText("Document preview");
expect(section).toHaveClass("bg-panel-surface/85");
expect(section).toHaveClass("backdrop-blur-lg");
expect(section).toHaveClass("border-border/40");
expect(section).toHaveClass("shadow-lg");
// Pulser dot
const pulser = section.querySelector(".animate-pulse");
expect(pulser).toBeInTheDocument();
expect(pulser).toHaveClass("bg-primary");
expect(pulser).toHaveClass("w-2");
expect(pulser).toHaveClass("h-2");
});
it("shifts the layout to a full mobile Sheet drawer overlay when the viewport is squeezed (< 1024px)", () => {
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
setReady({ mediaKind: "text", filename: "notes.txt" });
render(React.createElement(PreviewPanel, { open: true }));
// Radix UI Sheet component should render dialog role in mobile viewports
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveClass("preview-sheet-content");
expect(screen.getByText("Document preview")).toBeInTheDocument();
});
});
// ── Pure-logic: inline allowlist (always green) ───────────────────────
// Uses the real production isInlineBlobAllowed (D1.5 fix: no local copy).
describe("inline object URL allowlist (contracts §5.4, pure logic)", () => {
const inlineSafe: PreviewMediaKind[] = ["pdf", "text", "image"];
const inlineUnsafe: PreviewMediaKind[] = ["html", "docx", "unknown"];
it.each(inlineSafe)("mediaKind=%s is inline-safe", (mk) => {
expect(isInlineBlobAllowed(mk)).toBe(true);
});
it.each(inlineUnsafe)("mediaKind=%s is NOT inline-safe (Risk #3)", (mk) => {
expect(isInlineBlobAllowed(mk)).toBe(false);
});
});
describe("preview-panel stable scrollbars, sheets, layouts, and downloads", () => {
let createObjectUrl: MockInstance<typeof URL.createObjectURL>;
let revokeObjectUrl: MockInstance<typeof URL.revokeObjectURL>;
beforeEach(() => {
createObjectUrl = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:safe-url");
revokeObjectUrl = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => undefined);
});
afterEach(() => {
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
});
it("asserts stable scrollbar style classes are present on panel content", () => {
setReady({
mediaKind: "text",
filename: "notes.txt",
snippet: "Some long content that requires scrolling ".repeat(20),
});
render(React.createElement(PreviewPanel, { open: true }));
// The snippet is rendered in a <pre> element. Check if it has overflow-auto
const preElement = screen.getByText(LONG_CONTENT_TEXT);
expect(preElement).toHaveClass("overflow-auto");
expect(preElement).toHaveClass("flex-1");
});
it("asserts non-nested sheets are rendered in squeezed viewports", () => {
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
setReady({ mediaKind: "text", filename: "notes.txt" });
render(React.createElement(PreviewPanel, { open: true }));
const dialogs = screen.getAllByRole("dialog");
expect(dialogs.length).toBe(1);
expect(dialogs[0]).toHaveClass("preview-sheet-content");
const nestedDialogs = dialogs[0].querySelectorAll("[role='dialog']");
expect(nestedDialogs.length).toBe(0);
});
it("supports responsive collapses under different viewport widths", () => {
const mockMatchMedia = vi.fn().mockImplementation((query) => ({
matches: query.includes("max-width: 1023px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
window.matchMedia = mockMatchMedia;
setReady({ mediaKind: "text", filename: "notes.txt" });
const { unmount } = render(
React.createElement(PreviewPanel, { open: true }),
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
unmount();
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
render(React.createElement(PreviewPanel, { open: true }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByLabelText("Document preview")).toBeInTheDocument();
});
it("asserts object URL download logic: uses URL.createObjectURL for safe types and Data URL for unsafe types", async () => {
setReady({
mediaKind: "text",
filename: "notes.txt",
contentType: "text/plain",
snippet: "Text snippet.",
});
render(React.createElement(PreviewPanel, { open: true }));
fireEvent.click(screen.getByRole("button", { name: DOWNLOAD_BUTTON_NAME }));
await waitFor(() => {
expect(mockAuthFetch).toHaveBeenCalledWith(
"/api/rag/documents/doc-abc/file",
);
});
expect(createObjectUrl).toHaveBeenCalled();
});
});
describe("PreviewTextView precise highlights matching", () => {
it("highlights with character ranges", () => {
setReady({
mediaKind: "text",
filename: "notes.txt",
snippet: "Line 1: Hello World\nLine 2: Target Phrase\nLine 3: Goodbye",
pageCharStart: 28,
pageCharEnd: 41,
});
render(React.createElement(PreviewPanel, { open: true }));
const mark = screen.getByText("Target Phrase");
expect(mark.tagName).toBe("MARK");
expect(mark).toHaveClass("bg-primary/20", "ring-primary/60");
});
it("highlights with line numbers", () => {
setReady({
mediaKind: "text",
filename: "notes.txt",
snippet: "Line one text\nLine two text\nLine three text",
lineStart: 2,
lineEnd: 2,
});
render(React.createElement(PreviewPanel, { open: true }));
const mark = screen.getByText("Line two text");
expect(mark.tagName).toBe("MARK");
expect(mark).toHaveClass("bg-primary/20", "ring-primary/60");
});
it("highlights with fuzzy fallback matching high density line", () => {
setReady({
mediaKind: "text",
filename: "notes.txt",
snippet: "...\nAlphanumericDensity123456\n...",
lineStart: 999, // Trigger hasLocator without matching any specific line range
});
render(React.createElement(PreviewPanel, { open: true }));
const mark = screen.getByText("AlphanumericDensity123456");
expect(mark.tagName).toBe("MARK");
});
});

View file

@ -0,0 +1,386 @@
import type { PreviewTarget } from "@/features/rag/api/rag-api";
import type { PreviewPdfRegion } from "@/features/rag/api/rag-api";
import { PreviewPdfView } from "@/features/rag/components/preview-pdf-view";
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const SOURCE_EXCERPT_TEXT = /source excerpt/i;
/** The thumbnail rail also renders mocked `<Page>` elements, so every
* test that targets the main render must scope through the
* `pdf-main-page` wrapper instead of taking the first `pdf-page`. */
async function findMainPdfPage(): Promise<HTMLElement> {
const wrapper = await screen.findByTestId("pdf-main-page");
return within(wrapper).getByTestId("pdf-page");
}
function getMainPdfPage(): HTMLElement {
const wrapper = screen.getByTestId("pdf-main-page");
return within(wrapper).getByTestId("pdf-page");
}
vi.mock("react-pdf", () => ({
Document: ({
children,
file,
onLoadSuccess,
}: {
children: React.ReactNode;
file?: unknown;
onLoadSuccess?: (result: { numPages: number }) => void;
}) => {
onLoadSuccess?.({ numPages: 1 });
return React.createElement(
"div",
{
"data-testid": "pdf-document",
"data-file-kind": file instanceof Blob ? "blob" : typeof file,
"data-file-url":
file && typeof file === "object" && "url" in file
? String((file as { url: string }).url)
: "",
},
children,
);
},
Page: ({
customTextRenderer,
width,
renderTextLayer,
}: {
customTextRenderer?: (item: { str: string }) => string;
width?: number;
renderTextLayer?: boolean;
}) => {
const html =
customTextRenderer?.({ str: "target phrase" }) ?? "target phrase";
return React.createElement("div", {
"data-testid": "pdf-page",
"data-width": String(width ?? ""),
"data-render-text-layer": String(renderTextLayer),
"data-rendered-html": html,
});
},
pdfjs: { GlobalWorkerOptions: { workerSrc: "" } },
}));
function target(overrides: Partial<PreviewTarget> = {}): PreviewTarget {
return {
documentId: "doc-abc",
filename: "report.pdf",
contentType: "application/pdf",
mediaKind: "pdf",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: "chunk-1",
chunkIndex: 0,
targetPage: 1,
snippet: "target phrase appears here",
kind: "text",
imageUrl: null,
sourcePageIndex: 0,
pageCharStart: 0,
pageCharEnd: 13,
lineStart: 1,
lineEnd: 1,
pdfRegions: [],
...overrides,
};
}
beforeEach(() => {
class ResizeObserverMock implements ResizeObserver {
observe(_target: Element, _options?: ResizeObserverOptions) {
// jsdom has no layout observer; the component only needs the API shape.
}
unobserve(_target: Element) {
// jsdom has no layout observer; the component only needs the API shape.
}
disconnect() {
// jsdom has no layout observer; the component only needs the API shape.
}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
});
describe("PreviewPdfView smoke", () => {
it("renders a range URL source with text search and exact region overlay", async () => {
render(
React.createElement(PreviewPdfView, {
target: target({
pdfRegions: [
{
pageIndex: 0,
pageNumber: 1,
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.04,
confidence: "exact",
source: "pymupdf-search",
},
],
}),
file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed",
}),
);
expect(screen.getByTestId("pdf-document")).toHaveAttribute(
"data-file-kind",
"object",
);
expect(screen.getByTestId("pdf-document")).toHaveAttribute(
"data-file-url",
expect.stringContaining("/file-signed?token=signed"),
);
const page = await findMainPdfPage();
await waitFor(() => {
expect(page.getAttribute("data-render-text-layer")).toBe("true");
});
expect(page.getAttribute("data-rendered-html")).toBe("target phrase");
expect(page.getAttribute("data-rendered-html")).not.toContain("<mark>");
// Verify brand green highlight overlays
const regionHighlight = screen.getByTestId("pdf-region-highlight");
expect(regionHighlight).toBeInTheDocument();
expect(regionHighlight).toHaveClass("bg-primary/20");
expect(regionHighlight).toHaveClass("ring-primary/60");
// Verify Tailwind v4 light-mode isolation reset wrapper. After the
// thumbnail-rail refactor, the light wrapper lives INSIDE the
// Document and directly wraps the main-page block.
const wrapper = screen.getByTestId("pdf-main-page").parentElement;
expect(wrapper).toHaveClass("light");
expect(wrapper).toHaveClass("bg-white");
expect(wrapper).toHaveClass("text-slate-900");
// Verify Shadcn toolbar elements and rounded-full pill groups
const zoomInBtn = screen.getByRole("button", { name: "Zoom in" });
expect(zoomInBtn).toHaveClass("rounded-full");
expect(zoomInBtn.parentElement).toHaveClass(
"bg-muted/40",
"p-0.5",
"shadow-xs",
);
// Source-excerpt card uses a neutral muted surface (no brand-coloured
// left rail) so it sits inside the panel without visually competing.
const excerptCard = screen.getByText(SOURCE_EXCERPT_TEXT).parentElement;
expect(excerptCard).toHaveClass("border-border/60");
expect(excerptCard).toHaveClass("bg-muted/30");
expect(excerptCard).not.toHaveClass("border-l-primary");
fireEvent.change(screen.getByLabelText("Search this PDF"), {
target: { value: "phrase" },
});
await waitFor(() => {
expect(
getMainPdfPage().getAttribute("data-rendered-html"),
).toContain("<mark>phrase</mark>");
});
const beforeZoom = Number(page.getAttribute("data-width"));
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
await waitFor(() => {
expect(
Number(getMainPdfPage().getAttribute("data-width")),
).toBeGreaterThan(beforeZoom);
});
});
it("debounces ResizeObserver transitions to prevent infinite rendering loops", async () => {
const resizeCallbacks: ResizeObserverCallback[] = [];
class FakeResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback);
}
observe(_target: Element, _options?: ResizeObserverOptions) {
// jsdom has no layout observer; the component only needs the API shape.
}
unobserve(_target: Element) {
// jsdom has no layout observer; the component only needs the API shape.
}
disconnect() {
// jsdom has no layout observer; the component only needs the API shape.
}
}
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
render(
React.createElement(PreviewPdfView, {
target: target(),
file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed",
}),
);
// Initial render sets width synchronously on mount. Let's capture the initial width.
const page = await findMainPdfPage();
const initialWidth = Number(page.getAttribute("data-width"));
// Activate fake timers AFTER finding the elements to avoid findByTestId timeout
vi.useFakeTimers();
// Set up HTMLDivElement.prototype.clientWidth mock
const originalClientWidth = Object.getOwnPropertyDescriptor(
HTMLDivElement.prototype,
"clientWidth",
);
let clientWidthValue = 300;
Object.defineProperty(HTMLDivElement.prototype, "clientWidth", {
get() {
return clientWidthValue;
},
configurable: true,
});
// Now trigger resize callback after changing clientWidth
clientWidthValue = 600;
const resizeCallback = resizeCallbacks[0];
if (!resizeCallback) {
throw new Error("Expected ResizeObserver callback to be registered");
}
const resizeObserver: ResizeObserver = {
observe() {
// The callback under test ignores the observer instance.
},
unobserve() {
// The callback under test ignores the observer instance.
},
disconnect() {
// The callback under test ignores the observer instance.
},
};
resizeCallback([], resizeObserver);
// Width should NOT be updated immediately because of the 100ms debounce
expect(Number(getMainPdfPage().getAttribute("data-width"))).toBe(
initialWidth,
);
// Fast-forward time by 100ms to trigger the debounced callback and flush updates
act(() => {
vi.advanceTimersByTime(100);
vi.runAllTimers();
});
// Now the width should have updated
expect(
Number(getMainPdfPage().getAttribute("data-width")),
).not.toBe(initialWidth);
expect(Number(getMainPdfPage().getAttribute("data-width"))).toBe(572); // 600 - 28 (PDF_BODY_GUTTER_PX)
// Clean up prototype descriptor
if (originalClientWidth) {
Object.defineProperty(
HTMLDivElement.prototype,
"clientWidth",
originalClientWidth,
);
} else {
Reflect.deleteProperty(HTMLDivElement.prototype, "clientWidth");
}
vi.useRealTimers();
});
it("renders only 'exact' confidence highlights and positions them with correct percentages", async () => {
const nonExactRegion = {
pageIndex: 0,
pageNumber: 1,
x: 0.5,
y: 0.5,
width: 0.2,
height: 0.2,
confidence: "fuzzy",
source: "pymupdf-search",
} as unknown as PreviewPdfRegion;
render(
React.createElement(PreviewPdfView, {
target: target({
pdfRegions: [
{
pageIndex: 0,
pageNumber: 1,
x: 0.15,
y: 0.25,
width: 0.35,
height: 0.45,
confidence: "exact",
source: "pymupdf-search",
},
nonExactRegion,
],
}),
file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed",
}),
);
await findMainPdfPage();
const highlights = screen.getAllByTestId("pdf-region-highlight");
expect(highlights.length).toBe(1);
const exactHighlight = highlights[0];
expect(exactHighlight.style.left).toBe("15%");
expect(exactHighlight.style.top).toBe("25%");
expect(exactHighlight.style.width).toBe("35%");
expect(exactHighlight.style.height).toBe("45%");
});
it("uses stable scrollbar style classes in the PDF sidebar and page container to prevent shifting", async () => {
render(
React.createElement(PreviewPdfView, {
target: target(),
file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed",
}),
);
const mainPageWrapper = await screen.findByTestId("pdf-main-page");
// pdf-main-page → light-wrapper → scrollContainer
const scrollContainer =
mainPageWrapper.parentElement?.parentElement ?? null;
expect(scrollContainer).toHaveClass("preview-scrollbar");
expect(scrollContainer).toHaveClass("overflow-y-scroll");
expect(scrollContainer).toHaveClass("overflow-x-auto");
const sidebar = screen.getByRole("button", {
name: "Go to page 1",
}).parentElement;
expect(sidebar).toHaveClass("preview-scrollbar");
expect(sidebar).toHaveClass("overflow-y-auto");
});
it("highlights search terms using the custom text renderer with the mark wrapper", async () => {
render(
React.createElement(PreviewPdfView, {
target: target({
snippet: "this snippet contains some special keyword",
}),
file: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed",
}),
);
await findMainPdfPage();
fireEvent.change(screen.getByLabelText("Search this PDF"), {
target: { value: "phrase" },
});
await waitFor(() => {
expect(
getMainPdfPage().getAttribute("data-rendered-html"),
).toContain("<mark>phrase</mark>");
});
});
});

View file

@ -0,0 +1,285 @@
/**
* Tests for preview-store object URL lifecycle (contracts §5, T4).
*
* Coverage:
* - open() revokes previous object URL before assigning a new one.
* - close() revokes any live object URL.
* - Opening doc B while doc A is loaded revokes doc A's URL.
* - PDFs use a signed range URL instead of a full blob download.
* - Inline object URLs are created ONLY for safe non-PDF mediaKind (text/image).
* - For unsafe mediaKind (html/docx/unknown) blob fetch is skipped; previewBlobUrl = null.
* - isInlineBlobAllowed pure predicate matches contracts §5.4 allowlist.
* - __previewStoreInternals() verifies module-scoped cleanup.
*/
import type {
PreviewMediaKind,
PreviewTarget,
} from "@/features/rag/api/rag-api";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mock rag-api BEFORE importing the store ───────────────────────────
// vi.hoisted ensures the mock refs are initialised before vi.mock factory
// runs (vi.mock is hoisted to the top of the file by Vitest's transformer).
const {
mockFetchPreviewTarget,
mockFetchPreviewFileBlob,
mockFetchPreviewFileUrl,
} = vi.hoisted(() => ({
mockFetchPreviewTarget:
vi.fn<
(documentId: string, chunkId?: string | null) => Promise<PreviewTarget>
>(),
mockFetchPreviewFileBlob:
vi.fn<(documentId: string, signal?: AbortSignal) => Promise<Blob>>(),
mockFetchPreviewFileUrl: vi.fn<
(
documentId: string,
signal?: AbortSignal,
) => Promise<{ url: string; expiresAt: number }>
>(),
}));
vi.mock("@/features/rag/api/rag-api", async (importOriginal) => {
const original =
await importOriginal<typeof import("@/features/rag/api/rag-api")>();
return {
...original,
fetchPreviewTarget: mockFetchPreviewTarget,
fetchPreviewFileBlob: mockFetchPreviewFileBlob,
fetchPreviewFileUrl: mockFetchPreviewFileUrl,
};
});
// ── Import store AFTER mock registration ─────────────────────────────
import {
__previewStoreInternals,
isInlineBlobAllowed,
usePreviewStore,
} from "@/features/rag/stores/preview-store";
// ── Mock URL.createObjectURL / revokeObjectURL ────────────────────────
let urlCounter = 0;
beforeEach(() => {
urlCounter = 0;
vi.spyOn(URL, "createObjectURL").mockImplementation(() => {
return `blob:test/${++urlCounter}`;
});
vi.spyOn(URL, "revokeObjectURL").mockImplementation((_url: string) => {
/* no-op */
});
mockFetchPreviewTarget.mockReset();
mockFetchPreviewFileBlob.mockReset();
mockFetchPreviewFileUrl.mockReset();
// Reset store to idle between tests
usePreviewStore.getState().close();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Helpers ───────────────────────────────────────────────────────────
function makeTarget(overrides: Partial<PreviewTarget> = {}): PreviewTarget {
return {
documentId: "doc-abc",
filename: "report.pdf",
contentType: "application/pdf",
mediaKind: "pdf",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: null,
chunkIndex: null,
targetPage: null,
snippet: null,
kind: null,
imageUrl: null,
sourcePageIndex: null,
pageCharStart: null,
pageCharEnd: null,
lineStart: null,
lineEnd: null,
pdfRegions: [],
...overrides,
};
}
function makePdfBlob(): Blob {
return new Blob(["%PDF-1.4"], { type: "application/pdf" });
}
// ── Pure-logic: isInlineBlobAllowed (always green) ────────────────────
describe("isInlineBlobAllowed (contracts §5.4, pure logic)", () => {
const safe: PreviewMediaKind[] = ["pdf", "text", "image"];
const unsafe: PreviewMediaKind[] = ["html", "docx", "unknown"];
it.each(safe)("mediaKind=%s is inline-safe", (mk) => {
expect(isInlineBlobAllowed(mk)).toBe(true);
});
it.each(unsafe)("mediaKind=%s is NOT inline-safe (Risk #3)", (mk) => {
expect(isInlineBlobAllowed(mk)).toBe(false);
});
});
// ── Integration tests against real store ─────────────────────────────
describe("preview-store open/close lifecycle (contracts §5)", () => {
it("open() for pdf stores a signed URL without creating an object URL", async () => {
mockFetchPreviewTarget.mockResolvedValue(makeTarget({ mediaKind: "pdf" }));
mockFetchPreviewFileUrl.mockResolvedValue({
url: "http://127.0.0.1:8888/api/rag/documents/doc-abc/file-signed?token=signed",
expiresAt: 1_700_000_000,
});
await usePreviewStore.getState().open({ documentId: "doc-abc" });
expect(mockFetchPreviewFileBlob).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
const { previewBlob, previewBlobUrl, previewFileUrl, status } =
usePreviewStore.getState();
expect(previewBlob).toBeNull();
expect(previewBlobUrl).toBeNull();
expect(previewFileUrl).toContain("/file-signed?token=signed");
expect(status).toBe("ready");
});
it("open() passes backendChunkId through to fetchPreviewTarget", async () => {
mockFetchPreviewTarget.mockResolvedValue(makeTarget({ mediaKind: "pdf" }));
mockFetchPreviewFileUrl.mockResolvedValue({
url: "/api/rag/documents/doc-abc/file-signed?token=signed",
expiresAt: 1_700_000_000,
});
await usePreviewStore.getState().open({
documentId: "doc-abc",
backendChunkId: "chunk-xyz",
});
expect(mockFetchPreviewTarget).toHaveBeenCalledWith(
"doc-abc",
"chunk-xyz",
);
});
it("close() revokes the live object URL and clears state", async () => {
mockFetchPreviewTarget.mockResolvedValue(
makeTarget({ mediaKind: "text", filename: "notes.txt" }),
);
mockFetchPreviewFileBlob.mockResolvedValue(makePdfBlob());
await usePreviewStore.getState().open({ documentId: "doc-abc" });
const blobUrl = usePreviewStore.getState().previewBlobUrl;
expect(blobUrl).toMatch(/^blob:/);
usePreviewStore.getState().close();
expect(URL.revokeObjectURL).toHaveBeenCalledWith(blobUrl);
const { previewBlob, previewBlobUrl, target, status } =
usePreviewStore.getState();
expect(previewBlob).toBeNull();
expect(previewBlobUrl).toBeNull();
expect(target).toBeNull();
expect(status).toBe("idle");
});
it("opening doc B revokes doc A's URL before creating doc B's (contracts §5.1)", async () => {
mockFetchPreviewTarget.mockResolvedValue(
makeTarget({ mediaKind: "text", filename: "notes.txt" }),
);
mockFetchPreviewFileBlob.mockResolvedValue(makePdfBlob());
await usePreviewStore.getState().open({ documentId: "doc-A" });
const urlA = usePreviewStore.getState().previewBlobUrl;
expect(urlA).toMatch(/^blob:/);
await usePreviewStore.getState().open({ documentId: "doc-B" });
expect(URL.revokeObjectURL).toHaveBeenCalledWith(urlA);
const urlB = usePreviewStore.getState().previewBlobUrl;
expect(urlB).not.toBe(urlA);
expect(urlB).toMatch(/^blob:/);
});
it("html mediaKind skips blob fetch and sets previewBlobUrl = null (Risk #3)", async () => {
mockFetchPreviewTarget.mockResolvedValue(
makeTarget({ mediaKind: "html", filename: "evil.html" }),
);
await usePreviewStore.getState().open({ documentId: "doc-html" });
expect(mockFetchPreviewFileBlob).not.toHaveBeenCalled();
expect(mockFetchPreviewFileUrl).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
const { previewBlob, previewBlobUrl, status } = usePreviewStore.getState();
expect(previewBlob).toBeNull();
expect(previewBlobUrl).toBeNull();
expect(status).toBe("ready");
});
it("docx mediaKind skips blob fetch and sets previewBlobUrl = null (Risk #3)", async () => {
mockFetchPreviewTarget.mockResolvedValue(
makeTarget({ mediaKind: "docx", filename: "report.docx" }),
);
await usePreviewStore.getState().open({ documentId: "doc-docx" });
expect(mockFetchPreviewFileBlob).not.toHaveBeenCalled();
expect(mockFetchPreviewFileUrl).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(usePreviewStore.getState().previewBlob).toBeNull();
expect(usePreviewStore.getState().previewBlobUrl).toBeNull();
});
it("unknown mediaKind skips blob fetch and sets previewBlobUrl = null", async () => {
mockFetchPreviewTarget.mockResolvedValue(
makeTarget({ mediaKind: "unknown", filename: "data.bin" }),
);
await usePreviewStore.getState().open({ documentId: "doc-bin" });
expect(mockFetchPreviewFileBlob).not.toHaveBeenCalled();
expect(mockFetchPreviewFileUrl).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(usePreviewStore.getState().previewBlob).toBeNull();
expect(usePreviewStore.getState().previewBlobUrl).toBeNull();
});
it("close() when nothing is open does not throw", () => {
expect(() => usePreviewStore.getState().close()).not.toThrow();
});
it("__previewStoreInternals shows no activeBlobUrl after close()", async () => {
mockFetchPreviewTarget.mockResolvedValue(
makeTarget({ mediaKind: "text", filename: "notes.txt" }),
);
mockFetchPreviewFileBlob.mockResolvedValue(makePdfBlob());
await usePreviewStore.getState().open({ documentId: "doc-abc" });
expect(__previewStoreInternals().activeBlobUrl).toMatch(/^blob:/);
usePreviewStore.getState().close();
expect(__previewStoreInternals().activeBlobUrl).toBeNull();
expect(__previewStoreInternals().hasInflightController).toBe(false);
});
it("fetchPreviewTarget error sets status=error and clears target", async () => {
mockFetchPreviewTarget.mockRejectedValue(new Error("404 not found"));
await usePreviewStore.getState().open({ documentId: "missing" });
const { status, error, target } = usePreviewStore.getState();
expect(status).toBe("error");
expect(error).toMatch(/404/);
expect(target).toBeNull();
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,64 @@
import type { PreviewTarget } from "@/features/rag/api/rag-api";
import { PreviewTextView } from "@/features/rag/components/preview-text-view";
import { render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/features/auth", () => ({
authFetch: vi.fn(),
}));
function target(overrides: Partial<PreviewTarget> = {}): PreviewTarget {
return {
documentId: "doc-abc",
filename: "notes.txt",
contentType: "text/plain",
mediaKind: "text",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: "chunk-1",
chunkIndex: 0,
targetPage: 2,
snippet: "alpha\nhighlighted line\nomega",
kind: "text",
imageUrl: null,
sourcePageIndex: null,
pageCharStart: null,
pageCharEnd: null,
lineStart: null,
lineEnd: null,
pdfRegions: [],
...overrides,
};
}
describe("PreviewTextView locator highlight fallback", () => {
it("emphasizes the source excerpt when nullable locators are present", () => {
render(
React.createElement(PreviewTextView, {
target: target({
sourcePageIndex: 1,
pageCharStart: 6,
pageCharEnd: 22,
lineStart: 2,
lineEnd: 2,
}),
}),
);
expect(screen.getByText(/highlighted source excerpt/i)).toBeInTheDocument();
expect(document.querySelector("mark")?.textContent).toContain(
"highlighted line",
);
});
it("keeps the source excerpt visible when locators are missing", () => {
render(React.createElement(PreviewTextView, { target: target() }));
expect(screen.getByText(/source excerpt/i)).toBeInTheDocument();
expect(document.querySelector("mark")).toBeNull();
expect(screen.getByText(/highlighted line/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,191 @@
import type { PreviewTarget } from "@/features/rag/api/rag-api";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
mockAuthFetch,
mockGetAuthToken,
mockEventSourceInstances,
eventSourcePolyfillExport,
authorizationHeader,
} = vi.hoisted(() => ({
mockAuthFetch: vi.fn(),
mockGetAuthToken: vi.fn(),
mockEventSourceInstances: [] as MockEventSource[],
eventSourcePolyfillExport: "EventSourcePolyfill",
authorizationHeader: "Authorization",
}));
vi.mock("@/features/auth", () => ({
authFetch: mockAuthFetch,
getAuthToken: mockGetAuthToken,
}));
interface MockEventSource {
url: string;
options: unknown;
onmessage: ((event: MessageEvent) => void) | null;
onerror: (() => void) | null;
close: ReturnType<typeof vi.fn>;
}
vi.mock("event-source-polyfill", () => ({
[eventSourcePolyfillExport]: class {
url: string;
options: unknown;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
constructor(url: string, options?: unknown) {
this.url = url;
this.options = options;
mockEventSourceInstances.push(this);
}
},
}));
import {
backfillDocumentLocators,
fetchPreviewFileUrl,
fetchPreviewTarget,
subscribeToJobEvents,
} from "@/features/rag/api/rag-api";
function target(): PreviewTarget {
return {
documentId: "doc-abc",
filename: "report.pdf",
contentType: "application/pdf",
mediaKind: "pdf",
byteSize: 100,
status: "completed",
kbId: "kb-1",
threadId: null,
chunkId: "chunk-xyz",
chunkIndex: 0,
targetPage: 1,
snippet: "excerpt",
kind: "text",
imageUrl: null,
sourcePageIndex: 0,
pageCharStart: 0,
pageCharEnd: 7,
lineStart: 1,
lineEnd: 1,
pdfRegions: [],
};
}
beforeEach(() => {
mockAuthFetch.mockReset();
mockGetAuthToken.mockReset();
mockEventSourceInstances.length = 0;
});
describe("RAG API preview target", () => {
it("URL-encodes documentId and chunk_id", async () => {
mockAuthFetch.mockResolvedValue(
new Response(JSON.stringify(target()), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await fetchPreviewTarget("doc id/with?slash", "chunk id/with?amp&eq=1");
expect(mockAuthFetch).toHaveBeenCalledWith(
"/api/rag/documents/doc%20id%2Fwith%3Fslash/preview-target?chunk_id=chunk%20id%2Fwith%3Famp%26eq%3D1",
);
});
it("fetches signed preview URL without adding a bearer token query", async () => {
mockAuthFetch.mockResolvedValue(
new Response(
JSON.stringify({
url: "/api/rag/documents/doc-abc/file-signed?token=signed-preview",
expiresAt: 1_700_000_000,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
const result = await fetchPreviewFileUrl("doc id/with?slash");
expect(mockAuthFetch).toHaveBeenCalledWith(
"/api/rag/documents/doc%20id%2Fwith%3Fslash/file-url",
undefined,
);
expect(result.url).toContain("token=signed-preview");
expect(result.url).not.toContain("Bearer");
expect(result.url).not.toContain("Authorization");
});
it("posts the explicit locator backfill action", async () => {
mockAuthFetch.mockResolvedValue(
new Response(
JSON.stringify({
documentId: "doc-abc",
totalChunks: 1,
matched: 1,
alreadyLocated: 0,
ambiguous: 0,
missing: 0,
skipped: 0,
regionsMatched: 0,
pagesRefreshed: 1,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await backfillDocumentLocators("doc id/with?slash");
expect(mockAuthFetch).toHaveBeenCalledWith(
"/api/rag/documents/doc%20id%2Fwith%3Fslash/locators/backfill",
{ method: "POST" },
);
});
});
describe("RAG API job events", () => {
it("opens SSE with Authorization header instead of token query params", () => {
mockGetAuthToken.mockReturnValue("mock-token-123");
const unsubscribe = subscribeToJobEvents("job id/with?slash", {});
expect(mockEventSourceInstances).toHaveLength(1);
const source = mockEventSourceInstances[0];
expect(source.url).toContain(
"/api/rag/jobs/job%20id%2Fwith%3Fslash/events",
);
expect(source.url).not.toContain("token=");
expect(source.options).toEqual({
headers: {
[authorizationHeader]: "Bearer mock-token-123",
},
});
unsubscribe();
expect(source.close).toHaveBeenCalled();
});
it("omits EventSource options when there is no bearer token", () => {
mockGetAuthToken.mockReturnValue(null);
const unsubscribe = subscribeToJobEvents("job-abc", {});
expect(mockEventSourceInstances).toHaveLength(1);
const source = mockEventSourceInstances[0];
expect(source.url).toContain("/api/rag/jobs/job-abc/events");
expect(source.url).not.toContain("token=");
expect(source.options).toBeUndefined();
unsubscribe();
});
});

View file

@ -0,0 +1,84 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOpenPreview } = vi.hoisted(() => ({
mockOpenPreview: vi.fn(),
}));
vi.mock("@/features/rag/stores/preview-store", () => ({
usePreviewStore: (
selector?: (state: { open: typeof mockOpenPreview }) => unknown,
) => {
const state = { open: mockOpenPreview };
return typeof selector === "function" ? selector(state) : state;
},
}));
vi.mock("@assistant-ui/react", () => ({
useAuiState: (
selector: (state: { message: { content: unknown[] } }) => unknown,
) =>
selector({
message: { content: [{ type: "text", text: "Answer ready." }] },
}),
}));
import { SearchKnowledgeBaseToolUI } from "@/components/assistant-ui/tool-ui-search-knowledge-base";
const TOOL_UI = SearchKnowledgeBaseToolUI as React.ComponentType<
Record<string, unknown>
>;
const SEARCHED_DOCS_BUTTON_RE = /searched docs/i;
const MAIN_PREVIEW_BUTTON_RE = /open preview of main\.pdf/i;
const LEGACY_PREVIEW_BUTTON_RE = /open preview of legacy\.pdf/i;
function renderTool(result: string) {
return render(
React.createElement(TOOL_UI, {
args: { query: "what is interior modeling?" },
result,
status: { type: "complete" },
}),
);
}
beforeEach(() => {
mockOpenPreview.mockClear();
});
describe("SearchKnowledgeBaseToolUI preview routing", () => {
it("opens preview from a retrieved chunk source label", async () => {
renderTool(
'<chunk id="1" source="main.pdf" page="3" document_id="doc-abc" chunk_id="chunk-xyz">The paper objectives.</chunk>',
);
await userEvent.click(
screen.getByRole("button", { name: SEARCHED_DOCS_BUTTON_RE }),
);
await userEvent.click(
screen.getByRole("button", { name: MAIN_PREVIEW_BUTTON_RE }),
);
expect(mockOpenPreview).toHaveBeenCalledWith({
documentId: "doc-abc",
backendChunkId: "chunk-xyz",
});
});
it("keeps legacy chunk labels non-clickable without durable IDs", async () => {
renderTool(
'<chunk id="1" source="legacy.pdf" page="3">Legacy chunk text.</chunk>',
);
await userEvent.click(
screen.getByRole("button", { name: SEARCHED_DOCS_BUTTON_RE }),
);
expect(
screen.queryByRole("button", { name: LEGACY_PREVIEW_BUTTON_RE }),
).toBeNull();
expect(mockOpenPreview).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,80 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockOpen } = vi.hoisted(() => ({ mockOpen: vi.fn() }));
vi.mock("@/features/rag/stores/preview-store", () => ({
usePreviewStore: (
selector?: (state: { open: typeof mockOpen }) => unknown,
) => {
const state = { open: mockOpen };
return typeof selector === "function" ? selector(state) : state;
},
}));
import { DocumentSourceBadge } from "@/components/assistant-ui/sources";
function source(overrides: Record<string, unknown> = {}) {
return {
kind: "document" as const,
chunkId: "2",
documentId: "doc-abc",
backendChunkId: "chunk-xyz",
filename: "report.pdf",
page: "7",
score: "0.85",
text: "The margin rose to 18%.",
...overrides,
};
}
beforeEach(() => {
mockOpen.mockClear();
});
describe("DocumentSourceBadge preview routing", () => {
it("opens preview with durable document and backend chunk IDs on click", async () => {
render(React.createElement(DocumentSourceBadge, { source: source() }));
await userEvent.click(
screen.getByRole("button", { name: /open preview/i }),
);
expect(mockOpen).toHaveBeenCalledWith({
documentId: "doc-abc",
backendChunkId: "chunk-xyz",
});
});
it("opens preview from Enter and Space", () => {
render(React.createElement(DocumentSourceBadge, { source: source() }));
const badge = screen.getByRole("button", { name: /open preview/i });
fireEvent.keyDown(badge, { key: "Enter" });
fireEvent.keyDown(badge, { key: " " });
expect(mockOpen).toHaveBeenCalledTimes(2);
});
it("legacy source without durable IDs remains hover-only", async () => {
render(
React.createElement(DocumentSourceBadge, {
source: source({ documentId: null, backendChunkId: null }),
}),
);
expect(screen.queryByRole("button", { name: /open preview/i })).toBeNull();
await userEvent.click(screen.getByText("[2]"));
expect(mockOpen).not.toHaveBeenCalled();
});
it("applies brand-aligned interactive styling when preview is clickable", () => {
render(React.createElement(DocumentSourceBadge, { source: source() }));
const badge = screen.getByRole("button", { name: /open preview/i });
expect(badge).toHaveClass("cursor-pointer");
expect(badge).toHaveClass("hover:bg-chat-icon-bg-hover!");
expect(badge).toHaveClass("focus-visible:ring-ring/50");
});
});

View file

@ -11,6 +11,7 @@ import { Route as chatRoute } from "./routes/chat";
import { Route as exportRoute } from "./routes/export";
import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as indexRoute } from "./routes/index";
import { Route as knowledgeBasesRoute } from "./routes/knowledge-bases";
import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as changePasswordRoute } from "./routes/change-password";
@ -24,6 +25,7 @@ const routeTree = rootRoute.addChildren([
changePasswordRoute,
gridTestRoute,
settingsRoute,
knowledgeBasesRoute,
studioRoute,
chatRoute,
exportRoute,

View file

@ -5,6 +5,7 @@ import { AppSidebar } from "@/components/app-sidebar";
import { Navbar } from "@/components/navbar";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { IngestionToastStack } from "@/features/rag/components/ingestion-toast-stack";
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
import { useTrainingUnloadGuard } from "@/features/training";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
@ -113,6 +114,7 @@ function RootLayout() {
return (
<AppProvider>
<SettingsDialog />
<IngestionToastStack />
{hideNavbar ? (
<main className="flex-1">
<Suspense fallback={<RouteFallback />}>

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute, redirect } from "@tanstack/react-router";
import { getPostAuthRoute } from "@/features/auth";
import { useSettingsDialogStore } from "@/features/settings";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
// /knowledge-bases is a deep link to the settings modal's Knowledge
// Bases tab. Open it, then redirect home. Mirrors /settings.
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/knowledge-bases",
staticData: { title: "Knowledge Bases" },
beforeLoad: async () => {
await requireAuth();
useSettingsDialogStore.getState().openDialog("knowledge-bases");
throw redirect({ to: getPostAuthRoute() });
},
component: () => null,
});

View file

@ -1,23 +1,26 @@
"use client";
import { openLink } from "@/lib/open-link";
import {
memo,
useState,
useRef,
useEffect,
useCallback,
type ComponentProps,
type FC,
} from "react";
import { useMessage } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { Badge, badgeVariants, type BadgeProps } from "./badge";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { usePreviewStore } from "@/features/rag/stores/preview-store";
import { openLink } from "@/lib/open-link";
import { cn } from "@/lib/utils";
import { useMessage } from "@assistant-ui/react";
import { FileTextIcon } from "lucide-react";
import {
type ComponentProps,
type FC,
type KeyboardEvent as ReactKeyboardEvent,
memo,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Badge, type BadgeProps, badgeVariants } from "./badge";
// ── Helpers ──────────────────────────────────────────────────
@ -44,8 +47,12 @@ function SourceIcon({
}: ComponentProps<"span"> & { url: string; size?: number }) {
const [hasError, setHasError] = useState(false);
const domain = extractDomain(url);
const SIZE_CLASSES: Record<number, string> = { 3: "size-3", 4: "size-4", 5: "size-5" };
const sizeClass = SIZE_CLASSES[size] ?? "size-3";
const sizeClasses: Record<number, string> = {
3: "size-3",
4: "size-4",
5: "size-5",
};
const sizeClass = sizeClasses[size] ?? "size-3";
if (hasError) {
return (
@ -100,7 +107,7 @@ function Source({
}: SourceProps) {
return (
<Badge
asChild
asChild={true}
variant={variant}
size={size}
className={cn(
@ -126,7 +133,8 @@ function Source({
// ── Source badge with hover card ─────────────────────────────
interface SourceData {
interface UrlSourceData {
kind: "url";
/**
* Stable per-citation key. Two Anthropic document citations into
* different spans of the same source share a ``url``, so React keys
@ -138,13 +146,32 @@ interface SourceData {
description?: string;
}
const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
interface DocSourceData {
kind: "document";
/** Visible citation id (the `[N]` reference). Display only. */
chunkId: string;
/** Durable backend `rag_documents.id`. null on legacy sources. */
documentId: string | null;
/** Durable backend `rag_chunks.id`. null on legacy sources. */
backendChunkId: string | null;
filename: string;
page?: string;
text: string;
}
type SourceData = UrlSourceData | DocSourceData;
function sourceKey(source: SourceData): string {
return source.kind === "url" ? `url:${source.url}` : `doc:${source.chunkId}`;
}
const SourceBadge: FC<{ source: UrlSourceData }> = ({ source }) => {
const domain = extractDomain(source.url);
const displayTitle = source.title || domain;
return (
<HoverCard openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild>
<HoverCardTrigger asChild={true}>
<span className="inline-block">
<Source href={source.url}>
<SourceIcon url={source.url} />
@ -177,6 +204,98 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
);
};
const DocumentSourceBadge: FC<{ source: DocSourceData }> = ({ source }) => {
const metaParts: string[] = [];
if (source.page) metaParts.push(`page ${source.page}`);
// Preview is clickable IFF both durable IDs are present (contracts
// §4.1 routing rule + Q3). Legacy sources fall through to a
// non-interactive badge with hover-only behavior.
const isClickable =
source.documentId !== null && source.backendChunkId !== null;
const openPreview = usePreviewStore((s) => s.open);
const handleOpen = useCallback(() => {
if (!isClickable || !source.documentId) return;
void openPreview({
documentId: source.documentId,
backendChunkId: source.backendChunkId,
});
}, [isClickable, openPreview, source.documentId, source.backendChunkId]);
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLElement>) => {
if (!isClickable) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOpen();
}
},
[isClickable, handleOpen],
);
return (
<HoverCard openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild={true}>
<span className="inline-block">
<Badge
variant="outline"
{...(isClickable
? {
role: "button",
tabIndex: 0,
onClick: handleOpen,
onKeyDown: handleKeyDown,
"aria-label": `Open preview of ${source.filename}`,
}
: {})}
className={cn(
"rounded-full inline-flex items-center gap-1.5 outline-none",
isClickable
? "cursor-pointer hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
: "cursor-default",
)}
>
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
[{source.chunkId}]
</span>
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<SourceTitle>{source.filename}</SourceTitle>
</Badge>
</span>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="start"
className="!bg-black !text-white !w-72 !p-3 !rounded-2xl !shadow-md !ring-0 !duration-0"
style={{ animation: "none" }}
>
<div className="flex gap-2.5">
<FileTextIcon className="size-4 mt-0.5 shrink-0 text-white/70" />
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold leading-tight truncate">
{source.filename}
</p>
{metaParts.length > 0 ? (
<p className="text-xs text-white/60 truncate">
{metaParts.join(" · ")}
</p>
) : null}
<p className="text-xs text-white/70 leading-relaxed line-clamp-3 whitespace-pre-wrap">
{source.text}
</p>
{isClickable ? (
<p className="mt-1 text-[10px] text-white/50">
Click to open preview
</p>
) : null}
</div>
</div>
</HoverCardContent>
</HoverCard>
);
};
// ── Grouped sources with 2-row collapse ─────────────────────
const SourcesGroup: FC = () => {
@ -185,7 +304,7 @@ const SourcesGroup: FC = () => {
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false);
// Extract source parts from the message
// Extract source parts (both URL and document) from the message
const sources: SourceData[] = [];
if (message.content) {
for (const part of message.content) {
@ -202,12 +321,37 @@ const SourcesGroup: FC = () => {
? ((part as { id: string }).id)
: url;
sources.push({
kind: "url",
id: partId,
url,
title: (part as { title?: string }).title || "",
description: (part as { metadata?: { description?: string } })
.metadata?.description,
});
} else if (
part.type === "source" &&
"sourceType" in part &&
(part as { sourceType?: string }).sourceType === "document"
) {
const docPart = part as {
chunkId?: string;
documentId?: string | null;
backendChunkId?: string | null;
filename?: string;
page?: string;
text?: string;
};
if (docPart.chunkId && docPart.filename) {
sources.push({
kind: "document",
chunkId: docPart.chunkId,
documentId: docPart.documentId ?? null,
backendChunkId: docPart.backendChunkId ?? null,
filename: docPart.filename,
page: docPart.page,
text: docPart.text ?? "",
});
}
}
}
}
@ -266,24 +410,43 @@ const SourcesGroup: FC = () => {
{/* Hidden measurement container — renders all badges to measure row positions */}
<div
ref={containerRef}
aria-hidden
aria-hidden={true}
className="flex w-full flex-wrap gap-1 invisible absolute pointer-events-none"
>
{sources.map((source) => (
<span key={source.id} className="inline-block">
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceTitle>{source.title || extractDomain(source.url)}</SourceTitle>
</Source>
<span key={sourceKey(source)} className="inline-block">
{source.kind === "url" ? (
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceTitle>
{source.title || extractDomain(source.url)}
</SourceTitle>
</Source>
) : (
<Badge
variant="outline"
className="rounded-full inline-flex items-center gap-1.5"
>
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
[{source.chunkId}]
</span>
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<SourceTitle>{source.filename}</SourceTitle>
</Badge>
)}
</span>
))}
</div>
{/* Visible container */}
<div className="flex flex-wrap gap-1">
{displayedSources.map((source) => (
<SourceBadge key={source.id} source={source} />
))}
{displayedSources.map((source) =>
source.kind === "url" ? (
<SourceBadge key={sourceKey(source)} source={source} />
) : (
<DocumentSourceBadge key={sourceKey(source)} source={source} />
),
)}
{shouldCollapse && !expanded && (
<button
type="button"
@ -338,5 +501,6 @@ export {
Source,
SourceIcon,
SourceTitle,
DocumentSourceBadge,
badgeVariants as sourceVariants,
};

View file

@ -25,9 +25,16 @@ import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { SearchKnowledgeBaseToolUI } from "@/components/assistant-ui/tool-ui-search-knowledge-base";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { PendingDocChips } from "@/features/chat/components/pending-doc-chips";
import {
DOCUMENT_ACCEPT,
isDocumentFile,
useThreadDocUploads,
} from "@/features/chat/hooks/use-thread-doc-uploads";
import {
IntentAwareScrollProvider,
useIntentAwareAutoScroll,
@ -70,11 +77,15 @@ import { flushResourcesSync } from "@assistant-ui/tap";
import {
ArrowDownIcon,
ArrowUpIcon,
BookOpenIcon,
ChevronLeftIcon,
ChevronRightIcon,
DownloadIcon,
FolderIcon,
GlobeIcon,
HeadphonesIcon,
ImageIcon,
PaperclipIcon,
LightbulbIcon,
LightbulbOffIcon,
MicIcon,
@ -449,14 +460,26 @@ const Composer: FC<{
const hasPendingAudio = useChatRuntimeStore((s) =>
Boolean(s.pendingAudioName),
);
const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled);
const { pendingDocs, addDoc, removeDoc, clearDocs, isIndexing } =
useThreadDocUploads();
const referenceThreadId = threadId ?? activeThreadId ?? null;
const hasSendableContent =
composerText.trim().length > 0 || hasAttachments || hasPendingAudio;
const shouldBlockSend = useCallback(
() =>
!hasSendableContent || isComposingRef.current || hasPendingAttachments,
[hasPendingAttachments, hasSendableContent, isComposingRef],
!hasSendableContent ||
isComposingRef.current ||
hasPendingAttachments ||
isIndexing,
[hasPendingAttachments, hasSendableContent, isComposingRef, isIndexing],
);
const sendBlocked =
disabled ||
!hasSendableContent ||
isComposing ||
hasPendingAttachments ||
isIndexing;
const handleSubmit = useCallback(
(event: Parameters<NonNullable<ComponentProps<"form">["onSubmit"]>>[0]) => {
@ -506,9 +529,12 @@ const Composer: FC<{
});
closeOverlay();
}
// Drop chips on send; docs stay searchable in the backend.
clearDocs();
},
[
aui,
clearDocs,
closeOverlay,
composerText,
disabled,
@ -524,6 +550,7 @@ const Composer: FC<{
<>
<ComposerAttachments />
<PendingAudioChip />
<PendingDocChips docs={pendingDocs} onRemove={removeDoc} />
<ToolStatusDisplay />
<ComposerPrimitive.Input
placeholder={
@ -541,13 +568,10 @@ const Composer: FC<{
{...inputProps}
/>
<ComposerAction
disabled={
disabled ||
!hasSendableContent ||
isComposing ||
hasPendingAttachments
}
disabled={sendBlocked}
shouldBlockSend={shouldBlockSend}
ragModeOn={ragToolEnabled}
onAddDoc={addDoc}
/>
</>
);
@ -1117,6 +1141,50 @@ const ImagesToggle: FC = () => {
);
};
// Master RAG switch (mirrors shared-composer); sidebar configures the rest.
const RagToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled);
const setRagToolEnabled = useChatRuntimeStore((s) => s.setRagToolEnabled);
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const isExternalModel = parseExternalModelId(checkpoint) !== null;
// Local models need tool-calling (search_knowledge_base loop); external
// providers use the prefetch path, so RAG is allowed regardless of the
// local supportsTools flag (mirrors shared-composer's ragDisabled).
const disabled = !modelLoaded || (!supportsTools && !isExternalModel);
return (
<button
type="button"
disabled={disabled}
onClick={() => {
const next = !ragToolEnabled;
setRagToolEnabled(next);
if (next && ragSource.kind === "off") {
setRagSource({ kind: "thread" });
}
}}
className="composer-pill-btn"
data-active={ragToolEnabled && !disabled ? "true" : "false"}
aria-label={ragToolEnabled ? "Disable RAG" : "Enable RAG"}
title={
disabled
? "RAG needs a model that supports tool calling"
: ragToolEnabled
? "RAG on — the model can search your attached documents"
: "Enable RAG — let the model search your documents"
}
>
<BookOpenIcon className="size-3.5" />
<span>RAG</span>
</button>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -1175,20 +1243,94 @@ const ToolStatusDisplay: FC = () => {
);
};
// RAG-aware + button: picks doc formats and routes to ingest pipeline.
// A second button picks a whole folder (webkitdirectory) and routes every
// compatible file through the same pipeline (which drains at the configured
// parallel-indexing rate).
const RagDocAttachment: FC<{ onSelect: (file: File) => void }> = ({
onSelect,
}) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
const selectCompatible = (files: FileList | null) => {
if (!files) return;
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (f && isDocumentFile(f)) onSelect(f);
}
};
return (
<>
<input
ref={inputRef}
type="file"
accept={DOCUMENT_ACCEPT}
multiple
className="hidden"
onChange={(e) => {
selectCompatible(e.target.files);
e.target.value = "";
}}
/>
<input
// webkitdirectory isn't in React's input prop types; set it on the
// element directly so the picker selects a folder (returns every
// file recursively, which selectCompatible then filters).
ref={(el) => {
folderInputRef.current = el;
if (el) el.setAttribute("webkitdirectory", "");
}}
type="file"
multiple
className="hidden"
onChange={(e) => {
selectCompatible(e.target.files);
e.target.value = "";
}}
/>
<TooltipIconButton
tooltip="Attach document for RAG"
aria-label="Attach document for RAG"
variant="ghost"
className="size-8 rounded-full text-muted-foreground"
onClick={() => inputRef.current?.click()}
>
<PaperclipIcon className="size-4" />
</TooltipIconButton>
<TooltipIconButton
tooltip="Attach a folder for RAG"
aria-label="Attach a folder for RAG"
variant="ghost"
className="size-8 rounded-full text-muted-foreground"
onClick={() => folderInputRef.current?.click()}
>
<FolderIcon className="size-4" />
</TooltipIconButton>
</>
);
};
const ComposerAction: FC<{
disabled?: boolean;
shouldBlockSend?: () => boolean;
}> = ({ disabled, shouldBlockSend }) => {
ragModeOn?: boolean;
onAddDoc?: (file: File) => void;
}> = ({ disabled, shouldBlockSend, ragModeOn, onAddDoc }) => {
return (
<div className="aui-composer-action-wrapper composer-action-wrapper">
<div className="flex items-center gap-0.5">
<ComposerAddAttachment />
{ragModeOn && onAddDoc ? (
<RagDocAttachment onSelect={onAddDoc} />
) : (
<ComposerAddAttachment />
)}
<ComposerAudioUpload />
<ReasoningToggle />
<PreserveThinkingToggle />
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<RagToggle />
</div>
<div className="flex items-center gap-1">
<ComposerPrimitive.If dictation={false}>
@ -1316,6 +1458,7 @@ const AssistantMessage: FC = () => {
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI,
search_knowledge_base: SearchKnowledgeBaseToolUI,
},
Fallback: ToolFallback,
},

View file

@ -0,0 +1,308 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { authFetch } from "@/features/auth";
import { usePreviewStore } from "@/features/rag/stores/preview-store";
import { cn } from "@/lib/utils";
import {
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { FileTextIcon, ImageIcon, LoaderIcon } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
export interface ParsedChunk {
/** Visible citation id the model uses inside `[N]` references. Display
* only; never sent to the backend as a chunk_id. */
id: string;
source: string;
page?: string;
chunkIndex?: string;
tokens?: string;
sourcePageIndex?: string;
pageCharStart?: string;
pageCharEnd?: string;
lineStart?: string;
lineEnd?: string;
kind?: string;
imageUrl?: string;
text: string;
/** Durable `rag_documents.id`. Carries through when the tool XML
* includes `document_id="..."`. Absent on legacy tool output. */
documentId?: string;
/** Durable `rag_chunks.id`. Carries through when the tool XML
* includes `chunk_id="..."`. Absent on legacy tool output. The
* preview routing value sent as `?chunk_id=` to `/preview-target`;
* never the same as the visible `id`. */
backendChunkId?: string;
}
const ATTR_RE = /(\w+)="([^"]*)"/g;
const CHUNK_RE = /<chunk\s+([^>]+)>\s*([\s\S]*?)\s*<\/chunk>/g;
function decodeXml(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
export function parseChunks(raw: string): ParsedChunk[] {
if (!raw) return [];
const out: ParsedChunk[] = [];
let match: RegExpExecArray | null = CHUNK_RE.exec(raw);
while (match !== null) {
const attrBlob = match[1];
const text = match[2];
const attrs: Record<string, string> = {};
let attrMatch: RegExpExecArray | null = ATTR_RE.exec(attrBlob);
while (attrMatch !== null) {
attrs[attrMatch[1]] = decodeXml(attrMatch[2]);
attrMatch = ATTR_RE.exec(attrBlob);
}
if (attrs.id) {
out.push({
id: attrs.id,
source: attrs.source ?? "unknown",
page: attrs.page,
chunkIndex: attrs.chunk_index,
tokens: attrs.tokens,
sourcePageIndex: attrs.source_page_index,
pageCharStart: attrs.page_char_start,
pageCharEnd: attrs.page_char_end,
lineStart: attrs.line_start,
lineEnd: attrs.line_end,
kind: attrs.kind,
imageUrl: attrs.image_url,
text,
// Durable backend ids (legacy XML omits both → preview gated off).
...(attrs.document_id ? { documentId: attrs.document_id } : {}),
...(attrs.chunk_id ? { backendChunkId: attrs.chunk_id } : {}),
});
}
match = CHUNK_RE.exec(raw);
}
CHUNK_RE.lastIndex = 0;
ATTR_RE.lastIndex = 0;
return out;
}
/** Fetch a backend image via the bearer-authed `authFetch`, expose it
* as a blob URL for `<img src>`. Cleans up the object URL on unmount. */
function useAuthedImageUrl(path: string | undefined): string | undefined {
const [url, setUrl] = useState<string | undefined>(undefined);
useEffect(() => {
if (!path) {
setUrl(undefined);
return;
}
let cancelled = false;
let objectUrl: string | undefined;
authFetch(path)
.then((response) => {
if (!response.ok) {
throw new Error(`image fetch ${response.status}`);
}
return response.blob();
})
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => {
if (!cancelled) setUrl(undefined);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [path]);
return url;
}
function ChunkImage({ url, alt }: { url: string; alt: string }) {
const blobUrl = useAuthedImageUrl(url);
if (!blobUrl) {
return (
<div className="mb-2 flex h-32 items-center justify-center rounded-md bg-muted/60 text-[10px] text-muted-foreground">
<ImageIcon className="mr-1.5 size-3" />
Loading image
</div>
);
}
return (
<img
src={blobUrl}
alt={alt}
className="mb-2 max-h-64 w-full rounded-md object-contain"
/>
);
}
function ChunkCard({ chunk }: { chunk: ParsedChunk }) {
const openPreview = usePreviewStore((s) => s.open);
const meta: string[] = [];
if (chunk.page) meta.push(`page ${chunk.page}`);
if (chunk.tokens) meta.push(`${chunk.tokens} tok`);
if (chunk.chunkIndex) meta.push(`#${chunk.chunkIndex}`);
if (chunk.kind && chunk.kind !== "text") meta.push(chunk.kind);
const documentId = chunk.documentId;
const backendChunkId = chunk.backendChunkId;
const isPreviewable = Boolean(documentId && backendChunkId);
const handleOpenPreview = useCallback(() => {
if (!(documentId && backendChunkId)) {
return;
}
Promise.resolve(
openPreview({
documentId,
backendChunkId,
}),
).catch(() => undefined);
}, [backendChunkId, documentId, openPreview]);
const sourceLabel = (
<>
<span className="rounded bg-foreground/10 px-1.5 py-0.5 font-mono text-[10px] font-semibold">
[{chunk.id}]
</span>
{chunk.kind === "image" ? (
<ImageIcon className="size-3 shrink-0 text-muted-foreground" />
) : (
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium" title={chunk.source}>
{chunk.source}
</span>
</>
);
return (
<div
data-slot="rag-chunk-card"
className="rounded-md border border-foreground/10 bg-muted/40 p-2.5 text-xs"
>
<div className="mb-1.5 flex items-center justify-between gap-2">
{isPreviewable ? (
<button
type="button"
className={cn(
"flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-left outline-none transition-colors",
"hover:text-primary focus-visible:ring-[3px] focus-visible:ring-ring/50",
)}
onClick={handleOpenPreview}
aria-label={`Open preview of ${chunk.source}`}
title="Open preview"
>
{sourceLabel}
</button>
) : (
<div className="flex min-w-0 items-center gap-1.5">{sourceLabel}</div>
)}
{meta.length > 0 ? (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{meta.join(" · ")}
</span>
) : null}
</div>
{chunk.kind === "image" && chunk.imageUrl ? (
<ChunkImage url={chunk.imageUrl} alt={chunk.source} />
) : null}
{chunk.text ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-foreground/80">
{chunk.text}
</pre>
) : null}
</div>
);
}
const SearchKnowledgeBaseToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const query = (args as { query?: string })?.query ?? "";
const isRunning = status?.type === "running";
const resultText = typeof result === "string" ? result : "";
const chunks = parseChunks(resultText);
const isErrorOrEmpty =
!isRunning && chunks.length === 0 && resultText.length > 0;
const hasText = useAuiState(({ message }) =>
message.content.some(
(p) =>
p.type === "text" &&
"text" in p &&
(p as { text: string }).text.length > 0,
),
);
const [open, setOpen] = useState(isRunning);
useEffect(() => {
if (isRunning) {
setOpen(true);
} else if (hasText) {
setOpen(false);
}
}, [isRunning, hasText]);
const triggerLabel = query
? `Searched docs: "${query}"`
: "Search knowledge base";
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={triggerLabel}
status={status}
icon={FileTextIcon}
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>
{query ? (
<>Retrieving for &ldquo;{query}&rdquo;&hellip;</>
) : (
<>Retrieving&hellip;</>
)}
</span>
</div>
) : chunks.length > 0 ? (
<div className="flex flex-col gap-1.5">
<div className="text-[10px] text-muted-foreground">
{chunks.length} chunk{chunks.length === 1 ? "" : "s"} retrieved
</div>
{chunks.map((chunk) => (
<ChunkCard key={chunk.id} chunk={chunk} />
))}
</div>
) : isErrorOrEmpty ? (
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs",
)}
>
{resultText}
</pre>
) : null}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const SearchKnowledgeBaseToolUI = memo(
SearchKnowledgeBaseToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
SearchKnowledgeBaseToolUI.displayName = "SearchKnowledgeBaseToolUI";

View file

@ -21,6 +21,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={(resolvedTheme as ToasterProps["theme"]) ?? "light"}
className="toaster group"
position="top-right"
duration={5000}
icons={{
success: (

View file

@ -1,7 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
type ParsedChunk,
parseChunks,
} from "@/components/assistant-ui/tool-ui-search-knowledge-base";
import { getAuthToken } from "@/features/auth";
import {
type SearchHit,
type SearchRequest,
listKBDocuments,
listThreadDocuments,
prefetchRag,
} from "@/features/rag/api/rag-api";
import { apiUrl } from "@/lib/api-base";
import { toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
@ -46,12 +57,12 @@ import type {
OpenAIReasoningContentPart,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
getStoredChatThread,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
parseAssistantContent,
@ -65,6 +76,7 @@ import {
streamChatCompletions,
validateModel,
} from "./chat-api";
import type { RagMode, RagSource } from "./chat-settings-api";
import {
createOpenAIContainer,
listOpenAIContainers,
@ -74,6 +86,71 @@ import {
isProviderKeyRotationError,
} from "./providers-api";
function extractMessageText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const out: string[] = [];
for (const part of content) {
if (
part &&
typeof part === "object" &&
(part as { type?: unknown }).type === "text" &&
typeof (part as { text?: unknown }).text === "string"
) {
out.push((part as { text: string }).text);
}
}
return out.join(" ");
}
function buildRagRequest(
source: RagSource,
query: string,
resolvedThreadId: string | undefined,
enableRerank: boolean,
topK: number,
minScore: number,
mode: RagMode,
): SearchRequest | null {
const base: SearchRequest = {
query,
top_k: topK,
mode,
enable_rerank: enableRerank,
min_score: minScore,
};
if (source.kind === "thread") {
if (!resolvedThreadId) return null;
return { ...base, thread_id: resolvedThreadId };
}
if (source.kind === "kb") {
return { ...base, kb_id: source.kbId };
}
return null;
}
function _xmlAttr(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
/** Format prefetched hits as `<chunk id="N" >` blocks the same shape the
* search_knowledge_base tool result uses, so `parseChunks` renders chunk
* cards and `extractCitedIds`/`buildDocumentSourceParts` map `[N]` citations
* back to them. ids are stable 1..N so the model's `[N]` lines resolve. */
function formatRagChunksXml(hits: SearchHit[]): string {
return hits
.map((h, i) => {
const id = i + 1;
const source = _xmlAttr(h.filename ?? `chunk ${h.chunk_index}`);
const pageAttr =
h.page_number != null ? ` page="${h.page_number}"` : "";
const idxAttr =
h.chunk_index != null ? ` chunk_index="${h.chunk_index}"` : "";
return `<chunk id="${id}" source="${source}"${pageAttr}${idxAttr}>\n${h.text}\n</chunk>`;
})
.join("\n\n");
}
/** Server-side usage data from llama-server (via stream_options.include_usage). */
interface ServerUsage {
prompt_tokens: number;
@ -314,6 +391,146 @@ function parseSourcesFromResult(raw: string): {
return sources;
}
interface DocumentSourcePart {
type: "source";
sourceType: "document";
id: string;
/** Display alias of `citationId`. Kept so the existing sources.tsx
* renderer keeps working; new code SHOULD use `citationId`. NEVER
* sent to backend as the durable chunk_id. */
chunkId: string;
/** Visible model-citation id (the `[N]` reference). Display only. */
citationId: string;
/** Durable `rag_documents.id` from tool XML `document_id=`. Null
* when the source came from legacy XML lacking the attribute;
* preview routing is gated off in that case. */
documentId: string | null;
/** Durable `rag_chunks.id` from tool XML `chunk_id=`. Null on
* legacy XML. Sent as `?chunk_id=` to `/preview-target`. */
backendChunkId: string | null;
filename: string;
page?: string;
sourcePageIndex?: string;
pageCharStart?: string;
pageCharEnd?: string;
lineStart?: string;
lineEnd?: string;
text: string;
}
/** Pull every `[N]` token the model wrote in its reply.
* Naive: regex over the whole text. False positives (e.g. `[1]` inside a
* code fence or list marker) are tolerated the worst case is a stray
* badge for an id that exists in the retrieval set. */
const CITATION_RE = /\[(\d+)\]/g;
function extractCitedIds(text: string): Set<string> {
const ids = new Set<string>();
let match: RegExpExecArray | null = CITATION_RE.exec(text);
while (match !== null) {
ids.add(match[1]);
match = CITATION_RE.exec(text);
}
CITATION_RE.lastIndex = 0;
return ids;
}
function indexChunksByCitationId(
allChunks: ParsedChunk[],
): Map<string, ParsedChunk> {
const byId = new Map<string, ParsedChunk>();
for (const chunk of allChunks) {
if (!byId.has(chunk.id)) {
byId.set(chunk.id, chunk);
}
}
return byId;
}
function documentSourceIds(
allChunks: ParsedChunk[],
citedIds: Set<string>,
requireCitations: boolean,
): string[] {
if (citedIds.size > 0) {
return Array.from(citedIds);
}
// Prefetch injects docs unconditionally, so zero citations means the model
// judged them irrelevant — emit no badges rather than falsely attributing an
// off-topic answer to every retrieved chunk. The tool path keeps the lenient
// fallback since the model itself chose to search.
if (requireCitations) {
return [];
}
return allChunks.map((chunk) => chunk.id);
}
function toDocumentSourcePart(
id: string,
chunk: ParsedChunk,
): DocumentSourcePart {
const part: DocumentSourcePart = {
type: "source",
sourceType: "document",
id: `rag-${id}`,
chunkId: id,
citationId: id,
documentId: chunk.documentId ?? null,
backendChunkId: chunk.backendChunkId ?? null,
filename: chunk.source,
text: chunk.text,
};
if (chunk.page) {
part.page = chunk.page;
}
if (chunk.sourcePageIndex) {
part.sourcePageIndex = chunk.sourcePageIndex;
}
if (chunk.pageCharStart) {
part.pageCharStart = chunk.pageCharStart;
}
if (chunk.pageCharEnd) {
part.pageCharEnd = chunk.pageCharEnd;
}
if (chunk.lineStart) {
part.lineStart = chunk.lineStart;
}
if (chunk.lineEnd) {
part.lineEnd = chunk.lineEnd;
}
return part;
}
/** Build doc-shaped source parts for chunks the model cited.
* `allChunks` is the flat union of every search_knowledge_base tool
* result in this turn (deduped by id). If the model forgets literal
* `[N]` ids, fall back to retrieved chunks so source chips remain
* visible and previewable. Hallucinated `[99]` refs without a matching
* chunk are silently dropped. */
function buildDocumentSourceParts(
allChunks: ParsedChunk[],
citedIds: Set<string>,
requireCitations: boolean,
): DocumentSourcePart[] {
const byId = indexChunksByCitationId(allChunks);
const idsToShow = documentSourceIds(allChunks, citedIds, requireCitations);
const out: DocumentSourcePart[] = [];
const emittedIds = new Set<string>();
for (const id of idsToShow) {
if (emittedIds.has(id)) {
continue;
}
const chunk = byId.get(id);
if (!chunk) {
continue;
}
emittedIds.add(id);
out.push(toDocumentSourcePart(id, chunk));
}
return out;
}
function estimateTokenCount(text: string): number | undefined {
const trimmed = text.trim();
if (!trimmed) {
@ -1470,14 +1687,158 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
);
}
const ragSource = runtime.ragSource;
const ragToolEnabled = runtime.ragToolEnabled;
// Even when RAG is toggled on, the tool + system-prompt nudge are
// useless if the active scope has no indexed documents — the model
// would call the tool, get back "no chunks", and waste a turn. Do
// a lightweight scope-has-docs check up front and treat the empty
// scope as effectively "off" for this turn.
let ragScopeHasDocs = false;
if (ragToolEnabled && ragSource.kind !== "off") {
try {
if (ragSource.kind === "kb") {
const docs = await listKBDocuments(ragSource.kbId);
ragScopeHasDocs = docs.length > 0;
} else if (ragSource.kind === "thread" && resolvedThreadId) {
const docs = await listThreadDocuments(resolvedThreadId);
ragScopeHasDocs = docs.length > 0;
}
} catch (err) {
// If the doc-list endpoint is unreachable we err on the side
// of letting the tool through — better to attempt retrieval
// and surface an error than to silently skip RAG.
console.warn("RAG scope-has-docs check failed:", err);
ragScopeHasDocs = true;
}
}
const ragToolPathTaken =
ragToolEnabled
&& supportsTools
&& !isExternalRequest
&& ragScopeHasDocs;
const safeSystemPrompt =
typeof params.systemPrompt === "string" ? params.systemPrompt : "";
const systemPromptParts: string[] = [];
if (safeSystemPrompt.trim()) {
systemPromptParts.push(safeSystemPrompt.trim());
}
if (ragToolPathTaken && ragSource.kind !== "off") {
systemPromptParts.push(
"RAG retrieval is enabled for this conversation. Before answering " +
"ANY user question (including follow-ups, clarifications, or " +
"questions you think you already know), you MUST:\n" +
"1. Plan UP TO 3 focused search queries up front that together " +
"cover the user's question. Prefer fewer when the question is " +
"narrow — one query is fine for a simple lookup. Do NOT exceed 3.\n" +
"2. Issue ALL planned `search_knowledge_base` calls before " +
"writing any prose. Phrase each query as a focused question, " +
"not a keyword bag.\n" +
"3. After the tool calls return, ground your reply in the " +
"returned <chunk> blocks. CITE each chunk you use with its " +
'LITERAL id attribute — e.g. `<chunk id="7">` is cited as ' +
"`[7]`. IDs are unique across the whole turn; never renumber, " +
"never reuse a different number.\n" +
"4. Answer primarily from the knowledge base. Only use web " +
"search or web fetch if the returned chunks genuinely do not " +
"contain the answer — do not use them to double-check or expand " +
"an answer the documents already support.\n\n" +
"HARD STOP RULES — these override anything else:\n" +
"• Answer ONLY the question the user literally asked. Do NOT " +
"invent follow-up questions for yourself, do NOT proactively " +
"explain related topics the user did not ask about, and do NOT " +
"branch into new searches after you start writing prose.\n" +
"• You get ONE answer block per user turn. After you write it, " +
"STOP. No second answer, no restatement, no \"and additionally\" " +
"section, no further tool calls.\n" +
"• If the first search returned usable chunks, do NOT call " +
"`search_knowledge_base` again in this turn. The only reason to " +
"call it more than once is if your initial batch of up-to-3 " +
"queries from step 1 has not yet been issued.",
);
}
if (systemPromptParts.length > 0) {
outboundMessages.unshift({
role: "system",
content: safeSystemPrompt.trim(),
content: systemPromptParts.join("\n\n"),
});
}
// External-provider RAG prefetch. External providers can't run the
// local search_knowledge_base tool loop, so studio retrieves up front
// (the backend decomposes the question via the helper model), injects
// the chunks into the user prompt, and surfaces it as a synthetic tool
// call. Local models keep the tool path above. Gated on the same
// scope-has-docs check, so no docs → no prefetch (model answers plainly,
// preserving prior external behavior).
let ragPrefetchedThisTurn = false;
let ragPrefetchSynthetic: {
toolCallId: string;
query: string;
chunkXml: string;
} | null = null;
if (
isExternalRequest &&
ragToolEnabled &&
ragSource.kind !== "off" &&
ragScopeHasDocs
) {
const lastUser = [...outboundMessages]
.reverse()
.find((m) => m.role === "user");
const queryText = lastUser
? extractMessageText(lastUser.content)
: "";
const ragReq =
lastUser && queryText.trim()
? buildRagRequest(
ragSource,
queryText,
resolvedThreadId,
runtime.enableRerank,
runtime.ragTopK,
runtime.ragMinScore,
runtime.ragMode,
)
: null;
if (lastUser && ragReq) {
try {
const result = await prefetchRag(ragReq);
if (result.hits.length > 0) {
const chunkXml = formatRagChunksXml(result.hits);
const injected =
"Use the document excerpts below to answer the question, and " +
"cite each excerpt you use as `[N]` using its `id`. " +
"If they are not relevant to the question, IGNORE them " +
"silently and answer normally — do NOT mention the " +
"excerpts, do NOT say they are irrelevant, do NOT refer to " +
"any search or retrieval. Just answer the question " +
"directly as if no excerpts were provided.\n\n" +
chunkXml;
// Send-only mutation: the displayed user bubble comes from the
// runtime message store, not outboundMessages, so the injected
// chunks are invisible to the user.
if (typeof lastUser.content === "string") {
lastUser.content = `${lastUser.content}\n\n${injected}`;
} else if (Array.isArray(lastUser.content)) {
(
lastUser.content as Array<{ type: string; text?: string }>
).push({ type: "text", text: `\n\n${injected}` });
}
ragPrefetchedThisTurn = true;
ragPrefetchSynthetic = {
toolCallId: `prefetch_rag_${Date.now()}`,
query: result.queries.join(" / ") || queryText,
chunkXml,
};
}
} catch (err) {
console.warn("RAG prefetch failed:", err);
}
}
}
let disabledToolGuard: string | null = null;
const disabledToolGuardProviderType = externalProvider?.providerType;
if (
@ -1536,6 +1897,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
"inform the user that you do not have access to these capabilities. " +
"Do not return tool-call syntax inside your response.";
}
// RAG capability axis (extends PR #5674's disabled-tool guard).
// When RAG context was prefetched this turn, point the model at the
// injected excerpts so it doesn't claim it lacks document access;
// otherwise reinforce that it has no document-search capability.
if (ragPrefetchedThisTurn) {
if (disabledToolGuard) {
disabledToolGuard +=
" However, relevant document excerpts have been included in the " +
"user's message — use them to answer and cite each as [N] by its id.";
}
} else {
const noRag =
"You do not have document search or knowledge base (RAG) " +
"capabilities in this conversation. Do not claim to have searched " +
"or accessed the user's documents.";
disabledToolGuard = disabledToolGuard
? `${disabledToolGuard} ${noRag}`
: noRag;
}
}
if (disabledToolGuard) {
const firstMessage = outboundMessages[0];
@ -1706,6 +2086,21 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let reasoningContentOpen = false;
// Tool call parts, cumulative; result lands on tool_end.
const toolCallParts: ToolCallMessagePart[] = [];
// External-provider RAG prefetch (above) ran studio-side retrieval and
// produced chunks. Seed a synthetic search_knowledge_base tool-call part
// so the existing tool UI renders chunk cards and the end-of-stream
// source-badge logic (which reads search_knowledge_base results from
// toolCallParts) emits [N] citation badges — no model tool call needed.
if (ragPrefetchSynthetic) {
toolCallParts.push({
type: "tool-call" as const,
toolCallId: ragPrefetchSynthetic.toolCallId,
toolName: "search_knowledge_base",
argsText: JSON.stringify({ query: ragPrefetchSynthetic.query }),
args: { query: ragPrefetchSynthetic.query },
result: ragPrefetchSynthetic.chunkXml,
});
}
// Latest Gemini text-part thoughtSignature; pinned onto the final
// text MessagePart so next-turn replay carries it.
let latestTextThoughtSignature: string | undefined;
@ -1843,7 +2238,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
? reasoningEffort
: "low";
const externalReasoningEnabled =
!externalReasoningCaps.supportsReasoningOff ? true : reasoningEnabled;
externalReasoningCaps.supportsReasoningOff ? reasoningEnabled : true;
const buildRequestPayload = async (
forceRefreshPublicKey = false,
): Promise<OpenAIChatCompletionsRequest> => {
@ -2035,7 +2430,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
externalProvider.enablePromptCaching ?? true,
}
: {}),
// Anthropic prompt-cache TTL; unknown values no-op on backend.
// Anthropic-only: pass the cache TTL the user picked in
// Configuration → Provider. Omitted = inherit the default
// 5-minute pool. The backend's `_stream_anthropic` only
// attaches `cache_control.ttl` when the value is one of
// "5m" / "1h" (see external_provider.py near line 1375),
// so unknown values are a no-op end-to-end.
...(supportsProviderPromptCacheTtl(
externalProvider.providerType,
) &&
@ -2093,18 +2493,50 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(supportsPreserveThinking
? { preserve_thinking: preserveThinking }
: {}),
...(supportsTools && (toolsEnabled || codeToolsEnabled || mcpEnabledForChat)
...(supportsTools &&
(toolsEnabled ||
codeToolsEnabled ||
ragToolPathTaken ||
mcpEnabledForChat)
? {
enable_tools: true,
enabled_tools: [
// RAG goes first so the model sees it before any other
// tool when scanning the spec list.
...(ragToolPathTaken ? ["search_knowledge_base"] : []),
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
],
// Per-request scope for the LLM-invoked tool; tool path only.
...(ragToolPathTaken
? {
rag_scope: {
kb_id:
ragSource.kind === "kb" ? ragSource.kbId : null,
thread_id:
ragSource.kind === "thread"
? (resolvedThreadId ?? null)
: null,
enable_rerank: runtime.enableRerank,
default_top_k: runtime.ragTopK,
min_score: runtime.ragMinScore,
mode: runtime.ragMode,
},
}
: {}),
mcp_enabled: mcpEnabledForChat,
auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls,
max_tool_calls_per_message:
useChatRuntimeStore.getState().maxToolCallsPerMessage,
// With RAG active the model only needs up to 3 retrieval
// calls; cap low so it can't spiral into web search / fetch
// after already answering from the documents. Off-RAG turns
// keep the user's full budget.
max_tool_calls_per_message: ragToolPathTaken
? Math.min(
useChatRuntimeStore.getState().maxToolCallsPerMessage,
6,
)
: useChatRuntimeStore.getState().maxToolCallsPerMessage,
tool_call_timeout: (() => {
const mins = useChatRuntimeStore.getState().toolCallTimeout;
return mins >= 9999 ? 9999 : mins * 60;
@ -2604,11 +3036,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
if (reasoning) {
if (!reasoningContentOpen) {
if (reasoningContentOpen) {
cumulativeText += reasoning;
} else {
cumulativeText += `<think>${reasoning}`;
reasoningContentOpen = true;
} else {
cumulativeText += reasoning;
}
}
if (delta) {
@ -2686,7 +3118,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// tool calls. Both emit the same `Title:` / `URL:` / `Snippet:`
// block shape from the Anthropic backend, so the parser does
// not need to branch on tool name.
const sourceParts = toolCallParts.flatMap((tc) => {
const urlSourceParts = toolCallParts.flatMap((tc) => {
if (
(tc.toolName !== "web_search" && tc.toolName !== "web_fetch") ||
!tc.result
@ -2698,6 +3130,50 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
);
});
// RAG: flatten chunks across every search_knowledge_base call this
// turn, then emit previewable doc-source chips for cited chunks.
// If the model omits literal [N] ids, show the retrieved chunks so
// the answer still has a visible citation/preview affordance.
const ragChunks = toolCallParts.flatMap((tc) => {
if (tc.toolName !== "search_knowledge_base" || !tc.result) {
return [];
}
return parseChunks(typeof tc.result === "string" ? tc.result : "");
});
const citedIds = extractCitedIds(cumulativeText);
const documentSourceParts =
ragChunks.length > 0
? buildDocumentSourceParts(
ragChunks,
citedIds,
// Prefetched (external) chunks were injected unconditionally,
// so require explicit citations before attributing sources.
ragPrefetchedThisTurn,
)
: [];
// Prefetch surfaces a synthetic search_knowledge_base card every turn.
// If the model cited none of the injected chunks it judged them
// irrelevant, so drop the card from the final (persisted) content —
// same rationale as the suppressed source badges above.
if (ragPrefetchSynthetic && citedIds.size === 0) {
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === ragPrefetchSynthetic?.toolCallId,
);
if (idx !== -1) {
toolCallParts.splice(idx, 1);
}
}
// SDK's SourceMessagePart only types `sourceType: "url"` with a
// required `url` field. SourcesGroup branches on `sourceType` at
// runtime, so cast the doc-shaped parts through `unknown` rather
// than weakening the helper's strict typing.
const sourceParts = [
...urlSourceParts,
...(documentSourceParts as unknown as typeof urlSourceParts),
];
const meta = serverMetadata;
const finalTokenCount =
meta?.usage?.completion_tokens ?? estimateTokenCount(cumulativeText);

View file

@ -15,6 +15,13 @@ export interface PersistedChatPreset {
params: PersistedInferenceParams;
}
export type RagSource =
| { kind: "off" }
| { kind: "thread" }
| { kind: "kb"; kbId: string };
export type RagMode = "bm25" | "dense" | "hybrid";
export interface PersistedChatSettings {
inferenceParams?: PersistedInferenceParams;
customPresets?: PersistedChatPreset[];
@ -26,6 +33,13 @@ export interface PersistedChatSettings {
autoHealToolCalls?: boolean;
maxToolCallsPerMessage?: number;
toolCallTimeout?: number;
ragSource?: RagSource;
ragMode?: RagMode;
enableRerank?: boolean;
ragTopK?: number;
ragMinScore?: number;
ragIndexConcurrency?: number;
ragCaptionImages?: boolean;
}
interface ChatSettingsResponse {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { FileTextIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
import type { PendingDoc } from "../hooks/use-thread-doc-uploads";
interface PendingDocChipsProps {
docs: PendingDoc[];
onRemove: (id: string) => void;
}
export const PendingDocChips: FC<PendingDocChipsProps> = ({ docs, onRemove }) => {
if (docs.length === 0) return null;
return (
<div className="mb-2 flex w-full flex-row flex-wrap items-center gap-2 px-1.5 pt-0.5 pb-1">
{docs.map((doc) => {
const statusLabel =
doc.status === "uploading"
? "Uploading…"
: doc.status === "ingesting"
? "Indexing…"
: doc.status === "error"
? (doc.errorMessage ?? "Failed")
: "Ready";
const statusClass =
doc.status === "error"
? "text-destructive"
: doc.status === "ready"
? "text-muted-foreground"
: "text-muted-foreground italic";
return (
<div
key={doc.id}
className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs"
>
<FileTextIcon className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="max-w-48 truncate">{doc.file.name}</span>
<span className={cn("text-[10px] leading-tight", statusClass)}>
{statusLabel}
</span>
</div>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => onRemove(doc.id)}
aria-label="Remove document"
>
<XIcon className="size-3.5" />
</button>
</div>
);
})}
</div>
);
};

View file

@ -718,6 +718,11 @@ export function useChatModelRuntime() {
loadedIsMultimodal: isMultimodalResponse(loadResponse),
activeNativePathToken: nativePathToken ?? null,
});
// Reset RAG to off on every successful model load so the
// user always opts in explicitly per session. Goes through
// the setter so the persisted toggle in localStorage is
// kept in sync (setState alone would skip the saveBool).
useChatRuntimeStore.getState().setRagToolEnabled(false);
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
if (
modelId.toLowerCase().includes("qwen3") &&

View file

@ -0,0 +1,330 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useAui } from "@assistant-ui/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { cancelJob, subscribeToJobEvents } from "@/features/rag/api/rag-api";
import { useIndexProgressStore } from "@/features/rag/stores/index-progress-store";
import { useRagStore } from "@/features/rag/stores/rag-store";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { acquireIndexSlot, releaseIndexSlot } from "../utils/rag-index-queue";
export type PendingDoc = {
id: string;
file: File;
status: "uploading" | "ingesting" | "ready" | "error";
jobId?: string;
documentId?: string;
errorMessage?: string;
};
const DOCUMENT_EXTENSIONS = new Set([
".pdf",
".txt",
".md",
".markdown",
".docx",
".html",
".htm",
]);
export const DOCUMENT_ACCEPT =
".pdf,.txt,.md,.markdown,.docx,.html,.htm,application/pdf,text/plain,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/html";
export function isDocumentFile(file: File): boolean {
const lower = file.name.toLowerCase();
const dot = lower.lastIndexOf(".");
if (dot < 0) return false;
return DOCUMENT_EXTENSIONS.has(lower.slice(dot));
}
export interface UseThreadDocUploadsResult {
pendingDocs: PendingDoc[];
addDoc: (file: File) => void;
removeDoc: (id: string) => void;
clearDocs: () => void;
isIndexing: boolean;
}
/** RAG upload from the composer "+" button. Routes to whichever source is
* currently selected in the Retrieval dropdown: KB uploads to that KB;
* thread or off uploads to (and lazy-initializes) the current chat
* thread, then flips source to "thread" on first ingest if it was "off". */
export function useThreadDocUploads(): UseThreadDocUploadsResult {
const aui = useAui();
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const [pendingDocs, setPendingDocs] = useState<PendingDoc[]>([]);
// Track the scope key per chip so removeDoc can dispatch the right
// delete (KB docs vs thread docs use different scope keys in the store).
const [chipScopeKeys, setChipScopeKeys] = useState<Record<string, string>>(
{},
);
// Brand-new chats have no backend thread until the first message is sent.
// Initialize the current local thread to mint a remoteId so RAG uploads
// can attach before-send; the saved thread row gets created on first send.
const ensureThreadId = useCallback(async (): Promise<string | null> => {
const stored = useChatRuntimeStore.getState().activeThreadId;
if (stored) return stored;
try {
const runtime = aui.threads().__internal_getAssistantRuntime?.();
if (!runtime) return null;
const localId = runtime.threads.getState().mainThreadId;
if (!localId) return null;
const { remoteId } = await runtime.threads
.getItemById(localId)
.initialize();
useChatRuntimeStore.getState().setActiveThreadId(remoteId);
return remoteId;
} catch {
return null;
}
}, [aui]);
const addDoc = useCallback(
(file: File) => {
const localChipId = crypto.randomUUID();
// Lifecycle state shared between the upload flow and the cancel thunk.
// The cancel thunk closes over these `let`s by reference, so it always
// sees the latest job/document ids no matter when the user cancels.
const abort = new AbortController();
let jobId: string | undefined;
let documentId: string | undefined;
let scopeKey: string | null = null;
let unsubscribe: (() => void) | undefined;
let slotAcquired = false;
let slotReleased = false;
let cleaned = false;
const releaseSlot = () => {
if (slotAcquired && !slotReleased) {
slotReleased = true;
releaseIndexSlot();
}
};
const removeChip = () => {
setPendingDocs((prev) => prev.filter((d) => d.id !== localChipId));
setChipScopeKeys((m) => {
const { [localChipId]: _gone, ...rest } = m;
return rest;
});
};
// Stop the backend job (if started) and delete its document so the
// index resets. Idempotent: both a late in-flight abort and the toast
// cancel can reach here.
const cleanupBackend = async () => {
if (cleaned) return;
cleaned = true;
if (jobId) await cancelJob(jobId);
if (documentId && scopeKey) {
try {
await useRagStore.getState().deleteDocument(documentId, scopeKey);
} catch {}
}
};
setPendingDocs((prev) => [
...prev,
{ id: localChipId, file, status: "uploading" },
]);
// Register in the aggregate-progress store now (synchronously, for the
// whole batch) so the single toast counts queued files too.
const indexProgress = useIndexProgressStore.getState();
indexProgress.add(localChipId, file.name);
indexProgress.setCancel(localChipId, async () => {
abort.abort();
unsubscribe?.();
releaseSlot();
await cleanupBackend();
removeChip();
});
void (async () => {
// Hold an indexing slot for this document's whole lifecycle so bulk /
// folder uploads drain at the configured concurrency instead of
// spawning every ingestion at once. Released on every terminal path.
await acquireIndexSlot();
slotAcquired = true;
if (abort.signal.aborted) {
releaseSlot();
return;
}
indexProgress.setIndexing(localChipId);
const ragSource = useChatRuntimeStore.getState().ragSource;
let scope:
| { kind: "kb"; kbId: string }
| { kind: "thread"; threadId: string }
| null = null;
if (ragSource.kind === "kb") {
scope = { kind: "kb", kbId: ragSource.kbId };
scopeKey = `kb:${ragSource.kbId}`;
} else {
// ragSource is "thread" or "off" — fall back to thread.
const threadId = await ensureThreadId();
if (abort.signal.aborted) {
releaseSlot();
return;
}
if (!threadId) {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? {
...d,
status: "error",
errorMessage: "Could not create thread for upload",
}
: d,
),
);
toast.error("Could not create thread for upload");
indexProgress.setError(localChipId);
releaseSlot();
return;
}
scope = { kind: "thread", threadId };
scopeKey = `thread:${threadId}`;
}
setChipScopeKeys((m) => ({ ...m, [localChipId]: scopeKey as string }));
const uploadDocument = useRagStore.getState().uploadDocument;
const captionImages =
useChatRuntimeStore.getState().ragCaptionImages;
try {
const {
documentId: did,
jobId: jid,
alreadyIndexed,
} = await uploadDocument(scope, file, captionImages);
documentId = did;
jobId = jid;
if (abort.signal.aborted) {
// Cancelled while the upload was in flight: the document now
// exists on the backend, so tear it down here.
releaseSlot();
await cleanupBackend();
removeChip();
return;
}
if (alreadyIndexed) {
// Identical file already in this scope — no re-index. If a
// chip for this document already exists, drop the one we just
// added so the composer doesn't show the same doc twice;
// otherwise mark this chip ready.
setPendingDocs((prev) => {
const dupExists = prev.some(
(d) => d.id !== localChipId && d.documentId === did,
);
if (dupExists) {
return prev.filter((d) => d.id !== localChipId);
}
return prev.map((d) =>
d.id === localChipId
? { ...d, status: "ready", documentId: did }
: d,
);
});
toast.info(`${file.name} is already indexed`);
if (
scope?.kind === "thread" &&
useChatRuntimeStore.getState().ragSource.kind === "off"
) {
useChatRuntimeStore.getState().setRagSource({ kind: "thread" });
}
indexProgress.setReady(localChipId);
releaseSlot();
return;
}
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "ingesting", jobId: jid, documentId: did }
: d,
),
);
unsubscribe = subscribeToJobEvents(jid, {
onEvent: (event) => {
if (event.type === "progress") {
indexProgress.setProgress(localChipId, event.progress);
} else if (event.type === "complete") {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId ? { ...d, status: "ready" } : d,
),
);
// First ingest in an off-source chat: flip to thread so
// the model has somewhere to search. KB uploads don't
// need this — source is already a KB.
if (
scope?.kind === "thread" &&
useChatRuntimeStore.getState().ragSource.kind === "off"
) {
useChatRuntimeStore
.getState()
.setRagSource({ kind: "thread" });
}
indexProgress.setReady(localChipId, event.num_chunks);
releaseSlot();
} else if (event.type === "cancelled") {
releaseSlot();
} else if (event.type === "error") {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "error", errorMessage: event.error }
: d,
),
);
indexProgress.setError(localChipId);
releaseSlot();
}
},
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Upload failed";
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "error", errorMessage: message }
: d,
),
);
toast.error(`Document upload failed: ${message}`);
indexProgress.setError(localChipId);
releaseSlot();
}
})();
},
[ensureThreadId],
);
const removeDoc = useCallback(
(id: string) => {
setPendingDocs((prev) => {
const doc = prev.find((d) => d.id === id);
if (doc?.documentId) {
const scopeKey =
chipScopeKeys[id] ?? `thread:${activeThreadId ?? ""}`;
void useRagStore
.getState()
.deleteDocument(doc.documentId, scopeKey)
.catch(() => {});
}
return prev.filter((d) => d.id !== id);
});
setChipScopeKeys((m) => {
const { [id]: _gone, ...rest } = m;
return rest;
});
},
[activeThreadId, chipScopeKeys],
);
const clearDocs = useCallback(() => setPendingDocs([]), []);
const isIndexing = pendingDocs.some(
(d) => d.status === "uploading" || d.status === "ingesting",
);
return { pendingDocs, addDoc, removeDoc, clearDocs, isIndexing };
}

View file

@ -23,7 +23,9 @@ import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
BookOpenIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
LightbulbIcon,
@ -35,6 +37,10 @@ import {
} from "lucide-react";
import { Image03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cancelJob, subscribeToJobEvents } from "@/features/rag/api/rag-api";
import { useIndexProgressStore } from "@/features/rag/stores/index-progress-store";
import { useRagStore } from "@/features/rag/stores/rag-store";
import { acquireIndexSlot, releaseIndexSlot } from "./utils/rag-index-queue";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
@ -81,6 +87,32 @@ export interface CompareHandle {
}
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
const DOCUMENT_ACCEPT = ".pdf,.txt,.md,.markdown,.docx,.html,.htm";
const DOCUMENT_EXTENSIONS = new Set([
".pdf",
".txt",
".md",
".markdown",
".docx",
".html",
".htm",
]);
function isDocumentFile(file: File): boolean {
const lower = file.name.toLowerCase();
const dot = lower.lastIndexOf(".");
if (dot < 0) return false;
return DOCUMENT_EXTENSIONS.has(lower.slice(dot));
}
type PendingDoc = {
id: string;
file: File;
status: "uploading" | "ingesting" | "ready" | "error";
jobId?: string;
documentId?: string;
errorMessage?: string;
};
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
function isNativeComposing(event: Event) {
@ -304,6 +336,7 @@ export function SharedComposer({
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [pendingDocs, setPendingDocs] = useState<PendingDoc[]>([]);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -316,6 +349,10 @@ export function SharedComposer({
const checkpoint = s.params.checkpoint;
return s.models.find((m) => m.id === checkpoint);
});
const aui = useAui();
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const connectionsEnabled = useExternalProvidersStore(
(s) => s.connectionsEnabled,
@ -350,6 +387,8 @@ export function SharedComposer({
const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled,
);
const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled);
const setRagToolEnabled = useChatRuntimeStore((s) => s.setRagToolEnabled);
const webFetchToolsEnabled = useChatRuntimeStore(
(s) => s.webFetchToolsEnabled,
);
@ -481,6 +520,12 @@ export function SharedComposer({
// Images pill is only ever lit on OpenAI cloud's Responses-API models
// and Gemini Nano Banana family. No local tool runtime fallback.
const showImagePill = supportsBuiltinImageGeneration;
// Local models run RAG through the search_knowledge_base tool loop, so
// they need tool-calling. External providers use the prefetch path
// (studio retrieves + injects, no tool loop), so RAG is allowed for them
// regardless of the local supportsTools flag.
const ragDisabled =
!modelLoaded || (!supportsTools && !isExternalModel);
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
@ -518,6 +563,228 @@ export function SharedComposer({
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
const ensureThreadId = useCallback(async (): Promise<string | null> => {
const stored = useChatRuntimeStore.getState().activeThreadId;
if (stored) return stored;
try {
const runtime = aui.threads().__internal_getAssistantRuntime?.();
if (!runtime) return null;
const localId = runtime.threads.getState().mainThreadId;
if (!localId) return null;
const { remoteId } = await runtime.threads
.getItemById(localId)
.initialize();
useChatRuntimeStore.getState().setActiveThreadId(remoteId);
return remoteId;
} catch {
return null;
}
}, [aui]);
// Composer "+" upload — routes to whichever scope the Retrieval
// dropdown currently points at (KB or thread). Keeps "what you see is
// what you upload to" so users don't get silent thread-vs-KB mismatches.
const addDoc = useCallback(
(file: File) => {
const localChipId = crypto.randomUUID();
// Lifecycle state shared between the upload flow and the cancel thunk;
// the thunk closes over these `let`s so it sees the latest ids whenever
// the user cancels.
const abort = new AbortController();
let jobId: string | undefined;
let documentId: string | undefined;
let scopeKey: string | null = null;
let unsubscribe: (() => void) | undefined;
let slotAcquired = false;
let slotReleased = false;
let cleaned = false;
const releaseSlot = () => {
if (slotAcquired && !slotReleased) {
slotReleased = true;
releaseIndexSlot();
}
};
const removeChip = () => {
setPendingDocs((prev) => prev.filter((d) => d.id !== localChipId));
};
const cleanupBackend = async () => {
if (cleaned) return;
cleaned = true;
if (jobId) await cancelJob(jobId);
if (documentId && scopeKey) {
try {
await useRagStore.getState().deleteDocument(documentId, scopeKey);
} catch {}
}
};
setPendingDocs((prev) => [
...prev,
{ id: localChipId, file, status: "uploading" },
]);
// Register in the aggregate-progress store now (whole batch) so the
// single toast counts queued files too.
const indexProgress = useIndexProgressStore.getState();
indexProgress.add(localChipId, file.name);
indexProgress.setCancel(localChipId, async () => {
abort.abort();
unsubscribe?.();
releaseSlot();
await cleanupBackend();
removeChip();
});
void (async () => {
// Hold an indexing slot for the document's whole lifecycle so bulk /
// folder uploads drain at the configured concurrency. Released on
// every terminal path below.
await acquireIndexSlot();
slotAcquired = true;
if (abort.signal.aborted) {
releaseSlot();
return;
}
indexProgress.setIndexing(localChipId);
const ragSource = useChatRuntimeStore.getState().ragSource;
let scope:
| { kind: "kb"; kbId: string }
| { kind: "thread"; threadId: string }
| null = null;
if (ragSource.kind === "kb") {
scope = { kind: "kb", kbId: ragSource.kbId };
scopeKey = `kb:${ragSource.kbId}`;
} else {
const threadId = await ensureThreadId();
if (abort.signal.aborted) {
releaseSlot();
return;
}
if (!threadId) {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? {
...d,
status: "error",
errorMessage: "Could not create thread for upload",
}
: d,
),
);
toast.error("Could not create thread for upload");
indexProgress.setError(localChipId);
releaseSlot();
return;
}
scope = { kind: "thread", threadId };
scopeKey = `thread:${threadId}`;
}
const uploadDocument = useRagStore.getState().uploadDocument;
const captionImages =
useChatRuntimeStore.getState().ragCaptionImages;
try {
const {
documentId: did,
jobId: jid,
alreadyIndexed,
} = await uploadDocument(scope, file, captionImages);
documentId = did;
jobId = jid;
if (abort.signal.aborted) {
// Cancelled while uploading: the document now exists on the
// backend, so tear it down here.
releaseSlot();
await cleanupBackend();
removeChip();
return;
}
if (alreadyIndexed) {
// Drop the just-added chip if this doc is already represented
// so the composer never shows the same document twice.
setPendingDocs((prev) => {
const dupExists = prev.some(
(d) => d.id !== localChipId && d.documentId === did,
);
if (dupExists) {
return prev.filter((d) => d.id !== localChipId);
}
return prev.map((d) =>
d.id === localChipId
? { ...d, status: "ready", documentId: did }
: d,
);
});
toast.info(`${file.name} is already indexed`);
if (
scope?.kind === "thread" &&
useChatRuntimeStore.getState().ragSource.kind === "off"
) {
useChatRuntimeStore.getState().setRagSource({ kind: "thread" });
}
indexProgress.setReady(localChipId);
releaseSlot();
return;
}
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "ingesting", jobId: jid, documentId: did }
: d,
),
);
unsubscribe = subscribeToJobEvents(jid, {
onEvent: (event) => {
if (event.type === "progress") {
indexProgress.setProgress(localChipId, event.progress);
} else if (event.type === "complete") {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId ? { ...d, status: "ready" } : d,
),
);
if (
scope?.kind === "thread" &&
useChatRuntimeStore.getState().ragSource.kind === "off"
) {
useChatRuntimeStore
.getState()
.setRagSource({ kind: "thread" });
}
indexProgress.setReady(localChipId, event.num_chunks);
releaseSlot();
} else if (event.type === "cancelled") {
releaseSlot();
} else if (event.type === "error") {
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "error", errorMessage: event.error }
: d,
),
);
indexProgress.setError(localChipId);
releaseSlot();
}
},
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Upload failed";
setPendingDocs((prev) =>
prev.map((d) =>
d.id === localChipId
? { ...d, status: "error", errorMessage: message }
: d,
),
);
toast.error(`Document upload failed: ${message}`);
indexProgress.setError(localChipId);
releaseSlot();
}
})();
},
[ensureThreadId],
);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
@ -533,7 +800,10 @@ export function SharedComposer({
});
continue;
}
// Handle image files
if (isDocumentFile(file)) {
addDoc(file);
continue;
}
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
@ -546,12 +816,28 @@ export function SharedComposer({
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore, attachUnavailableReason]);
}, [setPendingAudioStore, attachUnavailableReason, addDoc]);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
const removePendingDoc = useCallback((id: string) => {
setPendingDocs((prev) => {
const doc = prev.find((d) => d.id === id);
if (doc?.documentId) {
void useRagStore
.getState()
.deleteDocument(
doc.documentId,
`thread:${activeThreadId ?? ""}`,
)
.catch(() => {});
}
return prev.filter((d) => d.id !== id);
});
}, [activeThreadId]);
function clearStuckImeTimer() {
if (stuckImeTimerRef.current) {
clearTimeout(stuckImeTimerRef.current);
@ -639,6 +925,8 @@ export function SharedComposer({
setPendingImages([]);
setPendingAudio(null);
clearPendingAudioStore();
// Docs stay in backend; drop chips only.
setPendingDocs([]);
textareaRef.current?.focus();
// Generalized compare: load each model before dispatching to its side
@ -820,7 +1108,17 @@ export function SharedComposer({
}
}
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
const docsIndexing = pendingDocs.some(
(d) => d.status === "uploading" || d.status === "ingesting",
);
const canSend =
(text.trim().length > 0 ||
pendingImages.length > 0 ||
pendingAudio !== null ||
pendingDocs.length > 0) &&
!busy &&
!isComposing &&
!docsIndexing;
return (
<div
@ -840,7 +1138,9 @@ export function SharedComposer({
addFiles(e.dataTransfer.files);
}}
>
{(pendingImages.length > 0 || pendingAudio) && (
{(pendingImages.length > 0 ||
pendingAudio ||
pendingDocs.length > 0) && (
<div className="mb-2 flex w-full flex-row flex-wrap items-center gap-2 px-1.5 pt-0.5 pb-1">
{pendingImages.map(({ id, file }) => (
<PendingImageThumb
@ -849,6 +1149,44 @@ export function SharedComposer({
onRemove={() => removePendingImage(id)}
/>
))}
{pendingDocs.map((doc) => {
const statusLabel =
doc.status === "uploading"
? "Uploading…"
: doc.status === "ingesting"
? "Indexing…"
: doc.status === "error"
? doc.errorMessage ?? "Failed"
: "Ready";
const statusClass =
doc.status === "error"
? "text-destructive"
: doc.status === "ready"
? "text-muted-foreground"
: "text-muted-foreground italic";
return (
<div
key={doc.id}
className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs"
>
<FileTextIcon className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="max-w-48 truncate">{doc.file.name}</span>
<span className={cn("text-[10px] leading-tight", statusClass)}>
{statusLabel}
</span>
</div>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => removePendingDoc(doc.id)}
aria-label="Remove document"
>
<XIcon className="size-3.5" />
</button>
</div>
);
})}
{pendingAudio && (
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">
<HeadphonesIcon className="size-3.5 text-muted-foreground" />
@ -901,7 +1239,7 @@ export function SharedComposer({
<input
ref={fileInputRef}
type="file"
accept={IMAGE_ACCEPT}
accept={`${IMAGE_ACCEPT},${DOCUMENT_ACCEPT}`}
multiple
className="hidden"
onChange={(e) => {
@ -1157,6 +1495,31 @@ export function SharedComposer({
<span>Images</span>
</button>
)}
{/* Master RAG toggle; sidebar Retrieval section configures the rest. */}
<button
type="button"
disabled={ragDisabled}
onClick={() => {
const next = !ragToolEnabled;
setRagToolEnabled(next);
if (next && ragSource.kind === "off") {
setRagSource({ kind: "thread" });
}
}}
className="composer-pill-btn"
data-active={ragToolEnabled && !ragDisabled ? "true" : "false"}
aria-label={ragToolEnabled ? "Disable RAG" : "Enable RAG"}
title={
ragDisabled
? "RAG needs a model that supports tool calling"
: ragToolEnabled
? "RAG on — the model can search your attached documents"
: "Enable RAG — let the model search your documents"
}
>
<BookOpenIcon className="size-3.5" />
<span>RAG</span>
</button>
{showWebFetchPill && (
<button
type="button"

View file

@ -21,12 +21,14 @@ import {
loadChatSettingsWithLegacyImport,
savePersistedChatSettingsPatch,
} from "../utils/chat-settings-storage";
import type { RagMode, RagSource } from "../api/chat-settings-api";
const HF_TOKEN_KEY = "unsloth_hf_token";
export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
export const CHAT_RAG_TOOL_ENABLED_KEY = "unsloth_chat_rag_tool_enabled";
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
@ -298,6 +300,7 @@ type ChatRuntimeStore = {
*/
supportsBuiltinWebFetch: boolean;
toolsEnabled: boolean;
ragToolEnabled: boolean;
codeToolsEnabled: boolean;
imageToolsEnabled: boolean;
mcpEnabledForChat: boolean;
@ -338,6 +341,19 @@ type ChatRuntimeStore = {
} | null;
modelLoading: boolean;
activeNativePathToken: string | null;
ragSource: RagSource;
ragMode: RagMode;
enableRerank: boolean;
ragTopK: number;
// Cosine floor; 0 disables. Set > 0 to drop off-topic hits.
ragMinScore: number;
// Max documents indexed in parallel (bulk/folder uploads drain at this
// rate). 1 = sequential. Keeps many concurrent ingestion subprocesses
// from thrashing the GPU/CPU.
ragIndexConcurrency: number;
// Caption figures/images during ingestion (default on). Off skips the VLM
// captioning pass for faster, text-only indexing.
ragCaptionImages: boolean;
hydratePersistedSettings: () => Promise<void>;
setModelLoading: (loading: boolean) => void;
setModelRequiresTrustRemoteCode: (required: boolean) => void;
@ -387,6 +403,14 @@ type ChatRuntimeStore = {
) => void;
clearPendingImageEditReference: () => void;
setContextUsage: (usage: ChatRuntimeStore["contextUsage"]) => void;
setRagSource: (source: RagSource) => void;
setRagMode: (mode: RagMode) => void;
setEnableRerank: (value: boolean) => void;
setRagTopK: (value: number) => void;
setRagMinScore: (value: number) => void;
setRagIndexConcurrency: (value: number) => void;
setRagCaptionImages: (value: boolean) => void;
setRagToolEnabled: (value: boolean) => void;
};
type PersistedChatSettings = Awaited<
@ -402,7 +426,14 @@ type ScalarSettingKey =
| "preserveThinking"
| "autoHealToolCalls"
| "maxToolCallsPerMessage"
| "toolCallTimeout";
| "toolCallTimeout"
| "ragSource"
| "ragMode"
| "enableRerank"
| "ragTopK"
| "ragMinScore"
| "ragIndexConcurrency"
| "ragCaptionImages";
type PresetHydrationVersions = {
customPresets: number;
@ -437,6 +468,13 @@ const SCALAR_SETTING_KEYS = [
"autoHealToolCalls",
"maxToolCallsPerMessage",
"toolCallTimeout",
"ragSource",
"ragMode",
"enableRerank",
"ragTopK",
"ragMinScore",
"ragIndexConcurrency",
"ragCaptionImages",
] as const satisfies readonly ScalarSettingKey[];
const inferenceParamMutationVersions = Object.fromEntries(
@ -617,6 +655,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
supportsBuiltinImageGeneration: false,
supportsBuiltinWebFetch: false,
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
// Defaults off; hydratePersistedSettings nudges it on for existing users.
ragToolEnabled: loadBool(CHAT_RAG_TOOL_ENABLED_KEY, false),
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
@ -645,6 +685,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
contextUsage: null,
modelLoading: false,
activeNativePathToken: null,
ragSource: { kind: "thread" },
ragMode: "hybrid",
enableRerank: false,
ragTopK: 5,
ragMinScore: 0,
ragIndexConcurrency: 1,
ragCaptionImages: true,
hydratePersistedSettings: async () => {
if (get().settingsHydrated) {
return;
@ -669,6 +716,17 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
),
...getHydratedSettingsState(settings, state, hydrationVersions),
};
// After hydration, if RAG is explicitly on (persisted), warm
// the embedder so the first message doesn't pay the cold load
// inline. RAG is opt-in by default — no auto-enable migration.
if (
nextState.ragToolEnabled === true ||
(nextState.ragToolEnabled === undefined && state.ragToolEnabled)
) {
void import("@/features/rag/api/rag-api")
.then((m) => m.warmupRagEmbedder())
.catch(() => {});
}
return nextState;
});
} catch {
@ -879,6 +937,54 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
);
return { preserveThinking };
}),
setRagSource: (ragSource) =>
set((state) => {
setScalarSettingVersion("ragSource", ragSource, state.ragSource);
return { ragSource };
}),
setRagMode: (ragMode) =>
set((state) => {
setScalarSettingVersion("ragMode", ragMode, state.ragMode);
return { ragMode };
}),
setEnableRerank: (enableRerank) =>
set((state) => {
setScalarSettingVersion(
"enableRerank",
enableRerank,
state.enableRerank,
);
return { enableRerank };
}),
setRagTopK: (ragTopK) =>
set((state) => {
setScalarSettingVersion("ragTopK", ragTopK, state.ragTopK);
return { ragTopK };
}),
setRagMinScore: (ragMinScore) =>
set((state) => {
setScalarSettingVersion("ragMinScore", ragMinScore, state.ragMinScore);
return { ragMinScore };
}),
setRagIndexConcurrency: (ragIndexConcurrency) =>
set((state) => {
const clamped = Math.max(1, Math.min(8, Math.round(ragIndexConcurrency)));
setScalarSettingVersion(
"ragIndexConcurrency",
clamped,
state.ragIndexConcurrency,
);
return { ragIndexConcurrency: clamped };
}),
setRagCaptionImages: (ragCaptionImages) =>
set((state) => {
setScalarSettingVersion(
"ragCaptionImages",
ragCaptionImages,
state.ragCaptionImages,
);
return { ragCaptionImages };
}),
setToolsEnabled: (toolsEnabled, options) =>
set(() => {
if (options?.persist !== false) {
@ -886,6 +992,19 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
}
return { toolsEnabled };
}),
setRagToolEnabled: (ragToolEnabled) =>
set((state) => {
saveBool(CHAT_RAG_TOOL_ENABLED_KEY, ragToolEnabled);
// Warmup on off→on transitions: kick the backend to preload the
// embedder so the user's first RAG-using message doesn't pay the
// cold-start (~30s for Qwen3-VL-Embedding-2B) inline. Fire-and-forget.
if (ragToolEnabled && !state.ragToolEnabled) {
void import("@/features/rag/api/rag-api")
.then((m) => m.warmupRagEmbedder())
.catch(() => {});
}
return { ragToolEnabled };
}),
setCodeToolsEnabled: (codeToolsEnabled) =>
set(() => {
saveBool(CHAT_CODE_TOOLS_ENABLED_KEY, codeToolsEnabled);

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
/** Bounds how many documents index in parallel. Each RAG upload acquires a
* slot before it starts and releases it once its ingestion job finishes
* (complete/error/already-indexed), so bulk/folder uploads drain at the
* user-configured `ragIndexConcurrency` rate instead of spawning a
* subprocess per file all at once. Module-scoped singleton shared across
* both composer surfaces. */
let active = 0;
const waiters: Array<() => void> = [];
function limit(): number {
const n = useChatRuntimeStore.getState().ragIndexConcurrency;
return Math.max(1, Number.isFinite(n) ? Math.round(n) : 1);
}
function admitWaiters(): void {
while (waiters.length > 0 && active < limit()) {
active += 1;
const next = waiters.shift();
next?.();
}
}
/** Resolves once a slot is free (immediately if under the limit). */
export function acquireIndexSlot(): Promise<void> {
return new Promise<void>((resolve) => {
waiters.push(resolve);
admitWaiters();
});
}
/** Release a previously-acquired slot and admit the next waiter. */
export function releaseIndexSlot(): void {
active = Math.max(0, active - 1);
admitWaiters();
}

View file

@ -0,0 +1,591 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch, getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
import { EventSourcePolyfill } from "event-source-polyfill";
export type ChunkingStrategy = "standard" | "late";
export type KBMode = "text" | "multimodal";
export interface KnowledgeBase {
id: string;
name: string;
description: string | null;
embedding_model: string;
chunking_strategy: ChunkingStrategy;
mode: KBMode;
created_at: number;
}
export interface RagDocument {
id: string;
kb_id: string | null;
thread_id: string | null;
filename: string;
content_type: string | null;
status: "pending" | "running" | "completed" | "failed";
num_chunks: number;
byte_size: number;
error: string | null;
created_at: number;
}
export interface UploadResponse {
document_id: string;
job_id: string;
filename: string;
/** True when an identical file was already indexed in this scope; no
* new ingestion job was started and job_id is "". */
already_indexed?: boolean;
}
export interface SearchHit {
chunk_id: string;
document_id: string;
chunk_index: number;
text: string;
score: number;
page_number: number | null;
filename: string | null;
kind?: "text" | "image" | "caption";
image_url?: string | null;
source_page_index?: number | null;
page_char_start?: number | null;
page_char_end?: number | null;
line_start?: number | null;
line_end?: number | null;
}
// --- Preview target (durable backend-id routing) ---
export type PreviewMediaKind =
| "pdf"
| "text"
| "docx"
| "html"
| "image"
| "unknown";
export type PreviewChunkKind = "text" | "image" | "caption";
export interface PreviewPdfRegion {
pageIndex: number;
pageNumber: number | null;
x: number;
y: number;
width: number;
height: number;
confidence: "exact";
source: string;
}
export interface PreviewTarget {
documentId: string;
filename: string;
contentType: string | null;
mediaKind: PreviewMediaKind;
byteSize: number;
status: string;
kbId: string | null;
threadId: string | null;
chunkId: string | null;
chunkIndex: number | null;
targetPage: number | null;
snippet: string | null;
kind: PreviewChunkKind | null;
imageUrl: string | null;
sourcePageIndex: number | null;
pageCharStart: number | null;
pageCharEnd: number | null;
lineStart: number | null;
lineEnd: number | null;
pdfRegions: PreviewPdfRegion[];
}
export interface PreviewFileUrl {
url: string;
expiresAt: number;
}
export interface LocatorBackfillResult {
documentId: string;
totalChunks: number;
matched: number;
alreadyLocated: number;
ambiguous: number;
missing: number;
skipped: number;
regionsMatched: number;
pagesRefreshed: number;
}
export interface SearchRequest {
query: string;
kb_id?: string;
thread_id?: string;
top_k?: number;
mode?: "bm25" | "dense" | "hybrid";
document_ids?: string[];
enable_rerank?: boolean;
reranker_model?: string;
/** Cosine-similarity floor (0..1). Hits below are dropped server-side. */
min_score?: number;
}
export type JobEvent =
| {
type: "status";
status: string;
stage?: string | null;
progress?: number;
error?: string | null;
}
| { type: "progress"; stage: string; progress: number }
| { type: "complete"; num_chunks: number }
| { type: "cancelled" }
| { type: "error"; error: string };
function parseErrorText(status: number, body: unknown): string {
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
const formatted = formatFastApiDetail(detail);
if (formatted) {
return formatted;
}
}
return `Request failed (${status})`;
}
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(parseErrorText(response.status, body));
}
return body as T;
}
async function throwOnError(response: Response): Promise<void> {
if (response.ok) return;
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));
}
// --- Knowledge bases ---
export async function listKnowledgeBases(): Promise<KnowledgeBase[]> {
const response = await authFetch("/api/rag/knowledge-bases");
const body = await parseJsonOrThrow<{ knowledge_bases: KnowledgeBase[] }>(
response,
);
return body.knowledge_bases;
}
export interface CreateKnowledgeBaseRequest {
name: string;
description?: string;
embedding_model?: string;
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
}
export async function createKnowledgeBase(
req: CreateKnowledgeBaseRequest,
): Promise<KnowledgeBase> {
const response = await authFetch("/api/rag/knowledge-bases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
return parseJsonOrThrow<KnowledgeBase>(response);
}
export async function deleteKnowledgeBase(kbId: string): Promise<void> {
const response = await authFetch(
`/api/rag/knowledge-bases/${encodeURIComponent(kbId)}`,
{ method: "DELETE" },
);
await throwOnError(response);
}
// --- Documents ---
export async function listKBDocuments(kbId: string): Promise<RagDocument[]> {
const response = await authFetch(
`/api/rag/knowledge-bases/${encodeURIComponent(kbId)}/documents`,
);
const body = await parseJsonOrThrow<{ documents: RagDocument[] }>(response);
return body.documents;
}
export async function listThreadDocuments(
threadId: string,
): Promise<RagDocument[]> {
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/documents`,
);
const body = await parseJsonOrThrow<{ documents: RagDocument[] }>(response);
return body.documents;
}
export async function uploadKBDocument(
kbId: string,
file: File,
captionImages = true,
): Promise<UploadResponse> {
const form = new FormData();
form.append("file", file);
const response = await authFetch(
`/api/rag/knowledge-bases/${encodeURIComponent(kbId)}/documents?caption_images=${captionImages}`,
{ method: "POST", body: form },
);
return parseJsonOrThrow<UploadResponse>(response);
}
export async function uploadThreadDocument(
threadId: string,
file: File,
captionImages = true,
): Promise<UploadResponse> {
const form = new FormData();
form.append("file", file);
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/documents?caption_images=${captionImages}`,
{ method: "POST", body: form },
);
return parseJsonOrThrow<UploadResponse>(response);
}
export async function deleteDocument(documentId: string): Promise<void> {
const response = await authFetch(
`/api/rag/documents/${encodeURIComponent(documentId)}`,
{ method: "DELETE" },
);
await throwOnError(response);
}
export interface ThreadIndexSummary {
thread_id: string;
title: string | null;
num_documents: number;
num_chunks: number;
}
export async function listThreadIndexes(): Promise<ThreadIndexSummary[]> {
const response = await authFetch("/api/rag/thread-indexes");
const body = await parseJsonOrThrow<{ threads: ThreadIndexSummary[] }>(
response,
);
return body.threads;
}
export async function clearThreadDocuments(threadId: string): Promise<void> {
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/documents`,
{ method: "DELETE" },
);
await throwOnError(response);
}
export interface ReingestResponse {
job_ids: string[];
document_ids: string[];
}
export interface ReingestKBOptions {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string;
caption_images?: boolean;
}
export async function reingestKnowledgeBase(
kbId: string,
opts: ReingestKBOptions = {},
): Promise<ReingestResponse> {
const response = await authFetch(
`/api/rag/knowledge-bases/${encodeURIComponent(kbId)}/reingest`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
return parseJsonOrThrow<ReingestResponse>(response);
}
export interface ThreadRagSettings {
chunking_strategy: ChunkingStrategy;
mode: KBMode;
embedding_model: string | null;
}
export interface UpdateThreadRagSettingsRequest {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string | null;
// Only consulted by reingest (not persisted as a thread setting).
caption_images?: boolean;
}
export async function getThreadRagSettings(
threadId: string,
): Promise<ThreadRagSettings> {
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/settings`,
);
return parseJsonOrThrow<ThreadRagSettings>(response);
}
export async function setThreadRagSettings(
threadId: string,
payload: UpdateThreadRagSettingsRequest,
): Promise<ThreadRagSettings> {
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/settings`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
return parseJsonOrThrow<ThreadRagSettings>(response);
}
export async function reingestThreadDocuments(
threadId: string,
opts: UpdateThreadRagSettingsRequest = {},
): Promise<ReingestResponse> {
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/reingest`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
return parseJsonOrThrow<ReingestResponse>(response);
}
export interface RagDefaults {
chunking_strategy: ChunkingStrategy;
mode: KBMode;
embedding_model: string | null;
}
export async function getRagDefaults(): Promise<RagDefaults> {
const response = await authFetch("/api/rag/defaults");
return parseJsonOrThrow<RagDefaults>(response);
}
export interface UpdateRagDefaultsRequest {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string | null;
}
export async function setRagDefaults(
payload: UpdateRagDefaultsRequest,
): Promise<RagDefaults> {
const response = await authFetch("/api/rag/defaults", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return parseJsonOrThrow<RagDefaults>(response);
}
/** Preload the configured embedder on the backend. Long-running (cold
* load can take 30s+). Fire-and-forget: failure is non-fatal because
* the first real query will lazy-load again. */
export async function warmupRagEmbedder(): Promise<void> {
await authFetch("/api/rag/warmup", { method: "POST" });
}
/** Download reranker weights into the HF cache. ~1.1 GB on first call;
* no-op when cached. Called when the user flips the reranker toggle so
* the download lands on an explicit action, not the first chat turn. */
export async function precacheRagReranker(): Promise<{
ok: boolean;
model: string;
error?: string;
}> {
const response = await authFetch("/api/rag/reranker/precache", {
method: "POST",
});
return parseJsonOrThrow<{ ok: boolean; model: string; error?: string }>(
response,
);
}
// --- Search ---
export async function search(req: SearchRequest): Promise<SearchHit[]> {
const response = await authFetch("/api/rag/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
const body = await parseJsonOrThrow<{ hits: SearchHit[] }>(response);
return body.hits;
}
export interface PrefetchRequest {
query: string;
kb_id?: string;
thread_id?: string;
top_k?: number;
mode?: "bm25" | "dense" | "hybrid";
enable_rerank?: boolean;
reranker_model?: string;
min_score?: number;
}
export interface PrefetchResult {
queries: string[];
hits: SearchHit[];
}
/** External-provider RAG prefetch: the backend decomposes the question via
* the helper model and retrieves, returning the (possibly multi-query)
* list of queries it used plus the merged hits. */
export async function prefetchRag(
req: PrefetchRequest,
): Promise<PrefetchResult> {
const response = await authFetch("/api/rag/prefetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
return parseJsonOrThrow<PrefetchResult>(response);
}
// --- Ingestion SSE ---
/** Cancel an in-flight ingestion job. Best-effort: a 404/already-terminal job
* resolves without error so batch cancellation never throws on stale ids. */
export async function cancelJob(jobId: string): Promise<void> {
await authFetch(`/api/rag/jobs/${encodeURIComponent(jobId)}/cancel`, {
method: "POST",
}).catch(() => {});
}
/** Subscribe to a job's SSE stream; returns an unsubscribe fn.
* Use the EventSource polyfill so the bearer token rides in an
* Authorization header instead of leaking through URL query params. */
export function subscribeToJobEvents(
jobId: string,
handlers: {
onEvent?: (event: JobEvent) => void;
onError?: (error: Error) => void;
onClose?: () => void;
},
): () => void {
const token = getAuthToken();
const url = apiUrl(`/api/rag/jobs/${encodeURIComponent(jobId)}/events`);
const source = new EventSourcePolyfill(
url,
token
? {
headers: {
Authorization: `Bearer ${token}`,
},
}
: undefined,
);
source.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data) as JobEvent;
handlers.onEvent?.(parsed);
if (
parsed.type === "complete" ||
parsed.type === "error" ||
parsed.type === "cancelled"
) {
source.close();
handlers.onClose?.();
}
} catch (err) {
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
}
};
source.onerror = () => {
// Browser auto-reconnects unless we close; let the consumer decide instead.
source.close();
handlers.onError?.(new Error("SSE connection lost"));
handlers.onClose?.();
};
return () => {
source.close();
handlers.onClose?.();
};
}
// --- Preview target + blob ---
/** Fetch the preview-target metadata for a document. When `chunkId` is
* provided it must belong to `documentId`; mismatch collapses to 404
* with the same "Document not found" body as missing/unauthorized. */
export async function fetchPreviewTarget(
documentId: string,
chunkId?: string | null,
): Promise<PreviewTarget> {
const params = chunkId ? `?chunk_id=${encodeURIComponent(chunkId)}` : "";
const response = await authFetch(
`/api/rag/documents/${encodeURIComponent(documentId)}/preview-target${params}`,
);
return parseJsonOrThrow<PreviewTarget>(response);
}
/** Mint a short-lived URL that PDF.js can range-load without putting
* the user's bearer token in a query string. */
export async function fetchPreviewFileUrl(
documentId: string,
signal?: AbortSignal,
): Promise<PreviewFileUrl> {
const response = await authFetch(
`/api/rag/documents/${encodeURIComponent(documentId)}/file-url`,
signal ? { signal } : undefined,
);
const body = await parseJsonOrThrow<PreviewFileUrl>(response);
return {
...body,
url: apiUrl(body.url),
};
}
export async function backfillDocumentLocators(
documentId: string,
): Promise<LocatorBackfillResult> {
const response = await authFetch(
`/api/rag/documents/${encodeURIComponent(documentId)}/locators/backfill`,
{ method: "POST" },
);
return parseJsonOrThrow<LocatorBackfillResult>(response);
}
/** Download the original uploaded file as a Blob via `authFetch` (so
* the bearer token rides in the Authorization header never a query
* string). The caller (preview-store) creates and revokes the object
* URL so blob lifecycle stays in one place.
*
* `signal` lets the caller abort the fetch when the user switches
* documents mid-load. */
export async function fetchPreviewFileBlob(
documentId: string,
signal?: AbortSignal,
): Promise<Blob> {
const response = await authFetch(
`/api/rag/documents/${encodeURIComponent(documentId)}/file`,
signal ? { signal } : undefined,
);
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));
}
return response.blob();
}

View file

@ -0,0 +1,123 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { RagDocument } from "../api/rag-api";
const STATUS_VARIANT: Record<
RagDocument["status"],
"default" | "secondary" | "destructive" | "outline"
> = {
pending: "outline",
running: "secondary",
completed: "default",
failed: "destructive",
};
function humanBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function DocumentRow({
doc,
onDelete,
onPreview,
rightSlot,
className,
}: {
doc: RagDocument;
onDelete?: () => void;
/** Fired when the row body (not the delete button) is clicked.
* Per decision Q9, callers should only pass this for completed
* documents. */
onPreview?: () => void;
rightSlot?: React.ReactNode;
className?: string;
}) {
const isPreviewable = !!onPreview;
const handleRowClick = (e: MouseEvent<HTMLDivElement>) => {
if (!onPreview) return;
// If a button/anchor/control was clicked (e.g. the delete icon),
// skip preview — let that handler win. Buttons inside this row
// additionally call stopPropagation, but this is defense in depth
// for any descendant Button that forgets to.
const target = e.target as HTMLElement | null;
const interactive = target?.closest("button, a, [role=button]");
if (interactive && interactive !== e.currentTarget) return;
onPreview();
};
const handleRowKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (!onPreview) return;
if (e.target !== e.currentTarget) return; // ignore child key events
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPreview();
}
};
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-2",
isPreviewable &&
"cursor-pointer outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 hover:bg-muted/40",
className,
)}
{...(isPreviewable
? {
role: "button",
tabIndex: 0,
onClick: handleRowClick,
onKeyDown: handleRowKey,
"aria-label": `Open preview of ${doc.filename}`,
}
: {})}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium" title={doc.filename}>
{doc.filename}
</span>
<Badge variant={STATUS_VARIANT[doc.status]} className="capitalize">
{doc.status}
</Badge>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>{humanBytes(doc.byte_size)}</span>
{doc.status === "completed" ? (
<span>{doc.num_chunks} chunks</span>
) : null}
{doc.error ? (
<span className="text-destructive">{doc.error}</span>
) : null}
</div>
{rightSlot}
</div>
{onDelete ? (
<Button
variant="ghost"
size="icon"
aria-label="Delete document"
onClick={(e) => {
// Stop propagation so the row's onClick (preview open)
// does not fire when the user is asking to delete.
e.stopPropagation();
onDelete();
}}
className="text-muted-foreground hover:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
) : null}
</div>
);
}

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Upload04Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useRef, useState } from "react";
const ACCEPTED = ".pdf,.txt,.md,.markdown,.docx,.html,.htm";
export function DocumentUploadDropzone({
onFiles,
disabled,
className,
}: {
onFiles: (files: File[]) => void | Promise<void>;
disabled?: boolean;
className?: string;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const handleFiles = (files: FileList | null) => {
if (!files || files.length === 0 || disabled) return;
void onFiles(Array.from(files));
};
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-border/60 bg-muted/30",
disabled && "opacity-60",
className,
)}
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
}}
>
<HugeiconsIcon
icon={Upload04Icon}
size={24}
className="text-muted-foreground"
/>
<div className="text-sm text-muted-foreground">
Drop files here or
<Button
variant="link"
size="sm"
className="px-1"
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
browse
</Button>
</div>
<div className="text-xs text-muted-foreground">
PDF, TXT, MD, DOCX, HTML
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED}
multiple
hidden
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
/>
</div>
);
}

View file

@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { useIngestionEvents } from "../hooks/use-ingestion-events";
const STAGE_LABELS: Record<string, string> = {
queued: "Queued",
parse: "Parsing document",
caption_images: "Captioning images",
extract_images: "Extracting images",
load_model: "Loading embedder",
chunk: "Chunking text",
embed: "Embedding chunks",
done: "Indexing complete",
};
export function IngestionProgress({
jobId,
className,
}: {
jobId: string;
className?: string;
}) {
const event = useIngestionEvents(jobId);
if (!event) {
return (
<div className={cn("text-xs text-muted-foreground", className)}>
Starting
</div>
);
}
if (event.type === "error") {
return (
<div className={cn("text-xs text-destructive", className)}>
{event.error}
</div>
);
}
if (event.type === "cancelled") {
return (
<div className={cn("text-xs text-muted-foreground", className)}>
Cancelled
</div>
);
}
if (event.type === "complete") {
const chunks = event.num_chunks;
return (
<div className={cn("text-xs text-muted-foreground", className)}>
1 document and {chunks} chunk{chunks === 1 ? "" : "s"} indexed
</div>
);
}
const stage =
"stage" in event && event.stage ? (event.stage as string) : "queued";
const progress =
"progress" in event && typeof event.progress === "number"
? event.progress
: 0;
const label = STAGE_LABELS[stage] ?? stage;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">{label}</span>
<span className="shrink-0 tabular-nums">{Math.round(progress * 100)}%</span>
</div>
<Progress value={Math.round(progress * 100)} className="h-1" />
</div>
);
}

View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useIndexProgressStore } from "../stores/index-progress-store";
/** Single aggregate indexing toast (top-right). One entry per upload batch:
* "Indexing documents · 2/5 · 40%" while in flight, "RAG index ready" when
* the last document finishes. Replaces the old one-toast-per-file stack. */
const DISMISS_DELAY_MS = 4000;
export function IngestionToastStack() {
const entries = useIndexProgressStore((s) => s.entries);
const clear = useIndexProgressStore((s) => s.clear);
const cancelAll = useIndexProgressStore((s) => s.cancelAll);
const reduced = useReducedMotion();
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [cancelling, setCancelling] = useState(false);
const onCancel = async () => {
setCancelling(true);
try {
await cancelAll();
} finally {
setCancelling(false);
}
};
const items = Object.values(entries);
const total = items.length;
const done = items.filter(
(e) => e.status === "ready" || e.status === "error",
).length;
const errored = items.filter((e) => e.status === "error").length;
const totalChunks = items.reduce((sum, e) => sum + (e.chunks || 0), 0);
const allDone = total > 0 && done === total;
// Overall progress: completed/errored files count as 1, in-flight files
// contribute their fractional progress. Smooth even for a handful of files.
const overall =
total === 0
? 0
: items.reduce(
(sum, e) =>
sum + (e.status === "ready" || e.status === "error" ? 1 : e.progress),
0,
) / total;
const pct = Math.round(overall * 100);
// Auto-dismiss once the whole batch is terminal; cancel if a new upload
// re-opens the batch (entries change back to not-all-done).
useEffect(() => {
if (allDone) {
if (dismissTimerRef.current === null) {
dismissTimerRef.current = setTimeout(() => {
dismissTimerRef.current = null;
clear();
}, DISMISS_DELAY_MS);
}
} else if (dismissTimerRef.current !== null) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
return () => {
if (dismissTimerRef.current !== null) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
};
}, [allDone, clear]);
if (total === 0) return null;
const title = allDone
? "RAG index ready"
: total > 1
? "Indexing documents"
: "Indexing document";
let subtitle: string;
if (allDone) {
const indexed = total - errored;
subtitle =
`${indexed} document${indexed === 1 ? "" : "s"} and ` +
`${totalChunks} chunk${totalChunks === 1 ? "" : "s"} indexed` +
(errored > 0 ? ` · ${errored} failed` : "");
} else {
// Show the file currently being worked on (1-based), not the completed
// count — so a fresh batch reads "1/8" rather than "0/8".
const current = Math.min(total, done + 1);
subtitle = `${current}/${total} · ${pct}%`;
}
return (
<div className="pointer-events-none fixed right-4 top-4 z-[9999] flex w-72 flex-col gap-2">
<AnimatePresence initial={false}>
<motion.div
key="rag-index-aggregate"
layout
initial={reduced ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: reduced ? 0 : 0.15 }}
className="pointer-events-auto rounded-md border border-border bg-popover px-3 py-2 shadow-md"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1.5">
<span className="truncate text-xs font-medium">{title}</span>
{allDone ? (
<span className="text-xs text-muted-foreground">{subtitle}</span>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">
{errored > 0 ? "Indexing (some failed)" : "Indexing"}
</span>
<span className="shrink-0 tabular-nums">{subtitle}</span>
</div>
<Progress value={pct} className="h-1" />
</div>
)}
</div>
{/* While indexing: "Cancel" (left) stops the batch and resets the
index; the "X" (right) only dismisses the toast and lets
indexing continue in the background. When done, just the X. */}
<div className="flex shrink-0 items-center gap-1">
{!allDone && (
<Button
variant="ghost"
size="sm"
disabled={cancelling}
className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
onClick={onCancel}
>
{cancelling ? "Cancelling…" : "Cancel"}
</Button>
)}
<Button
variant="ghost"
size="icon"
aria-label="Dismiss"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => clear()}
>
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</Button>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
);
}

View file

@ -0,0 +1,253 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import type {
ChunkingStrategy,
KBMode,
KnowledgeBase,
} from "../api/rag-api";
import { useKnowledgeBases } from "../hooks/use-knowledge-bases";
import { useRagStore } from "../stores/rag-store";
export function KBCreateDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (kb: KnowledgeBase) => void;
}) {
const { createKB } = useKnowledgeBases();
const defaults = useRagStore((s) => s.defaults);
const loadDefaults = useRagStore((s) => s.loadDefaults);
const initialStrategy: ChunkingStrategy =
defaults?.chunking_strategy ?? "standard";
const initialMode: KBMode = defaults?.mode ?? "text";
const initialEmbedder = defaults?.embedding_model ?? "";
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [embeddingModel, setEmbeddingModel] = useState(initialEmbedder);
const [chunkingStrategy, setChunkingStrategy] =
useState<ChunkingStrategy>(initialStrategy);
const [mode, setMode] = useState<KBMode>(initialMode);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open && !defaults) {
void loadDefaults();
}
}, [open, defaults, loadDefaults]);
useEffect(() => {
if (open && defaults) {
setChunkingStrategy(defaults.chunking_strategy);
setMode(defaults.mode);
setEmbeddingModel(defaults.embedding_model ?? "");
}
// Only on open-flip, not on every defaults change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const reset = () => {
setName("");
setDescription("");
setEmbeddingModel(defaults?.embedding_model ?? "");
setChunkingStrategy(defaults?.chunking_strategy ?? "standard");
setMode(defaults?.mode ?? "text");
setError(null);
setSubmitting(false);
};
// Forbid (multimodal, late); disable the other side when one is picked.
const lateDisabled = mode === "multimodal";
const multimodalDisabled = chunkingStrategy === "late";
const placeholderEmbedder =
mode === "multimodal"
? "Defaults to BAAI/BGE-VL-base"
: chunkingStrategy === "late"
? "Defaults to nomic-ai/nomic-embed-text-v1.5"
: "Defaults to BAAI/bge-small-en-v1.5";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || submitting) return;
setSubmitting(true);
setError(null);
try {
const kb = await createKB({
name: name.trim(),
description: description.trim() || undefined,
embedding_model: embeddingModel.trim() || undefined,
chunking_strategy: chunkingStrategy,
mode,
});
onCreated?.(kb);
reset();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) reset();
onOpenChange(o);
}}
>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Create knowledge base</DialogTitle>
<DialogDescription>
A knowledge base groups documents you can reuse across multiple
chat threads.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="kb-name">Name</Label>
<Input
id="kb-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Internal docs"
autoFocus
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="kb-description">Description (optional)</Label>
<Input
id="kb-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's in this KB?"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="kb-mode">Mode</Label>
<Select
value={mode}
onValueChange={(v) => setMode(v as KBMode)}
>
<SelectTrigger id="kb-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">
Text only embed text chunks
</SelectItem>
<SelectItem
value="multimodal"
disabled={multimodalDisabled}
title={
multimodalDisabled
? "Multimodal cannot be combined with late chunking"
: undefined
}
>
Multimodal also embed images alongside text
</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Multimodal mode extracts figures from your documents and
embeds them in a shared text + image vector space (BGE-VL),
so retrieval can match visual content. Larger embedder
(~1.5&nbsp;GB VRAM) and cannot be combined with late
chunking.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="kb-strategy">Chunking strategy</Label>
<Select
value={chunkingStrategy}
onValueChange={(v) => setChunkingStrategy(v as ChunkingStrategy)}
>
<SelectTrigger id="kb-strategy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">
Standard heading-aware recursive splitter
</SelectItem>
<SelectItem
value="late"
disabled={lateDisabled}
title={
lateDisabled
? "Late chunking cannot be combined with multimodal mode"
: undefined
}
>
Late chunking single-pass embedder, slower ingest
</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Late chunking embeds the whole document in one pass, so each
chunk vector carries full-document context. Slower to ingest
(one forward pass per doc) but improves retrieval on long,
cross-referenced text. Cannot be combined with multimodal
mode.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="kb-model">Embedding model (optional)</Label>
<Input
id="kb-model"
value={embeddingModel}
onChange={(e) => setEmbeddingModel(e.target.value)}
placeholder={placeholderEmbedder}
/>
</div>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import {
acquireIndexSlot,
releaseIndexSlot,
} from "@/features/chat/utils/rag-index-queue";
import { cn } from "@/lib/utils";
import { FileTextIcon, Trash2Icon, XIcon } from "lucide-react";
import { useState } from "react";
import type { KnowledgeBase, RagDocument } from "../api/rag-api";
import { subscribeToJobEvents } from "../api/rag-api";
import { useKBDocuments } from "../hooks/use-kb-documents";
import { useIndexProgressStore } from "../stores/index-progress-store";
import { usePreviewStore } from "../stores/preview-store";
import { useRagStore } from "../stores/rag-store";
import { DocumentUploadDropzone } from "./document-upload-dropzone";
import { KBReconfigureDialog } from "./kb-reconfigure-dialog";
function humanBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const STATUS_LABEL: Record<RagDocument["status"], string> = {
pending: "Queued",
running: "Indexing…",
completed: "Completed",
failed: "Failed",
};
export function KBDetailPanel({
kb,
panel,
onClose,
}: {
kb: KnowledgeBase;
panel: "upload" | "files";
onClose: () => void;
}) {
const { documents, loading, error, remove } = useKBDocuments(kb.id);
const [reconfigureOpen, setReconfigureOpen] = useState(false);
const openPreview = usePreviewStore((s) => s.open);
// Route uploads through the shared aggregate indexing toast (same as the
// chat composer) so a multi-file upload shows ONE progress bar instead of a
// per-row spinner. The doc list refreshes via uploadDocument's own job
// subscription; here we only drive the toast + concurrency semaphore.
const handleFiles = (files: File[]) => {
const indexProgress = useIndexProgressStore.getState();
const captionImages = useChatRuntimeStore.getState().ragCaptionImages;
const uploadDocument = useRagStore.getState().uploadDocument;
for (const file of files) {
const chipId = crypto.randomUUID();
indexProgress.add(chipId, file.name);
void (async () => {
await acquireIndexSlot();
indexProgress.setIndexing(chipId);
let released = false;
const release = () => {
if (!released) {
released = true;
releaseIndexSlot();
}
};
try {
const { jobId, alreadyIndexed } = await uploadDocument(
{ kind: "kb", kbId: kb.id },
file,
captionImages,
);
if (alreadyIndexed || !jobId) {
indexProgress.setReady(chipId);
release();
return;
}
subscribeToJobEvents(jobId, {
onEvent: (event) => {
if (event.type === "progress") {
indexProgress.setProgress(chipId, event.progress);
} else if (event.type === "complete") {
indexProgress.setReady(chipId, event.num_chunks);
release();
} else if (event.type === "cancelled") {
release();
} else if (event.type === "error") {
indexProgress.setError(chipId);
release();
}
},
});
} catch {
indexProgress.setError(chipId);
release();
}
})();
}
};
return (
<div className="flex h-full min-w-0 flex-col gap-4 pt-2">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<h2 className="truncate text-lg font-semibold">{kb.name}</h2>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setReconfigureOpen(true)}
disabled={documents.length === 0}
title={
documents.length === 0
? "Upload at least one document before re-indexing"
: undefined
}
>
Reconfigure
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Close panel"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
{/* Own full-width row so the embedder id fits on one line instead of
wrapping next to the action buttons. */}
<p className="truncate text-xs text-muted-foreground">
{kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""}
{kb.chunking_strategy === "late" ? "⚡ Late · " : ""}
Embedder: <code>{kb.embedding_model}</code>
</p>
</div>
{panel === "upload" ? (
<DocumentUploadDropzone onFiles={handleFiles} />
) : (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Documents</h3>
<span className="text-xs text-muted-foreground">
{loading ? "Loading…" : `${documents.length} total`}
</span>
</div>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
{/* Native scroll, not Radix ScrollArea (its display:table wrapper
breaks truncation). overflow-x-hidden is explicit because WebKit
(Tauri webview) does NOT auto-compute overflow-x to auto when
only overflow-y is set, so a long filename would otherwise spill
and widen the whole dialog. */}
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pr-1">
{documents.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border/60 px-3 py-6 text-center text-xs text-muted-foreground">
No documents yet. Use the upload button to add some.
</div>
) : (
<div className="flex flex-col gap-2">
{documents.map((doc) => {
const previewable = doc.status === "completed";
return (
<div
key={doc.id}
className={cn(
// grid minmax(0,1fr)/auto (same as the Connections
// rows) so the name column shrinks and the delete
// button stays put — never widening the panel.
"grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs",
previewable && "cursor-pointer hover:bg-muted/70",
)}
onClick={
previewable
? () => void openPreview({ documentId: doc.id })
: undefined
}
>
<div className="flex min-w-0 items-center gap-2">
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col">
<span className="truncate" title={doc.filename}>
{doc.filename}
</span>
<span
className={cn(
"text-[10px] leading-tight text-muted-foreground",
doc.status === "failed" && "text-destructive",
)}
>
{humanBytes(doc.byte_size)} · {doc.num_chunks} chunks
{" · "}
{STATUS_LABEL[doc.status]}
</span>
</div>
</div>
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-destructive"
aria-label={`Delete ${doc.filename}`}
onClick={(e) => {
e.stopPropagation();
void remove(doc.id);
}}
>
<Trash2Icon className="size-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
</>
)}
<KBReconfigureDialog
open={reconfigureOpen}
onOpenChange={setReconfigureOpen}
kb={kb}
documentCount={documents.length}
/>
</div>
);
}

View file

@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { FilesIcon, Trash2Icon, UploadIcon } from "lucide-react";
import type { KnowledgeBase } from "../api/rag-api";
import { useKnowledgeBases } from "../hooks/use-knowledge-bases";
export type KBPanel = "upload" | "files";
export function KBList({
activeKbId,
activePanel,
onPanel,
onDeleted,
}: {
activeKbId: string | null;
activePanel: KBPanel | null;
onPanel: (kb: KnowledgeBase, panel: KBPanel) => void;
onDeleted: (kbId: string) => void;
}) {
const { knowledgeBases, loading, error, deleteKB } = useKnowledgeBases();
return (
<div className="flex flex-col gap-1">
{error ? <div className="text-xs text-destructive">{error}</div> : null}
{/* Capped, content-sized list: short when empty, scrolls when long. */}
<ScrollArea className="max-h-[320px]">
<div className="flex flex-col gap-1 pr-2">
{knowledgeBases.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border/60 px-3 py-6 text-center text-xs text-muted-foreground">
No knowledge bases yet.
</div>
) : null}
{knowledgeBases.map((kb) => {
const isActive = kb.id === activeKbId;
return (
<div
key={kb.id}
className={cn(
"group flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors",
isActive
? "border-primary/50 bg-accent"
: "border-border/60 hover:bg-accent/50",
)}
>
<div className="flex min-w-0 flex-col">
<span className="flex min-w-0 items-center gap-1.5 truncate text-sm font-medium">
<span className="truncate">{kb.name}</span>
{kb.chunking_strategy === "late" ? (
<span
className="shrink-0 rounded-sm bg-amber-500/15 px-1 text-[10px] font-medium text-amber-700 dark:text-amber-300"
title="Late chunking enabled"
>
Late
</span>
) : null}
{kb.mode === "multimodal" ? (
<span
className="shrink-0 rounded-sm bg-violet-500/15 px-1 text-[10px] font-medium text-violet-700 dark:text-violet-300"
title="Multimodal — text + image embeddings"
>
🖼 MM
</span>
) : null}
</span>
{kb.description ? (
<span className="truncate text-xs text-muted-foreground">
{kb.description}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={`Upload documents to ${kb.name}`}
title="Upload documents"
className={cn(
"h-7 w-7",
isActive && activePanel === "upload" && "text-primary",
)}
onClick={() => onPanel(kb, "upload")}
>
<UploadIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`View documents in ${kb.name}`}
title="View documents"
className={cn(
"h-7 w-7",
isActive && activePanel === "files" && "text-primary",
)}
onClick={() => onPanel(kb, "files")}
>
<FilesIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Delete ${kb.name}`}
title="Delete knowledge base"
className="h-7 w-7 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
onClick={() => {
if (
window.confirm(
`Delete "${kb.name}" and all its documents?`,
)
) {
void deleteKB(kb.id).then(() => onDeleted(kb.id));
}
}}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,209 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import type {
ChunkingStrategy,
KBMode,
KnowledgeBase,
} from "../api/rag-api";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useRagStore } from "../stores/rag-store";
export function KBReconfigureDialog({
open,
onOpenChange,
kb,
documentCount,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
kb: KnowledgeBase;
documentCount: number;
}) {
const reingestKB = useRagStore((s) => s.reingestKB);
const [chunkingStrategy, setChunkingStrategy] = useState<ChunkingStrategy>(
kb.chunking_strategy,
);
const [mode, setMode] = useState<KBMode>(kb.mode);
const [embeddingModel, setEmbeddingModel] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-sync when the dialog opens against a different KB.
useEffect(() => {
if (open) {
setChunkingStrategy(kb.chunking_strategy);
setMode(kb.mode);
setEmbeddingModel("");
setError(null);
setSubmitting(false);
}
}, [open, kb.id, kb.chunking_strategy, kb.mode]);
const lateDisabled = mode === "multimodal";
const multimodalDisabled = chunkingStrategy === "late";
const placeholderEmbedder =
mode === "multimodal"
? `Current: ${kb.embedding_model}`
: chunkingStrategy === "late"
? `Current: ${kb.embedding_model}`
: `Current: ${kb.embedding_model}`;
const changedSettings =
chunkingStrategy !== kb.chunking_strategy ||
mode !== kb.mode ||
embeddingModel.trim() !== "";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
const verb = changedSettings ? "Reconfigure and re-index" : "Re-index";
if (
!window.confirm(
`${verb} ${documentCount} document${documentCount === 1 ? "" : "s"}? ` +
`Existing chunks will be deleted and rebuilt from the original files. ` +
`Search will be unavailable until ingestion finishes.`,
)
) {
return;
}
setSubmitting(true);
setError(null);
try {
await reingestKB(kb.id, {
chunking_strategy: chunkingStrategy,
mode,
embedding_model: embeddingModel.trim() || undefined,
caption_images: useChatRuntimeStore.getState().ragCaptionImages,
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Reconfigure {kb.name}</DialogTitle>
<DialogDescription>
Change the chunking strategy, mode, or embedder for this KB.
All {documentCount} document{documentCount === 1 ? "" : "s"}{" "}
will be re-ingested from the originals on disk.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reconf-mode">Mode</Label>
<Select
value={mode}
onValueChange={(v) => setMode(v as KBMode)}
>
<SelectTrigger id="reconf-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text only</SelectItem>
<SelectItem
value="multimodal"
disabled={multimodalDisabled}
title={
multimodalDisabled
? "Multimodal cannot be combined with late chunking"
: undefined
}
>
Multimodal text + images
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reconf-strategy">Chunking strategy</Label>
<Select
value={chunkingStrategy}
onValueChange={(v) => setChunkingStrategy(v as ChunkingStrategy)}
>
<SelectTrigger id="reconf-strategy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">
Standard heading-aware recursive splitter
</SelectItem>
<SelectItem
value="late"
disabled={lateDisabled}
title={
lateDisabled
? "Late chunking cannot be combined with multimodal mode"
: undefined
}
>
Late chunking single-pass embedder
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reconf-model">Embedding model (optional)</Label>
<Input
id="reconf-model"
value={embeddingModel}
onChange={(e) => setEmbeddingModel(e.target.value)}
placeholder={placeholderEmbedder}
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to keep the current model (or pick the matrix
default when mode/strategy changes).
</p>
</div>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? "Re-indexing…"
: changedSettings
? "Reconfigure & re-index"
: "Re-index"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,235 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { LoaderIcon, XIcon } from "lucide-react";
import {
type FC,
type ReactNode,
useEffect,
useSyncExternalStore,
} from "react";
import type { PreviewTarget } from "../api/rag-api";
import {
type PreviewLoadStatus,
isInlineBlobAllowed,
usePreviewStore,
} from "../stores/preview-store";
import { PreviewPdfView } from "./preview-pdf-view";
import { PreviewTextView } from "./preview-text-view";
import { PreviewUnavailable } from "./preview-unavailable";
interface PreviewPanelProps {
/** Whether the panel is currently being shown in its host slot.
* When the host hides the slot (e.g. the user closes both
* settings and preview from the chat header), we run the close
* side-effect so the blob URL is revoked. */
open: boolean;
disableDrawer?: boolean;
}
const LG_BREAKPOINT = 1024;
const MEDIA_QUERY = `(max-width: ${LG_BREAKPOINT - 1}px)`;
function getLgSnapshot(): boolean {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return false;
}
return window.matchMedia(MEDIA_QUERY).matches;
}
function lgSubscribe(callback: () => void): () => void {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return () => undefined;
}
const mql = window.matchMedia(MEDIA_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function useIsViewportSqueezed(): boolean {
return useSyncExternalStore(lgSubscribe, getLgSnapshot, () => false);
}
interface PreviewBodyArgs {
error: string | null;
previewBlob: Blob | null;
previewFileUrl: string | null;
status: PreviewLoadStatus;
target: PreviewTarget | null;
}
function renderPreviewBody({
error,
previewBlob,
previewFileUrl,
status,
target,
}: PreviewBodyArgs): ReactNode {
if (status === "loading") {
return (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
Loading preview
</div>
);
}
if (status === "error") {
// Treat 404s as "document missing" so a stale citation reads
// like "no longer available" rather than a generic error.
const isMissing = (error ?? "").toLowerCase().includes("not found");
return (
<PreviewUnavailable
filename={target?.filename}
reason={error ?? "Preview unavailable."}
variant={isMissing ? "missing" : "error"}
/>
);
}
if (status === "ready" && target) {
const pdfFile = previewFileUrl ?? previewBlob;
if (
target.mediaKind === "pdf" &&
pdfFile &&
isInlineBlobAllowed(target.mediaKind)
) {
return <PreviewPdfView target={target} file={pdfFile} />;
}
// text / image / docx / html / unknown — all routed through
// text-view. text gets the snippet rendered inline; docx/html
// /unknown skip inline-render entirely (contracts §5.4 + Risk #3).
return <PreviewTextView target={target} />;
}
return null;
}
/** Body-only renderer. The host slot (desktop aside or mobile sheet)
* is owned by the host (chat-settings panel slot, kb-detail panel,
* etc.) this component is purely the content of the right slot. */
export const PreviewPanel: FC<PreviewPanelProps> = ({
open,
disableDrawer = false,
}) => {
const target = usePreviewStore((s) => s.target);
const previewBlob = usePreviewStore((s) => s.previewBlob);
const previewFileUrl = usePreviewStore((s) => s.previewFileUrl);
const status = usePreviewStore((s) => s.status);
const error = usePreviewStore((s) => s.error);
const close = usePreviewStore((s) => s.close);
const isSqueezed = useIsViewportSqueezed() && !disableDrawer;
// Unmount + visibility cleanup: when the panel is hidden or
// unmounted, revoke the live object URL (contracts §5.5).
useEffect(() => {
if (!open) {
close();
}
}, [open, close]);
useEffect(() => {
return () => {
// Component truly unmounting (e.g. navigation away). Cleanup
// anything still live.
usePreviewStore.getState().close();
};
}, []);
// Keyboard accessibility: ESC closes the preview.
useEffect(() => {
if (!open) {
return;
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
close();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
const body = renderPreviewBody({
error,
previewBlob,
previewFileUrl,
status,
target,
});
const renderedContent = (
<section
aria-label="Document preview"
className="flex h-full flex-col overflow-hidden bg-panel-surface/85 dark:bg-background/85 backdrop-blur-lg text-panel-surface-fg border border-border/40 shadow-lg menu-soft-surface"
>
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2 font-heading">
<div className="flex items-center gap-1.5">
<span
className="h-2 w-2 animate-pulse rounded-full bg-primary [--pulse-color:color-mix(in_oklab,var(--primary)_35%,transparent)]"
aria-hidden="true"
/>
<span className="text-sm font-semibold">Preview</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={close}
aria-label="Close preview"
className="size-7"
>
<XIcon className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">{body}</div>
</section>
);
if (isSqueezed) {
return (
<>
<div className="hidden" aria-hidden="true" />
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) {
close();
}
}}
>
<SheetContent
side="right"
showCloseButton={false}
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
className="preview-sheet-content p-0 font-heading data-[side=right]:w-full data-[side=right]:sm:max-w-md"
>
<SheetHeader className="sr-only">
<SheetTitle>Document preview</SheetTitle>
<SheetDescription>
Preview of the active document citation
</SheetDescription>
</SheetHeader>
<div className="flex h-full flex-col">{renderedContent}</div>
</SheetContent>
</Sheet>
</>
);
}
return renderedContent;
};

View file

@ -0,0 +1,600 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import {
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
LoaderIcon,
RotateCcwIcon,
SearchIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import {
type CSSProperties,
type FC,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import type { PreviewPdfRegion, PreviewTarget } from "../api/rag-api";
import { PreviewUnavailable } from "./preview-unavailable";
// Configure pdfjs worker in the same module where react-pdf is used,
// per the react-pdf README. `import.meta.url` resolves to the JS bundle
// containing this module, and Vite (+ Tauri) rewrites the URL during
// build so the worker is co-located with the chunk.
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
type PreviewPdfFile = Blob | string;
interface PreviewPdfViewProps {
target: PreviewTarget;
file: PreviewPdfFile;
}
type LoadSuccess = { numPages: number };
type PdfLightThemeStyle = CSSProperties & Record<`--${string}`, string>;
const RESIZE_DEBOUNCE_MS = 100;
const MIN_PDF_WIDTH = 280;
// Body has p-2 (8px each side) + stable scrollbar gutter (~10px) + a tiny
// breathing margin so the page render doesn't kiss the scrollbar.
const PDF_BODY_GUTTER_PX = 28;
const PDF_THUMBNAIL_WIDTH = 64;
const PDF_LIGHT_THEME_STYLE: PdfLightThemeStyle = {
"--background": "oklch(1 0 0)",
"--foreground": "oklch(0.2686 0 0)",
"--card": "oklch(1 0 0)",
"--card-foreground": "oklch(0.1281 0.0179 169.2764)",
"--popover": "oklch(1 0 0)",
"--popover-foreground": "oklch(0.1281 0.0179 169.2764)",
"--primary": "#17b88b",
"--primary-foreground": "oklch(1 0 0)",
"--secondary": "oklch(0.9596 0.0275 167.8295)",
"--secondary-foreground": "oklch(0.2868 0.0649 159.9823)",
"--muted": "oklch(0.9702 0 0)",
"--muted-foreground": "oklch(0.5486 0 0)",
"--accent": "oklch(0.9596 0.0275 167.8295)",
"--accent-foreground": "oklch(0.2868 0.0649 159.9823)",
"--border": "oklch(0.9208 0.0101 164.8536)",
"--input": "oklch(0.9208 0.0101 164.8536)",
"--ring": "#17b88b",
colorScheme: "light",
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function markFirstMatch(text: string, needle: string): string | null {
const trimmed = needle.trim();
if (trimmed.length < 2) {
return null;
}
const lower = text.toLowerCase();
const start = lower.indexOf(trimmed.toLowerCase());
if (start < 0) {
return null;
}
const end = start + trimmed.length;
return `${escapeHtml(text.slice(0, start))}<mark>${escapeHtml(
text.slice(start, end),
)}</mark>${escapeHtml(text.slice(end))}`;
}
// Keep text-layer highlighting opt-in. Citation snippets render in the card
// below; using them here would mark common words across unrelated PDF text.
function highlightPdfText(text: string, searchTerm: string): string {
const trimmed = searchTerm.trim();
if (trimmed.length < 2) {
return escapeHtml(text);
}
const searchHit = markFirstMatch(text, trimmed);
if (searchHit) {
return searchHit;
}
return escapeHtml(text);
}
function regionIsOnPage(region: PreviewPdfRegion, pageNumber: number): boolean {
if (region.confidence !== "exact") {
return false;
}
if (region.pageNumber != null) {
return region.pageNumber === pageNumber;
}
return region.pageIndex === pageNumber - 1;
}
interface PdfThumbnailProps {
pageNumber: number;
active: boolean;
onSelect: (pageNumber: number) => void;
}
/** Lazy thumbnail rendered via IntersectionObserver only mounts the
* inner <Page> when scrolled into view (or close to it), so large PDFs
* stay responsive even when the rail caps at 80 buttons. */
const PdfThumbnail: FC<PdfThumbnailProps> = ({
pageNumber,
active,
onSelect,
}) => {
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (shouldRender) {
return;
}
const el = buttonRef.current;
if (!el || typeof IntersectionObserver === "undefined") {
// Fallback for jsdom / older browsers: render eagerly.
setShouldRender(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setShouldRender(true);
observer.disconnect();
return;
}
}
},
{ rootMargin: "320px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [shouldRender]);
useEffect(() => {
const el = buttonRef.current;
if (!active || !el || typeof el.scrollIntoView !== "function") {
return;
}
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [active]);
return (
<button
ref={buttonRef}
type="button"
onClick={() => onSelect(pageNumber)}
aria-label={`Go to page ${pageNumber}`}
aria-current={active ? "page" : undefined}
className={cn(
"mb-1.5 flex w-full flex-col items-center gap-0.5 rounded-md p-1 outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
active
? "bg-secondary/70 text-secondary-foreground"
: "hover:bg-muted/60",
)}
>
<div
className={cn(
"overflow-hidden rounded-sm border bg-white shadow-xs",
active
? "border-primary/70 ring-1 ring-primary/40"
: "border-border/60",
)}
style={{
width: PDF_THUMBNAIL_WIDTH,
minHeight: Math.round(PDF_THUMBNAIL_WIDTH * 1.3),
}}
>
{shouldRender ? (
<Page
pageNumber={pageNumber}
width={PDF_THUMBNAIL_WIDTH}
renderTextLayer={false}
renderAnnotationLayer={false}
loading={
<div
className="flex h-full w-full animate-pulse items-center justify-center bg-muted/40"
style={{
minHeight: Math.round(PDF_THUMBNAIL_WIDTH * 1.3),
}}
/>
}
error={
<div
className="flex h-full w-full items-center justify-center text-[8px] text-muted-foreground"
style={{
minHeight: Math.round(PDF_THUMBNAIL_WIDTH * 1.3),
}}
>
?
</div>
}
className="pointer-events-none [&_canvas]:!h-auto [&_canvas]:!w-full"
/>
) : null}
</div>
<span
className={cn(
"tabular-nums text-[10px]",
active ? "font-semibold" : "text-muted-foreground",
)}
>
{pageNumber}
</span>
</button>
);
};
export const PreviewPdfView: FC<PreviewPdfViewProps> = ({ target, file }) => {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState<number>(target.targetPage ?? 1);
const [loadError, setLoadError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [copied, setCopied] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const resizeTimeoutRef = useRef<number | null>(null);
const lastMeasuredWidthRef = useRef<number | null>(null);
const lastResetKeyRef = useRef<string | null>(null);
const [width, setWidth] = useState<number | null>(null);
const searchInputId = useId();
const sourceKey =
typeof file === "string"
? file
: `${target.documentId}:${target.chunkId ?? ""}:${file.size}:${file.type}`;
const documentFile = useMemo(() => {
return typeof file === "string" ? { url: file } : file;
}, [file]);
const resetKey = `${sourceKey}:${target.targetPage ?? ""}`;
useEffect(() => {
if (lastResetKeyRef.current === resetKey) {
return;
}
lastResetKeyRef.current = resetKey;
setNumPages(null);
setLoadError(null);
setPageNumber(target.targetPage ?? 1);
setZoom(1);
setSearchTerm("");
setCopied(false);
}, [resetKey, target.targetPage]);
const measureWidth = useCallback(() => {
const el = containerRef.current;
if (!el) {
return;
}
const next = Math.max(MIN_PDF_WIDTH, el.clientWidth - PDF_BODY_GUTTER_PX);
if (lastMeasuredWidthRef.current === next) {
return;
}
lastMeasuredWidthRef.current = next;
setWidth(next);
}, []);
// Callback ref instead of useRef + mount effect: the scroll container
// lives INSIDE <Document>, so it only enters the DOM after the PDF
// loads. Attaching the ResizeObserver the instant the node mounts
// (rather than on the component's mount effect, when the node is still
// absent) is what keeps the main page from rendering at width 0 — the
// thin white strip regression.
const attachContainer = useCallback(
(node: HTMLDivElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current);
resizeTimeoutRef.current = null;
}
containerRef.current = node;
if (!node) {
return;
}
measureWidth();
const observer = new ResizeObserver(() => {
if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = window.setTimeout(
measureWidth,
RESIZE_DEBOUNCE_MS,
);
});
observer.observe(node);
observerRef.current = observer;
},
[measureWidth],
);
useEffect(() => {
if (!copied) {
return;
}
const id = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(id);
}, [copied]);
const handleLoadSuccess = useCallback(({ numPages }: LoadSuccess) => {
setNumPages(numPages);
setLoadError(null);
}, []);
const handleLoadError = useCallback((err: Error) => {
setLoadError(err.message || "Failed to load PDF");
}, []);
const goPrev = useCallback(() => {
setPageNumber((p) => Math.max(1, p - 1));
}, []);
const goNext = useCallback(() => {
setPageNumber((p) =>
numPages == null ? p + 1 : Math.min(numPages, p + 1),
);
}, [numPages]);
const textRenderer = useCallback(
({ str }: { str: string }) => highlightPdfText(str, searchTerm),
[searchTerm],
);
const currentRegions = useMemo(
() =>
(target.pdfRegions ?? []).filter((region) =>
regionIsOnPage(region, pageNumber),
),
[target.pdfRegions, pageNumber],
);
const visiblePageNumbers = useMemo(() => {
if (!numPages) {
return [];
}
const maxButtons = 80;
if (numPages <= maxButtons) {
return Array.from({ length: numPages }, (_, index) => index + 1);
}
const half = Math.floor(maxButtons / 2);
let start = Math.max(1, pageNumber - half);
const end = Math.min(numPages, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
return Array.from({ length: end - start + 1 }, (_, index) => start + index);
}, [numPages, pageNumber]);
const pageWidth = width == null ? null : Math.round(width * zoom);
const excerptKey = `${sourceKey}:${target.chunkId ?? ""}:${
target.targetPage ?? ""
}:${pageNumber}`;
const copyExcerpt = useCallback(() => {
copyToClipboard(target.snippet ?? "").then(setCopied);
}, [target.snippet]);
if (loadError) {
return (
<PreviewUnavailable
filename={target.filename}
reason={loadError}
variant="error"
/>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2 text-xs">
<span
className="min-w-0 flex-1 truncate font-semibold font-heading"
title={target.filename}
>
{target.filename}
</span>
<div className="flex shrink-0 items-center gap-2">
{/* Navigation Pill Group */}
<div className="flex items-center rounded-full border border-border/60 bg-muted/40 p-0.5 shadow-xs">
<Button
variant="ghost"
size="icon"
onClick={goPrev}
disabled={pageNumber <= 1}
aria-label="Previous page"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ChevronLeftIcon className="size-3.5" />
</Button>
<span className="min-w-12 text-center tabular-nums text-[10px] font-medium text-muted-foreground">
{numPages == null
? `${pageNumber}/?`
: `${pageNumber}/${numPages}`}
</span>
<Button
variant="ghost"
size="icon"
onClick={goNext}
disabled={numPages != null && pageNumber >= numPages}
aria-label="Next page"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
{/* Zoom Pill Group */}
<div className="flex items-center rounded-full border border-border/60 bg-muted/40 p-0.5 shadow-xs">
<Button
variant="ghost"
size="icon"
onClick={() => setZoom((value) => Math.max(0.6, value - 0.1))}
aria-label="Zoom out"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ZoomOutIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setZoom(1)}
aria-label="Reset zoom"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<RotateCcwIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setZoom((value) => Math.min(2.5, value + 0.1))}
aria-label="Zoom in"
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<ZoomInIcon className="size-3.5" />
</Button>
</div>
{/* Copy Excerpt Pill Group */}
<div className="flex items-center rounded-full border border-border/60 bg-muted/40 p-0.5 shadow-xs">
<Button
variant="ghost"
size="icon"
onClick={copyExcerpt}
disabled={!target.snippet}
aria-label={
copied ? "Copied source excerpt" : "Copy source excerpt"
}
className="h-7 w-7 rounded-full hover:bg-background/80"
>
<CopyIcon className="size-3.5" />
</Button>
</div>
</div>
<label
htmlFor={searchInputId}
className="flex min-w-48 max-w-full flex-1 items-center gap-1 rounded-md border border-border/60 bg-background px-2"
>
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
<Input
id={searchInputId}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search this PDF"
aria-label="Search this PDF"
className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
/>
</label>
</div>
{target.snippet ? (
<div
key={excerptKey}
className="m-2 rounded-lg border border-border/60 bg-muted/30 p-3 shadow-xs text-[11px] leading-relaxed text-foreground/80 transition-all duration-300 animate-in fade-in"
>
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/80">
Source Excerpt
{target.targetPage != null ? ` · Page ${target.targetPage}` : ""}
</p>
<p className="line-clamp-4 whitespace-pre-wrap font-sans text-muted-foreground">
{target.snippet}
</p>
</div>
) : null}
<Document
file={documentFile}
onLoadSuccess={handleLoadSuccess}
onLoadError={handleLoadError}
loading={
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
Loading PDF...
</div>
}
error={
<PreviewUnavailable
filename={target.filename}
reason="The PDF could not be opened."
variant="error"
/>
}
className="flex min-h-0 flex-1"
>
<div className="preview-scrollbar w-[88px] shrink-0 overflow-y-auto border-r border-border/60 bg-muted/20 p-1.5">
{visiblePageNumbers.map((page) => (
<PdfThumbnail
key={page}
pageNumber={page}
active={page === pageNumber}
onSelect={setPageNumber}
/>
))}
</div>
<div
ref={attachContainer}
className="preview-scrollbar flex-1 overflow-y-scroll overflow-x-auto bg-muted/20 p-2 [scrollbar-gutter:stable]"
>
<div
className="light [color-scheme:light] bg-white text-slate-900 rounded-md p-1 shadow-sm border border-border/30 [&_mark]:bg-primary/20 [&_mark]:text-slate-900 [&_mark]:ring-1 [&_mark]:ring-primary/60 [&_mark]:rounded-xs flex min-w-fit flex-col items-center"
style={PDF_LIGHT_THEME_STYLE}
>
{pageWidth != null ? (
<div
data-testid="pdf-main-page"
className="relative inline-block"
>
<Page
pageNumber={pageNumber}
width={pageWidth}
customTextRenderer={textRenderer}
renderTextLayer={true}
renderAnnotationLayer={false}
loading={
<div className="py-4 text-xs text-muted-foreground">
Rendering page...
</div>
}
className="shadow-sm"
/>
{currentRegions.map((region, index) => (
<div
key={`${region.pageIndex}-${region.x}-${region.y}-${index}`}
data-testid="pdf-region-highlight"
className="pointer-events-none absolute rounded-sm bg-primary/20 ring-1 ring-primary/60"
style={{
left: `${region.x * 100}%`,
top: `${region.y * 100}%`,
width: `${region.width * 100}%`,
height: `${region.height * 100}%`,
}}
/>
))}
</div>
) : null}
</div>
</div>
</Document>
</div>
);
};

View file

@ -0,0 +1,300 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { authFetch } from "@/features/auth";
import { DownloadIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
import { type FC, type ReactNode, useCallback } from "react";
import type { PreviewTarget } from "../api/rag-api";
import { isInlineBlobAllowed } from "../stores/preview-store";
interface PreviewTextViewProps {
target: PreviewTarget;
}
/** Fetch the original document bytes via authFetch so the bearer
* token rides in the Authorization header. `window.open(url)` and
* `<a download href=url>` cannot set custom headers, so handing
* them the raw `/file` URL gets a 401 (HTTPBearer-only backend
* see D1.3). */
async function fetchOriginalBlob(target: PreviewTarget): Promise<Blob> {
const response = await authFetch(
`/api/rag/documents/${encodeURIComponent(target.documentId)}/file`,
);
if (!response.ok) {
throw new Error(`Failed to fetch document (${response.status})`);
}
return response.blob();
}
function clickDownloadUrl(url: string, filename: string): void {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
async function downloadOriginal(target: PreviewTarget): Promise<void> {
const blob = await fetchOriginalBlob(target);
const url = URL.createObjectURL(blob);
clickDownloadUrl(url, target.filename);
// Defer revocation so the browser's download pipeline gets the bytes
// before the URL goes away.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function openOriginalInNewTab(target: PreviewTarget): Promise<void> {
// Defense in depth: refuse to create an inline blob URL for the
// unsafe types even if a future caller forgets the gate. The
// browser would render an html-blob as live HTML in the new tab,
// which is the Risk #3 / contracts §2.3 trip.
if (!isInlineBlobAllowed(target.mediaKind)) {
throw new Error(
`Inline open not allowed for mediaKind "${target.mediaKind}" — use Download instead.`,
);
}
const blob = await fetchOriginalBlob(target);
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
// Defer revocation so the new tab loads the bytes first.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
/** Extracted-text / snippet preview used for:
* - `text` mediaKind (txt/md) the snippet is the only inline
* rendering we trust, and the original is one click away.
* - `docx`, `html`, `unknown` the original is NEVER rendered
* inline from an object URL (Risk #3); we show the cited chunk
* text plus a safe download/open action.
*
* When `chunk_id` was not supplied (document-row preview per
* contracts §1.3 + decision Q2), `snippet` is `null` and we show a
* metadata-only state instead of guessing a first chunk. */
interface MatchRange {
start: number;
end: number;
}
function findCharacterRange(
snippet: string,
target: PreviewTarget,
): MatchRange | null {
const { pageCharStart, pageCharEnd } = target;
if (pageCharStart !== null && pageCharEnd !== null) {
const start = Math.max(0, pageCharStart);
const end = Math.min(snippet.length, pageCharEnd);
if (start < end) {
return { start, end };
}
}
return null;
}
function findLineRange(
snippet: string,
target: PreviewTarget,
): MatchRange | null {
const { lineStart, lineEnd } = target;
if (lineStart !== null) {
const lines = snippet.split("\n");
const startLineIndex = Math.max(0, lineStart - 1);
const endLineIndex =
lineEnd !== null
? Math.min(lines.length - 1, lineEnd - 1)
: startLineIndex;
let charOffset = 0;
let startChar = -1;
let endChar = -1;
for (let i = 0; i < lines.length; i++) {
if (i === startLineIndex) {
startChar = charOffset;
}
charOffset += lines[i].length;
if (i === endLineIndex) {
endChar = charOffset;
break;
}
charOffset += 1; // for '\n'
}
if (startChar !== -1 && endChar !== -1 && startChar < endChar) {
return { start: startChar, end: endChar };
}
}
return null;
}
function findDensestLineRange(snippet: string): MatchRange | null {
const lines = snippet.split("\n");
let bestLineIndex = -1;
let maxAlphanumericCount = 0;
for (let i = 0; i < lines.length; i++) {
const alphanumericCount = lines[i].replace(/[^a-zA-Z0-9]/g, "").length;
if (alphanumericCount > maxAlphanumericCount) {
maxAlphanumericCount = alphanumericCount;
bestLineIndex = i;
}
}
if (bestLineIndex !== -1) {
let charOffset = 0;
for (let i = 0; i < bestLineIndex; i++) {
charOffset += lines[i].length + 1;
}
return { start: charOffset, end: charOffset + lines[bestLineIndex].length };
}
return null;
}
function findFuzzyMatch(
snippet: string,
target: PreviewTarget,
): MatchRange | null {
return (
findCharacterRange(snippet, target) ??
findLineRange(snippet, target) ??
findDensestLineRange(snippet)
);
}
const renderHighlightedSnippet = (
snippet: string,
target: PreviewTarget,
): ReactNode => {
const match = findFuzzyMatch(snippet, target);
if (!match) {
return snippet;
}
const before = snippet.slice(0, match.start);
const highlighted = snippet.slice(match.start, match.end);
const after = snippet.slice(match.end);
return (
<>
{before}
<mark className="rounded bg-primary/20 px-0.5 text-foreground ring-1 ring-primary/60">
{highlighted}
</mark>
{after}
</>
);
};
/** Extracted-text / snippet preview used for:
* - `text` mediaKind (txt/md) the snippet is the only inline
* rendering we trust, and the original is one click away.
* - `docx`, `html`, `unknown` the original is NEVER rendered
* inline from an object URL (Risk #3); we show the cited chunk
* text plus a safe download/open action.
*
* When `chunk_id` was not supplied (document-row preview per
* contracts §1.3 + decision Q2), `snippet` is `null` and we show a
* metadata-only state instead of guessing a first chunk. */
export const PreviewTextView: FC<PreviewTextViewProps> = ({ target }) => {
const snippet = target.snippet;
const hasSnippet = snippet !== null && snippet.trim().length > 0;
const hasLocator =
target.lineStart !== null ||
target.lineEnd !== null ||
target.pageCharStart !== null ||
target.pageCharEnd !== null;
// "Open original" creates a blob: URL of the original bytes and
// passes it to `window.open`. For `html` the new tab would render
// it as live HTML — exactly the Risk #3 / contracts §2.3 trip
// ("MUST refuse to create an inline object URL for mediaKind ==
// 'html' | 'docx' | 'unknown'"). For those types the only safe
// action is Download (backend already sets
// Content-Disposition: attachment for those Content-Types). The
// pdf/text/image allowlist is the same one the preview-store
// uses to decide whether to fetch the blob at all (§5.4). */
const canOpenInline = isInlineBlobAllowed(target.mediaKind);
const handleDownload = useCallback(() => {
downloadOriginal(target).catch(() => {
// best-effort; the user can retry the action.
});
}, [target]);
const handleOpenExternal = useCallback(() => {
// Re-fetch through authFetch and hand the new tab a blob URL.
// `window.open(rawApiUrl)` would send the request WITHOUT the
// Authorization header (window.open can't set custom headers)
// and the HTTPBearer-protected /file route would respond 401.
// See D1.3 finding.
openOriginalInNewTab(target).catch(() => {
// best-effort; the user can retry.
});
}, [target]);
return (
<div className="flex h-full flex-col gap-3 overflow-hidden p-4">
<div className="flex items-center gap-2 text-sm">
<FileTextIcon
className="size-4 shrink-0 text-muted-foreground"
aria-hidden={true}
/>
<span className="truncate font-medium" title={target.filename}>
{target.filename}
</span>
</div>
{target.targetPage != null ? (
<p className="text-xs text-muted-foreground">
Cited from page {target.targetPage}
</p>
) : null}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden rounded-md border border-border/60 bg-muted/30 p-3">
{hasSnippet ? (
<>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{hasLocator ? "Highlighted source excerpt" : "Source excerpt"}
</p>
{hasLocator ? (
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-words text-xs leading-relaxed text-foreground/85">
{renderHighlightedSnippet(snippet, target)}
</pre>
) : (
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-words text-xs leading-relaxed text-foreground/85">
{snippet}
</pre>
)}
</>
) : (
<p className="my-auto text-center text-xs text-muted-foreground">
{canOpenInline
? "No source excerpt — open the original to view this document."
: "No source excerpt — download the original to view this document."}
</p>
)}
</div>
<div className="flex gap-2">
{canOpenInline ? (
<Button
variant="outline"
size="sm"
onClick={handleOpenExternal}
className="flex-1"
>
<ExternalLinkIcon className="size-3.5" />
Open original
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={handleDownload}
className="flex-1"
>
<DownloadIcon className="size-3.5" />
Download
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { AlertCircleIcon, FileXIcon } from "lucide-react";
import type { FC } from "react";
interface PreviewUnavailableProps {
/** Filename if known; "Document" otherwise. */
filename?: string;
/** One-line reason pulled from the backend's error body when
* available, otherwise a generic copy. */
reason: string;
/** "missing" deleted/404 case; "error" other failures. The icon
* + tone change so a stale citation reads as "no longer available"
* rather than a transient blip. */
variant?: "missing" | "error";
}
export const PreviewUnavailable: FC<PreviewUnavailableProps> = ({
filename,
reason,
variant = "error",
}) => {
const Icon = variant === "missing" ? FileXIcon : AlertCircleIcon;
const headline =
variant === "missing" ? "Document unavailable" : "Couldn't load preview";
return (
<output className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<Icon
className="size-10 text-muted-foreground"
strokeWidth={1.5}
aria-hidden={true}
/>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{headline}</p>
{filename ? (
<p className="text-xs text-muted-foreground" title={filename}>
{filename}
</p>
) : null}
<p className="mt-1 max-w-xs text-xs text-muted-foreground">{reason}</p>
</div>
</output>
);
};

View file

@ -0,0 +1,124 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import type { ChunkingStrategy, KBMode } from "../api/rag-api";
import { useRagStore } from "../stores/rag-store";
/** Defaults pre-fill the KB create dialog. Same (multimodal, late) rejection as create. */
export function RagDefaultsSection() {
const defaults = useRagStore((s) => s.defaults);
const loadDefaults = useRagStore((s) => s.loadDefaults);
const updateDefaults = useRagStore((s) => s.updateDefaults);
const [chunkingStrategy, setChunkingStrategy] =
useState<ChunkingStrategy>("standard");
const [mode, setMode] = useState<KBMode>("text");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void loadDefaults();
}, [loadDefaults]);
useEffect(() => {
if (defaults) {
setChunkingStrategy(defaults.chunking_strategy);
setMode(defaults.mode);
}
}, [defaults]);
const lateDisabled = mode === "multimodal";
const multimodalDisabled = chunkingStrategy === "late";
const persist = (patch: {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string | null;
}) => {
setError(null);
void updateDefaults(patch).catch((err) => {
setError(err instanceof Error ? err.message : String(err));
});
};
return (
<div className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-medium">Defaults for new knowledge bases</h3>
<p className="text-xs text-muted-foreground">
Pre-fills the KB create dialog. Existing KBs keep their own
settings use the Reconfigure button to change those.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="defaults-mode">Mode</Label>
<Select
value={mode}
onValueChange={(v) => {
const next = v as KBMode;
setMode(next);
persist({ mode: next });
}}
>
<SelectTrigger id="defaults-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text only</SelectItem>
<SelectItem
value="multimodal"
disabled={multimodalDisabled}
title={
multimodalDisabled
? "Multimodal cannot be combined with late chunking"
: undefined
}
>
Multimodal
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="defaults-strategy">Chunking strategy</Label>
<Select
value={chunkingStrategy}
onValueChange={(v) => {
const next = v as ChunkingStrategy;
setChunkingStrategy(next);
persist({ chunking_strategy: next });
}}
>
<SelectTrigger id="defaults-strategy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem
value="late"
disabled={lateDisabled}
title={
lateDisabled
? "Late chunking cannot be combined with multimodal mode"
: undefined
}
>
Late chunking
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
</div>
);
}

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect } from "react";
import { useRagStore } from "../stores/rag-store";
export function ThreadIndexList() {
const threadIndexes = useRagStore((s) => s.threadIndexes);
const loading = useRagStore((s) => s.threadIndexesLoading);
const loadThreadIndexes = useRagStore((s) => s.loadThreadIndexes);
const clearThreadIndex = useRagStore((s) => s.clearThreadIndex);
useEffect(() => {
void loadThreadIndexes();
}, [loadThreadIndexes]);
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Thread documents</h3>
<span className="text-xs text-muted-foreground">
{loading
? "Loading…"
: `${threadIndexes.length} thread${threadIndexes.length === 1 ? "" : "s"}`}
</span>
</div>
<p className="text-xs text-muted-foreground">
Documents attached directly to a chat thread. Deleting a thread (from
the sidebar) also wipes its index.
</p>
<ScrollArea className="max-h-[160px]">
<div className="flex flex-col gap-1 pr-2">
{threadIndexes.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border/60 px-3 py-4 text-center text-xs text-muted-foreground">
No threads have attached documents.
</div>
) : null}
{threadIndexes.map((t) => (
<div
key={t.thread_id}
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{t.title ?? <em className="font-normal">Unsaved thread</em>}
</span>
<span className="text-xs text-muted-foreground">
{t.num_documents} document
{t.num_documents === 1 ? "" : "s"} · {t.num_chunks} chunks
</span>
</div>
<Button
variant="ghost"
size="icon"
aria-label="Clear thread index"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (
window.confirm(
`Delete all ${t.num_documents} document${t.num_documents === 1 ? "" : "s"} from this thread's index? This cannot be undone.`,
)
) {
void clearThreadIndex(t.thread_id);
}
}}
>
<HugeiconsIcon icon={Delete02Icon} size={14} />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import { useRagStore } from "../stores/rag-store";
/** Subscribe to a job's SSE; returns latest event, null skips. */
export function useIngestionEvents(jobId: string | null) {
const event = useRagStore((s) =>
jobId ? (s.jobs[jobId] ?? null) : null,
);
const subscribeJob = useRagStore((s) => s.subscribeJob);
useEffect(() => {
if (jobId) subscribeJob(jobId);
}, [jobId, subscribeJob]);
return event;
}

View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import type { RagDocument } from "../api/rag-api";
import { kbScopeKey, threadScopeKey, useRagStore } from "../stores/rag-store";
// Stable sentinel: inline `[]` in the selector causes React error #185
// (new ref each call → Zustand re-renders → infinite loop).
const EMPTY_DOCS: RagDocument[] = [];
export function useKBDocuments(kbId: string | null) {
const scopeKey = kbId ? kbScopeKey(kbId) : "";
const documents = useRagStore((s) =>
scopeKey ? (s.documentsByScope[scopeKey] ?? EMPTY_DOCS) : EMPTY_DOCS,
);
const loading = useRagStore((s) => (scopeKey ? !!s.docsLoading[scopeKey] : false));
const error = useRagStore((s) =>
scopeKey ? (s.docsError[scopeKey] ?? null) : null,
);
const loadKBDocuments = useRagStore((s) => s.loadKBDocuments);
const uploadDocument = useRagStore((s) => s.uploadDocument);
const deleteDocument = useRagStore((s) => s.deleteDocument);
useEffect(() => {
if (kbId) void loadKBDocuments(kbId);
}, [kbId, loadKBDocuments]);
return {
documents,
loading,
error,
refresh: () => (kbId ? loadKBDocuments(kbId) : Promise.resolve()),
upload: (file: File) =>
kbId
? uploadDocument({ kind: "kb", kbId }, file)
: Promise.reject(new Error("no KB selected")),
remove: (documentId: string) =>
scopeKey ? deleteDocument(documentId, scopeKey) : Promise.resolve(),
};
}
export function useThreadDocuments(threadId: string | null) {
const scopeKey = threadId ? threadScopeKey(threadId) : "";
const documents = useRagStore((s) =>
scopeKey ? (s.documentsByScope[scopeKey] ?? EMPTY_DOCS) : EMPTY_DOCS,
);
const loading = useRagStore((s) => (scopeKey ? !!s.docsLoading[scopeKey] : false));
const error = useRagStore((s) =>
scopeKey ? (s.docsError[scopeKey] ?? null) : null,
);
const loadThreadDocuments = useRagStore((s) => s.loadThreadDocuments);
const uploadDocument = useRagStore((s) => s.uploadDocument);
const deleteDocument = useRagStore((s) => s.deleteDocument);
useEffect(() => {
if (threadId) void loadThreadDocuments(threadId);
}, [threadId, loadThreadDocuments]);
return {
documents,
loading,
error,
refresh: () =>
threadId ? loadThreadDocuments(threadId) : Promise.resolve(),
upload: (file: File) =>
threadId
? uploadDocument({ kind: "thread", threadId }, file)
: Promise.reject(new Error("no thread selected")),
remove: (documentId: string) =>
scopeKey ? deleteDocument(documentId, scopeKey) : Promise.resolve(),
};
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import { useRagStore } from "../stores/rag-store";
export function useKnowledgeBases() {
const knowledgeBases = useRagStore((s) => s.knowledgeBases);
const loading = useRagStore((s) => s.kbsLoading);
const error = useRagStore((s) => s.kbsError);
const load = useRagStore((s) => s.loadKnowledgeBases);
const createKB = useRagStore((s) => s.createKB);
const deleteKB = useRagStore((s) => s.deleteKB);
useEffect(() => {
if (knowledgeBases.length === 0 && !loading) {
void load();
}
// Only run on mount — store-level cache prevents refetch loops.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { knowledgeBases, loading, error, refresh: load, createKB, deleteKB };
}

View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
interface UseResizableWidthOptions {
storageKey: string;
defaultWidth: number;
minWidth: number;
/** 0..1 fraction of viewport.innerWidth used as max width. Default 0.8. */
maxWidthFraction?: number;
/** Persist + listen for viewport-resize clamping only while true. */
enabled?: boolean;
}
interface UseResizableWidthResult {
width: number;
isResizing: boolean;
startResize: (event: ReactPointerEvent<HTMLElement>) => void;
adjustWidth: (delta: number) => void;
resetWidth: () => void;
}
function readStored(key: string, fallback: number): number {
if (typeof window === "undefined") {
return fallback;
}
try {
const raw = window.localStorage.getItem(key);
if (raw == null) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
/** Drag-to-resize hook for a right-anchored panel. The handle sits on
* the panel's LEFT edge width grows as the pointer moves toward
* viewport x=0. Persists to localStorage and re-clamps on viewport
* changes so a wide panel cannot eclipse the host content. */
export function useResizablePanelWidth({
storageKey,
defaultWidth,
minWidth,
maxWidthFraction = 0.8,
enabled = true,
}: UseResizableWidthOptions): UseResizableWidthResult {
const [width, setWidth] = useState<number>(() =>
readStored(storageKey, defaultWidth),
);
const [isResizing, setIsResizing] = useState(false);
const rafRef = useRef<number | null>(null);
const clampWidth = useCallback(
(next: number): number => {
if (typeof window === "undefined") {
return Math.max(minWidth, next);
}
const max = Math.floor(window.innerWidth * maxWidthFraction);
return Math.max(minWidth, Math.min(max, next));
},
[minWidth, maxWidthFraction],
);
useEffect(() => {
if (!enabled || typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(storageKey, String(width));
} catch {
// localStorage may be unavailable (private mode, quota); persist
// is best-effort.
}
}, [width, storageKey, enabled]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const onResize = () => {
setWidth((w) => clampWidth(w));
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [clampWidth]);
const startResize = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled) {
return;
}
event.preventDefault();
const target = event.currentTarget;
const pointerId = event.pointerId;
try {
target.setPointerCapture(pointerId);
} catch {
// Pointer-capture isn't available everywhere (e.g. test envs).
}
setIsResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (e: PointerEvent) => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setWidth(clampWidth(window.innerWidth - e.clientX));
});
};
const cleanup = (e: PointerEvent) => {
setIsResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
try {
target.releasePointerCapture(e.pointerId);
} catch {
// Already released or not captured.
}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", cleanup);
window.removeEventListener("pointercancel", cleanup);
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", cleanup);
window.addEventListener("pointercancel", cleanup);
},
[enabled, clampWidth],
);
const adjustWidth = useCallback(
(delta: number) => {
setWidth((w) => clampWidth(w + delta));
},
[clampWidth],
);
const resetWidth = useCallback(() => {
setWidth(clampWidth(defaultWidth));
}, [clampWidth, defaultWidth]);
return { width, isResizing, startResize, adjustWidth, resetWidth };
}

View file

@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
/** Tracks every document in the current upload batch so the toast can show
* ONE aggregate "Indexing documents" entry (with overall %) instead of a
* separate toast per file. Unlike the per-job rag-store map, entries are
* registered at addDoc time, so queued-but-not-yet-started files (held by
* the concurrency semaphore) are counted in the denominator too. */
export type IndexEntryStatus = "queued" | "indexing" | "ready" | "error";
export interface IndexEntry {
filename: string;
status: IndexEntryStatus;
/** 0..1; only meaningful while `indexing`. */
progress: number;
/** Chunks this file produced (from the job's complete event); 0 until done. */
chunks: number;
/** Tear down this upload and remove its document from the index. Registered
* by the upload surface so the aggregate toast can cancel the whole batch
* without owning the per-file job/SSE/semaphore handles. */
cancel?: () => Promise<void> | void;
}
interface IndexProgressState {
entries: Record<string, IndexEntry>;
add: (id: string, filename: string) => void;
setIndexing: (id: string) => void;
setProgress: (id: string, progress: number) => void;
setReady: (id: string, chunks?: number) => void;
setError: (id: string) => void;
setCancel: (id: string, cancel: () => Promise<void> | void) => void;
cancelAll: () => Promise<void>;
clear: () => void;
}
function patch(
set: (fn: (s: IndexProgressState) => Partial<IndexProgressState>) => void,
id: string,
changes: Partial<IndexEntry>,
): void {
set((s) => {
const existing = s.entries[id];
if (!existing) return s;
return { entries: { ...s.entries, [id]: { ...existing, ...changes } } };
});
}
export const useIndexProgressStore = create<IndexProgressState>((set, get) => ({
entries: {},
add: (id, filename) =>
set((s) => ({
entries: {
...s.entries,
[id]: { filename, status: "queued", progress: 0, chunks: 0 },
},
})),
setIndexing: (id) => patch(set, id, { status: "indexing" }),
setProgress: (id, progress) =>
patch(set, id, { status: "indexing", progress }),
setReady: (id, chunks = 0) =>
patch(set, id, { status: "ready", progress: 1, chunks }),
setError: (id) => patch(set, id, { status: "error" }),
setCancel: (id, cancel) => patch(set, id, { cancel }),
// Cancel every file in the batch (running, queued, and already-finished) so
// the index returns to its pre-batch state, then drop all toast entries.
cancelAll: async () => {
const handles = Object.values(get().entries)
.map((e) => e.cancel)
.filter((c): c is NonNullable<typeof c> => Boolean(c));
await Promise.allSettled(handles.map((c) => c()));
set({ entries: {} });
},
clear: () => set({ entries: {} }),
}));

View file

@ -0,0 +1,275 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import {
type PreviewMediaKind,
type PreviewTarget,
fetchPreviewFileBlob,
fetchPreviewFileUrl,
fetchPreviewTarget,
} from "../api/rag-api";
/** mediaKinds that may safely back an inline object URL (e.g. PDF.js
* worker, plain text, raster image). HTML, DOCX, and unknown are
* forced through the extracted-text fallback per contracts §5.4 +
* Risk #3 (unsafe HTML inline rendering). */
const INLINE_BLOB_ALLOWLIST: ReadonlySet<PreviewMediaKind> = new Set([
"pdf",
"text",
"image",
]);
export function isInlineBlobAllowed(mediaKind: PreviewMediaKind): boolean {
return INLINE_BLOB_ALLOWLIST.has(mediaKind);
}
/** What the panel should mount for the current target. Computed from
* `target.mediaKind` so the panel never has to re-derive it. */
export type PreviewLoadStatus = "idle" | "loading" | "ready" | "error";
export interface PreviewRequest {
/** Durable `rag_documents.id`. The only field required to open. */
documentId: string;
/** Durable `rag_chunks.id`. Optional absence means document-row
* preview (contracts §1.3 + decision Q2: snippet/targetPage stay
* null, no first-chunk fallback). */
backendChunkId?: string | null;
}
interface PreviewState {
/** Currently-open preview, or null when closed. */
target: PreviewTarget | null;
/** Object URL for the original file blob (PDF / text / image only).
* Null for docx / html / unknown (extracted-text fallback) and
* while the fetch is still in flight. */
previewBlobUrl: string | null;
/** Original fetched file blob for text/image fallback previews. PDFs
* prefer `previewFileUrl` so PDF.js can issue range requests. */
previewBlob: Blob | null;
/** Short-lived signed URL for PDF.js range requests. */
previewFileUrl: string | null;
previewFileUrlExpiresAt: number | null;
/** `target`-fetch + `blob`-fetch combined status. */
status: PreviewLoadStatus;
/** Last error message, if `status === "error"`. */
error: string | null;
/** Open key uniquely identifying the current request used by tests
* and by consumers that need to react to "the open call changed
* underneath me" (e.g. re-fetch after stale closure). */
openKey: number;
/** Open or replace the current preview. If a previous preview is
* open, its object URL is revoked and its in-flight fetch is
* aborted before the new request begins. */
open: (req: PreviewRequest) => Promise<void>;
/** Close the current preview. Revokes the object URL and aborts any
* in-flight fetch. Safe to call when nothing is open. */
close: () => void;
}
// State that can't live inside the zustand object without being
// part of the React render cycle. Kept module-scoped because the
// preview store is a singleton.
let activeAbortController: AbortController | null = null;
let activeBlobUrl: string | null = null;
let activeOpenKey = 0;
let restoreFocusElement: HTMLElement | null = null;
function revokeActiveBlobUrl(): void {
if (activeBlobUrl) {
URL.revokeObjectURL(activeBlobUrl);
activeBlobUrl = null;
}
}
function abortActive(): void {
if (activeAbortController) {
activeAbortController.abort();
activeAbortController = null;
}
}
export const usePreviewStore = create<PreviewState>((set) => ({
target: null,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "idle",
error: null,
openKey: 0,
async open(req) {
// Single-slot invariant (contracts §5.1): tear down whatever was
// there before assigning the new target. revoke → abort → reset.
revokeActiveBlobUrl();
abortActive();
activeOpenKey += 1;
const myKey = activeOpenKey;
const controller = new AbortController();
activeAbortController = controller;
const activeElement = document.activeElement;
restoreFocusElement =
activeElement instanceof HTMLElement ? activeElement : null;
set({
target: null,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "loading",
error: null,
openKey: myKey,
});
let target: PreviewTarget;
try {
target = await fetchPreviewTarget(
req.documentId,
req.backendChunkId ?? null,
);
} catch (err) {
if (myKey !== activeOpenKey) return; // superseded
if (activeAbortController === controller) activeAbortController = null;
set({
status: "error",
error: err instanceof Error ? err.message : String(err),
openKey: myKey,
});
return;
}
if (myKey !== activeOpenKey) {
// The user opened a different document while we were waiting.
return;
}
// For mediaKinds outside the allowlist (docx / html / unknown),
// skip the blob fetch entirely — the panel mounts the
// extracted-text fallback (contracts §5.4 + Risk #3).
if (!isInlineBlobAllowed(target.mediaKind)) {
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "ready",
error: null,
openKey: myKey,
});
return;
}
if (target.mediaKind === "pdf") {
try {
const previewFile = await fetchPreviewFileUrl(
req.documentId,
controller.signal,
);
if (myKey !== activeOpenKey) return;
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: previewFile.url,
previewFileUrlExpiresAt: previewFile.expiresAt,
status: "ready",
error: null,
openKey: myKey,
});
} catch (err) {
if (controller.signal.aborted || myKey !== activeOpenKey) return;
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "error",
error: err instanceof Error ? err.message : String(err),
openKey: myKey,
});
}
return;
}
let blob: Blob;
try {
blob = await fetchPreviewFileBlob(req.documentId, controller.signal);
} catch (err) {
if (controller.signal.aborted || myKey !== activeOpenKey) return;
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "error",
error: err instanceof Error ? err.message : String(err),
openKey: myKey,
});
return;
}
if (myKey !== activeOpenKey) {
// Superseded between target fetch and blob fetch — drop the bytes.
return;
}
const objectUrl = URL.createObjectURL(blob);
activeBlobUrl = objectUrl;
if (activeAbortController === controller) activeAbortController = null;
set({
target,
previewBlobUrl: objectUrl,
previewBlob: blob,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "ready",
error: null,
openKey: myKey,
});
},
close() {
revokeActiveBlobUrl();
abortActive();
activeOpenKey += 1; // poison any in-flight fetch that lands after this
const focusTarget = restoreFocusElement;
restoreFocusElement = null;
set({
target: null,
previewBlobUrl: null,
previewBlob: null,
previewFileUrl: null,
previewFileUrlExpiresAt: null,
status: "idle",
error: null,
openKey: activeOpenKey,
});
if (focusTarget?.isConnected) {
focusTarget.focus();
}
},
}));
/** Test-only inspector: returns whether the module-scoped blob URL is
* still live. Used by `preview-store.test.ts` to assert
* URL.revokeObjectURL was paired with URL.createObjectURL. */
export function __previewStoreInternals(): {
activeBlobUrl: string | null;
hasInflightController: boolean;
} {
return {
activeBlobUrl,
hasInflightController: activeAbortController !== null,
};
}

View file

@ -0,0 +1,353 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import {
clearThreadDocuments as apiClearThreadDocuments,
createKnowledgeBase,
type CreateKnowledgeBaseRequest,
deleteDocument as apiDeleteDocument,
deleteKnowledgeBase as apiDeleteKB,
getRagDefaults as apiGetRagDefaults,
getThreadRagSettings as apiGetThreadSettings,
type JobEvent,
type KnowledgeBase,
listKBDocuments,
listKnowledgeBases,
listThreadDocuments,
listThreadIndexes,
type RagDefaults,
type RagDocument,
type ReingestKBOptions,
reingestKnowledgeBase as apiReingestKB,
reingestThreadDocuments as apiReingestThread,
setRagDefaults as apiSetRagDefaults,
setThreadRagSettings as apiSetThreadSettings,
subscribeToJobEvents,
type ThreadIndexSummary,
type ThreadRagSettings,
type UpdateRagDefaultsRequest,
type UpdateThreadRagSettingsRequest,
uploadKBDocument,
uploadThreadDocument,
} from "../api/rag-api";
interface RagStoreState {
knowledgeBases: KnowledgeBase[];
kbsLoading: boolean;
kbsError: string | null;
documentsByScope: Record<string, RagDocument[]>;
docsLoading: Record<string, boolean>;
docsError: Record<string, string | null>;
jobs: Record<string, JobEvent>;
jobUnsubscribers: Record<string, () => void>;
threadIndexes: ThreadIndexSummary[];
threadIndexesLoading: boolean;
loadKnowledgeBases: () => Promise<void>;
createKB: (req: CreateKnowledgeBaseRequest) => Promise<KnowledgeBase>;
deleteKB: (kbId: string) => Promise<void>;
loadKBDocuments: (kbId: string) => Promise<void>;
loadThreadDocuments: (threadId: string) => Promise<void>;
uploadDocument: (
scope: { kind: "kb"; kbId: string } | { kind: "thread"; threadId: string },
file: File,
captionImages?: boolean,
) => Promise<{
documentId: string;
jobId: string;
alreadyIndexed: boolean;
}>;
deleteDocument: (documentId: string, scopeKey: string) => Promise<void>;
loadThreadIndexes: () => Promise<void>;
clearThreadIndex: (threadId: string) => Promise<void>;
reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise<string[]>;
reingestThread: (
threadId: string,
opts?: UpdateThreadRagSettingsRequest,
) => Promise<string[]>;
defaults: RagDefaults | null;
loadDefaults: () => Promise<void>;
updateDefaults: (patch: UpdateRagDefaultsRequest) => Promise<void>;
threadSettings: Record<string, ThreadRagSettings>;
loadThreadSettings: (threadId: string) => Promise<void>;
updateThreadSettings: (
threadId: string,
patch: UpdateThreadRagSettingsRequest,
) => Promise<ThreadRagSettings>;
subscribeJob: (jobId: string, onComplete?: () => void) => void;
}
function kbScopeKey(kbId: string): string {
return `kb:${kbId}`;
}
function threadScopeKey(threadId: string): string {
return `thread:${threadId}`;
}
export const useRagStore = create<RagStoreState>((set, get) => ({
knowledgeBases: [],
kbsLoading: false,
kbsError: null,
documentsByScope: {},
docsLoading: {},
docsError: {},
jobs: {},
jobUnsubscribers: {},
threadIndexes: [],
threadIndexesLoading: false,
defaults: null,
threadSettings: {},
async loadKnowledgeBases() {
set({ kbsLoading: true, kbsError: null });
try {
const kbs = await listKnowledgeBases();
set({ knowledgeBases: kbs, kbsLoading: false });
} catch (err) {
set({
kbsLoading: false,
kbsError: err instanceof Error ? err.message : String(err),
});
}
},
async createKB(req) {
const kb = await createKnowledgeBase(req);
set((state) => ({ knowledgeBases: [kb, ...state.knowledgeBases] }));
return kb;
},
async deleteKB(kbId) {
await apiDeleteKB(kbId);
set((state) => {
const scopeKey = kbScopeKey(kbId);
const { [scopeKey]: _docs, ...restDocs } = state.documentsByScope;
return {
knowledgeBases: state.knowledgeBases.filter((k) => k.id !== kbId),
documentsByScope: restDocs,
};
});
},
async loadKBDocuments(kbId) {
const key = kbScopeKey(kbId);
set((state) => ({
docsLoading: { ...state.docsLoading, [key]: true },
docsError: { ...state.docsError, [key]: null },
}));
try {
const docs = await listKBDocuments(kbId);
set((state) => ({
documentsByScope: { ...state.documentsByScope, [key]: docs },
docsLoading: { ...state.docsLoading, [key]: false },
}));
} catch (err) {
set((state) => ({
docsLoading: { ...state.docsLoading, [key]: false },
docsError: {
...state.docsError,
[key]: err instanceof Error ? err.message : String(err),
},
}));
}
},
async loadThreadDocuments(threadId) {
const key = threadScopeKey(threadId);
set((state) => ({
docsLoading: { ...state.docsLoading, [key]: true },
docsError: { ...state.docsError, [key]: null },
}));
try {
const docs = await listThreadDocuments(threadId);
set((state) => ({
documentsByScope: { ...state.documentsByScope, [key]: docs },
docsLoading: { ...state.docsLoading, [key]: false },
}));
} catch (err) {
set((state) => ({
docsLoading: { ...state.docsLoading, [key]: false },
docsError: {
...state.docsError,
[key]: err instanceof Error ? err.message : String(err),
},
}));
}
},
async uploadDocument(scope, file, captionImages = true) {
const result =
scope.kind === "kb"
? await uploadKBDocument(scope.kbId, file, captionImages)
: await uploadThreadDocument(scope.threadId, file, captionImages);
const scopeKey =
scope.kind === "kb"
? kbScopeKey(scope.kbId)
: threadScopeKey(scope.threadId);
if (scope.kind === "kb") {
void get().loadKBDocuments(scope.kbId);
} else {
void get().loadThreadDocuments(scope.threadId);
}
// Identical file already indexed in this scope: no job to track.
if (!result.already_indexed && result.job_id) {
get().subscribeJob(result.job_id, () => {
if (scope.kind === "kb") {
void get().loadKBDocuments(scope.kbId);
} else {
void get().loadThreadDocuments(scope.threadId);
}
});
}
return {
documentId: result.document_id,
jobId: result.job_id,
alreadyIndexed: result.already_indexed ?? false,
scopeKey,
} as {
documentId: string;
jobId: string;
alreadyIndexed: boolean;
};
},
async deleteDocument(documentId, scopeKey) {
await apiDeleteDocument(documentId);
set((state) => {
const current = state.documentsByScope[scopeKey] ?? [];
return {
documentsByScope: {
...state.documentsByScope,
[scopeKey]: current.filter((d) => d.id !== documentId),
},
};
});
},
async loadThreadIndexes() {
set({ threadIndexesLoading: true });
try {
const threads = await listThreadIndexes();
set({ threadIndexes: threads, threadIndexesLoading: false });
} catch {
set({ threadIndexesLoading: false });
}
},
async clearThreadIndex(threadId) {
await apiClearThreadDocuments(threadId);
const scopeKey = threadScopeKey(threadId);
set((state) => {
const { [scopeKey]: _docs, ...restDocs } = state.documentsByScope;
return {
documentsByScope: restDocs,
threadIndexes: state.threadIndexes.filter(
(t) => t.thread_id !== threadId,
),
};
});
},
async reingestKB(kbId, opts) {
const response = await apiReingestKB(kbId, opts ?? {});
void get().loadKnowledgeBases();
void get().loadKBDocuments(kbId);
for (const jobId of response.job_ids) {
get().subscribeJob(jobId, () => {
void get().loadKBDocuments(kbId);
});
}
return response.job_ids;
},
async loadDefaults() {
try {
const defaults = await apiGetRagDefaults();
set({ defaults });
} catch {
// Best-effort; null defaults fall back to hard-coded UI defaults.
}
},
async updateDefaults(patch) {
const defaults = await apiSetRagDefaults(patch);
set({ defaults });
},
async reingestThread(threadId, opts) {
const response = await apiReingestThread(threadId, opts ?? {});
void get().loadThreadDocuments(threadId);
void get().loadThreadIndexes();
if (opts) {
void get().loadThreadSettings(threadId);
}
for (const jobId of response.job_ids) {
get().subscribeJob(jobId, () => {
void get().loadThreadDocuments(threadId);
});
}
return response.job_ids;
},
async loadThreadSettings(threadId) {
try {
const settings = await apiGetThreadSettings(threadId);
set((state) => ({
threadSettings: { ...state.threadSettings, [threadId]: settings },
}));
} catch {
// Best-effort; UI falls back to defaults when missing.
}
},
async updateThreadSettings(threadId, patch) {
const settings = await apiSetThreadSettings(threadId, patch);
set((state) => ({
threadSettings: { ...state.threadSettings, [threadId]: settings },
}));
return settings;
},
subscribeJob(jobId, onComplete) {
// `in` check avoids always-truthy lint without noUncheckedIndexedAccess.
if (jobId in get().jobUnsubscribers) return;
const unsubscribe = subscribeToJobEvents(jobId, {
onEvent: (event) => {
set((state) => ({ jobs: { ...state.jobs, [jobId]: event } }));
if (
event.type === "complete" ||
event.type === "error" ||
event.type === "cancelled"
) {
onComplete?.();
}
},
onClose: () => {
set((state) => {
const { [jobId]: _gone, ...rest } = state.jobUnsubscribers;
return { jobUnsubscribers: rest };
});
},
});
set((state) => ({
jobUnsubscribers: { ...state.jobUnsubscribers, [jobId]: unsubscribe },
}));
},
}));
export { kbScopeKey, threadScopeKey };

View file

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import {
Cancel01Icon,
CloudIcon,
Database01Icon,
Globe02Icon,
HelpCircleIcon,
Message01Icon,
@ -32,6 +33,7 @@ import { AppearanceTab } from "./tabs/appearance-tab";
import { ChatTab } from "./tabs/chat-tab";
import { ConnectionsTab } from "./tabs/connections-tab";
import { GeneralTab } from "./tabs/general-tab";
import { KnowledgeBasesTab } from "./tabs/knowledge-bases-tab";
import { ProfileTab } from "./tabs/profile-tab";
interface TabDef {
@ -50,6 +52,12 @@ const TABS: TabDef[] = [
icon: PaintBrush02Icon,
},
{ id: "chat", labelKey: "settings.tabs.chat", icon: Message01Icon },
{
id: "knowledge-bases",
labelKey: "settings.tabs.knowledgeBases",
icon: Database01Icon,
badgeKey: "common.new",
},
{
id: "connections",
labelKey: "settings.tabs.connections",
@ -75,6 +83,8 @@ function renderTab(tab: SettingsTab) {
return <AppearanceTab />;
case "chat":
return <ChatTab />;
case "knowledge-bases":
return <KnowledgeBasesTab />;
case "connections":
return <ConnectionsTab />;
case "api-keys":
@ -97,6 +107,7 @@ export function SettingsDialog() {
profile: null,
appearance: null,
chat: null,
"knowledge-bases": null,
connections: null,
"api-keys": null,
about: null,

View file

@ -8,6 +8,7 @@ export type SettingsTab =
| "profile"
| "appearance"
| "chat"
| "knowledge-bases"
| "connections"
| "api-keys"
| "about";
@ -40,6 +41,7 @@ function loadInitialTab(): SettingsTab {
"profile",
"appearance",
"chat",
"knowledge-bases",
"connections",
"api-keys",
"about",

Some files were not shown because too many files have changed in this diff Show more