Compare commits
126 commits
main
...
parked/ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eab078311 | ||
|
|
d676cb8600 | ||
|
|
1923cb4eca |
||
|
|
bab409ef84 | ||
|
|
8cc0b80704 | ||
|
|
978028bf82 | ||
|
|
1a7c1cccba | ||
|
|
aa68568ff5 | ||
|
|
187a5853ba | ||
|
|
80e7ad4daf | ||
|
|
6388f3f349 | ||
|
|
71b5214ca7 | ||
|
|
b17b3ad727 | ||
|
|
1844ec0776 | ||
|
|
fc7fa0b5a7 | ||
|
|
1a31aa5b27 | ||
|
|
7335dc07a9 | ||
|
|
c149cf58d1 | ||
|
|
1112fc891b | ||
|
|
4635b0bc36 | ||
|
|
2b0c6cf220 | ||
|
|
68646a7abf | ||
|
|
05944a1e9b | ||
|
|
c34dd5f19a | ||
|
|
cbe4b0866a | ||
|
|
8c6acdc6a3 | ||
|
|
08cee7cc0a | ||
|
|
dafc7092ad | ||
|
|
b836c3c76b | ||
|
|
cf7dec1f13 | ||
|
|
f30c0a48dd | ||
|
|
266342a64e | ||
|
|
d55e5d1474 | ||
|
|
32e57fa1c5 | ||
|
|
f4b34f71c5 | ||
|
|
ba78141ac5 | ||
|
|
3a0c774795 | ||
|
|
3173689b59 |
||
|
|
95622dc405 | ||
|
|
290201f62e | ||
|
|
d6a7c9f8c7 | ||
|
|
f25ea25570 | ||
|
|
7e3e69db3f | ||
|
|
c0f8d486a4 | ||
|
|
3117cb5f99 | ||
|
|
d652f03b6e | ||
|
|
79fc69741c | ||
|
|
9eb0778628 | ||
|
|
27b0a50a84 | ||
|
|
dd3ee02648 | ||
|
|
3d3a00c2be | ||
|
|
41e43b6c7b | ||
|
|
6c1761f16c | ||
|
|
29e8cad8b8 | ||
|
|
1db654abb1 | ||
|
|
8fb2fb9e2a | ||
|
|
3f6a390df6 | ||
|
|
8198459597 | ||
|
|
5edffa3feb | ||
|
|
336ad815b3 | ||
|
|
8145f1d527 | ||
|
|
0be7ca39a4 | ||
|
|
6659bdf152 | ||
|
|
ba0fd85e8b | ||
|
|
0481ac30c6 | ||
|
|
3372a79043 | ||
|
|
6debd0ab19 | ||
|
|
af7917c45a | ||
|
|
aedede2f2e | ||
|
|
26d2421b6c | ||
|
|
bdbf47a341 | ||
|
|
c6a1935dd2 | ||
|
|
f522545b65 | ||
|
|
c1bc79bc84 | ||
|
|
5883a2d6c6 | ||
|
|
9d6e893ed0 | ||
|
|
d0e894726b | ||
|
|
38e22c15d7 | ||
|
|
5351822ff8 | ||
|
|
c22fb88b4f | ||
|
|
7c1a09b350 | ||
|
|
9d5719a475 | ||
|
|
7c1f8efe99 | ||
|
|
d6a5abb4ba | ||
|
|
719bd38ddf | ||
|
|
1f3a92cab9 | ||
|
|
3248052269 | ||
|
|
ae013e3383 | ||
|
|
1b45656bf5 | ||
|
|
04d4909ed6 | ||
|
|
86b52503dd | ||
|
|
3b477a816c | ||
|
|
e48d836ffc | ||
|
|
7b3a13fea4 | ||
|
|
005234c953 | ||
|
|
2093fb1608 | ||
|
|
b931b0039b | ||
|
|
0130c1d1ff | ||
|
|
fe124f5b63 | ||
|
|
8666ec9bd6 | ||
|
|
810e3a80be | ||
|
|
ad6fb95dba | ||
|
|
16c0367e84 | ||
|
|
d5e62df2b8 | ||
|
|
da698cbdae | ||
|
|
236ad37091 | ||
|
|
9ce194482b | ||
|
|
ccebbed190 | ||
|
|
e27c079e3d | ||
|
|
6c694d2ef8 | ||
|
|
30856de739 | ||
|
|
510509318a | ||
|
|
5edbc99916 | ||
|
|
810b2e27f3 | ||
|
|
a3ad6015bc | ||
|
|
c74fc13ebc | ||
|
|
7e069816a1 | ||
|
|
ee1ff2bb50 | ||
|
|
ca83bea538 | ||
|
|
2b85a165e6 | ||
|
|
d9dfc7db80 | ||
|
|
68114fd223 | ||
|
|
673b7f86ba | ||
|
|
4c1ab745d6 | ||
|
|
c4b5889e53 | ||
|
|
92994e8b83 |
121 changed files with 21222 additions and 756 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
||||
|
|
|
|||
2
studio/backend/core/rag/__init__.py
Normal file
2
studio/backend/core/rag/__init__.py
Normal 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
|
||||
121
studio/backend/core/rag/authorization.py
Normal file
121
studio/backend/core/rag/authorization.py
Normal 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
|
||||
111
studio/backend/core/rag/bm25.py
Normal file
111
studio/backend/core/rag/bm25.py
Normal 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)
|
||||
212
studio/backend/core/rag/captioner.py
Normal file
212
studio/backend/core/rag/captioner.py
Normal 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))
|
||||
309
studio/backend/core/rag/chunking.py
Normal file
309
studio/backend/core/rag/chunking.py
Normal 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
|
||||
91
studio/backend/core/rag/db.py
Normal file
91
studio/backend/core/rag/db.py
Normal 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
|
||||
456
studio/backend/core/rag/embeddings.py
Normal file
456
studio/backend/core/rag/embeddings.py
Normal 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
|
||||
1000
studio/backend/core/rag/ingestion.py
Normal file
1000
studio/backend/core/rag/ingestion.py
Normal file
File diff suppressed because it is too large
Load diff
500
studio/backend/core/rag/locators.py
Normal file
500
studio/backend/core/rag/locators.py
Normal 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),
|
||||
)
|
||||
181
studio/backend/core/rag/parsers/__init__.py
Normal file
181
studio/backend/core/rag/parsers/__init__.py
Normal 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)
|
||||
90
studio/backend/core/rag/parsers/docx.py
Normal file
90
studio/backend/core/rag/parsers/docx.py
Normal 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,
|
||||
)
|
||||
86
studio/backend/core/rag/parsers/html.py
Normal file
86
studio/backend/core/rag/parsers/html.py
Normal 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,
|
||||
)
|
||||
163
studio/backend/core/rag/parsers/pdf.py
Normal file
163
studio/backend/core/rag/parsers/pdf.py
Normal 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)
|
||||
30
studio/backend/core/rag/parsers/text.py
Normal file
30
studio/backend/core/rag/parsers/text.py
Normal 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 = [],
|
||||
)
|
||||
122
studio/backend/core/rag/query_decompose.py
Normal file
122
studio/backend/core/rag/query_decompose.py
Normal 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))
|
||||
221
studio/backend/core/rag/reranker.py
Normal file
221
studio/backend/core/rag/reranker.py
Normal 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
|
||||
239
studio/backend/core/rag/retrieval.py
Normal file
239
studio/backend/core/rag/retrieval.py
Normal 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]
|
||||
49
studio/backend/core/rag/scope.py
Normal file
49
studio/backend/core/rag/scope.py
Normal 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
|
||||
289
studio/backend/core/rag/tool.py
Normal file
289
studio/backend/core/rag/tool.py
Normal 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("&", "&")
|
||||
.replace('"', """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
189
studio/backend/core/rag/vector_store.py
Normal file
189
studio/backend/core/rag/vector_store.py
Normal 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()
|
||||
|
|
@ -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 ============
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
studio/backend/requirements/rag.txt
Normal file
35
studio/backend/requirements/rag.txt
Normal 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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1862
studio/backend/routes/rag.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
272
studio/backend/tests/test_rag_authorization.py
Normal file
272
studio/backend/tests/test_rag_authorization.py
Normal 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
|
||||
279
studio/backend/tests/test_rag_chunk_locators.py
Normal file
279
studio/backend/tests/test_rag_chunk_locators.py
Normal 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
|
||||
140
studio/backend/tests/test_rag_locator_backfill.py
Normal file
140
studio/backend/tests/test_rag_locator_backfill.py
Normal 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
|
||||
86
studio/backend/tests/test_rag_locator_migration.py
Normal file
86
studio/backend/tests/test_rag_locator_migration.py
Normal 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,
|
||||
}
|
||||
626
studio/backend/tests/test_rag_preview_routes.py
Normal file
626
studio/backend/tests/test_rag_preview_routes.py
Normal 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")
|
||||
136
studio/backend/tests/test_rag_preview_target_locators.py
Normal file
136
studio/backend/tests/test_rag_preview_target_locators.py
Normal 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
|
||||
243
studio/backend/tests/test_rag_source_identity.py
Normal file
243
studio/backend/tests/test_rag_source_identity.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2
studio/backend/utils/rag/__init__.py
Normal file
2
studio/backend/utils/rag/__init__.py
Normal 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
|
||||
84
studio/backend/utils/rag/config.py
Normal file
84
studio/backend/utils/rag/config.py
Normal 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"}
|
||||
)
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
1192
studio/frontend/package-lock.json
generated
1192
studio/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
125
studio/frontend/src/__tests__/chat-adapter.test.ts
Normal file
125
studio/frontend/src/__tests__/chat-adapter.test.ts
Normal 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", () => {
|
||||
// & should decode to & in the source attribute (decodeXml in parseChunks)
|
||||
const xml = `<chunk id="1" source="report & 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");
|
||||
});
|
||||
});
|
||||
92
studio/frontend/src/__tests__/document-row.test.tsx
Normal file
92
studio/frontend/src/__tests__/document-row.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
82
studio/frontend/src/__tests__/knowledge-bases-tab.test.tsx
Normal file
82
studio/frontend/src/__tests__/knowledge-bases-tab.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
93
studio/frontend/src/__tests__/preview-a11y.test.tsx
Normal file
93
studio/frontend/src/__tests__/preview-a11y.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
521
studio/frontend/src/__tests__/preview-panel.test.tsx
Normal file
521
studio/frontend/src/__tests__/preview-panel.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
386
studio/frontend/src/__tests__/preview-pdf-smoke.test.tsx
Normal file
386
studio/frontend/src/__tests__/preview-pdf-smoke.test.tsx
Normal 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>");
|
||||
});
|
||||
});
|
||||
});
|
||||
285
studio/frontend/src/__tests__/preview-store.test.ts
Normal file
285
studio/frontend/src/__tests__/preview-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
191
studio/frontend/src/__tests__/rag-api.test.ts
Normal file
191
studio/frontend/src/__tests__/rag-api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
80
studio/frontend/src/__tests__/sources.test.tsx
Normal file
80
studio/frontend/src/__tests__/sources.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />}>
|
||||
|
|
|
|||
22
studio/frontend/src/app/routes/knowledge-bases.tsx
Normal file
22
studio/frontend/src/app/routes/knowledge-bases.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/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 “{query}”…</>
|
||||
) : (
|
||||
<>Retrieving…</>
|
||||
)}
|
||||
</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";
|
||||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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") &&
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
41
studio/frontend/src/features/chat/utils/rag-index-queue.ts
Normal file
41
studio/frontend/src/features/chat/utils/rag-index-queue.ts
Normal 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();
|
||||
}
|
||||
591
studio/frontend/src/features/rag/api/rag-api.ts
Normal file
591
studio/frontend/src/features/rag/api/rag-api.ts
Normal 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();
|
||||
}
|
||||
123
studio/frontend/src/features/rag/components/document-row.tsx
Normal file
123
studio/frontend/src/features/rag/components/document-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
253
studio/frontend/src/features/rag/components/kb-create-dialog.tsx
Normal file
253
studio/frontend/src/features/rag/components/kb-create-dialog.tsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
231
studio/frontend/src/features/rag/components/kb-detail-panel.tsx
Normal file
231
studio/frontend/src/features/rag/components/kb-detail-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
studio/frontend/src/features/rag/components/kb-list.tsx
Normal file
129
studio/frontend/src/features/rag/components/kb-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
235
studio/frontend/src/features/rag/components/preview-panel.tsx
Normal file
235
studio/frontend/src/features/rag/components/preview-panel.tsx
Normal 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;
|
||||
};
|
||||
600
studio/frontend/src/features/rag/components/preview-pdf-view.tsx
Normal file
600
studio/frontend/src/features/rag/components/preview-pdf-view.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
73
studio/frontend/src/features/rag/hooks/use-kb-documents.ts
Normal file
73
studio/frontend/src/features/rag/hooks/use-kb-documents.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
158
studio/frontend/src/features/rag/hooks/use-resizable-width.ts
Normal file
158
studio/frontend/src/features/rag/hooks/use-resizable-width.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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: {} }),
|
||||
}));
|
||||
275
studio/frontend/src/features/rag/stores/preview-store.ts
Normal file
275
studio/frontend/src/features/rag/stores/preview-store.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
353
studio/frontend/src/features/rag/stores/rag-store.ts
Normal file
353
studio/frontend/src/features/rag/stores/rag-store.ts
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue