Merge remote-tracking branch 'origin/main' into woa-nvidia-wsl-fallback

This commit is contained in:
Daniel Han 2026-06-09 23:30:10 -07:00
commit ccd5b30b7e
85 changed files with 12669 additions and 176 deletions

View file

@ -269,7 +269,8 @@ jobs:
tests/saving/test_patch_saving_none_tokenizer.py \
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py
python -m pytest --collect-only -q "$RUNNER_TEMP/unsloth-zoo/tests/"
- name: import_fixes drift detectors (18 tests, HARD GATE)
@ -333,11 +334,9 @@ jobs:
python -m pytest -v --tb=short tests/test_callback_signature_drift.py
- name: unsloth Bucket-A — CPU tests not in Repo tests (CPU)
# 16 tests across 5 files. They live inside tests/saving/ and
# tests/utils/, both of which Repo tests (CPU) excludes via --ignore
# because their sibling files need real GPUs / real HF weights.
# The five files below are pure-Python + AST/protobuf/regex tests
# that run cleanly on CPU. Env inherited from the job block.
# CPU tests across 6 files under tests/saving/, tests/utils/, tests/python/
# that Repo tests (CPU) --ignores. AST/protobuf/regex plus tiny CPU model
# loads; run cleanly here (transformers/torch installed).
run: |
python -m pytest -q --tb=short \
tests/saving/test_save_shell_injection.py \
@ -345,11 +344,12 @@ jobs:
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py \
--deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
# The deselected test monkeypatches flash_attn_varlen_func, which is
# only bound on the module when `flash_attn` is importable. flash_attn
# requires CUDA + dev toolchain, which the CPU-only ubuntu-latest
# runner does not have. The other 15 Bucket-A tests pass cleanly.
# runner does not have. The other Bucket-A tests pass cleanly.
- name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU)
# 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip

View file

@ -758,6 +758,7 @@ class InferenceBackend:
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
):
"""Run an agentic tool loop on top of ``generate_chat_response``.
@ -807,6 +808,7 @@ class InferenceBackend:
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
rag_scope = rag_scope,
)
def generate_chat_response(

View file

@ -50,6 +50,8 @@ from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from core.inference.tool_call_parser import (
RAG_MAX_SEARCHES_PER_TURN,
RAG_SEARCH_CAP_NUDGE,
TOOL_XML_SIGNALS,
parse_tool_calls_from_text as _shared_parse_tool_calls_from_text,
)
@ -4303,6 +4305,7 @@ class LlamaCppBackend:
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
seed: Optional[int] = None,
disable_parallel_tool_use: bool = False,
) -> Generator[dict, None, None]:
@ -4314,12 +4317,21 @@ class LlamaCppBackend:
{"type": "content", "text": "token"} -- streamed content tokens (cumulative)
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
"""
from core.inference.tools import execute_tool
from core.inference.tools import build_rag_autoinject, execute_tool
if not self.is_loaded:
raise RuntimeError("llama-server is not loaded")
conversation = list(messages)
# Forced first-pass RAG so a doc question doesn't lose to web_search. Emits
# the same tool card + citations a real call would.
_auto = build_rag_autoinject(conversation, rag_scope)
if _auto:
for _ev in _auto["events"]:
yield _ev
conversation.extend(_auto["messages"])
url = f"{self.base_url}/v1/chat/completions"
_accumulated_completion_tokens = 0
_accumulated_predicted_ms = 0.0
@ -4356,6 +4368,9 @@ class LlamaCppBackend:
_MAX_BUFFER_CHARS = 32
_append_budget_exhausted_nudge = True
# RAG: cap knowledge-base searches per assistant turn. The controller is
# tool-agnostic, so this gate stays in the loop.
_kb_search_count = 0
# ── Re-prompt on plan-without-action ─────────────────
# When the model describes what it intends to do (forward-looking
@ -4996,13 +5011,23 @@ class LlamaCppBackend:
yield decision.tool_start_event()
_effective_timeout = None if tool_call_timeout >= 9999 else tool_call_timeout
result = execute_tool(
decision.tool_name,
decision.arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
)
# RAG: cap paraphrased KB re-searches that slip past the dup guard.
if (
decision.tool_name == "search_knowledge_base"
and _kb_search_count >= RAG_MAX_SEARCHES_PER_TURN
):
result = RAG_SEARCH_CAP_NUDGE
else:
result = execute_tool(
decision.tool_name,
decision.arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
rag_scope = rag_scope,
)
if decision.tool_name == "search_knowledge_base":
_kb_search_count += 1
completion = tool_controller.record_result(decision, result)
yield completion.tool_end_event()
conversation.append(completion.tool_message())

View file

@ -821,6 +821,7 @@ class InferenceOrchestrator:
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
use_adapter: Optional[Union[bool, str]] = None,
stats_holder: Optional[dict] = None,
**_unused,
@ -881,6 +882,7 @@ class InferenceOrchestrator:
max_tool_iterations = max_tool_iterations,
tool_call_timeout = tool_call_timeout,
session_id = session_id,
rag_scope = rag_scope,
)
def generate_with_adapter_control(

View file

@ -23,6 +23,8 @@ from loggers import get_logger
from core.inference.tool_call_parser import (
_TOOL_ALL_PATS,
BUDGET_EXHAUSTED_NUDGE,
RAG_MAX_SEARCHES_PER_TURN,
RAG_SEARCH_CAP_NUDGE,
TOOL_XML_SIGNALS,
parse_tool_calls_from_text,
strip_tool_markup,
@ -143,6 +145,7 @@ def run_safetensors_tool_loop(
max_tool_iterations: int = 25,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
rag_scope: Optional[dict] = None,
) -> Generator[dict, None, None]:
"""Drive an agentic tool loop on top of a cumulative-text generator.
@ -167,11 +170,23 @@ def run_safetensors_tool_loop(
* ``{"type": "tool_end", "tool_name", "tool_call_id", "result"}``
"""
conversation = list(messages)
# Forced first-pass RAG (mirrors the GGUF loop) so doc Qs don't lose to web_search.
from core.inference.tools import build_rag_autoinject
_auto = build_rag_autoinject(conversation, rag_scope)
if _auto:
for _ev in _auto["events"]:
yield _ev
conversation.extend(_auto["messages"])
unrestricted_tools = not tools
tool_controller = ToolLoopController(
tools = None if unrestricted_tools else tools,
auto_heal_tool_calls = auto_heal_tool_calls,
)
# RAG: cap knowledge-base searches per assistant turn (controller-agnostic).
kb_search_count = 0
final_attempt_done = False
next_call_id = 0
@ -498,17 +513,27 @@ def run_safetensors_tool_loop(
yield decision.tool_start_event()
eff_timeout = None if tool_call_timeout >= 9999 else tool_call_timeout
try:
result = execute_tool(
decision.tool_name,
decision.arguments,
cancel_event = cancel_event,
timeout = eff_timeout,
session_id = session_id,
)
except Exception as exc:
logger.exception("Tool %s raised: %s", decision.tool_name, exc)
result = f"Error: tool raised an exception: {exc}"
# RAG: cap paraphrased KB re-searches that slip past the dup guard.
if (
decision.tool_name == "search_knowledge_base"
and kb_search_count >= RAG_MAX_SEARCHES_PER_TURN
):
result = RAG_SEARCH_CAP_NUDGE
else:
try:
result = execute_tool(
decision.tool_name,
decision.arguments,
cancel_event = cancel_event,
timeout = eff_timeout,
session_id = session_id,
rag_scope = rag_scope,
)
except Exception as exc:
logger.exception("Tool %s raised: %s", decision.tool_name, exc)
result = f"Error: tool raised an exception: {exc}"
if decision.tool_name == "search_knowledge_base":
kb_search_count += 1
completion = tool_controller.record_result(decision, result)
yield completion.tool_end_event()

View file

@ -64,6 +64,15 @@ BUDGET_EXHAUSTED_NUDGE = (
"any more tools."
)
# The exact-args dup guard misses paraphrased re-searches, so also cap executed
# KB searches per turn, then nudge.
RAG_MAX_SEARCHES_PER_TURN = 3
RAG_SEARCH_CAP_NUDGE = (
"You have already searched the knowledge base several times this turn. "
"Do not search again. Answer the question using the passages already "
"retrieved above; if they do not contain the answer, say so plainly."
)
# Pre-compiled patterns reused by ``parse_tool_calls_from_text``.
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")

View file

@ -234,9 +234,11 @@ def is_tool_error(result: str) -> bool:
def strip_result_for_model(result: str) -> str:
"""Remove frontend-only image sentinels before feeding the model."""
if "__IMAGES__:" in result:
return result.split("__IMAGES__:", 1)[0].rstrip()
"""Remove frontend-only sentinels (image paths, RAG source map) before
feeding the result back to the model."""
for sentinel in ("__IMAGES__:", "__RAG_SOURCES__:"):
if sentinel in result:
result = result.split(sentinel, 1)[0].rstrip()
return result

View file

@ -39,6 +39,9 @@ logger = get_logger(__name__)
_EXEC_TIMEOUT = 300 # 5 minutes
# Splits the UI source-map from the result; loops strip it (like __IMAGES__).
RAG_SOURCES_SENTINEL = "\n__RAG_SOURCES__:"
# Import these at module level so the preexec_fn closure triggers no imports in
# the forked child (which can deadlock multi-threaded servers).
_libc = None
@ -539,7 +542,41 @@ RENDER_HTML_TOOL = {
},
}
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, RENDER_HTML_TOOL]
# Duplicated (not imported from core.rag.tool) so the registry never pulls in
# the RAG stack; dispatch imports it lazily.
SEARCH_KNOWLEDGE_BASE_TOOL = {
"type": "function",
"function": {
"name": "search_knowledge_base",
"description": (
"Search the user's uploaded documents and knowledge bases for "
"relevant passages. Use this whenever the question may be answered "
"by the attached documents, then cite the returned chunks."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural-language search query.",
},
"top_k": {
"type": "integer",
"description": "Max chunks to return.",
},
},
"required": ["query"],
},
},
}
ALL_TOOLS = [
WEB_SEARCH_TOOL,
PYTHON_TOOL,
TERMINAL_TOOL,
RENDER_HTML_TOOL,
SEARCH_KNOWLEDGE_BASE_TOOL,
]
# OpenAI's function.name regex ^[a-zA-Z0-9_-]{1,64}$, enforced before streaming.
@ -652,14 +689,19 @@ def execute_tool(
cancel_event = None,
timeout: int | None = _TIMEOUT_UNSET,
session_id: str | None = None,
rag_scope: dict | None = None,
) -> str:
"""Execute a tool by name with the given arguments; returns a string.
``timeout``: int seconds, ``None`` = no limit, unset = ``_EXEC_TIMEOUT``.
``session_id``: optional ID for per-conversation sandbox isolation.
``rag_scope``: hidden per-request RAG context the model never sees; consumed
by ``search_knowledge_base``.
"""
logger.info(f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}")
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "search_knowledge_base":
return _search_knowledge_base(arguments, rag_scope)
if name == "render_html":
return _render_html_result(arguments)
if name.startswith(MCP_TOOL_PREFIX):
@ -696,6 +738,208 @@ def execute_tool(
return f"Unknown tool: {name}"
def _opt_int(v) -> int | None:
try:
return int(v) if v is not None else None
except (TypeError, ValueError):
return None
def _scope_retrieval_kwargs(scope: dict) -> dict:
"""Retrieval mode from rag_scope; candidate pools and RRF come from config."""
mode = scope.get("mode")
return {"mode": mode if mode in ("hybrid", "dense", "lexical") else "hybrid"}
def _search_knowledge_base(arguments: dict, rag_scope: dict | None) -> str:
"""Run the RAG search bound to the hidden per-request ``rag_scope`` (the model
supplies only ``query``/``top_k``). Lazy import; missing sqlite-vec degrades
to a friendly message."""
scope = rag_scope or {}
query = (arguments or {}).get("query", "")
if not query or not str(query).strip():
return "Error: query is empty."
try:
from storage import rag_db
if not rag_db.RAG_AVAILABLE:
return "Knowledge base search is unavailable on this server."
from core.rag.tool import search_knowledge_base_with_sources
except Exception as exc: # noqa: BLE001
logger.warning("RAG tool unavailable: %s", exc)
return "Knowledge base search is unavailable on this server."
top_k = _opt_int((arguments or {}).get("top_k") or scope.get("default_top_k"))
text, sources = search_knowledge_base_with_sources(
query = str(query),
scope_kb_id = scope.get("kb_id"),
scope_thread_id = scope.get("thread_id"),
top_k = top_k,
**_scope_retrieval_kwargs(scope),
)
# Append the UI source-map after the sentinel; loops strip it before the model.
if sources:
import json as _json
return text + RAG_SOURCES_SENTINEL + _json.dumps(sources, ensure_ascii = False)
return text
# Forced first-pass RAG retrieval: a high cosine floor keeps it precise (fires on
# on-topic queries, skips weak ones) and helps small models that under-call the tool.
# Tunable via RAG_AUTOINJECT_MIN_SCORE.
_AUTOINJECT_DEFAULT_FLOOR = 0.70
def _autoinject_enabled() -> bool:
return os.environ.get("RAG_AUTOINJECT", "1").strip().lower() not in (
"0",
"false",
"no",
"off",
)
def _autoinject_floor() -> float:
raw = os.environ.get("RAG_AUTOINJECT_MIN_SCORE")
if raw is not None:
try:
return float(raw)
except ValueError:
pass
return _AUTOINJECT_DEFAULT_FLOOR
# Lean: injecting the full top_k every turn prefills thousands of tokens.
_AUTOINJECT_DEFAULT_TOP_K = 4
def _autoinject_top_k() -> int:
raw = os.environ.get("RAG_AUTOINJECT_TOP_K")
if raw is not None:
try:
return max(1, int(raw))
except ValueError:
pass
return _AUTOINJECT_DEFAULT_TOP_K
def _last_user_text(conversation: list[dict]) -> str:
"""Plain text of the most recent user turn (text parts only)."""
for msg in reversed(conversation):
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = [
p.get("text", "")
for p in content
if isinstance(p, dict) and p.get("type") in ("text", "input_text")
]
return " ".join(t for t in parts if t).strip()
return ""
return ""
def build_rag_autoinject(conversation: list[dict], rag_scope: dict | None) -> dict | None:
"""Pre-retrieve the latest user turn; if a hit clears the cosine floor return
``{"events": [...], "messages": [...]}`` to splice into the loop, else ``None``.
Toggle via ``rag_scope.autoinject`` (else env ``RAG_AUTOINJECT``); floor via
``rag_scope.autoinject_min_score`` (else env ``RAG_AUTOINJECT_MIN_SCORE``).
Also the small-model fallback: models below ~4B often answer from memory
instead of calling ``search_knowledge_base``, so forcing retrieval here keeps
attachments consulted regardless of model size."""
if not rag_scope:
return None
enabled = rag_scope.get("autoinject")
if enabled is None:
enabled = _autoinject_enabled()
if not enabled:
return None
query = _last_user_text(conversation)
if not query:
return None
try:
from storage import rag_db
if not rag_db.RAG_AVAILABLE:
return None
from core.rag.tool import search_for_autoinject
except Exception as exc: # noqa: BLE001
logger.warning("RAG auto-inject unavailable: %s", exc)
return None
floor_override = rag_scope.get("autoinject_min_score")
floor = float(floor_override) if floor_override is not None else _autoinject_floor()
# Cap at the lean top_k, but honor a lower user setting.
lean_k = _autoinject_top_k()
sidebar_k = _opt_int(rag_scope.get("default_top_k"))
top_k = min(sidebar_k, lean_k) if sidebar_k is not None else lean_k
try:
found = search_for_autoinject(
query = query,
scope_kb_id = rag_scope.get("kb_id"),
scope_thread_id = rag_scope.get("thread_id"),
top_k = top_k,
min_dense_score = floor,
**_scope_retrieval_kwargs(rag_scope),
)
except Exception as exc: # noqa: BLE001
logger.warning("RAG auto-inject retrieval failed: %s", exc)
return None
if not found:
logger.info("RAG auto-inject: no passage >= %.2f; skipping", floor)
return None
text, sources = found
import json as _json
import uuid as _uuid
call_id = "rag_auto_" + _uuid.uuid4().hex[:12]
args = {"query": query}
full_result = text + RAG_SOURCES_SENTINEL + _json.dumps(sources, ensure_ascii = False)
events = [
{"type": "status", "text": f"Searching documents: {query[:60]}"},
{
"type": "tool_start",
"tool_name": "search_knowledge_base",
"tool_call_id": call_id,
"arguments": args,
},
{
"type": "tool_end",
"tool_name": "search_knowledge_base",
"tool_call_id": call_id,
"result": full_result,
},
{"type": "status", "text": ""},
]
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": "search_knowledge_base",
"arguments": _json.dumps(args, ensure_ascii = False),
},
}
],
},
{
"role": "tool",
"name": "search_knowledge_base",
"tool_call_id": call_id,
"content": text,
},
]
logger.info("RAG auto-inject: %d passage(s) >= %.2f for %r", len(sources), floor, query[:80])
return {"events": events, "messages": messages}
_MAX_PAGE_CHARS = 16000 # cap fetched page text (after HTML-to-MD conversion)
# Raw download cap > _MAX_PAGE_CHARS because SSR pages embed large <head>
# sections stripped during conversion; 512 KB reaches article content even

View file

@ -0,0 +1,16 @@
# 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 core package. Import submodules lazily; keep this free of top-level
submodule imports to avoid pulling in heavy deps."""
__all__ = [
"config",
"parsers",
"chunking",
"embeddings",
"store",
"retrieval",
"tool",
"ingestion",
]

View file

@ -0,0 +1,107 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Caption figures with the loaded vision model and splice the text into the page
so images are searchable via the normal FTS5 + dense path. No-op (never raises)
without a vision model or on failure; gated by ``config.CAPTION_IMAGES``."""
from __future__ import annotations
import base64
import logging
from . import config
logger = logging.getLogger(__name__)
_CAPTION_PROMPT = (
"Describe this figure or image from a document in one or two concise "
"sentences, for search indexing. State what it depicts (e.g. a diagram, "
"chart, table or photo) and its key content. Do not add commentary."
)
def vision_endpoint() -> tuple[str, str] | None:
"""``(base_url, model)`` for a loaded vision GGUF model, else None."""
try:
from routes.inference import get_llama_cpp_backend
backend = get_llama_cpp_backend()
if getattr(backend, "is_loaded", False) and getattr(backend, "is_vision", False):
return backend.base_url, "local"
except Exception: # noqa: BLE001 - never let discovery break ingestion
return None
return None
def _caption_one(base_url: str, model: str, image_bytes: bytes, timeout: float) -> str | None:
import httpx
data_url = "data:image/png;base64," + base64.b64encode(image_bytes).decode("ascii")
payload = {
"model": model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": _CAPTION_PROMPT},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
"max_tokens": 200,
"temperature": 0.2,
"stream": False,
# Off: thinking models would spend the budget reasoning, returning "".
"chat_template_kwargs": {"enable_thinking": False},
}
try:
r = httpx.post(f"{base_url}/v1/chat/completions", json = payload, timeout = timeout)
r.raise_for_status()
text = r.json()["choices"][0]["message"]["content"]
return text.strip() or None
except Exception: # noqa: BLE001 - a failed caption is non-fatal
logger.debug("caption request failed", exc_info = True)
return None
def caption_images(
images: list, *, endpoint: tuple[str, str] | None = None
) -> dict[int, list[str]]:
"""Caption ``ParsedImage`` objects, keyed by 1-based page number; ``{}`` when
disabled, no vision model, or no images. Bounded by ``CAPTION_MAX_IMAGES``."""
if not config.CAPTION_IMAGES or not images:
return {}
ep = endpoint or vision_endpoint()
if ep is None:
return {}
base_url, model = ep
out: dict[int, list[str]] = {}
for img in images[: config.CAPTION_MAX_IMAGES]:
image_bytes = getattr(img, "image_bytes", None)
if not image_bytes:
continue
caption = _caption_one(base_url, model, image_bytes, config.CAPTION_TIMEOUT_S)
if caption:
page = getattr(img, "page_number", None) or 0
out.setdefault(int(page), []).append(caption)
return out
def splice_captions(pages: list, captions: dict[int, list[str]]) -> list:
"""Append captions to their page's text so the chunker indexes them, keeping
figures attributable in retrieved chunks. Returns new ``Page`` objects."""
if not captions:
return pages
from .parsers import Page
out: list = []
for page in pages:
caps = captions.get(page.page_number or 0)
if not caps:
out.append(page)
continue
extra = "".join(f"\n\n[Figure on page {page.page_number}: {c}]" for c in caps)
text = page.text + extra
out.append(Page(text = text, page_number = page.page_number, char_count = len(text)))
return out

View file

@ -0,0 +1,121 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Page-aware recursive-separator chunking with token overlap. Each chunk records
its ``[page_char_start, page_char_end)`` span and ``source_page_index``, used by
the locator pass to highlight it on the PDF page."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from .parsers import Page
TokenCounter = Callable[[str], int]
SEPARATORS = ("\n# ", "\n## ", "\n### ", "\n\n", "\n", ". ", " ", "")
@dataclass(frozen = True)
class Chunk:
text: str
token_count: int
page_number: int | None
source_page_index: int
chunk_index: int
page_char_start: int
page_char_end: int
def _split(text: str, seps: tuple[str, ...], max_tokens: int, count: TokenCounter) -> list[str]:
"""Recursively split into pieces each <= max_tokens (best effort). Pieces
rejoin to ``text`` exactly, so offsets are a running length."""
if count(text) <= max_tokens:
return [text]
for i, sep in enumerate(seps):
parts = list(text) if sep == "" else text.split(sep)
if len(parts) <= 1:
continue
if sep: # re-attach the separator
parts = [p + sep for p in parts[:-1]] + parts[-1:]
out: list[str] = []
for p in parts:
out.extend(
[p] if count(p) <= max_tokens else _split(p, seps[i + 1 :], max_tokens, count)
)
return [p for p in out if p]
n = max(1, max_tokens * 4)
return [text[j : j + n] for j in range(0, len(text), n)]
def _merge(
pieces: list[str], starts: list[int], max_tokens: int, overlap: int, count: TokenCounter
) -> list[tuple[str, int, int]]:
"""Greedy-merge pieces into <= max_tokens chunks with token overlap.
``starts[i]`` is ``pieces[i]``'s page char offset; returns
``(chunk_text, char_start, char_end)`` spans."""
chunks: list[tuple[str, int, int]] = []
buf: list[str] = []
buf_starts: list[int] = []
buf_tok = 0
def _flush() -> None:
raw = "".join(buf)
stripped = raw.strip()
if not stripped:
return
lead = len(raw) - len(raw.lstrip())
trail = len(raw) - len(raw.rstrip())
start = buf_starts[0] + lead
end = buf_starts[0] + len(raw) - trail
chunks.append((stripped, start, end))
for piece, start in zip(pieces, starts):
pt = count(piece)
if buf and buf_tok + pt > max_tokens:
_flush()
# Bound the carry so carry + this piece fits max_tokens; else a full
# overlap before a near-max piece overflows the embedder.
carry_budget = min(overlap, max(0, max_tokens - pt))
carry, carry_starts, run = [], [], 0
for prev, prev_start in zip(reversed(buf), reversed(buf_starts)):
if run + count(prev) > carry_budget:
break
carry.insert(0, prev)
carry_starts.insert(0, prev_start)
run += count(prev)
buf, buf_starts, buf_tok = carry, carry_starts, run
buf.append(piece)
buf_starts.append(start)
buf_tok += pt
if buf:
_flush()
return chunks
def chunk_pages(
pages: list[Page], *, max_tokens: int, overlap: int, count: TokenCounter
) -> list[Chunk]:
"""Split each page into overlapping chunks, tracking per-page char offsets."""
out: list[Chunk] = []
for page_index, page in enumerate(pages):
pieces = _split(page.text, SEPARATORS, max_tokens, count)
# _split preserves offsets, so a running cursor gives exact ones.
starts: list[int] = []
cursor = 0
for piece in pieces:
starts.append(cursor)
cursor += len(piece)
for text, char_start, char_end in _merge(pieces, starts, max_tokens, overlap, count):
out.append(
Chunk(
text = text,
token_count = count(text),
page_number = page.page_number,
source_page_index = page_index,
chunk_index = len(out),
page_char_start = char_start,
page_char_end = char_end,
)
)
return out

View file

@ -0,0 +1,41 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""RAG config; every value is env-overridable."""
from __future__ import annotations
import os
EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "unsloth/bge-small-en-v1.5")
# Under bge's 512 limit, leaving headroom for the 2 special tokens (else overflow:
# llama-server 500s, ST truncates). Keep <= embedder_max - ~12.
CHUNK_TOKENS = int(os.environ.get("RAG_CHUNK_TOKENS", "500"))
CHUNK_OVERLAP = int(os.environ.get("RAG_CHUNK_OVERLAP", "64"))
TOP_K_LEXICAL = int(os.environ.get("RAG_TOP_K_LEXICAL", "30"))
TOP_K_DENSE = int(os.environ.get("RAG_TOP_K_DENSE", "30"))
TOP_K_HYBRID = int(os.environ.get("RAG_TOP_K_HYBRID", "10"))
RRF_K = int(os.environ.get("RAG_RRF_K", "60"))
UPLOAD_EXTS = {".pdf", ".txt", ".md", ".markdown", ".docx", ".html", ".htm"}
# Figure captioning via the loaded vision model; off by default since each caption
# is a model call. MAX_IMAGES bounds per-doc cost.
CAPTION_IMAGES = os.environ.get("RAG_CAPTION_IMAGES", "0") == "1"
CAPTION_MAX_IMAGES = int(os.environ.get("RAG_CAPTION_MAX_IMAGES", "8"))
CAPTION_TIMEOUT_S = float(os.environ.get("RAG_CAPTION_TIMEOUT_S", "30"))
# Embedder backend. "auto": sentence-transformers on a CUDA/ROCm GPU (torch fp16
# wins bulk indexing), else torch-free GGUF llama-server. Switching backends changes
# the vectors, so the index must be rebuilt.
EMBED_BACKEND = os.environ.get("RAG_EMBED_BACKEND", "auto")
# llama-server backend only. F16 over Q8_0: faster (no per-block dequant for this
# tiny model) and exact vs fp32, for ~30MB more on disk.
EMBED_GGUF_REPO = os.environ.get("RAG_EMBED_GGUF_REPO", "unsloth/bge-small-en-v1.5-GGUF")
EMBED_GGUF_VARIANT = os.environ.get("RAG_EMBED_GGUF_VARIANT", "F16")
EMBED_DEVICE = os.environ.get("RAG_EMBED_DEVICE", "auto") # "auto" | "gpu" | "cpu"
EMBED_HOST = os.environ.get("RAG_EMBED_HOST", "127.0.0.1")
EMBED_PORT = int(os.environ.get("RAG_EMBED_PORT", "0")) # 0 = auto-pick a free port
EMBED_BATCH = int(os.environ.get("RAG_EMBED_BATCH", "64"))
EMBED_STARTUP_TIMEOUT_S = float(os.environ.get("RAG_EMBED_STARTUP_TIMEOUT_S", "120"))
EMBED_REQUEST_TIMEOUT_S = float(os.environ.get("RAG_EMBED_REQUEST_TIMEOUT_S", "60"))

View file

@ -0,0 +1,448 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""GGUF embedder over the bundled llama.cpp, served via HTTP (no torch).
Opt-in (``RAG_EMBED_BACKEND=llama-server``). Runs a dedicated
``llama-server --embedding`` subprocess on its own port and calls its OpenAI-style
``/v1/embeddings`` + ``/tokenize``, fully isolated from the chat backend.
Device is ``auto`` (GPU when present, else CPU, falling back to CPU if a GPU start
fails); ``RAG_EMBED_DEVICE`` forces it. We call only llama_cpp's *static* helpers
(no torch), copying the instance-coupled bits locally, since constructing a
``LlamaCppBackend`` runs an ``__init__`` reaper that kills any Studio llama-server
-- so each request re-spawns ours if it died (self-heal).
"""
from __future__ import annotations
import atexit
import logging
import os
import subprocess
import threading
import time
from functools import lru_cache
from pathlib import Path
import httpx
import numpy as np
from utils.native_path_leases import child_env_without_native_path_secret
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
from . import config
logger = logging.getLogger(__name__)
# httpx transport errors meaning "the server is gone" -> trigger a respawn.
_TRANSPORT_ERRORS = (
httpx.ConnectError,
httpx.ReadError,
httpx.RemoteProtocolError,
httpx.WriteError,
)
class LlamaServerBackend:
"""Manages a llama.cpp embedding subprocess and talks to it over HTTP."""
def __init__(self) -> None:
# Lifecycle (spawn/restart/kill) is serialized; HTTP requests are not.
self._lifecycle_lock = threading.Lock()
self._process: subprocess.Popen | None = None
self._port: int | None = None
self._stdout_lines: list[str] = []
self._stdout_thread: threading.Thread | None = None
self._dim: int | None = None
self._dim_lock = threading.Lock()
self._model_path: str | None = None
self._binary: str | None = None
# Sticky after an auto GPU start fails: later spawns stay on CPU.
self._force_cpu = False
# Pooled client; requests pass full URLs, so a respawn's new port needs
# no rebuild.
self._client = httpx.Client(timeout = config.EMBED_REQUEST_TIMEOUT_S)
atexit.register(self._shutdown)
@property
def _base_url(self) -> str:
return f"http://{config.EMBED_HOST}:{self._port}"
def _resolve_binary(self) -> str:
"""Find llama-server, verify embeddings support, cache it. Raises if
missing/unsupported."""
if self._binary is not None:
return self._binary
from core.inference.llama_cpp import LlamaCppBackend
binary = LlamaCppBackend._find_llama_server_binary()
if not binary:
raise RuntimeError(
"llama-server binary not found; cannot use RAG_EMBED_BACKEND="
"llama-server. Install llama.cpp or set LLAMA_SERVER_PATH / "
"UNSLOTH_LLAMA_CPP_PATH."
)
self._assert_embedding_support(binary)
self._binary = binary
return binary
@staticmethod
@lru_cache(maxsize = 8)
def _help_text(binary: str) -> str:
"""`llama-server --help`, cached. Ignore exit code (some builds exit
non-zero on --help)."""
try:
proc = subprocess.run(
[binary, "--help"],
capture_output = True,
text = True,
timeout = 30,
**windows_hidden_subprocess_kwargs(),
)
return (proc.stdout or "") + (proc.stderr or "")
except Exception as e: # noqa: BLE001
logger.warning("could not run `llama-server --help`: %s", e)
return ""
def _assert_embedding_support(self, binary: str) -> None:
help_text = self._help_text(binary)
# Empty help -> assume capable (a missing flag still fails at spawn).
if help_text and "--embedding" not in help_text:
raise RuntimeError(
"the bundled llama-server build lacks --embedding support; "
"RAG_EMBED_BACKEND=llama-server requires an embeddings-capable build"
)
def _resolve_model_path(self) -> str:
"""Download (or cache-hit) the variant-matching, non-mmproj GGUF embedder,
returning its local path."""
if self._model_path is not None:
return self._model_path
from huggingface_hub import hf_hub_download, list_repo_files
repo = config.EMBED_GGUF_REPO
token = os.environ.get("HF_TOKEN") or None
files = [f for f in list_repo_files(repo, token = token) if f.lower().endswith(".gguf")]
files = [f for f in files if "mmproj" not in f.lower()]
if not files:
raise RuntimeError(f"no .gguf file found in embedder repo {repo!r}")
variant = config.EMBED_GGUF_VARIANT.lower()
match = [f for f in files if variant in f.lower()] or files
filename = sorted(match, key = len)[0]
logger.info("resolving GGUF embedder %s/%s", repo, filename)
self._model_path = hf_hub_download(repo_id = repo, filename = filename, token = token)
return self._model_path
# Min free VRAM (MiB) for the embedder; below this, auto stays on CPU.
_MIN_GPU_FREE_MIB = 1024
def _use_gpu(self) -> bool:
"""``RAG_EMBED_DEVICE``: ``gpu``/``cpu`` force it; ``auto`` uses a GPU when
present. A sticky CPU fallback (after an auto GPU start fails) wins."""
dev = config.EMBED_DEVICE.lower()
if dev == "gpu":
return True
if dev == "cpu" or self._force_cpu:
return False
return self._gpu_available() # auto
@staticmethod
def _gpu_available() -> bool:
"""Apple Metal, or an NVIDIA/ROCm GPU with enough free VRAM. Reuses
llama_cpp's static probe (nvidia-smi first, so the common path needs no
torch)."""
from utils.hardware import is_apple_silicon
if is_apple_silicon():
return True # bundled mac build offloads to Metal
from core.inference.llama_cpp import LlamaCppBackend
gpus = LlamaCppBackend._get_gpu_free_memory() # [(idx, free_mib)], honors CVD
return any(free >= LlamaServerBackend._MIN_GPU_FREE_MIB for _, free in gpus)
def _build_cmd(self, binary: str, model_path: str, port: int, *, use_gpu: bool) -> list[str]:
# No --embd-normalize (not in every build; we normalize in Python to match
# the ST path). --fit off: don't auto-resize ctx/offload to device memory.
cmd = [
binary,
"-m",
model_path,
"--host",
config.EMBED_HOST,
"--port",
str(port),
"--embedding",
"--pooling",
"cls",
"--fit",
"off",
]
# -1 offloads every layer (matches the chat server); 0 keeps it on CPU.
cmd += ["-ngl", "-1" if use_gpu else "0"]
return cmd
def _build_env(self, binary: str, *, use_gpu: bool) -> dict[str, str]:
env = child_env_without_native_path_secret()
env["LLAMA_SET_ROWS"] = "1" # ggml set_rows fast path
if use_gpu:
self._add_linux_cuda_libs(env, str(Path(binary).parent))
else:
# Blank devices so a CUDA build stays on CPU and reserves no VRAM.
env["CUDA_VISIBLE_DEVICES"] = ""
return env
@staticmethod
def _add_linux_cuda_libs(env: dict[str, str], binary_dir: str) -> None:
"""Best-effort LD_LIBRARY_PATH so the prebuilt binary finds CUDA libs."""
import glob
import platform
import sys
if sys.platform == "win32":
return # Windows resolves CUDA via PATH in the inherited env.
arch = platform.machine()
lib_dirs = [binary_dir]
for pattern in (
os.path.join(sys.prefix, "lib", "python*", "site-packages", "nvidia", "cu*", "lib"),
os.path.join(sys.prefix, "lib", "python*", "site-packages", "nvidia", "cudnn", "lib"),
):
lib_dirs.extend(d for d in glob.glob(pattern) if os.path.isdir(d))
for cuda_lib in (
"/usr/local/cuda/lib64",
f"/usr/local/cuda/targets/{arch}-linux/lib",
"/usr/local/cuda-12/lib64",
):
if os.path.isdir(cuda_lib):
lib_dirs.append(cuda_lib)
existing = env.get("LD_LIBRARY_PATH", "")
joined = ":".join(lib_dirs)
env["LD_LIBRARY_PATH"] = f"{joined}:{existing}" if existing else joined
def _drain_stdout(self, proc: subprocess.Popen) -> None:
"""Drain the child's stdout so its pipe buffer never deadlocks; keep the
tail for crash diagnostics."""
try:
for line in proc.stdout: # type: ignore[union-attr]
line = line.rstrip()
if line:
self._stdout_lines.append(line)
if len(self._stdout_lines) > 200:
del self._stdout_lines[:-200]
logger.debug("[llama-embed] %s", line)
except Exception: # noqa: BLE001 - drain thread must never raise
pass
def _spawn(self) -> None:
"""Start the embed server (caller holds the lock). On ``auto``, a failed
GPU start falls back to CPU once; explicit ``gpu``/``cpu`` do not."""
use_gpu = self._use_gpu()
try:
self._spawn_once(use_gpu)
except RuntimeError:
auto = config.EMBED_DEVICE.lower() not in ("gpu", "cpu")
if use_gpu and auto:
logger.warning("embed server GPU start failed; falling back to CPU")
self._force_cpu = True
self._spawn_once(False)
else:
raise
def _spawn_once(self, use_gpu: bool) -> None:
binary = self._resolve_binary()
model_path = self._resolve_model_path()
port = config.EMBED_PORT or self._find_free_port()
env = self._build_env(binary, use_gpu = use_gpu)
cmd = self._build_cmd(binary, model_path, port, use_gpu = use_gpu)
logger.info(
"starting llama-server embedder (%s): %s",
"gpu" if use_gpu else "cpu",
" ".join(cmd),
)
self._stdout_lines = []
proc = subprocess.Popen(
cmd,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
env = env,
**windows_hidden_subprocess_kwargs(),
)
self._process = proc
self._port = port
self._stdout_thread = threading.Thread(
target = self._drain_stdout,
args = (proc,),
daemon = True,
name = "llama-embed-stdout",
)
self._stdout_thread.start()
if not self._wait_for_health(config.EMBED_STARTUP_TIMEOUT_S):
tail = "\n".join(self._stdout_lines[-30:])
self._kill_process()
raise RuntimeError(
f"llama-server embedder failed to become healthy. Last output:\n{tail[:2000]}"
)
@staticmethod
def _find_free_port() -> int:
from core.inference.llama_cpp import LlamaCppBackend
return LlamaCppBackend._find_free_port()
def _wait_for_health(
self,
timeout: float,
interval: float = 0.5,
) -> bool:
"""Poll /health until 200; bail early if the process exits."""
deadline = time.monotonic() + timeout
url = f"{self._base_url}/health"
while time.monotonic() < deadline:
if not self._process_alive():
code = None if self._process is None else self._process.returncode
logger.error("llama-server embedder exited early (code %s)", code)
return False
try:
if httpx.get(url, timeout = 2.0).status_code == 200:
return True
except (*_TRANSPORT_ERRORS, httpx.TimeoutException):
pass
time.sleep(interval)
logger.error("llama-server embedder health check timed out after %ss", timeout)
return False
def _process_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def _ensure_ready(self) -> None:
"""Guarantee a live server, (re)spawning if needed. Double-checked so the
alive path takes no lock; self-heals after the chat reaper kills us."""
if self._process_alive():
return
with self._lifecycle_lock:
if self._process_alive():
return
self._kill_process()
self._spawn()
def _restart(self) -> None:
with self._lifecycle_lock:
self._kill_process()
self._spawn()
def _kill_process(self) -> None:
proc = self._process
if proc is None:
return
try:
proc.terminate()
proc.wait(timeout = 5)
except subprocess.TimeoutExpired:
logger.warning("llama-server embedder did not exit on SIGTERM; killing")
proc.kill()
try:
proc.wait(timeout = 5)
except Exception: # noqa: BLE001
pass
except Exception as e: # noqa: BLE001
logger.warning("error killing llama-server embedder: %s", e)
finally:
self._process = None
if self._stdout_thread is not None:
self._stdout_thread.join(timeout = 2)
self._stdout_thread = None
def _shutdown(self) -> None:
try:
self._kill_process()
finally:
try:
self._client.close()
except Exception: # noqa: BLE001
pass
def _post(self, path: str, payload: dict) -> dict:
"""POST to the server, restarting once and retrying on a dropped connection
(the reaper may have killed us) or a timeout (the bundled build sometimes
wedges a request); a fresh server unsticks both."""
last_exc: Exception | None = None
for attempt in range(2):
self._ensure_ready()
try:
resp = self._client.post(f"{self._base_url}{path}", json = payload)
resp.raise_for_status()
return resp.json()
except (*_TRANSPORT_ERRORS, httpx.TimeoutException) as e:
last_exc = e
if attempt == 0:
self._restart()
continue
except httpx.HTTPStatusError as e:
body = e.response.text[:500] if e.response is not None else ""
raise RuntimeError(
f"llama-server embedder POST {path} -> {e.response.status_code}: {body}"
) from e
raise RuntimeError(f"llama-server embedder POST {path} failed after retry") from last_exc
def encode(
self,
texts,
*,
model_name = None,
normalize = True,
):
"""Embed texts -> (N, dim) float32. ``model_name`` is ignored (the GGUF is
fixed by config). Normalizes in Python to match the ST backend."""
n = len(texts)
if n == 0:
return np.zeros((0, self.dim()), dtype = np.float32)
rows: list[list[float]] = []
batch = max(1, config.EMBED_BATCH)
for start in range(0, n, batch):
chunk = list(texts[start : start + batch])
data = self._post(
"/v1/embeddings",
{"input": chunk, "model": "embedding", "encoding_format": "float"},
)
items = data.get("data", [])
if len(items) != len(chunk):
raise RuntimeError(
f"embedder returned {len(items)} vectors for {len(chunk)} inputs"
)
# OpenAI spec lets the server reorder; sort back by index.
items = sorted(items, key = lambda d: d.get("index", 0))
rows.extend(d["embedding"] for d in items)
arr = np.asarray(rows, dtype = np.float32)
if arr.ndim != 2:
raise RuntimeError(f"embedder returned ragged vectors: shape {arr.shape}")
if normalize:
norms = np.linalg.norm(arr, axis = 1, keepdims = True)
norms[norms == 0] = 1.0
arr = arr / norms
return arr
def dim(self, *, model_name = None) -> int:
"""Embedding width, probed once via a 1-text encode and cached."""
if self._dim is not None:
return self._dim
with self._dim_lock:
if self._dim is None:
vec = self.encode(["x"], normalize = False)
self._dim = int(vec.shape[1])
return self._dim
def warm(self, *, model_name = None) -> None:
"""Start the server and probe dim off the request path."""
self._ensure_ready()
self.dim()
def token_counter(self, *, model_name = None):
"""Count tokens via the GGUF's /tokenize so chunk sizing matches the
embedder. Cached per text."""
@lru_cache(maxsize = 4096)
def _count(text: str) -> int:
data = self._post("/tokenize", {"content": text, "add_special": False})
return len(data.get("tokens", []))
return _count

View file

@ -0,0 +1,297 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Dense embedder facade dispatching to a process-wide backend from
``config.EMBED_BACKEND`` (``auto`` picks by hardware): ``sentence-transformers``
(torch) or ``llama-server`` (GGUF, no torch).
Backends produce different vectors, so switching requires rebuilding the index. We
degrade to llama.cpp rather than crash when ST breaks on a machine: an init-time
probe falls back before any vector is produced (so spaces can't mix), and a
runtime ``encode`` failure swaps the process to llama-server for the rest of its
life (KBs already embedded with ST should then be reindexed).
"""
from __future__ import annotations
import logging
import os
import threading
from functools import lru_cache
from typing import Callable
from utils.hardware.hardware import DeviceType, get_device
from . import config
logger = logging.getLogger(__name__)
# "false" silences the fast tokenizer's fork warning; encode() flips it to "true"
# only during a batch tokenize (rayon speedup), then restores it.
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
_lock = threading.Lock()
# Serializes encode/tokenize (HF fast tokenizer isn't thread-safe). Separate from
# _lock so a long encode never blocks a reload.
_compute_lock = threading.Lock()
_model = None
_name: str | None = None
# Studio device -> torch device string. Apple has no torch device -> CPU.
_TORCH_DEVICE = {DeviceType.CUDA: "cuda", DeviceType.XPU: "xpu"}
def _device() -> str:
return _TORCH_DEVICE.get(get_device(), "cpu")
def _get(model_name: str | None = None):
"""Cached SentenceTransformer, (re)loading on a name change. Loaded in fp16
for a ~1.5x speedup at negligible accuracy loss."""
global _model, _name
name = model_name or config.EMBEDDING_MODEL
with _lock:
if _model is None or _name != name:
from sentence_transformers import SentenceTransformer
device = _device()
logger.info("loading embedding model %s on %s", name, device)
_model = SentenceTransformer(
name, device = device, model_kwargs = {"torch_dtype": "float16"}
)
_name = name
return _model
@lru_cache(maxsize = 1)
def _inference_ctx_factory():
"""``torch.inference_mode`` if torch imports, else ``nullcontext``. Returns the
factory so each call gets a fresh single-use guard."""
try:
import torch
return torch.inference_mode
except Exception: # noqa: BLE001 - torch may be missing or broken
from contextlib import nullcontext
return nullcontext
def _inference_ctx():
return _inference_ctx_factory()()
def _st_encode(
texts: list[str],
*,
model_name: str | None = None,
normalize: bool = True,
):
"""ST encode -> (N, dim) float32. Serialized (fast-tokenizer borrow check),
under inference_mode when torch is present, with rayon enabled for the call."""
model = _get(model_name)
with _compute_lock:
os.environ["TOKENIZERS_PARALLELISM"] = "true"
try:
with _inference_ctx():
out = model.encode(
texts,
normalize_embeddings = normalize,
convert_to_numpy = True,
show_progress_bar = False,
)
finally:
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# fp16 weights yield fp16 output; store float32 for sqlite-vec + stable cosine.
if hasattr(out, "astype"):
out = out.astype("float32", copy = False)
return out
def _st_dim(model_name: str | None = None) -> int:
return _get(model_name).get_sentence_embedding_dimension()
def _st_token_counter(model_name: str | None = None) -> Callable[[str], int]:
"""Token counter using the model's tokenizer, under the compute lock (the same
fast tokenizer backs encode and isn't thread-safe), with rayon enabled for the
call. Mirrors ``_st_encode``."""
tok = _get(model_name).tokenizer
def _count(t: str) -> int:
with _compute_lock:
os.environ["TOKENIZERS_PARALLELISM"] = "true"
try:
return len(tok.encode(t, add_special_tokens = False))
finally:
os.environ["TOKENIZERS_PARALLELISM"] = "false"
return _count
class _SentenceTransformersBackend:
"""Default backend; delegates to the module-level ST helpers so the ``_get``
monkeypatch in tests keeps working."""
def encode(
self,
texts,
*,
model_name = None,
normalize = True,
):
try:
return _st_encode(texts, model_name = model_name, normalize = normalize)
except Exception as st_err: # noqa: BLE001 - runtime ST/CUDA encode failure
# ST loaded but this encode blew up; swap the process to the llama-server
# embedder (so later encodes stay in one space) and retry.
fallback = _switch_to_llama_fallback(st_err)
if fallback is None:
raise
return fallback.encode(texts, model_name = model_name, normalize = normalize)
def token_counter(self, *, model_name = None):
return _st_token_counter(model_name)
def dim(self, *, model_name = None):
return _st_dim(model_name)
def warm(self, *, model_name = None):
_get(model_name)
_backend_lock = threading.Lock()
_backend = None
_backend_key: str | None = None
_ST_ALIASES = frozenset({"sentence-transformers", "sentence_transformers", "st"})
_LLAMA_ALIASES = frozenset(
{"llama-server", "llama_server", "llama", "llama.cpp", "llamacpp", "gguf"}
)
_AUTO_ALIASES = frozenset({"auto", ""})
def _resolve_auto() -> str:
"""Pick a backend for ``auto``: sentence-transformers when a CUDA/ROCm GPU is
present (torch fp16 wins bulk indexing), else the torch-free GGUF llama-server
-- or ST if its binary is missing. GPU check is torch-free (nvidia-smi)."""
from core.inference.llama_cpp import LlamaCppBackend
if LlamaCppBackend._get_gpu_free_memory():
return "sentence-transformers"
if LlamaCppBackend._find_llama_server_binary():
return "llama-server"
return "sentence-transformers"
def _try_make_llama_backend():
"""A llama-server GGUF embedding backend if its binary is present, else None.
Construction is lazy -- no server starts until warm."""
from core.inference.llama_cpp import LlamaCppBackend
if not LlamaCppBackend._find_llama_server_binary():
return None
from .embed_llama_server import LlamaServerBackend
return LlamaServerBackend()
def _build_st_backend_or_fallback():
"""Build the ST backend, probing it by loading the model now. If the probe
raises (no torch, CUDA mismatch, bad wheel) and the GGUF llama-server embedder
is available, fall back to it. The probe runs before any vector is produced, so
this never mixes spaces. Re-raises if no embedder can start."""
backend = _SentenceTransformersBackend()
try:
backend.warm(model_name = None)
return backend
except Exception as st_err: # noqa: BLE001 - any ST/torch import or load failure
fallback = _try_make_llama_backend()
if fallback is None:
raise
logger.warning(
"sentence-transformers embedder unavailable (%s); falling back to the "
"llama-server GGUF embedder",
st_err,
)
return fallback
def _switch_to_llama_fallback(err):
"""An ST encode failed at runtime even though the model had loaded. Swap the
process embedder to llama-server so every later encode stays in one space, and
return it (None if no binary). Vectors written before the swap were ST, so any
KB already embedded with ST should be reindexed."""
global _backend, _backend_key
with _backend_lock:
if not isinstance(_backend, _SentenceTransformersBackend):
return _backend # another thread already swapped (or was never ST)
fallback = _try_make_llama_backend()
if fallback is None:
return None
logger.warning(
"sentence-transformers encode failed (%s); switching to the llama-server "
"embedder for the rest of this process. Reindex any knowledge base that "
"was already embedded with sentence-transformers.",
err,
)
_backend = fallback
_backend_key = (config.EMBED_BACKEND or "auto").strip().lower()
return fallback
def _get_backend():
"""The process-wide embedding backend for ``config.EMBED_BACKEND``, built once.
Cached by the raw config value, so ``auto`` detection runs only on a miss and a
config change rebuilds it."""
global _backend, _backend_key
raw = (config.EMBED_BACKEND or "auto").strip().lower()
with _backend_lock:
if _backend is not None and _backend_key == raw:
return _backend
key = _resolve_auto() if raw in _AUTO_ALIASES else raw
if key in _ST_ALIASES:
_backend = _build_st_backend_or_fallback()
elif key in _LLAMA_ALIASES:
# Imported lazily so the ST path never imports llama plumbing.
from .embed_llama_server import LlamaServerBackend
_backend = LlamaServerBackend()
else:
raise ValueError(
f"Unknown RAG_EMBED_BACKEND={config.EMBED_BACKEND!r}; expected "
"'auto', 'sentence-transformers' or 'llama-server'"
)
_backend_key = raw
return _backend
def _reset_backend() -> None:
"""Drop the cached backend (test teardown / re-init)."""
global _backend, _backend_key
with _backend_lock:
_backend = None
_backend_key = None
def warm(model_name: str | None = None) -> None:
"""Eagerly load the embedder so the first real request isn't slow."""
_get_backend().warm(model_name = model_name)
def encode(
texts: list[str],
*,
model_name: str | None = None,
normalize: bool = True,
):
"""Embed texts into an (N, dim) float32 numpy array."""
return _get_backend().encode(texts, model_name = model_name, normalize = normalize)
def dim(model_name: str | None = None) -> int:
"""Embedding dimension for the (loaded) model."""
return _get_backend().dim(model_name = model_name)
def token_counter(model_name: str | None = None) -> Callable[[str], int]:
"""Callable counting tokens with the embedder's own tokenizer."""
return _get_backend().token_counter(model_name = model_name)

View file

@ -0,0 +1,251 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""In-process threaded ingestion: parse -> chunk -> embed -> store.
``start_ingestion`` returns ``(document_id, job_id)`` immediately and runs on a
daemon thread, pushing progress onto a per-job queue (streamed as SSE by
``job_events``). Documents are deduped by content hash per scope."""
from __future__ import annotations
import hashlib
import logging
import os
import queue
import threading
from storage import rag_db
from . import captioner, chunking, config, embeddings, parsers, store
logger = logging.getLogger(__name__)
# Per-job event queues, drained by job_events; ``None`` ends the stream.
_jobs: dict[str, "queue.Queue"] = {}
_jobs_lock = threading.Lock()
_EMBED_BATCH = 64 # bounds peak memory
def _sha256_file(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
def _emit(job_id: str, event: dict) -> None:
with _jobs_lock:
q = _jobs.get(job_id)
if q is not None:
q.put(event)
def _set_job(
conn,
job_id: str,
*,
status: str | None = None,
stage: str | None = None,
progress: float | None = None,
error: str | None = None,
) -> None:
conn.execute(
"UPDATE ingestion_jobs SET "
"status=COALESCE(?, status), "
"stage=COALESCE(?, stage), "
"progress=COALESCE(?, progress), "
"error=COALESCE(?, error) "
"WHERE id=?",
(status, stage, progress, error, job_id),
)
conn.commit()
def _progress(conn, job_id: str, stage: str, progress: float) -> None:
_set_job(conn, job_id, status = "running", stage = stage, progress = progress)
_emit(job_id, {"type": "progress", "stage": stage, "progress": progress})
def _embed_all(texts: list[str], model_name: str | None):
"""Embed texts in batches into a flat vector list."""
vectors: list = []
for i in range(0, len(texts), _EMBED_BATCH):
batch = texts[i : i + _EMBED_BATCH]
out = embeddings.encode(batch, model_name = model_name, normalize = True)
vectors.extend(out)
return vectors
def _run(
job_id: str, document_id: str, scope: str, stored_path: str, model_name: str | None
) -> None:
conn = rag_db.get_connection()
try:
_progress(conn, job_id, "parsing", 0.1)
pages = parsers.parse(stored_path)
if config.CAPTION_IMAGES and stored_path.lower().endswith(".pdf"):
# Caption figures, splice into page text (no-op without a vision model).
try:
figures = parsers.render_pdf_figures(
stored_path, max_figures = config.CAPTION_MAX_IMAGES
)
except Exception:
logger.warning("figure rendering failed for job %s", job_id, exc_info = True)
figures = []
if figures:
_progress(conn, job_id, "captioning", 0.2)
captions = captioner.caption_images(figures)
pages = captioner.splice_captions(pages, captions)
_progress(conn, job_id, "chunking", 0.3)
count = embeddings.token_counter(model_name)
chunks = chunking.chunk_pages(
pages,
max_tokens = config.CHUNK_TOKENS,
overlap = config.CHUNK_OVERLAP,
count = count,
)
if not chunks:
store.set_document_status(conn, document_id, "completed", num_chunks = 0)
_set_job(conn, job_id, status = "completed", stage = "done", progress = 1.0)
_emit(job_id, {"type": "complete", "num_chunks": 0})
return
_progress(conn, job_id, "embedding", 0.5)
vectors = _embed_all([c.text for c in chunks], model_name)
# Locate each chunk's highlight regions (non-PDFs/failures yield none).
regions = None
if stored_path.lower().endswith(".pdf"):
try:
from . import locators
regions = locators.pdf_regions_for_chunks(stored_path, pages, chunks)
except Exception:
logger.warning("pdf region location failed for job %s", job_id, exc_info = True)
regions = None
_progress(conn, job_id, "storing", 0.9)
store.add_chunks(conn, scope, document_id, chunks, vectors, regions)
store.set_document_status(conn, document_id, "completed", num_chunks = len(chunks))
_set_job(conn, job_id, status = "completed", stage = "done", progress = 1.0)
_emit(job_id, {"type": "complete", "num_chunks": len(chunks)})
except Exception as exc: # noqa: BLE001 - report any failure to the client
logger.exception("ingestion job %s failed", job_id)
try:
store.set_document_status(conn, document_id, "failed", error = str(exc))
_set_job(conn, job_id, status = "failed", stage = "error", error = str(exc))
except Exception: # noqa: BLE001
logger.exception("failed to record ingestion failure for job %s", job_id)
_emit(job_id, {"type": "error", "stage": "error", "error": str(exc)})
finally:
conn.close()
_emit(job_id, None)
def start_ingestion(
scope: str,
kb_id: str | None,
thread_id: str | None,
filename: str,
stored_path: str,
*,
model_name: str | None = None,
) -> tuple[str, str]:
"""Create the document + job rows and spawn the worker, returning
``(document_id, job_id)``. A duplicate content hash in this scope returns the
existing id with an already-completed job (no re-ingest)."""
ext = os.path.splitext(stored_path)[1].lower()
if ext not in config.UPLOAD_EXTS:
raise ValueError(f"unsupported file type: {ext}")
sha = _sha256_file(stored_path)
conn = rag_db.get_connection()
try:
existing = store.document_by_hash(conn, scope, sha)
if existing is not None:
job_id = _new_job(conn, existing, scope, status = "completed", progress = 1.0)
with _jobs_lock:
_jobs[job_id] = queue.Queue()
_emit(job_id, {"type": "complete", "num_chunks": 0, "deduped": True})
_emit(job_id, None)
return existing, job_id
document_id = store.create_document(
conn,
scope = scope,
filename = filename,
sha256 = sha,
kb_id = kb_id,
thread_id = thread_id,
status = "pending",
stored_path = stored_path,
)
job_id = _new_job(conn, document_id, scope)
finally:
conn.close()
with _jobs_lock:
_jobs[job_id] = queue.Queue()
threading.Thread(
target = _run,
args = (job_id, document_id, scope, stored_path, model_name),
daemon = True,
).start()
return document_id, job_id
def _new_job(
conn,
document_id: str,
scope: str,
*,
status: str = "pending",
progress: float = 0.0,
) -> str:
import uuid
from datetime import datetime, timezone
job_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO ingestion_jobs(id, document_id, scope, status, stage, progress, created_at) "
"VALUES(?,?,?,?,?,?,?)",
(
job_id,
document_id,
scope,
status,
None,
progress,
datetime.now(timezone.utc).isoformat(),
),
)
conn.commit()
return job_id
def job_events(job_id: str):
"""Yield job events for SSE; ends when the worker signals completion."""
with _jobs_lock:
q = _jobs.get(job_id)
if q is None:
return
while True:
event = q.get()
if event is None:
break
yield event
with _jobs_lock:
_jobs.pop(job_id, None)
def get_job_status(job_id: str) -> dict | None:
"""Read the persisted ingestion job row (status / stage / progress / error)."""
conn = rag_db.get_connection()
try:
row = conn.execute("SELECT * FROM ingestion_jobs WHERE id=?", (job_id,)).fetchone()
return dict(row) if row else None
finally:
conn.close()

View file

@ -0,0 +1,182 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Map a chunk to highlight rectangles on its page (computed at ingest).
The chunk's leading phrase is anchored in the page word list (``get_text("words")``),
so matching survives ligatures and dehyphenation that glyph-exact ``search_for``
misses. Matched words union per line into rects normalized to 0..1. Missing
PyMuPDF, a too-short anchor, or no unique match yields no regions (never a guess).
"""
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Anchor: up to MAX interior words from the chunk's start, shrunk toward MIN
# to recover a unique match.
MAX_ANCHOR_WORDS = 12
MIN_ANCHOR_WORDS = 4
@dataclass(frozen = True)
class LocatorMatch:
page_index: int
page_number: int | None
start: int
end: int
def _norm_token(token: str) -> str:
"""Canonical match form: NFKC (decomposes ligatures), casefold, strip
surrounding punctuation/markdown. "" if punctuation-only."""
token = unicodedata.normalize("NFKC", token).casefold()
return token.strip(" \t\r\n*#`[]()_.,;:!?\"'“”‘’-–—…|/\\")
def _anchor_tokens(page_text: str, match: LocatorMatch) -> list[str]:
"""Normalized anchor tokens from the chunk's leading span. Drops first and last
token (boundaries often slice mid-word) when long enough."""
segment = page_text[match.start : match.end]
raw = segment.split()
if len(raw) >= MIN_ANCHOR_WORDS + 2:
raw = raw[1:-1]
tokens = [t for t in (_norm_token(w) for w in raw) if t]
return tokens[:MAX_ANCHOR_WORDS]
def _find_subsequences(haystack: list[str], needle: list[str]) -> list[int]:
"""Start indices where ``needle`` occurs consecutively in ``haystack``."""
n, m = len(haystack), len(needle)
if m == 0 or m > n:
return []
first = needle[0]
out: list[int] = []
for i in range(n - m + 1):
if haystack[i] == first and haystack[i : i + m] == needle:
out.append(i)
return out
def _locate(page_words: list, needle: list[str]) -> list[int] | None:
"""Matched word indices for the best anchor, or None. Tries the full anchor
then shorter prefixes, taking the first that matches exactly once; else the
first hit if still ambiguous."""
# Skip punctuation-only words so they never break a phrase.
tokens: list[str] = []
idx_map: list[int] = []
for j, w in enumerate(page_words):
t = _norm_token(w[4])
if t:
tokens.append(t)
idx_map.append(j)
ambiguous_first: list[int] | None = None
for size in range(len(needle), MIN_ANCHOR_WORDS - 1, -1):
sub = needle[:size]
hits = _find_subsequences(tokens, sub)
if len(hits) == 1:
p = hits[0]
return [idx_map[p + k] for k in range(size)]
if hits and ambiguous_first is None:
p = hits[0]
ambiguous_first = [idx_map[p + k] for k in range(size)]
return ambiguous_first
def _rects_from_words(page_words: list, indices: list[int], pw: float, ph: float):
"""Union matched words per (block, line) into normalized page rectangles."""
lines: dict[tuple, list[float]] = {}
for j in indices:
w = page_words[j]
x0, y0, x1, y1 = float(w[0]), float(w[1]), float(w[2]), float(w[3])
key = (w[5], w[6]) # block, line
box = lines.get(key)
if box is None:
lines[key] = [x0, y0, x1, y1]
else:
box[0], box[1] = min(box[0], x0), min(box[1], y0)
box[2], box[3] = max(box[2], x1), max(box[3], y1)
out: list[dict[str, Any]] = []
for x0, y0, x1, y1 in lines.values():
w = x1 - x0
h = y1 - y0
if w <= 0 or h <= 0:
continue
out.append(
{
"x": max(0.0, min(1.0, x0 / pw)),
"y": max(0.0, min(1.0, y0 / ph)),
"width": max(0.0, min(1.0, w / pw)),
"height": max(0.0, min(1.0, h / ph)),
}
)
return out
def _regions_for_match(doc: Any, page_text: str, match: LocatorMatch) -> list[dict[str, Any]]:
try:
if match.page_index < 0 or match.page_index >= len(doc):
return []
needle = _anchor_tokens(page_text, match)
if len(needle) < MIN_ANCHOR_WORDS:
return []
page = doc[match.page_index]
page_words = page.get_text("words") or []
if not page_words:
return []
indices = _locate(page_words, needle)
if not indices:
return []
pw = float(page.rect.width)
ph = float(page.rect.height)
if pw <= 0 or ph <= 0:
return []
rects = _rects_from_words(page_words, indices, pw, ph)
for r in rects:
r["pageIndex"] = match.page_index
r["pageNumber"] = match.page_number
return rects
except Exception:
return []
def pdf_regions_for_chunks(pdf_path: Path, pages: list, chunks: list) -> list[list[dict[str, Any]]]:
"""Region rects per chunk (parallel to ``chunks``), keyed off each chunk's
``source_page_index`` / ``page_char_start`` / ``page_char_end``. Non-PDFs and
failures yield [], never an exception."""
pdf_path = Path(pdf_path)
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
match = LocatorMatch(
page_index = int(page_index),
page_number = getattr(chunk, "page_number", None),
start = int(start),
end = int(end),
)
regions.append(_regions_for_match(doc, pages[page_index].text, match))
return regions
finally:
doc.close()

View file

@ -0,0 +1,216 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Document parsing -> list[Page], one dispatch with lazy optional deps.
PDFs keep per-page boundaries (``page_number``); txt/md/docx/html return a single
page. ``parse(path, want_images=True)`` also returns embedded images. Heavy imports
are lazy, so importing this module never fails on a missing dep.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from html.parser import HTMLParser
logger = logging.getLogger(__name__)
@dataclass(frozen = True)
class Page:
"""A unit of extracted text. ``page_number`` is 1-based (None if N/A)."""
text: str
page_number: int | None = None
char_count: int = 0
@dataclass(frozen = True)
class ParsedImage:
"""A raster image embedded in a document (PDF only)."""
image_bytes: bytes
page_number: int | None
xref: int
def _page(text: str, page_number: int | None) -> Page:
return Page(text = text, page_number = page_number, char_count = len(text))
class _Stripper(HTMLParser):
"""Collect visible text, skipping <script>/<style>."""
def __init__(self) -> None:
super().__init__()
self._skip = 0
self.out: list[str] = []
def handle_starttag(self, tag, attrs):
if tag in ("script", "style"):
self._skip += 1
def handle_endtag(self, tag):
if tag in ("script", "style") and self._skip:
self._skip -= 1
def handle_data(self, data):
if not self._skip and data.strip():
self.out.append(data.strip())
def _html(raw: str) -> list[Page]:
parser = _Stripper()
parser.feed(raw)
return [_page("\n".join(parser.out), 1)]
def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
import fitz # PyMuPDF
pages: list[Page] = []
images: list[ParsedImage] = []
doc = fitz.open(path)
try:
for i, page in enumerate(doc):
text = page.get_text("text") or ""
pages.append(_page(text, i + 1))
if want_images:
for img in page.get_images(full = True):
xref = img[0]
try:
extracted = doc.extract_image(xref)
except Exception as exc: # noqa: BLE001
logger.debug("skipping image xref %s: %s", xref, exc)
continue
image_bytes = extracted.get("image")
if image_bytes:
images.append(
ParsedImage(
image_bytes = image_bytes,
page_number = i + 1,
xref = xref,
)
)
finally:
doc.close()
return pages, images
def _merge_rects(boxes: list) -> list:
"""Union overlapping rectangles (largest-first) into figure regions."""
import pymupdf
rects = [pymupdf.Rect(b) for b in boxes]
rects = [r for r in rects if r.width > 5 and r.height > 5]
merged: list = []
for box in sorted(rects, key = lambda r: -r.get_area()):
placed = False
for m in merged:
if m.intersects(box):
m |= box
placed = True
break
if not placed:
merged.append(+box)
return merged
def render_pdf_figures(
path: str,
*,
dpi: int = 130,
min_area_frac: float = 0.04,
min_side: float = 40.0,
max_figures: int = 8,
) -> list[ParsedImage]:
"""Detect figure regions and render each to a PNG for captioning.
Academic figures are vector, so raster extraction yields fragments; instead
cluster vector drawings + raster placements into boxes, keep the page-spanning
ones, and render them. Any failure yields [], never an exception.
"""
try:
import pymupdf
except Exception:
return []
out: list[ParsedImage] = []
try:
doc = pymupdf.open(path)
except Exception:
return []
try:
for i, page in enumerate(doc):
boxes: list = []
try:
boxes.extend(info["bbox"] for info in page.get_image_info())
except Exception:
pass
try:
boxes.extend(page.cluster_drawings())
except Exception:
pass
if not boxes:
continue
page_area = page.rect.width * page.rect.height
for box in _merge_rects(boxes):
if (
box.get_area() >= min_area_frac * page_area
and box.width >= min_side
and box.height >= min_side
):
try:
pix = page.get_pixmap(dpi = dpi, clip = box)
out.append(
ParsedImage(
image_bytes = pix.tobytes("png"),
page_number = i + 1,
xref = 0,
)
)
except Exception:
continue
if len(out) >= max_figures:
return out
return out
finally:
doc.close()
def _docx(path: str) -> list[Page]:
import docx
document = docx.Document(path)
text = "\n".join(p.text for p in document.paragraphs)
return [_page(text, None)]
def parse(path: str, *, want_images: bool = False):
"""Parse a file into pages by extension. Returns ``list[Page]``, or
``(list[Page], list[ParsedImage])`` when ``want_images=True`` (only PDFs yield
images). Raises ValueError on unsupported ext."""
ext = os.path.splitext(path)[1].lower()
if ext == ".pdf":
pages, images = _pdf(path, want_images)
return (pages, images) if want_images else pages
if ext == ".docx":
pages = _docx(path)
return (pages, []) if want_images else pages
if ext in (".html", ".htm", ".txt", ".md", ".markdown"):
with open(path, encoding = "utf-8", errors = "replace") as f:
raw = f.read()
pages = _html(raw) if ext in (".html", ".htm") else [_page(raw, None)]
return (pages, []) if want_images else pages
raise ValueError(f"unsupported file type: {ext}")
def parse_text(text: str) -> list[Page]:
"""Wrap already-extracted text as a single Page (tests / in-memory ingest)."""
return [_page(text, None)]

View file

@ -0,0 +1,96 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Lexical (FTS5) + dense (vec0 cosine) retrieval fused via Reciprocal Rank
Fusion. ``dense_score`` is carried so callers can apply a similarity floor."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from . import config, embeddings, store
@dataclass
class Hit:
chunk_id: str
score: float
lexical_score: float | None = None
dense_score: float | None = None
def retrieve_lexical(
conn: sqlite3.Connection,
scope: str,
query: str,
k: int | None = None,
) -> list[Hit]:
k = k or config.TOP_K_LEXICAL
return [Hit(cid, s, lexical_score = s) for cid, s in store.search_lexical(conn, scope, query, k)]
def retrieve_dense(
conn: sqlite3.Connection,
scope: str,
query: str,
k: int | None = None,
*,
model_name: str | None = None,
) -> list[Hit]:
k = k or config.TOP_K_DENSE
vec = embeddings.encode([query], model_name = model_name, normalize = True)[0]
return [Hit(cid, s, dense_score = s) for cid, s in store.search_dense(conn, scope, vec, k)]
def _rrf(rankings: list[list[Hit]], rrf_k: int, top_k: int) -> list[Hit]:
fused: dict[str, float] = {}
best: dict[str, Hit] = {}
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)
cur = best.get(hit.chunk_id)
if cur is None:
best[hit.chunk_id] = Hit(hit.chunk_id, 0.0, hit.lexical_score, hit.dense_score)
else:
cur.lexical_score = (
cur.lexical_score if cur.lexical_score is not None else hit.lexical_score
)
cur.dense_score = (
cur.dense_score if cur.dense_score is not None else hit.dense_score
)
out: list[Hit] = []
for cid, s in sorted(fused.items(), key = lambda kv: kv[1], reverse = True)[:top_k]:
h = best[cid]
h.score = s
out.append(h)
return out
def retrieve_hybrid(
conn: sqlite3.Connection,
scope: str,
query: str,
*,
k: int | None = None,
model_name: str | None = None,
mode: str = "hybrid",
) -> list[Hit]:
"""``mode`` picks the backend: lexical-only, dense-only, or RRF of both
(default). Pool sizes and the RRF constant come from config."""
k = k if k is not None else config.TOP_K_HYBRID
k = int(k) # tool-call / scope top_k may arrive as a float; LIMIT + slice need int
if mode == "lexical":
return retrieve_lexical(conn, scope, query, k)
if mode == "dense":
return retrieve_dense(conn, scope, query, k, model_name = model_name)
lexical = retrieve_lexical(conn, scope, query, config.TOP_K_LEXICAL)
dense = retrieve_dense(conn, scope, query, config.TOP_K_DENSE, model_name = model_name)
return _rrf([lexical, dense], config.RRF_K, k)
def filter_min_score(hits: list[Hit], min_score: float) -> list[Hit]:
"""Cosine floor; gates only hits with a dense_score (lexical-only pass)."""
if min_score <= 0:
return hits
return [h for h in hits if h.dense_score is None or h.dense_score >= min_score]

View file

@ -0,0 +1,261 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Unified SQLite store: relational chunks + FTS5 lexical + sqlite-vec dense.
Module-level functions each take a ``conn`` the caller opens and closes. Inserts
are incremental: ``add_chunks`` appends one document's rows without rebuilding the
scope. Scope ("kb_<id>" / "thread_<id>") is a column on every table and the vec0
partition key.
"""
from __future__ import annotations
import json
import re
import sqlite3
import struct
import uuid
from datetime import datetime, timezone
from storage import rag_db
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 _f32(vector) -> bytes:
"""Pack a vector into float32 bytes for vec0."""
return struct.pack(f"{len(vector)}f", *(float(x) for x in vector))
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
_TOKEN = re.compile(r"\w+", re.UNICODE)
def _match_query(query: str) -> str:
"""User text -> safe FTS5 OR-of-quoted-terms query; quoting defuses FTS5
operators. "" (no tokens) means no lexical results."""
toks = _TOKEN.findall(query.lower())
return " OR ".join(f'"{t}"' for t in toks)
def create_kb(
conn: sqlite3.Connection,
*,
name: str,
description: str | None = None,
embedding_model: str | None = None,
kb_id: str | None = None,
) -> str:
kb_id = kb_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO knowledge_bases(id, name, description, embedding_model, created_at) "
"VALUES(?,?,?,?,?)",
(kb_id, name, description, embedding_model, _now()),
)
conn.commit()
return kb_id
def list_kbs(conn: sqlite3.Connection) -> list[dict]:
rows = conn.execute("SELECT * FROM knowledge_bases ORDER BY created_at").fetchall()
return [dict(r) for r in rows]
def get_kb(conn: sqlite3.Connection, kb_id: str) -> dict | None:
row = conn.execute("SELECT * FROM knowledge_bases WHERE id=?", (kb_id,)).fetchone()
return dict(row) if row else None
def delete_kb(conn: sqlite3.Connection, kb_id: str) -> None:
"""Delete a knowledge base and every document (+ chunks) under it."""
scope = kb_scope(kb_id)
doc_ids = [
r["id"] for r in conn.execute("SELECT id FROM documents WHERE scope=?", (scope,)).fetchall()
]
for doc_id in doc_ids:
delete_document(conn, doc_id)
conn.execute("DELETE FROM knowledge_bases WHERE id=?", (kb_id,))
conn.commit()
def create_document(
conn: sqlite3.Connection,
*,
scope: str,
filename: str,
sha256: str,
kb_id: str | None = None,
thread_id: str | None = None,
status: str = "pending",
stored_path: str | None = None,
document_id: str | None = None,
) -> str:
document_id = document_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO documents(id, scope, kb_id, thread_id, filename, sha256, status, "
"stored_path, created_at) VALUES(?,?,?,?,?,?,?,?,?)",
(
document_id,
scope,
kb_id,
thread_id,
filename,
sha256,
status,
stored_path,
_now(),
),
)
conn.commit()
return document_id
def set_document_status(
conn: sqlite3.Connection,
document_id: str,
status: str,
*,
num_chunks: int | None = None,
error: str | None = None,
) -> None:
conn.execute(
"UPDATE documents SET status=?, num_chunks=COALESCE(?, num_chunks), error=? WHERE id=?",
(status, num_chunks, error, document_id),
)
conn.commit()
def list_documents(conn: sqlite3.Connection, scope: str) -> list[dict]:
rows = conn.execute(
"SELECT id, scope, kb_id, thread_id, filename, sha256, status, error, num_chunks, created_at "
"FROM documents WHERE scope=? ORDER BY created_at DESC",
(scope,),
).fetchall()
return [dict(r) for r in rows]
def get_document(conn: sqlite3.Connection, document_id: str) -> dict | None:
row = conn.execute("SELECT * FROM documents WHERE id=?", (document_id,)).fetchone()
return dict(row) if row else None
def document_by_hash(conn: sqlite3.Connection, scope: str, sha256: str) -> str | None:
row = conn.execute(
"SELECT id FROM documents WHERE scope=? AND sha256=?", (scope, sha256)
).fetchone()
return row["id"] if row else None
def add_chunks(
conn: sqlite3.Connection,
scope: str,
document_id: str,
chunks,
vectors,
regions = None,
) -> None:
"""Incrementally index one document's chunks into chunks + FTS5 + vec0.
``vectors`` parallels ``chunks``; optional ``regions`` (also parallel) holds
per-chunk PDF highlight rects, stored as JSON."""
if len(vectors):
rag_db.ensure_vec(conn, len(vectors[0]))
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
chunk_id = f"{document_id}:{chunk.chunk_index}"
chunk_regions = regions[i] if regions and i < len(regions) else None
regions_json = json.dumps(chunk_regions) if chunk_regions else None
conn.execute(
"INSERT OR REPLACE INTO chunks("
"id, document_id, scope, chunk_index, text, page_number, "
"source_page_index, token_count, kind, pdf_regions_json) "
"VALUES(?,?,?,?,?,?,?,?,?,?)",
(
chunk_id,
document_id,
scope,
chunk.chunk_index,
chunk.text,
chunk.page_number,
chunk.source_page_index,
chunk.token_count,
getattr(chunk, "kind", "text"),
regions_json,
),
)
conn.execute(
"INSERT INTO chunks_fts(text, chunk_id, scope) VALUES(?,?,?)",
(chunk.text, chunk_id, scope),
)
conn.execute(
"INSERT INTO chunks_vec(scope, chunk_id, embedding) VALUES(?,?,?)",
(scope, chunk_id, _f32(vector)),
)
conn.commit()
def delete_document(conn: sqlite3.Connection, document_id: str) -> None:
"""Remove a document and all its chunks (+ fts + vec rows)."""
ids = [
r["id"]
for r in conn.execute(
"SELECT id FROM chunks WHERE document_id=?", (document_id,)
).fetchall()
]
has_vec = rag_db.vec_table_exists(conn)
for chunk_id in ids:
conn.execute("DELETE FROM chunks_fts WHERE chunk_id=?", (chunk_id,))
if has_vec:
conn.execute("DELETE FROM chunks_vec WHERE chunk_id=?", (chunk_id,))
conn.execute("DELETE FROM chunks WHERE document_id=?", (document_id,))
conn.execute("DELETE FROM documents WHERE id=?", (document_id,))
conn.commit()
def search_lexical(conn: sqlite3.Connection, scope: str, query: str, k: int):
"""BM25 lexical search. Returns [(chunk_id, score)], higher = better."""
mq = _match_query(query)
if not mq:
return []
rows = conn.execute(
"SELECT chunk_id, bm25(chunks_fts) AS s FROM chunks_fts "
"WHERE chunks_fts MATCH ? AND scope=? ORDER BY s LIMIT ?",
(mq, scope, k),
).fetchall()
# bm25() is negative (more negative = better); flip to higher-is-better.
return [(r["chunk_id"], -r["s"]) for r in rows]
def search_dense(conn: sqlite3.Connection, scope: str, vector, k: int):
"""Cosine KNN over vec0. Returns [(chunk_id, 1 - distance)]."""
if not rag_db.vec_table_exists(conn):
return []
rows = conn.execute(
"SELECT chunk_id, distance FROM chunks_vec "
"WHERE scope=? AND embedding MATCH ? ORDER BY distance LIMIT ?",
(scope, _f32(vector), k),
).fetchall()
return [(r["chunk_id"], 1.0 - r["distance"]) for r in rows]
def chunks_by_id(conn: sqlite3.Connection, ids) -> dict:
"""Hydrate chunk rows (joined with document filename), keyed by id."""
if not ids:
return {}
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"SELECT c.id, c.text, c.document_id, c.chunk_index, c.page_number, "
f"c.source_page_index, d.filename "
f"FROM chunks c JOIN documents d ON d.id=c.document_id "
f"WHERE c.id IN ({placeholders})",
list(ids),
).fetchall()
return {r["id"]: r for r in rows}

View file

@ -0,0 +1,193 @@
# 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`` LLM tool: scope resolution + hit formatting.
KB scope wins over thread scope. Hits render as ``<chunk>`` blocks for the model,
plus a parallel citation source-map for clickable sources. Each call opens and
closes its own ``rag_db`` connection.
"""
from __future__ import annotations
from xml.sax.saxutils import quoteattr
from storage import rag_db
from . import config, retrieval
from .store import kb_scope, thread_scope
SEARCH_KNOWLEDGE_BASE_TOOL = {
"type": "function",
"function": {
"name": "search_knowledge_base",
"description": (
"Search the user's uploaded documents and knowledge bases for relevant passages."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural-language search query.",
},
"top_k": {
"type": "integer",
"description": "Max chunks to return.",
},
},
"required": ["query"],
},
},
}
def _resolve_scope(scope_kb_id: str | None, scope_thread_id: str | None) -> str | None:
if scope_kb_id:
return kb_scope(scope_kb_id)
if scope_thread_id:
return thread_scope(scope_thread_id)
return None
def _format(rows, hits) -> tuple[str, list[dict]]:
"""Render hits as ``<chunk>`` blocks and build a citation source-map."""
if not hits:
return "No matching chunks were found in the knowledge base.", []
blocks: list[str] = []
sources: list[dict] = []
for i, h in enumerate(hits, 1):
r = rows.get(h.chunk_id)
filename = (r["filename"] if r else None) or "unknown"
page = r["page_number"] if r else None
text = r["text"] if r else ""
src = quoteattr(filename)
page_attr = f" page={quoteattr(str(page))}" if page else ""
blocks.append(f'<chunk id="{i}" source={src}{page_attr}>\n{text}\n</chunk>')
sources.append(
{
"citationId": i,
"chunkId": h.chunk_id,
"documentId": r["document_id"] if r else None,
"filename": filename,
"page": page,
"text": text,
"score": round(float(h.score), 4) if h.score is not None else None,
}
)
return "\n\n".join(blocks), sources
def search_knowledge_base_with_sources(
*,
query: str,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
top_k: int | None = None,
min_score: float = 0.0,
model_name: str | None = None,
mode: str = "hybrid",
) -> tuple[str, list[dict]]:
"""Search -> ``(rendered_text, citation_sources)``; each source aligns with a
rendered ``<chunk>`` block's ``id``."""
if not query or not query.strip():
return "Error: query is empty.", []
scope = _resolve_scope(scope_kb_id, scope_thread_id)
if scope is None:
return "No documents are attached to this chat.", []
conn = rag_db.get_connection()
try:
hits = retrieval.retrieve_hybrid(
conn,
scope,
query,
k = top_k or config.TOP_K_HYBRID,
model_name = model_name,
mode = mode,
)
hits = retrieval.filter_min_score(hits, min_score)
rows = store_rows(conn, hits)
finally:
conn.close()
return _format(rows, hits)
def store_rows(conn, hits):
"""Hydrate chunk rows for a list of hits."""
from . import store
return store.chunks_by_id(conn, [h.chunk_id for h in hits])
def search_for_autoinject(
*,
query: str,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
top_k: int | None = None,
min_dense_score: float = 0.70,
model_name: str | None = None,
mode: str = "hybrid",
) -> tuple[str, list[dict]] | None:
"""Forced-retrieval variant for auto-injection.
Returns ``(rendered_text, sources)`` only if some hit's cosine clears
``min_dense_score``, else ``None`` (inject nothing). The dense gate keeps
weak/off-topic matches out of answers. In ``lexical`` mode hits carry no
cosine, so the gate falls back to a dense 1-NN probe.
"""
if not query or not query.strip():
return None
scope = _resolve_scope(scope_kb_id, scope_thread_id)
if scope is None:
return None
k = top_k or config.TOP_K_HYBRID
conn = rag_db.get_connection()
try:
hits = retrieval.retrieve_hybrid(
conn,
scope,
query,
k = k,
model_name = model_name,
mode = mode,
)
strong = [
h for h in hits if h.dense_score is not None and h.dense_score >= min_dense_score
][:k]
if not strong and hits and mode == "lexical":
probe = retrieval.retrieve_dense(conn, scope, query, 1, model_name = model_name)
if (
probe
and probe[0].dense_score is not None
and (probe[0].dense_score >= min_dense_score)
):
strong = hits[:k]
if not strong:
return None
rows = store_rows(conn, strong)
finally:
conn.close()
text, sources = _format(rows, strong)
return (text, sources) if sources else None
def search_knowledge_base(
*,
query: str,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
top_k: int | None = None,
min_score: float = 0.0,
model_name: str | None = None,
) -> str:
"""Text-only variant of :func:`search_knowledge_base_with_sources`."""
text, _sources = search_knowledge_base_with_sources(
query = query,
scope_kb_id = scope_kb_id,
scope_thread_id = scope_thread_id,
top_k = top_k,
min_score = min_score,
model_name = model_name,
)
return text

View file

@ -7,6 +7,7 @@ Main FastAPI application for Unsloth UI Backend
import os
import sys
import threading
from pathlib import Path as _Path
import asyncio
from dataclasses import asdict
@ -216,6 +217,7 @@ from routes import (
mcp_servers_router,
models_router,
providers_router,
rag_router,
training_history_router,
training_router,
)
@ -230,6 +232,7 @@ from hub.utils.download_registry import (
terminate_active_downloads as terminate_hub_downloads,
)
from routes.settings import router as settings_router
from routes.prompts import router as prompts_router
from auth import storage
from auth.authentication import get_current_subject
from utils.hardware import (
@ -374,6 +377,21 @@ async def lifespan(app: FastAPI):
_start_helper_precache_if_enabled()
# Warm the RAG embedder so the first upload skips the cold load. Non-fatal.
def _warm_rag_embedder():
try:
from storage import rag_db
if not rag_db.RAG_AVAILABLE:
return
from core.rag import embeddings
embeddings.warm()
except Exception:
pass
threading.Thread(target = _warm_rag_embedder, daemon = True).start()
# Initialize RSA key pair for API key encryption (external providers)
from core.inference.key_exchange import init_key_pair
@ -738,9 +756,11 @@ app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
app.include_router(prompts_router, prefix = "/api/prompts", tags = ["prompts"])
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
app.include_router(export_router, prefix = "/api/export", tags = ["export"])
app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])
app.include_router(hub_inventory_router, prefix = "/api/hub", tags = ["hub"])
app.include_router(hub_datasets_router, prefix = "/api/hub/datasets", tags = ["hub"])

View file

@ -694,6 +694,16 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
)
rag_scope: Optional[dict] = Field(
None,
description = (
"[x-unsloth] Hidden RAG retrieval scope for the search_knowledge_base "
"tool: {kb_id?, thread_id?, default_top_k?, mode?, autoinject?, "
"autoinject_min_score?}. Candidate pools and the RRF constant come from "
"server config. The model never sees this; the server resolves which "
"documents to search."
),
)
cancel_id: Optional[str] = Field(
None,
description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.",

View file

@ -19,3 +19,8 @@ ddgs
cryptography>=42.0.0
httpx>=0.27.0
fastmcp>=3.0.2
# RAG (knowledge bases, hybrid retrieval). sentence-transformers lives in
# extras-no-deps.txt; these add the lexical+dense store and document parsing.
sqlite-vec==0.1.9
pymupdf==1.27.2.3
python-docx==1.2.0

View file

@ -17,6 +17,7 @@ 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.mcp_servers import router as mcp_servers_router
from routes.rag import router as rag_router
__all__ = [
"training_router",
@ -31,4 +32,8 @@ __all__ = [
"chat_history_router",
"providers_router",
"mcp_servers_router",
"rag_router",
]
# Bind the re-export so the import-hoist verifier counts it as used.
_ = (rag_router,)

View file

@ -81,6 +81,34 @@ def _install_httpcore_asyncgen_silencer() -> None:
_install_httpcore_asyncgen_silencer()
def _loaded_chat_template() -> Optional[str]:
"""Chat template of the currently loaded GGUF model, if any."""
try:
return get_llama_cpp_backend().chat_template
except Exception:
return None
def _template_raise_message(error_text: str, chat_template: Optional[str]) -> Optional[str]:
"""A chat-template raise_exception message to surface, but only when it appears
verbatim in chat_template (simple substring check), so we never leak arbitrary
llama-server text. Anchors on llama.cpp's "Jinja Exception:" prefix."""
if not chat_template:
return None
marker = "Jinja Exception:"
idx = error_text.find(marker)
if idx == -1:
return None
candidate = error_text[idx + len(marker) :]
# llama-server appends JSON after the message; cut at the first boundary.
for stop in ('"', "\n"):
cut = candidate.find(stop)
if cut != -1:
candidate = candidate[:cut]
candidate = candidate.strip()
return candidate if candidate and candidate in chat_template else None
def _friendly_error(exc: Exception) -> str:
"""Extract a user-friendly message from known llama-server errors."""
# httpx transport failures from the async pass-through helpers. Any
@ -106,6 +134,9 @@ def _friendly_error(exc: Exception) -> str:
return (
"Lost connection to the model server. It may have crashed -- try reloading the model."
)
template_msg = _template_raise_message(msg, _loaded_chat_template())
if template_msg:
return f"An internal error occurred: {template_msg}"
return "An internal error occurred"
@ -3088,6 +3119,12 @@ async def openai_chat_completions(
else:
tools_to_use = ALL_TOOLS
# Drop the RAG tool without a scope: nothing to search over.
if not payload.rag_scope:
tools_to_use = [
t for t in tools_to_use if t["function"]["name"] != "search_knowledge_base"
]
if _mcp_allowed:
tools_to_use = tools_to_use + await get_enabled_mcp_tools()
@ -3108,6 +3145,21 @@ async def openai_chat_completions(
model_name = model_name,
)
# Nudge the model to ground in attached documents instead of memory.
_tool_names = {(t.get("function") or {}).get("name") for t in (tools_to_use or [])}
_rag_active = "search_knowledge_base" in _tool_names and payload.rag_scope
if _rag_active:
_rag_nudge = (
"The user has attached documents to this conversation. Relevant "
"passages are retrieved and provided to you automatically; base "
"your answer on them and cite them. You can also call "
"search_knowledge_base to look for more. Do not answer from "
"memory when the attached documents are relevant."
)
# Prefix the date when the tool nudge is empty (RAG-only tool set).
_date_line = f"The current date is {_date.today().isoformat()}."
_nudge = _date_line + " " + _rag_nudge if not _nudge else _nudge + " " + _rag_nudge
if _nudge:
# Append nudge to system prompt (preserve user's prompt)
if system_prompt:
@ -3153,6 +3205,7 @@ async def openai_chat_completions(
if payload.tool_call_timeout is not None
else 300,
session_id = payload.session_id,
rag_scope = payload.rag_scope,
disable_parallel_tool_use = payload.parallel_tool_calls is False,
)
@ -3592,6 +3645,12 @@ async def openai_chat_completions(
else:
_sf_tools_to_use = ALL_TOOLS
# Drop the RAG tool unless the request carries a retrieval scope.
if not payload.rag_scope:
_sf_tools_to_use = [
t for t in _sf_tools_to_use if t["function"]["name"] != "search_knowledge_base"
]
if _sf_mcp_allowed:
_sf_tools_to_use = _sf_tools_to_use + await get_enabled_mcp_tools()
@ -3607,6 +3666,25 @@ async def openai_chat_completions(
model_name = model_name,
)
# RAG nudge, mirroring the GGUF path.
_sf_tool_names = {(t.get("function") or {}).get("name") for t in (_sf_tools_to_use or [])}
_sf_rag_active = "search_knowledge_base" in _sf_tool_names and payload.rag_scope
if _sf_rag_active:
_sf_rag_nudge = (
"The user has attached documents to this conversation. Relevant "
"passages are retrieved and provided to you automatically; base "
"your answer on them and cite them. You can also call "
"search_knowledge_base to look for more. Do not answer from "
"memory when the attached documents are relevant."
)
# Prefix the date when the tool nudge is empty (RAG-only tool set).
_sf_date_line = f"The current date is {_date.today().isoformat()}."
_sf_nudge = (
_sf_date_line + " " + _sf_rag_nudge
if not _sf_nudge
else _sf_nudge + " " + _sf_rag_nudge
)
_sf_system_prompt = system_prompt
if _sf_nudge:
if _sf_system_prompt:
@ -3658,6 +3736,7 @@ async def openai_chat_completions(
if payload.tool_call_timeout is not None
else 300,
session_id = payload.session_id,
rag_scope = payload.rag_scope,
use_adapter = payload.use_adapter,
stats_holder = _sf_stats_holder,
)
@ -5439,6 +5518,8 @@ async def anthropic_messages(
auto_heal_tool_calls = True,
tool_call_timeout = 300,
session_id = payload.session_id,
# Anthropic passthrough has no rag_scope field (RAG is local-only).
rag_scope = getattr(payload, "rag_scope", None),
disable_parallel_tool_use = _disable_parallel,
)

View file

@ -39,6 +39,18 @@ def _safe_is_dir(path) -> bool:
return False
def _is_hidden_model(*values: str | None) -> bool:
"""True if any id/path is the RAG embedding model (EMBEDDING_MODEL or
EMBED_GGUF_REPO basename), so pickers hide it (GGUF and non-GGUF)."""
from core.rag import config as rag_config
needles = (
rag_config.EMBEDDING_MODEL.split("/")[-1].lower(),
rag_config.EMBED_GGUF_REPO.split("/")[-1].lower(),
)
return any(v and any(n in v.lower() for n in needles) for v in values)
backend_path = Path(__file__).parent.parent.parent
if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
@ -754,6 +766,7 @@ async def list_local_models(
key = lambda item: (item.updated_at or 0),
reverse = True,
)
models = [m for m in models if not _is_hidden_model(m.id, m.path)]
return LocalModelListResponse(
models_dir = str(models_root),
@ -2379,6 +2392,8 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if _is_hidden_model(repo_id):
continue
total_size = _repo_gguf_size_bytes(repo_info)
if total_size == 0:
continue
@ -2416,6 +2431,8 @@ async def list_cached_models(current_subject: str = Depends(get_current_subject)
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if _is_hidden_model(repo_id):
continue
if _repo_has_gguf_files(repo_info):
continue
total_size = sum(

View file

@ -0,0 +1,101 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Prompt storage API routes backed by studio.db.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from auth.authentication import get_current_subject
from storage.studio_db import (
bulk_upsert_prompt_entries,
bulk_upsert_prompt_lists,
delete_prompt_entry,
delete_prompt_list_db,
list_prompt_entries,
list_prompt_lists_db,
upsert_prompt_entry,
upsert_prompt_list,
)
router = APIRouter()
class PromptEntry(BaseModel):
id: str = Field(max_length = 128)
name: str = Field(max_length = 500)
text: str = Field(max_length = 100_000)
createdAt: int
updatedAt: int
class PromptList(BaseModel):
id: str = Field(max_length = 128)
name: str = Field(max_length = 500)
items: list[str] = Field(max_length = 10_000)
createdAt: int
updatedAt: int
class BulkEntriesRequest(BaseModel):
entries: list[PromptEntry]
class BulkListsRequest(BaseModel):
lists: list[PromptList]
@router.get("/entries")
def get_entries(current_subject: str = Depends(get_current_subject)):
return {"entries": list_prompt_entries()}
@router.put("/entries/{entry_id}")
def put_entry(
entry_id: str,
entry: PromptEntry,
current_subject: str = Depends(get_current_subject),
):
if entry.id != entry_id:
raise HTTPException(status_code = 400, detail = "ID mismatch")
return upsert_prompt_entry(entry.model_dump())
@router.delete("/entries/{entry_id}", status_code = 204)
def remove_entry(entry_id: str, current_subject: str = Depends(get_current_subject)):
delete_prompt_entry(entry_id)
@router.post("/entries/bulk")
def bulk_entries(req: BulkEntriesRequest, current_subject: str = Depends(get_current_subject)):
count = bulk_upsert_prompt_entries([e.model_dump() for e in req.entries])
return {"count": count}
@router.get("/lists")
def get_lists(current_subject: str = Depends(get_current_subject)):
return {"lists": list_prompt_lists_db()}
@router.put("/lists/{list_id}")
def put_list(
list_id: str,
lst: PromptList,
current_subject: str = Depends(get_current_subject),
):
if lst.id != list_id:
raise HTTPException(status_code = 400, detail = "ID mismatch")
return upsert_prompt_list(lst.model_dump())
@router.delete("/lists/{list_id}", status_code = 204)
def remove_list(list_id: str, current_subject: str = Depends(get_current_subject)):
delete_prompt_list_db(list_id)
@router.post("/lists/bulk")
def bulk_lists(req: BulkListsRequest, current_subject: str = Depends(get_current_subject)):
count = bulk_upsert_prompt_lists([l.model_dump() for l in req.lists])
return {"count": count}

View file

@ -0,0 +1,456 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HTTP API for the RAG engine: KB CRUD, uploads, SSE ingestion, search.
Single-tenant: the subject gates access, not data. Without sqlite-vec the router
mounts but every endpoint returns 503.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import time
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from auth.authentication import get_current_subject
from core.rag import config, ingestion, retrieval, store
from storage import rag_db
from utils.paths import ensure_dir, rag_uploads_root
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_rag() -> None:
if not rag_db.RAG_AVAILABLE:
raise HTTPException(
status_code = 503,
detail = "RAG is unavailable: the sqlite-vec extension could not be loaded.",
)
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
def _sanitize_filename(name: str) -> str:
base = os.path.basename(name or "").strip() or "document"
base = _SAFE.sub("_", base)
return base[:200]
def _save_upload(file: UploadFile) -> tuple[str, str]:
"""Persist an upload; returns (stored_path, filename)."""
filename = _sanitize_filename(file.filename or "document")
ext = os.path.splitext(filename)[1].lower()
if ext not in config.UPLOAD_EXTS:
raise HTTPException(
status_code = 400,
detail = f"Unsupported file type '{ext}'. Allowed: {sorted(config.UPLOAD_EXTS)}",
)
uploads = ensure_dir(rag_uploads_root())
stored_path = str(uploads / f"{uuid.uuid4().hex}{ext}")
size = 0
with open(stored_path, "wb") as out:
while True:
block = file.file.read(1 << 20)
if not block:
break
size += len(block)
out.write(block)
if size == 0:
os.remove(stored_path)
raise HTTPException(status_code = 400, detail = "Uploaded file is empty.")
return stored_path, filename
def _doc_view(row: dict) -> dict:
return {
"id": row["id"],
"filename": row["filename"],
"status": row["status"],
"error": row.get("error"),
"numChunks": row.get("num_chunks") or 0,
"kbId": row.get("kb_id"),
"threadId": row.get("thread_id"),
"createdAt": row.get("created_at"),
}
class CreateKbRequest(BaseModel):
name: str = Field(min_length = 1, max_length = 200)
description: str | None = None
class UpdateKbRequest(BaseModel):
name: str | None = Field(default = None, max_length = 200)
description: str | None = None
class SearchRequest(BaseModel):
query: str
kb_id: str | None = None
thread_id: str | None = None
top_k: int = Field(default = config.TOP_K_HYBRID, ge = 1, le = 50)
min_score: float = 0.0
mode: str = "hybrid" # hybrid | lexical | dense
@router.get("/knowledge-bases")
def list_knowledge_bases(subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
kbs = store.list_kbs(conn)
out = []
for kb in kbs:
docs = store.list_documents(conn, store.kb_scope(kb["id"]))
out.append(
{
"id": kb["id"],
"name": kb["name"],
"description": kb.get("description"),
"createdAt": kb.get("created_at"),
"documentCount": len(docs),
}
)
return {"knowledgeBases": out}
finally:
conn.close()
@router.post("/knowledge-bases")
def create_knowledge_base(
payload: CreateKbRequest, subject: str = Depends(get_current_subject)
) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
kb_id = store.create_kb(
conn,
name = payload.name.strip(),
description = (payload.description or None),
embedding_model = config.EMBEDDING_MODEL,
)
return {"id": kb_id, "name": payload.name.strip()}
finally:
conn.close()
@router.patch("/knowledge-bases/{kb_id}")
def update_knowledge_base(
kb_id: str,
payload: UpdateKbRequest,
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_kb(conn, kb_id) is None:
raise HTTPException(status_code = 404, detail = "Knowledge base not found")
sets, params = [], []
if payload.name is not None:
sets.append("name=?")
params.append(payload.name.strip())
if payload.description is not None:
sets.append("description=?")
params.append(payload.description or None)
if sets:
params.append(kb_id)
conn.execute(f"UPDATE knowledge_bases SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
return {"ok": True}
finally:
conn.close()
@router.delete("/knowledge-bases/{kb_id}")
def delete_knowledge_base(kb_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_kb(conn, kb_id) is None:
raise HTTPException(status_code = 404, detail = "Knowledge base not found")
store.delete_kb(conn, kb_id)
return {"ok": True}
finally:
conn.close()
@router.post("/knowledge-bases/{kb_id}/documents")
async def upload_kb_document(
kb_id: str,
file: UploadFile = File(...),
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_kb(conn, kb_id) is None:
raise HTTPException(status_code = 404, detail = "Knowledge base not found")
finally:
conn.close()
stored_path, filename = _save_upload(file)
document_id, job_id = ingestion.start_ingestion(
store.kb_scope(kb_id), kb_id, None, filename, stored_path
)
return {"documentId": document_id, "jobId": job_id, "filename": filename}
@router.get("/knowledge-bases/{kb_id}/documents")
def list_kb_documents(kb_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
docs = store.list_documents(conn, store.kb_scope(kb_id))
return {"documents": [_doc_view(d) for d in docs]}
finally:
conn.close()
@router.post("/threads/{thread_id}/documents")
async def upload_thread_document(
thread_id: str,
file: UploadFile = File(...),
subject: str = Depends(get_current_subject),
) -> dict:
_require_rag()
stored_path, filename = _save_upload(file)
document_id, job_id = ingestion.start_ingestion(
store.thread_scope(thread_id), None, thread_id, filename, stored_path
)
return {"documentId": document_id, "jobId": job_id, "filename": filename}
@router.get("/threads/{thread_id}/documents")
def list_thread_documents(thread_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
docs = store.list_documents(conn, store.thread_scope(thread_id))
return {"documents": [_doc_view(d) for d in docs]}
finally:
conn.close()
@router.delete("/documents/{document_id}")
def delete_document(document_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
conn = rag_db.get_connection()
try:
if store.get_document(conn, document_id) is None:
raise HTTPException(status_code = 404, detail = "Document not found")
store.delete_document(conn, document_id)
return {"ok": True}
finally:
conn.close()
@router.get("/jobs/{job_id}")
def job_status(job_id: str, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
row = ingestion.get_job_status(job_id)
if row is None:
raise HTTPException(status_code = 404, detail = "Job not found")
return {
"id": row["id"],
"documentId": row["document_id"],
"status": row["status"],
"stage": row.get("stage"),
"progress": row.get("progress") or 0.0,
"error": row.get("error"),
}
@router.get("/jobs/{job_id}/events")
def job_events(job_id: str, subject: str = Depends(get_current_subject)) -> StreamingResponse:
_require_rag()
def gen():
try:
for event in ingestion.job_events(job_id):
yield f"data: {json.dumps(event)}\n\n"
except Exception as exc: # noqa: BLE001
yield f"data: {json.dumps({'type': 'error', 'error': str(exc)})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type = "text/event-stream",
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/search")
def search(payload: SearchRequest, subject: str = Depends(get_current_subject)) -> dict:
_require_rag()
if payload.kb_id:
scope = store.kb_scope(payload.kb_id)
elif payload.thread_id:
scope = store.thread_scope(payload.thread_id)
else:
raise HTTPException(status_code = 400, detail = "Provide kb_id or thread_id")
conn = rag_db.get_connection()
try:
if payload.mode == "lexical":
hits = retrieval.retrieve_lexical(conn, scope, payload.query, payload.top_k)
elif payload.mode == "dense":
hits = retrieval.retrieve_dense(conn, scope, payload.query, payload.top_k)
else:
hits = retrieval.retrieve_hybrid(conn, scope, payload.query, k = payload.top_k)
hits = retrieval.filter_min_score(hits, payload.min_score)
rows = store.chunks_by_id(conn, [h.chunk_id for h in hits])
results = []
for h in hits:
r = rows.get(h.chunk_id)
if r is None:
continue
results.append(
{
"chunkId": h.chunk_id,
"documentId": r["document_id"],
"filename": r["filename"],
"page": r["page_number"],
"score": h.score,
"text": r["text"],
}
)
return {"results": results}
finally:
conn.close()
# Per-process secret so pdf.js range requests fetch the file without a bearer
# header; tokens only work on this server instance.
_PREVIEW_SECRET = secrets.token_bytes(32)
_PREVIEW_TTL = 600 # seconds
_CONTENT_TYPES = {
".pdf": "application/pdf",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".markdown": "text/markdown; charset=utf-8",
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def _sign_document(document_id: str) -> str:
exp = int(time.time()) + _PREVIEW_TTL
payload = f"{document_id}.{exp}"
sig = hmac.new(_PREVIEW_SECRET, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def _verify_document_token(token: str) -> str | None:
try:
document_id, exp_s, sig = token.rsplit(".", 2)
except ValueError:
return None
expected = hmac.new(
_PREVIEW_SECRET, f"{document_id}.{exp_s}".encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
try:
if int(exp_s) < int(time.time()):
return None
except ValueError:
return None
return document_id
@router.get("/documents/{document_id}/preview-target")
def preview_target(
document_id: str,
chunk_id: str | None = Query(default = None),
subject: str = Depends(get_current_subject),
) -> dict:
"""Resolve a citation to filename, page, and highlight regions."""
_require_rag()
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
if doc is None:
raise HTTPException(status_code = 404, detail = "Document not found")
ext = os.path.splitext(doc["filename"])[1].lower()
out = {
"documentId": document_id,
"filename": doc["filename"],
"mediaKind": "pdf" if ext == ".pdf" else "text",
"targetPage": None,
"pdfRegions": [],
"text": None,
}
if chunk_id:
row = conn.execute(
"SELECT text, page_number, pdf_regions_json FROM chunks WHERE id=?",
(chunk_id,),
).fetchone()
if row is not None:
out["text"] = row["text"]
out["targetPage"] = row["page_number"]
if row["pdf_regions_json"]:
try:
out["pdfRegions"] = json.loads(row["pdf_regions_json"])
except Exception:
out["pdfRegions"] = []
return out
finally:
conn.close()
@router.get("/documents/{document_id}/file-url")
def document_file_url(document_id: str, subject: str = Depends(get_current_subject)) -> dict:
"""Mint a short-lived signed URL for the source file."""
_require_rag()
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
if doc is None or not doc.get("stored_path"):
raise HTTPException(status_code = 404, detail = "Document file not available")
finally:
conn.close()
token = _sign_document(document_id)
return {"url": f"/api/rag/documents/{document_id}/file-signed?token={token}"}
@router.get("/documents/{document_id}/file-signed", response_model = None)
def document_file_signed(document_id: str, token: str = Query(...)) -> FileResponse:
"""Serve the source file gated by the HMAC token (no bearer) so pdf.js range
requests work."""
_require_rag()
signed_id = _verify_document_token(token)
if signed_id != document_id:
raise HTTPException(status_code = 401, detail = "Invalid or expired token")
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, document_id)
finally:
conn.close()
stored_path = (doc or {}).get("stored_path")
if not doc or not stored_path or not os.path.isfile(stored_path):
raise HTTPException(status_code = 404, detail = "Document file not found")
# Confine to the uploads root (defense in depth).
uploads = os.path.realpath(str(rag_uploads_root()))
if not os.path.realpath(stored_path).startswith(uploads):
raise HTTPException(status_code = 403, detail = "Forbidden")
ext = os.path.splitext(doc["filename"])[1].lower()
return FileResponse(
stored_path,
media_type = _CONTENT_TYPES.get(ext, "application/octet-stream"),
filename = doc["filename"],
)

View file

@ -0,0 +1,153 @@
# 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 storage for the RAG engine.
Same pattern as providers_db.py / studio_db.py (module functions, raw sqlite3,
WAL, per-call connections, lazy schema), but every connection also loads
sqlite-vec (vec0 needs it per-connection). If it cannot load, RAG_AVAILABLE is
False and get_connection() raises rather than failing import.
One rag.db holds the ``documents`` / ``chunks`` model, the FTS5 lexical index
(``chunks_fts``) and the sqlite-vec dense index (``chunks_vec``, created lazily
by ensure_vec once the embedding dim is known, since vec0 bakes the dim into the
column type).
"""
import logging
import sqlite3
import threading
logger = logging.getLogger(__name__)
from utils.paths import rag_db_path, ensure_dir
# Optional dep: import must never crash this module (imported unconditionally).
try:
import sqlite_vec
RAG_AVAILABLE = True
except Exception as exc: # noqa: BLE001 - any import failure disables RAG
sqlite_vec = None
RAG_AVAILABLE = False
logger.warning("RAG unavailable: sqlite-vec could not be imported (%s)", exc)
_RAG_UNAVAILABLE_MSG = "RAG unavailable: sqlite-vec extension could not be loaded"
_schema_lock = threading.Lock()
_schema_ready = False
def _ensure_schema(conn: sqlite3.Connection) -> None:
"""Create the RAG tables if absent (once per process). ``chunks_vec`` is
skipped: its column type needs the embedding dim, so ensure_vec() makes it
lazily at first ingest."""
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
embedding_model TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
id TEXT NOT NULL PRIMARY KEY,
scope TEXT NOT NULL,
kb_id TEXT,
thread_id TEXT,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
num_chunks INTEGER NOT NULL DEFAULT 0,
stored_path TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_documents_scope ON documents(scope);
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(scope, sha256);
CREATE TABLE IF NOT EXISTS chunks (
id TEXT NOT NULL PRIMARY KEY,
document_id TEXT NOT NULL,
scope TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
text TEXT NOT NULL,
page_number INTEGER,
source_page_index INTEGER,
token_count INTEGER,
kind TEXT NOT NULL DEFAULT 'text',
pdf_regions_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunks_scope ON chunks(scope);
CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(document_id);
CREATE TABLE IF NOT EXISTS ingestion_jobs (
id TEXT NOT NULL PRIMARY KEY,
document_id TEXT NOT NULL,
scope TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
stage TEXT,
progress REAL NOT NULL DEFAULT 0.0,
error TEXT,
created_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
text,
chunk_id UNINDEXED,
scope UNINDEXED,
tokenize='porter unicode61'
);
"""
)
def get_connection() -> sqlite3.Connection:
"""Open rag.db (WAL + sqlite-vec loaded, schema created once). Raises if the extension is unavailable."""
global _schema_ready
if not RAG_AVAILABLE:
raise RuntimeError(_RAG_UNAVAILABLE_MSG)
db_path = rag_db_path()
ensure_dir(db_path.parent)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
except Exception as exc: # noqa: BLE001
conn.close()
raise RuntimeError(_RAG_UNAVAILABLE_MSG) from exc
if not _schema_ready:
with _schema_lock:
if not _schema_ready:
try:
_ensure_schema(conn)
_schema_ready = True
except Exception:
conn.close()
raise
return conn
def ensure_vec(conn: sqlite3.Connection, dim: int) -> None:
"""Create the dense ``chunks_vec`` table once the embedding dim is known
(vec0 bakes it into the column type). Idempotent; dim fixed per db."""
conn.execute(
f"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0("
f"scope TEXT partition key, "
f"chunk_id TEXT, "
f"embedding float[{int(dim)}] distance_metric=cosine)"
)
def vec_table_exists(conn: sqlite3.Connection) -> bool:
"""True if the dense ``chunks_vec`` table exists."""
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks_vec'"
).fetchone()
return row is not None

View file

@ -293,6 +293,195 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
) WITHOUT ROWID
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS prompt_entries (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
text TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_prompt_entries_created_at ON prompt_entries(created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS prompt_lists (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
items_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_prompt_lists_created_at ON prompt_lists(created_at)"
)
def _prompt_entry_from_row(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"name": row["name"],
"text": row["text"],
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_prompt_entries() -> list[dict]:
conn = get_connection()
try:
rows = conn.execute("SELECT * FROM prompt_entries ORDER BY created_at DESC").fetchall()
return [_prompt_entry_from_row(r) for r in rows]
finally:
conn.close()
def upsert_prompt_entry(entry: dict) -> dict:
conn = get_connection()
try:
conn.execute(
"""
INSERT INTO prompt_entries (id, name, text, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
text = excluded.text,
updated_at = excluded.updated_at
""",
(
entry["id"],
entry["name"],
entry["text"],
entry["createdAt"],
entry["updatedAt"],
),
)
conn.commit()
return entry
finally:
conn.close()
def delete_prompt_entry(entry_id: str) -> None:
conn = get_connection()
try:
conn.execute("DELETE FROM prompt_entries WHERE id = ?", (entry_id,))
conn.commit()
finally:
conn.close()
def bulk_upsert_prompt_entries(entries: list[dict]) -> int:
if not entries:
return 0
conn = get_connection()
try:
conn.executemany(
"""
INSERT INTO prompt_entries (id, name, text, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
text = excluded.text,
updated_at = excluded.updated_at
""",
[(e["id"], e["name"], e["text"], e["createdAt"], e["updatedAt"]) for e in entries],
)
conn.commit()
return len(entries)
finally:
conn.close()
def _prompt_list_from_row(row: sqlite3.Row) -> dict:
return {
"id": row["id"],
"name": row["name"],
"items": json.loads(row["items_json"]),
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
}
def list_prompt_lists_db() -> list[dict]:
conn = get_connection()
try:
rows = conn.execute("SELECT * FROM prompt_lists ORDER BY created_at DESC").fetchall()
return [_prompt_list_from_row(r) for r in rows]
finally:
conn.close()
def upsert_prompt_list(lst: dict) -> dict:
conn = get_connection()
try:
conn.execute(
"""
INSERT INTO prompt_lists (id, name, items_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
items_json = excluded.items_json,
updated_at = excluded.updated_at
""",
(
lst["id"],
lst["name"],
json.dumps(lst["items"]),
lst["createdAt"],
lst["updatedAt"],
),
)
conn.commit()
return lst
finally:
conn.close()
def delete_prompt_list_db(list_id: str) -> None:
conn = get_connection()
try:
conn.execute("DELETE FROM prompt_lists WHERE id = ?", (list_id,))
conn.commit()
finally:
conn.close()
def bulk_upsert_prompt_lists(lists: list[dict]) -> int:
if not lists:
return 0
conn = get_connection()
try:
conn.executemany(
"""
INSERT INTO prompt_lists (id, name, items_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
items_json = excluded.items_json,
updated_at = excluded.updated_at
""",
[
(
lst["id"],
lst["name"],
json.dumps(lst["items"]),
lst["createdAt"],
lst["updatedAt"],
)
for lst in lists
],
)
conn.commit()
return len(lists)
finally:
conn.close()
def get_connection() -> sqlite3.Connection:

View file

@ -109,3 +109,71 @@ def base_url(studio_server):
def api_key(studio_server):
"""API key for the e2e Studio server (from ``studio_server``)."""
return studio_server[1]
# ── RAG fixtures ─────────────────────────────────────────────────────
@pytest.fixture
def rag_home(tmp_path, monkeypatch):
"""Isolate the RAG database under a fresh UNSLOTH_STUDIO_HOME per test.
Points the storage root at ``tmp_path`` and resets the lazy schema flag so
each test starts from an empty rag.db. Yields the temp home path.
"""
from storage import rag_db
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(rag_db, "_schema_ready", False)
return tmp_path
@pytest.fixture
def rag_conn(rag_home):
"""A fresh RAG connection bound to the isolated ``rag_home`` database."""
from storage import rag_db
conn = rag_db.get_connection()
try:
yield conn
finally:
conn.close()
@pytest.fixture
def stub_embeddings(monkeypatch):
"""Stub ``core.rag.embeddings`` with deterministic hash-based vectors.
Lets store / retrieval / ingestion tests run fast without downloading a
sentence-transformers model. Returns the fixed embedding dimension.
"""
import hashlib
import math
from core.rag import embeddings
dim = 32
def _vec(text: str):
seed = hashlib.sha256(text.encode("utf-8")).digest()
raw = [seed[i % len(seed)] / 255.0 for i in range(dim)]
norm = math.sqrt(sum(x * x for x in raw)) or 1.0
return [x / norm for x in raw]
def fake_encode(
texts,
*,
model_name = None,
normalize = True,
):
return [_vec(t) for t in texts]
monkeypatch.setattr(embeddings, "encode", fake_encode)
monkeypatch.setattr(embeddings, "dim", lambda model_name = None: dim)
monkeypatch.setattr(
embeddings,
"token_counter",
lambda model_name = None: (lambda t: len(t.split())),
)
monkeypatch.setattr(embeddings, "warm", lambda model_name = None: None)
return dim

View file

@ -419,6 +419,8 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
routes_module.__path__ = []
settings_module = ModuleType("routes.settings")
settings_module.router = APIRouter()
prompts_module = ModuleType("routes.prompts")
prompts_module.router = APIRouter()
for name, router in {
"auth_router": APIRouter(),
@ -431,6 +433,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
"mcp_servers_router": APIRouter(),
"models_router": APIRouter(),
"providers_router": APIRouter(),
"rag_router": APIRouter(),
"settings_router": settings_module.router,
"training_history_router": APIRouter(),
"training_router": APIRouter(),
@ -440,6 +443,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
monkeypatch.setitem(sys.modules, "routes", routes_module)
monkeypatch.setitem(sys.modules, "routes.settings", settings_module)
monkeypatch.setitem(sys.modules, "routes.prompts", prompts_module)
import studio.backend.main as backend_main

View file

@ -0,0 +1,105 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Multimodal captioning tests: gating, grouping, splice, retrieval."""
from __future__ import annotations
from core.rag import captioner
from core.rag.parsers import Page, ParsedImage
def _img(page):
return ParsedImage(image_bytes = b"\x89PNG fake", page_number = page, xref = page)
def test_caption_images_disabled_by_default(monkeypatch):
monkeypatch.setattr(captioner.config, "CAPTION_IMAGES", False)
assert captioner.caption_images([_img(1)], endpoint = ("http://x", "local")) == {}
def test_caption_images_groups_by_page(monkeypatch):
monkeypatch.setattr(captioner.config, "CAPTION_IMAGES", True)
monkeypatch.setattr(captioner.config, "CAPTION_MAX_IMAGES", 8)
monkeypatch.setattr(captioner, "_caption_one", lambda base, model, b, t: "a chart of results")
out = captioner.caption_images([_img(1), _img(1), _img(3)], endpoint = ("http://x", "local"))
assert out == {1: ["a chart of results", "a chart of results"], 3: ["a chart of results"]}
def test_caption_images_respects_cap(monkeypatch):
monkeypatch.setattr(captioner.config, "CAPTION_IMAGES", True)
monkeypatch.setattr(captioner.config, "CAPTION_MAX_IMAGES", 2)
calls = []
monkeypatch.setattr(captioner, "_caption_one", lambda *a: (calls.append(1) or "cap"))
captioner.caption_images([_img(i) for i in range(5)], endpoint = ("http://x", "local"))
assert len(calls) == 2
def test_caption_images_no_endpoint(monkeypatch):
monkeypatch.setattr(captioner.config, "CAPTION_IMAGES", True)
monkeypatch.setattr(captioner, "vision_endpoint", lambda: None)
assert captioner.caption_images([_img(1)]) == {}
def test_splice_captions_appends_to_right_page():
pages = [Page("body one", 1, 8), Page("body two", 2, 8)]
out = captioner.splice_captions(pages, {2: ["a diagram of X"]})
assert out[0].text == "body one"
assert "a diagram of X" in out[1].text
assert out[1].text.startswith("body two")
assert out[1].char_count == len(out[1].text)
def test_splice_captions_noop_when_empty():
pages = [Page("body", 1, 4)]
assert captioner.splice_captions(pages, {}) is pages
def test_render_pdf_figures_detects_drawing(tmp_path):
import pymupdf
from core.rag.parsers import render_pdf_figures
pdf = tmp_path / "fig.pdf"
doc = pymupdf.open()
page = doc.new_page()
shape = page.new_shape()
shape.draw_rect(pymupdf.Rect(60, 60, 540, 460))
for i in range(8):
shape.draw_line((80, 80 + i * 40), (520, 80 + i * 40))
shape.finish(color = (0, 0, 0), fill = (0.8, 0.8, 0.9))
shape.commit()
doc.save(str(pdf))
doc.close()
figs = render_pdf_figures(str(pdf))
assert figs, "expected at least one rendered figure region"
assert figs[0].image_bytes[:8] == b"\x89PNG\r\n\x1a\n"
assert figs[0].page_number == 1
def test_captioned_text_is_searchable(rag_home, stub_embeddings, monkeypatch):
from core.rag import retrieval, store
from storage import rag_db
pages = [Page("Section 1 intro text about models.", 1, 33)]
pages = captioner.splice_captions(
pages, {1: ["bar chart comparing throughput across quantizations"]}
)
from core.rag import chunking, embeddings
chunks = chunking.chunk_pages(
pages, max_tokens = 128, overlap = 16, count = embeddings.token_counter(None)
)
vecs = embeddings.encode([c.text for c in chunks], normalize = True)
conn = rag_db.get_connection()
try:
kb_id = store.create_kb(conn, name = "kb")
scope = store.kb_scope(kb_id)
doc_id = store.create_document(conn, scope = scope, filename = "d.pdf", sha256 = "h")
store.add_chunks(conn, scope, doc_id, chunks, vecs)
hits = retrieval.retrieve_lexical(conn, scope, "throughput quantizations", k = 5)
finally:
conn.close()
assert hits, "spliced caption text should be retrievable via lexical search"

View file

@ -0,0 +1,69 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Chunking unit tests (no DB, no model)."""
from core.rag.chunking import chunk_pages
from core.rag.parsers import Page, parse_text
WORDS = lambda t: len(t.split()) # noqa: E731
def _page(text: str, page_number = None) -> Page:
return Page(text = text, page_number = page_number, char_count = len(text))
def test_chunk_token_bounds_and_overlap():
text = " ".join(f"w{i}" for i in range(300))
chunks = chunk_pages([_page(text)], max_tokens = 128, overlap = 24, count = WORDS)
assert len(chunks) >= 3
assert all(c.token_count <= 128 for c in chunks)
a, b = chunks[0].text.split(), chunks[1].text.split()
shared = next((n for n in range(60, 0, -1) if a[-n:] == b[:n]), 0)
assert shared == 24 # exactly overlap tokens carried
def test_chunk_never_exceeds_max_with_overlap_carry():
"""Overlap carry is trimmed so no chunk exceeds max_tokens (else the embedder overflows)."""
s1 = " ".join("a" for _ in range(10))
s2 = " ".join("b" for _ in range(95)) # near max
chunks = chunk_pages([_page(f"{s1}. {s2}")], max_tokens = 100, overlap = 24, count = WORDS)
assert all(c.token_count <= 100 for c in chunks), [c.token_count for c in chunks]
def test_chunk_indices_are_sequential():
chunks = chunk_pages([_page("alpha. " * 200)], max_tokens = 32, overlap = 0, count = WORDS)
assert [c.chunk_index for c in chunks] == list(range(len(chunks)))
def test_chunk_tracks_source_page_index():
pages = [_page("alpha bravo " * 80, 1), _page("charlie delta " * 80, 2)]
chunks = chunk_pages(pages, max_tokens = 32, overlap = 0, count = WORDS)
page0 = [c for c in chunks if c.source_page_index == 0]
page1 = [c for c in chunks if c.source_page_index == 1]
assert page0 and page1
assert all(c.page_number == 1 for c in page0)
assert all(c.page_number == 2 for c in page1)
def test_chunk_char_offsets_locate_text_in_page():
# Each chunk's char span must slice back to text containing it.
page_text = "alpha bravo charlie delta echo foxtrot golf hotel " * 30
pages = [_page(page_text, 1)]
chunks = chunk_pages(pages, max_tokens = 16, overlap = 0, count = WORDS)
assert len(chunks) > 1
for c in chunks:
assert 0 <= c.page_char_start < c.page_char_end <= len(page_text)
sliced = page_text[c.page_char_start : c.page_char_end]
assert c.text in sliced or sliced.strip() == c.text
def test_empty_page_yields_no_chunks():
chunks = chunk_pages([_page(" \n ")], max_tokens = 32, overlap = 0, count = WORDS)
assert chunks == []
def test_parse_text_single_page():
pages = parse_text("hello world")
assert len(pages) == 1
assert pages[0].char_count == len("hello world")

View file

@ -0,0 +1,448 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""llama-server GGUF embedder tests, every boundary mocked."""
import subprocess
import sys
import textwrap
from pathlib import Path
import numpy as np
import pytest
from core.rag import config, embeddings
from core.rag import embed_llama_server as mod
from core.rag.embed_llama_server import LlamaServerBackend
@pytest.fixture(autouse = True)
def _reset_backend_singleton():
embeddings._reset_backend()
yield
embeddings._reset_backend()
class _FakeProc:
"""subprocess.Popen stand-in with controllable liveness."""
def __init__(
self,
alive = True,
returncode = 0,
):
self._alive = alive
self.returncode = returncode
self.stdout = iter(()) # drain thread exits immediately
def poll(self):
return None if self._alive else self.returncode
def terminate(self):
self._alive = False
def kill(self):
self._alive = False
def wait(self, timeout = None):
return self.returncode
def _mock_auto(monkeypatch, *, gpus, binary):
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setattr(config, "EMBED_BACKEND", "auto")
monkeypatch.setattr(LlamaCppBackend, "_get_gpu_free_memory", staticmethod(lambda: gpus))
monkeypatch.setattr(LlamaCppBackend, "_find_llama_server_binary", staticmethod(lambda: binary))
def _stub_st_load(monkeypatch):
# Make the ST probe succeed without importing sentence-transformers (absent in
# the torch-free backend CI); these tests assert selection, not a real load.
monkeypatch.setattr(embeddings, "_get", lambda *a, **k: object())
def test_auto_uses_st_with_cuda(monkeypatch):
_stub_st_load(monkeypatch)
_mock_auto(monkeypatch, gpus = [(0, 40000)], binary = "/bin/llama-server")
assert type(embeddings._get_backend()).__name__ == "_SentenceTransformersBackend"
def test_auto_uses_llama_without_cuda(monkeypatch):
_mock_auto(monkeypatch, gpus = [], binary = "/bin/llama-server")
assert isinstance(embeddings._get_backend(), LlamaServerBackend)
def test_auto_falls_back_to_st_without_binary(monkeypatch):
_stub_st_load(monkeypatch)
_mock_auto(monkeypatch, gpus = [], binary = None)
assert type(embeddings._get_backend()).__name__ == "_SentenceTransformersBackend"
def test_llama_backend_selected_by_config(monkeypatch):
monkeypatch.setattr(config, "EMBED_BACKEND", "llama-server")
assert isinstance(embeddings._get_backend(), LlamaServerBackend)
def test_unknown_backend_raises(monkeypatch):
monkeypatch.setattr(config, "EMBED_BACKEND", "bogus")
with pytest.raises(ValueError, match = "Unknown RAG_EMBED_BACKEND"):
embeddings._get_backend()
def test_explicit_backend_overrides_auto(monkeypatch):
_stub_st_load(monkeypatch)
monkeypatch.setattr(config, "EMBED_BACKEND", "sentence-transformers")
assert type(embeddings._get_backend()).__name__ == "_SentenceTransformersBackend"
monkeypatch.setattr(config, "EMBED_BACKEND", "llama-server")
assert isinstance(embeddings._get_backend(), LlamaServerBackend)
def test_llama_backend_imports_no_torch():
# Clean subprocess so the parent's imports don't mask a regression.
backend_dir = Path(__file__).resolve().parents[1]
code = textwrap.dedent(
"""
import sys
from core.rag import embeddings
b = embeddings._get_backend()
assert type(b).__name__ == "LlamaServerBackend", type(b).__name__
assert "torch" not in sys.modules, "torch was imported"
assert "sentence_transformers" not in sys.modules, "ST was imported"
print("OK")
"""
)
env = {
**__import__("os").environ,
"RAG_EMBED_BACKEND": "llama-server",
"PYTHONPATH": str(backend_dir),
}
proc = subprocess.run([sys.executable, "-c", code], capture_output = True, text = True, env = env)
assert proc.returncode == 0, proc.stderr
assert "OK" in proc.stdout
def test_build_cmd_cpu_flags():
b = LlamaServerBackend()
cmd = b._build_cmd("/bin/llama-server", "/m/bge.gguf", 9999, use_gpu = False)
assert "--embedding" in cmd
assert cmd[cmd.index("--pooling") + 1] == "cls"
assert cmd[cmd.index("--fit") + 1] == "off" # deterministic, no auto-resize
assert cmd[cmd.index("-ngl") + 1] == "0" # CPU keeps all off the GPU
assert cmd[cmd.index("--port") + 1] == "9999"
def test_build_cmd_gpu_offloads():
b = LlamaServerBackend()
cmd = b._build_cmd("/bin/llama-server", "/m/bge.gguf", 1, use_gpu = True)
assert cmd[cmd.index("-ngl") + 1] == "-1" # offload all, matching the chat server
def test_build_env_cpu_hides_gpus():
b = LlamaServerBackend()
env = b._build_env("/bin/llama-server", use_gpu = False)
assert env["CUDA_VISIBLE_DEVICES"] == "" # never contend with the chat model
assert env["LLAMA_SET_ROWS"] == "1"
def test_build_env_gpu_inherits_devices(monkeypatch):
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "0,1")
b = LlamaServerBackend()
env = b._build_env("/bin/llama-server", use_gpu = True)
assert env.get("CUDA_VISIBLE_DEVICES") == "0,1" # inherit Studio's selection
def test_use_gpu_explicit_modes(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(config, "EMBED_DEVICE", "gpu")
assert b._use_gpu() is True
monkeypatch.setattr(config, "EMBED_DEVICE", "cpu")
assert b._use_gpu() is False
def test_use_gpu_auto_follows_probe(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(config, "EMBED_DEVICE", "auto")
monkeypatch.setattr(LlamaServerBackend, "_gpu_available", staticmethod(lambda: True))
assert b._use_gpu() is True
monkeypatch.setattr(LlamaServerBackend, "_gpu_available", staticmethod(lambda: False))
assert b._use_gpu() is False
def test_use_gpu_sticky_cpu_fallback(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(config, "EMBED_DEVICE", "auto")
monkeypatch.setattr(LlamaServerBackend, "_gpu_available", staticmethod(lambda: True))
b._force_cpu = True # a prior GPU start failed
assert b._use_gpu() is False
def test_gpu_available_reuses_studio_probe(monkeypatch):
import utils.hardware as uh
from core.inference.llama_cpp import LlamaCppBackend
monkeypatch.setattr(uh, "is_apple_silicon", lambda: False)
# Ample free VRAM -> GPU; nearly full -> CPU; none -> CPU.
monkeypatch.setattr(LlamaCppBackend, "_get_gpu_free_memory", staticmethod(lambda: [(0, 40000)]))
assert LlamaServerBackend._gpu_available() is True
monkeypatch.setattr(LlamaCppBackend, "_get_gpu_free_memory", staticmethod(lambda: [(0, 100)]))
assert LlamaServerBackend._gpu_available() is False
monkeypatch.setattr(LlamaCppBackend, "_get_gpu_free_memory", staticmethod(lambda: []))
assert LlamaServerBackend._gpu_available() is False
def test_gpu_available_apple_metal(monkeypatch):
import utils.hardware as uh
monkeypatch.setattr(uh, "is_apple_silicon", lambda: True)
assert LlamaServerBackend._gpu_available() is True
def _patch_spawn_deps(
monkeypatch,
proc,
*,
free_port = 54321,
):
# Force CPU so spawn never depends on a host GPU.
monkeypatch.setattr(config, "EMBED_DEVICE", "cpu")
monkeypatch.setattr(LlamaServerBackend, "_resolve_binary", lambda self: "/bin/llama-server")
monkeypatch.setattr(LlamaServerBackend, "_resolve_model_path", lambda self: "/m/bge.gguf")
monkeypatch.setattr(LlamaServerBackend, "_find_free_port", staticmethod(lambda: free_port))
monkeypatch.setattr(mod.subprocess, "Popen", lambda *a, **k: proc)
def test_spawn_uses_explicit_port(monkeypatch):
monkeypatch.setattr(config, "EMBED_PORT", 8123)
b = LlamaServerBackend()
_patch_spawn_deps(monkeypatch, _FakeProc(alive = True))
monkeypatch.setattr(b, "_wait_for_health", lambda *a, **k: True)
b._spawn()
assert b._port == 8123
def test_spawn_uses_free_port_when_auto(monkeypatch):
monkeypatch.setattr(config, "EMBED_PORT", 0)
b = LlamaServerBackend()
_patch_spawn_deps(monkeypatch, _FakeProc(alive = True), free_port = 47000)
monkeypatch.setattr(b, "_wait_for_health", lambda *a, **k: True)
b._spawn()
assert b._port == 47000
def test_spawn_fails_loud_on_early_exit(monkeypatch):
monkeypatch.setattr(config, "EMBED_PORT", 8124)
b = LlamaServerBackend()
_patch_spawn_deps(monkeypatch, _FakeProc(alive = False, returncode = 1))
with pytest.raises(RuntimeError, match = "failed to become healthy"):
b._spawn()
def test_spawn_auto_falls_back_to_cpu_on_gpu_failure(monkeypatch):
monkeypatch.setattr(config, "EMBED_DEVICE", "auto")
monkeypatch.setattr(LlamaServerBackend, "_gpu_available", staticmethod(lambda: True))
b = LlamaServerBackend()
calls = []
def fake_spawn_once(use_gpu):
calls.append(use_gpu)
if use_gpu:
raise RuntimeError("CUDA out of memory")
monkeypatch.setattr(b, "_spawn_once", fake_spawn_once)
b._spawn()
assert calls == [True, False] # tried GPU, then fell back to CPU
assert b._force_cpu is True # sticky, so respawns stay on CPU
def test_spawn_explicit_gpu_does_not_fall_back(monkeypatch):
monkeypatch.setattr(config, "EMBED_DEVICE", "gpu")
b = LlamaServerBackend()
def fake_spawn_once(use_gpu):
raise RuntimeError("CUDA out of memory")
monkeypatch.setattr(b, "_spawn_once", fake_spawn_once)
with pytest.raises(RuntimeError, match = "out of memory"):
b._spawn()
assert b._force_cpu is False # explicit gpu never silently downgrades
def _embed_response(vectors):
# Reversed so the index sort is exercised.
items = [{"index": i, "embedding": v} for i, v in enumerate(vectors)]
return {"data": list(reversed(items))}
def test_encode_orders_and_returns_float32(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
captured = {}
def fake_post(path, payload):
captured["path"] = path
captured["input"] = payload["input"]
return _embed_response([[3.0, 4.0], [0.0, 5.0]])
monkeypatch.setattr(b, "_post", fake_post)
out = b.encode(["a", "b"], normalize = False)
assert captured["path"] == "/v1/embeddings"
assert out.dtype == np.float32
assert out.shape == (2, 2)
assert out[0].tolist() == [3.0, 4.0] # index sort restored order
def test_encode_normalizes(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
monkeypatch.setattr(b, "_post", lambda p, pl: _embed_response([[3.0, 4.0]]))
out = b.encode(["a"], normalize = True)
np.testing.assert_allclose(np.linalg.norm(out, axis = 1), [1.0], rtol = 1e-6)
def test_encode_empty_returns_zero_rows(monkeypatch):
b = LlamaServerBackend()
b._dim = 384
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
out = b.encode([])
assert out.shape == (0, 384)
assert out.dtype == np.float32
def test_encode_rejects_count_mismatch(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
monkeypatch.setattr(b, "_post", lambda p, pl: {"data": [{"index": 0, "embedding": [1.0]}]})
with pytest.raises(RuntimeError, match = "vectors for"):
b.encode(["a", "b"], normalize = False)
def test_encode_batches(monkeypatch):
monkeypatch.setattr(config, "EMBED_BATCH", 2)
b = LlamaServerBackend()
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
calls = []
def fake_post(path, payload):
chunk = payload["input"]
calls.append(len(chunk))
return _embed_response([[1.0, 0.0]] * len(chunk))
monkeypatch.setattr(b, "_post", fake_post)
out = b.encode(["a", "b", "c"], normalize = False)
assert out.shape == (3, 2)
assert calls == [2, 1] # batched at EMBED_BATCH=2
def test_dim_probes_once_and_caches(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
n_calls = {"n": 0}
def fake_post(path, payload):
n_calls["n"] += 1
return _embed_response([[0.1] * 384])
monkeypatch.setattr(b, "_post", fake_post)
assert b.dim() == 384
assert b.dim() == 384
assert n_calls["n"] == 1 # cached after the first probe
def test_token_counter_hits_tokenize(monkeypatch):
b = LlamaServerBackend()
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
seen = {}
def fake_post(path, payload):
seen["path"] = path
seen["content"] = payload["content"]
return {"tokens": [1, 2, 3, 4]}
monkeypatch.setattr(b, "_post", fake_post)
count = b.token_counter()
assert count("hello world") == 4
assert seen["path"] == "/tokenize"
assert seen["content"] == "hello world"
def test_ensure_ready_respawns_dead_process(monkeypatch):
b = LlamaServerBackend()
b._process = _FakeProc(alive = False, returncode = 0)
spawned = {"n": 0}
def fake_spawn():
spawned["n"] += 1
b._process = _FakeProc(alive = True)
monkeypatch.setattr(b, "_spawn", fake_spawn)
b._ensure_ready()
assert spawned["n"] == 1
assert b._process_alive()
# Already alive -> no second spawn.
b._ensure_ready()
assert spawned["n"] == 1
def test_post_restarts_once_on_connect_error(monkeypatch):
import httpx
b = LlamaServerBackend()
b._port = 9000
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
restarts = {"n": 0}
monkeypatch.setattr(b, "_restart", lambda: restarts.__setitem__("n", restarts["n"] + 1))
attempts = {"n": 0}
class _Client:
def post(self, url, json):
attempts["n"] += 1
if attempts["n"] == 1:
raise httpx.ConnectError("boom")
class _R:
def raise_for_status(self_inner):
return None
def json(self_inner):
return {"tokens": [1]}
return _R()
b._client = _Client()
out = b._post("/tokenize", {"content": "x"})
assert out == {"tokens": [1]}
assert restarts["n"] == 1 # one self-heal restart, then success
def test_post_restarts_once_on_read_timeout(monkeypatch):
# A wedged request (ReadTimeout) also triggers one restart-and-retry.
import httpx
b = LlamaServerBackend()
b._port = 9000
monkeypatch.setattr(b, "_ensure_ready", lambda: None)
restarts = {"n": 0}
monkeypatch.setattr(b, "_restart", lambda: restarts.__setitem__("n", restarts["n"] + 1))
attempts = {"n": 0}
class _Client:
def post(self, url, json):
attempts["n"] += 1
if attempts["n"] == 1:
raise httpx.ReadTimeout("timed out")
class _R:
def raise_for_status(self_inner):
return None
def json(self_inner):
return {"data": [{"index": 0, "embedding": [1.0, 0.0]}]}
return _R()
b._client = _Client()
out = b._post("/v1/embeddings", {"input": ["x"]})
assert out["data"][0]["embedding"] == [1.0, 0.0]
assert restarts["n"] == 1 # timeout self-heals like a transport error

View file

@ -0,0 +1,222 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Embedder concurrency tests: the fast tokenizer isn't thread-safe, so encode
and token counting must be serialized (else threads panic "Already borrowed")."""
import os
import threading
import time
import numpy as np
import pytest
from core.rag import config, embeddings
@pytest.fixture(autouse = True)
def _pin_st_backend(monkeypatch):
# Tests patch ST internals (_get), so force the ST backend.
monkeypatch.setattr(config, "EMBED_BACKEND", "sentence-transformers")
embeddings._reset_backend()
yield
embeddings._reset_backend()
class _ConcurrencyProbe:
"""Records whether two callers were in the guarded body at once."""
def __init__(self):
self.inside = 0
self.saw_overlap = False
self._g = threading.Lock()
def enter(self):
with self._g:
self.inside += 1
if self.inside > 1:
self.saw_overlap = True
time.sleep(0.005) # widen the race window
with self._g:
self.inside -= 1
class _FakeModel:
def __init__(self, probe):
self._probe = probe
self.tokenizer = _FakeTokenizer(probe)
def encode(self, texts, **_kw):
self._probe.enter()
return np.zeros((len(texts), 4), dtype = np.float32)
class _FakeTokenizer:
def __init__(self, probe):
self._probe = probe
def encode(self, text, **_kw):
self._probe.enter()
return list(range(len(text.split())))
def _hammer(fn, n = 8):
errors: list[Exception] = []
def worker():
try:
fn()
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [threading.Thread(target = worker) for _ in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
return errors
def test_encode_is_serialized(monkeypatch):
probe = _ConcurrencyProbe()
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: _FakeModel(probe))
errors = _hammer(lambda: embeddings.encode(["alpha beta", "gamma"]))
assert errors == []
assert probe.saw_overlap is False # compute lock serialized encode()
def test_token_counter_is_serialized(monkeypatch):
probe = _ConcurrencyProbe()
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: _FakeModel(probe))
count = embeddings.token_counter()
errors = _hammer(lambda: count("one two three four"))
assert errors == []
assert probe.saw_overlap is False # counting shares the tokenizer lock
def test_encode_enables_parallelism_only_during_call(monkeypatch):
seen = {}
class _M:
tokenizer = None
def encode(self, texts, **_kw):
seen["during"] = os.environ.get("TOKENIZERS_PARALLELISM")
return np.zeros((len(texts), 4), dtype = np.float32)
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: _M())
os.environ["TOKENIZERS_PARALLELISM"] = "false"
embeddings.encode(["alpha", "beta"])
assert seen["during"] == "true" # rayon batch tokenization enabled in-call
assert os.environ.get("TOKENIZERS_PARALLELISM") == "false" # restored after
def test_token_counter_enables_parallelism_only_during_call(monkeypatch):
seen = {}
class _Tok:
def encode(self, text, **_kw):
seen["during"] = os.environ.get("TOKENIZERS_PARALLELISM")
return list(range(len(text.split())))
class _M:
tokenizer = _Tok()
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: _M())
os.environ["TOKENIZERS_PARALLELISM"] = "false"
count = embeddings.token_counter()
count("alpha beta gamma")
assert seen["during"] == "true" # rayon enabled in-call, like _st_encode
assert os.environ.get("TOKENIZERS_PARALLELISM") == "false" # restored after
class _SentinelLlamaBackend:
"""Stand-in for LlamaServerBackend; never spawns a real server."""
def _force_st_load_failure(monkeypatch):
"""Make the ST warm-probe raise."""
def _boom(model_name = None):
raise RuntimeError("torch is broken on this machine")
monkeypatch.setattr(embeddings, "_get", _boom)
def _patch_llama_backend(monkeypatch, *, binary):
from core.inference.llama_cpp import LlamaCppBackend
from core.rag import embed_llama_server
monkeypatch.setattr(LlamaCppBackend, "_find_llama_server_binary", staticmethod(lambda: binary))
monkeypatch.setattr(embed_llama_server, "LlamaServerBackend", _SentinelLlamaBackend)
def test_st_failure_falls_back_to_llama_server(monkeypatch):
# ST can't load but llama-server is available -> use it.
_force_st_load_failure(monkeypatch)
_patch_llama_backend(monkeypatch, binary = "/fake/llama-server")
embeddings._reset_backend()
backend = embeddings._get_backend()
assert isinstance(backend, _SentinelLlamaBackend)
def test_st_failure_without_llama_binary_reraises(monkeypatch):
# No llama-server binary -> surface the failure, don't degrade to nothing.
_force_st_load_failure(monkeypatch)
_patch_llama_backend(monkeypatch, binary = None)
embeddings._reset_backend()
with pytest.raises(RuntimeError, match = "torch is broken"):
embeddings._get_backend()
def test_st_success_keeps_sentence_transformers(monkeypatch):
# Clean ST probe -> ST backend stays selected, no fallback.
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: object())
_patch_llama_backend(monkeypatch, binary = "/fake/llama-server")
embeddings._reset_backend()
backend = embeddings._get_backend()
assert isinstance(backend, embeddings._SentenceTransformersBackend)
class _BoomOnEncodeModel:
"""Loads fine (init probe passes) but raises when encoding."""
tokenizer = None
def encode(self, texts, **_kw):
raise RuntimeError("CUDA error during encode")
def test_st_encode_runtime_failure_switches_to_llama(monkeypatch):
# encode() blows up mid-run -> switch to llama-server and stay switched.
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: _BoomOnEncodeModel())
_patch_llama_backend(monkeypatch, binary = "/fake/llama-server")
calls = {}
def _sentinel_encode(
self,
texts,
*,
model_name = None,
normalize = True,
):
calls["used"] = True
return np.zeros((len(texts), 4), dtype = np.float32)
monkeypatch.setattr(_SentinelLlamaBackend, "encode", _sentinel_encode, raising = False)
embeddings._reset_backend()
out = embeddings.encode(["alpha", "beta"])
assert calls.get("used") is True # retried on the llama fallback
assert out.shape == (2, 4)
# Switch is process-wide: later calls keep using llama, not ST.
assert isinstance(embeddings._get_backend(), _SentinelLlamaBackend)
def test_st_encode_failure_without_llama_binary_reraises(monkeypatch):
# No llama-server binary -> surface the encode error.
monkeypatch.setattr(embeddings, "_get", lambda model_name = None: _BoomOnEncodeModel())
_patch_llama_backend(monkeypatch, binary = None)
embeddings._reset_backend()
with pytest.raises(RuntimeError, match = "CUDA error during encode"):
embeddings.encode(["alpha", "beta"])

View file

@ -0,0 +1,139 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Ingestion lifecycle tests: pending -> completed, SSE events, dedupe, delete."""
import os
import time
import pytest
from core.rag import ingestion, store
from storage import rag_db
def _write(tmp_path, name, text):
path = tmp_path / name
path.write_text(text, encoding = "utf-8")
return str(path)
def _drain(job_id):
return list(ingestion.job_events(job_id))
def _wait_completed(job_id, timeout = 30.0):
deadline = time.time() + timeout
while time.time() < deadline:
status = ingestion.get_job_status(job_id)
if status and status["status"] in ("completed", "failed"):
return status
time.sleep(0.05)
raise AssertionError("ingestion did not finish in time")
def test_ingestion_lifecycle_pending_to_completed(rag_home, stub_embeddings, tmp_path):
path = _write(tmp_path, "doc.txt", "alpha bravo charlie " * 50)
scope = store.kb_scope("K1")
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
conn = rag_db.get_connection()
try:
assert store.get_document(conn, doc_id)["status"] == "pending"
finally:
conn.close()
events = _drain(job_id)
assert any(e["type"] == "progress" for e in events)
assert events[-1]["type"] == "complete"
assert events[-1]["num_chunks"] > 0
status = _wait_completed(job_id)
assert status["status"] == "completed"
assert status["progress"] == 1.0
conn = rag_db.get_connection()
try:
doc = store.get_document(conn, doc_id)
assert doc["status"] == "completed"
assert doc["num_chunks"] > 0
assert store.search_lexical(conn, scope, "alpha", 10)
finally:
conn.close()
def test_ingestion_dedupe_by_hash(rag_home, stub_embeddings, tmp_path):
path = _write(tmp_path, "doc.txt", "alpha bravo charlie")
scope = store.kb_scope("K1")
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
_drain(job_id)
_wait_completed(job_id)
# Identical content -> same doc id, no re-ingest.
path2 = _write(tmp_path, "copy.txt", "alpha bravo charlie")
doc_id2, job_id2 = ingestion.start_ingestion(scope, "K1", None, "copy.txt", path2)
events = _drain(job_id2)
assert doc_id2 == doc_id
assert any(e.get("deduped") for e in events)
conn = rag_db.get_connection()
try:
assert len(store.list_documents(conn, scope)) == 1
finally:
conn.close()
def test_ingestion_delete_removes_all_rows(rag_home, stub_embeddings, tmp_path):
path = _write(tmp_path, "doc.txt", "alpha bravo charlie delta")
scope = store.kb_scope("K1")
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
_drain(job_id)
_wait_completed(job_id)
conn = rag_db.get_connection()
try:
store.delete_document(conn, doc_id)
assert store.get_document(conn, doc_id) is None
assert store.search_lexical(conn, scope, "alpha", 10) == []
assert store.list_documents(conn, scope) == []
finally:
conn.close()
def test_ingestion_rejects_unsupported_ext(rag_home, stub_embeddings, tmp_path):
path = _write(tmp_path, "doc.xyz", "alpha")
with pytest.raises(ValueError):
ingestion.start_ingestion(store.kb_scope("K1"), "K1", None, "doc.xyz", path)
def test_ingestion_empty_doc_completes_with_zero_chunks(rag_home, stub_embeddings, tmp_path):
path = _write(tmp_path, "empty.txt", " \n ")
scope = store.kb_scope("K1")
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "empty.txt", path)
events = _drain(job_id)
assert events[-1]["type"] == "complete"
assert events[-1]["num_chunks"] == 0
status = _wait_completed(job_id)
assert status["status"] == "completed"
@pytest.mark.skipif(
os.environ.get("RAG_REAL_EMBEDDER") != "1",
reason = "set RAG_REAL_EMBEDDER=1 to run the real sentence-transformers test",
)
def test_ingestion_with_real_embedder(rag_home, tmp_path):
path = _write(tmp_path, "doc.txt", "The Kestrel-9 turbine is rated at 9.5 megawatts.")
scope = store.kb_scope("K1")
doc_id, job_id = ingestion.start_ingestion(scope, "K1", None, "doc.txt", path)
_drain(job_id)
status = _wait_completed(job_id, timeout = 120.0)
assert status["status"] == "completed"
from core.rag import retrieval
conn = rag_db.get_connection()
try:
hits = retrieval.retrieve_hybrid(conn, scope, "how much power does the turbine make?", k = 5)
assert hits and hits[0].chunk_id == f"{doc_id}:0"
finally:
conn.close()

View file

@ -0,0 +1,174 @@
# 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 region locator + citation preview route tests."""
from __future__ import annotations
import time
import pytest
pytest.importorskip("pymupdf")
pytest.importorskip("sqlite_vec")
def _make_pdf(path) -> None:
import pymupdf
doc = pymupdf.open()
body = (
"BERT is designed to pre-train deep bidirectional representations.\n"
"The two pre-training objectives are masked language modeling and next "
"sentence prediction.\n"
"The Transformer base model uses eight attention heads in each layer.\n"
)
for _ in range(3):
page = doc.new_page()
page.insert_text((72, 72), body, fontsize = 11)
doc.save(str(path))
doc.close()
def _ingest(home, pdf_path):
from core.rag import ingestion, store
from storage import rag_db
conn = rag_db.get_connection()
kb_id = store.create_kb(conn, name = "kb")
conn.close()
doc_id, job_id = ingestion.start_ingestion(
store.kb_scope(kb_id), kb_id, None, "doc.pdf", str(pdf_path)
)
t0 = time.time()
while time.time() - t0 < 30:
s = ingestion.get_job_status(job_id)
if s and s["status"] in ("completed", "failed"):
break
time.sleep(0.05)
assert s and s["status"] == "completed", s
return kb_id, doc_id
def test_chunks_carry_pdf_regions(rag_home, stub_embeddings):
from utils.paths import ensure_dir, rag_uploads_root
pdf = ensure_dir(rag_uploads_root()) / "doc.pdf"
_make_pdf(pdf)
kb_id, doc_id = _ingest(rag_home, pdf)
from storage import rag_db
conn = rag_db.get_connection()
try:
rows = conn.execute(
"SELECT pdf_regions_json FROM chunks WHERE document_id=?", (doc_id,)
).fetchall()
stored_path = conn.execute(
"SELECT stored_path FROM documents WHERE id=?", (doc_id,)
).fetchone()["stored_path"]
finally:
conn.close()
assert rows, "no chunks were stored"
assert stored_path and stored_path.endswith(".pdf")
with_regions = [r for r in rows if r["pdf_regions_json"]]
assert with_regions, "expected at least one chunk with PDF highlight regions"
import json
region = json.loads(with_regions[0]["pdf_regions_json"])[0]
for key in ("pageIndex", "x", "y", "width", "height"):
assert key in region
if key in ("x", "y", "width", "height"):
assert 0.0 <= region[key] <= 1.0
def test_preview_routes_and_signed_file(rag_home, stub_embeddings):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from auth.authentication import get_current_subject
from routes.rag import router
from utils.paths import ensure_dir, rag_uploads_root
pdf = ensure_dir(rag_uploads_root()) / "doc.pdf"
_make_pdf(pdf)
kb_id, doc_id = _ingest(rag_home, pdf)
app = FastAPI()
app.include_router(router, prefix = "/api/rag")
app.dependency_overrides[get_current_subject] = lambda: "tester"
c = TestClient(app)
res = c.post(
"/api/rag/search",
json = {
"query": "masked language modeling next sentence",
"kb_id": kb_id,
"mode": "lexical",
},
).json()["results"]
assert res
chunk_id = res[0]["chunkId"]
pt = c.get(f"/api/rag/documents/{doc_id}/preview-target", params = {"chunk_id": chunk_id}).json()
assert pt["mediaKind"] == "pdf"
assert pt["text"]
url = c.get(f"/api/rag/documents/{doc_id}/file-url").json()["url"]
full = c.get(url)
assert full.status_code == 200 and full.content[:4] == b"%PDF"
rng = c.get(url, headers = {"Range": "bytes=0-99"})
assert rng.status_code in (200, 206)
assert (
c.get(
f"/api/rag/documents/{doc_id}/file-signed",
params = {"token": "bad.token.sig"},
).status_code
== 401
)
def test_norm_token_decomposes_ligatures():
# NFKC folds ligature glyphs to ASCII so anchors match (search_for misses these).
from core.rag.locators import _norm_token
assert _norm_token("significant") == "significant" # fi
assert _norm_token("effort.") == "effort" # ff + trailing punct
assert _norm_token("**Bold**") == "bold"
assert _norm_token("...") == ""
def test_locator_handles_midword_anchor_and_locates_line():
# A span beginning mid-word still locates: first/last tokens dropped.
import pymupdf
from core.rag.locators import LocatorMatch, _regions_for_match
doc = pymupdf.open()
page = doc.new_page()
page.insert_text((72, 200), "alpha beta gamma delta epsilon zeta eta theta", fontsize = 12)
page_text = doc[0].get_text("text") # mirrors what the parser stores
start = page_text.index("lpha")
end = page_text.index("theta") + 3
match = LocatorMatch(page_index = 0, page_number = 1, start = start, end = end)
rects = _regions_for_match(doc, page_text, match)
doc.close()
assert rects, "expected a located region for the interior phrase"
r = rects[0]
for k in ("pageIndex", "pageNumber", "x", "y", "width", "height"):
assert k in r
# Drawn near y=200 on a ~842pt page -> normalized y in the top half.
assert 0.0 < r["y"] < 0.5
assert r["width"] > 0 and r["height"] > 0
def test_sign_verify_roundtrip(rag_home):
from routes import rag as rag_routes
tok = rag_routes._sign_document("doc-123")
assert rag_routes._verify_document_token(tok) == "doc-123"
assert rag_routes._verify_document_token("doc-123.0.deadbeef") is None # expired/bad
assert rag_routes._verify_document_token("garbage") is None

View file

@ -0,0 +1,432 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Retrieval + tool tests: RRF fusion, min-score floor, scope, source-map."""
import math
import pytest
from core.rag import config, retrieval, store, tool
from core.rag.chunking import Chunk
VOCAB = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"]
def _embed(text):
v = [float(text.lower().count(w)) for w in VOCAB]
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]
@pytest.fixture
def bow_embeddings(monkeypatch):
"""Bag-of-words embedder matching the vectors stored in the db."""
from core.rag import embeddings
monkeypatch.setattr(
embeddings,
"encode",
lambda texts, *, model_name = None, normalize = True: [_embed(t) for t in texts],
)
monkeypatch.setattr(embeddings, "dim", lambda model_name = None: len(VOCAB))
def _chunk(
text,
index = 0,
page = None,
):
return Chunk(
text = text,
token_count = len(text.split()),
page_number = page,
source_page_index = 0,
chunk_index = index,
page_char_start = 0,
page_char_end = len(text),
)
def _add_doc(
conn,
scope,
doc_id,
filename,
sha,
text,
page = None,
):
store.create_document(conn, scope = scope, filename = filename, sha256 = sha, document_id = doc_id)
store.add_chunks(conn, scope, doc_id, [_chunk(text, 0, page)], [_embed(text)])
def test_rrf_ranks_doc_in_both_lists_first():
# A chunk near the top of both rankings beats one in a single list.
lexical = [
retrieval.Hit("a", 1.0, lexical_score = 1.0),
retrieval.Hit("b", 0.5, lexical_score = 0.5),
]
dense = [
retrieval.Hit("a", 0.9, dense_score = 0.9),
retrieval.Hit("c", 0.8, dense_score = 0.8),
]
fused = retrieval._rrf([lexical, dense], rrf_k = 60, top_k = 10)
assert fused[0].chunk_id == "a"
assert fused[0].lexical_score == 1.0 and fused[0].dense_score == 0.9
def test_retrieve_hybrid_returns_relevant_chunk(rag_conn, bow_embeddings):
_add_doc(rag_conn, "kb_a", "d1", "f1", "h1", "alpha bravo charlie")
_add_doc(rag_conn, "kb_a", "d2", "f2", "h2", "golf hotel delta")
hits = retrieval.retrieve_hybrid(rag_conn, "kb_a", "alpha bravo", k = 5)
assert hits[0].chunk_id == "d1:0"
def test_retrieve_dense_round_trips(rag_conn, bow_embeddings):
_add_doc(rag_conn, "kb_a", "d1", "f", "h1", "alpha alpha")
_add_doc(rag_conn, "kb_a", "d2", "f", "h2", "hotel golf")
hits = retrieval.retrieve_dense(rag_conn, "kb_a", "alpha", 5)
assert hits[0].chunk_id == "d1:0"
assert hits[0].dense_score is not None and hits[0].dense_score > 0.99
def test_filter_min_score_gates_dense_hits():
hits = [
retrieval.Hit("a", 1.0, dense_score = 0.9),
retrieval.Hit("b", 0.5, dense_score = 0.2),
retrieval.Hit("c", 0.4, lexical_score = 0.4), # no dense_score -> kept
]
out = retrieval.filter_min_score(hits, 0.5)
ids = {h.chunk_id for h in out}
assert ids == {"a", "c"} # b below floor, c lexical-only passes
assert retrieval.filter_min_score(hits, 0.0) == hits # floor off = identity
def test_tool_kb_scope_wins_over_thread(rag_conn, bow_embeddings, monkeypatch):
seen = {}
def fake(conn, scope, q, **k):
seen["scope"] = scope
return []
monkeypatch.setattr(retrieval, "retrieve_hybrid", fake)
tool.search_knowledge_base(query = "q", scope_kb_id = "K", scope_thread_id = "T")
assert seen["scope"] == "kb_K"
def test_tool_empty_query_errors(rag_home):
assert tool.search_knowledge_base(query = " ").startswith("Error")
def test_tool_missing_scope_message(rag_home):
out = tool.search_knowledge_base(query = "hello")
assert "No documents" in out
def test_tool_formats_chunks_and_sources(rag_conn, bow_embeddings, monkeypatch):
_add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3)
monkeypatch.setattr(
retrieval,
"retrieve_hybrid",
lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0)],
)
text, sources = tool.search_knowledge_base_with_sources(query = "q", scope_kb_id = "a")
assert '<chunk id="1" source="paper.pdf" page="3">' in text
assert "body text here" in text
assert sources == [
{
"citationId": 1,
"chunkId": "d1:0",
"documentId": "d1",
"filename": "paper.pdf",
"page": 3,
"text": "body text here",
"score": 1.0,
}
]
def test_tool_kb_scope_retrieves_from_db(rag_conn, bow_embeddings):
# End-to-end (no retrieve stub): doc found via its scope_kb_id (#8).
_add_doc(rag_conn, "kb_K", "d1", "kb.pdf", "h1", "alpha bravo charlie", page = 1)
text, sources = tool.search_knowledge_base_with_sources(query = "alpha bravo", scope_kb_id = "K")
assert "No matching chunks" not in text
assert sources and sources[0]["chunkId"] == "d1:0"
assert sources[0]["filename"] == "kb.pdf"
# A different KB id sees nothing (scope isolation).
other, other_sources = tool.search_knowledge_base_with_sources(
query = "alpha bravo", scope_kb_id = "OTHER"
)
assert other_sources == [] and "No matching chunks" in other
def test_dispatcher_appends_sources_sentinel(rag_conn, bow_embeddings, monkeypatch):
# JSON source-map appended after the sentinel; text before it stays clean.
import json
from core.inference import tools
_add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3)
monkeypatch.setattr(
retrieval,
"retrieve_hybrid",
lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0)],
)
out = tools._search_knowledge_base({"query": "q"}, {"kb_id": "a"})
assert tools.RAG_SOURCES_SENTINEL in out
model_text, _, payload = out.partition(tools.RAG_SOURCES_SENTINEL)
assert "__RAG_SOURCES__" not in model_text # model never sees the JSON
assert '<chunk id="1"' in model_text
sources = json.loads(payload)
assert sources[0]["documentId"] == "d1"
assert sources[0]["chunkId"] == "d1:0"
assert sources[0]["page"] == 3
def test_dispatcher_no_sentinel_when_no_hits(rag_home, monkeypatch):
from core.inference import tools
monkeypatch.setattr(retrieval, "retrieve_hybrid", lambda conn, scope, q, **k: [])
out = tools._search_knowledge_base({"query": "hello"}, {"kb_id": "missing"})
assert tools.RAG_SOURCES_SENTINEL not in out
def test_search_for_autoinject_gates_on_dense_score(rag_conn, bow_embeddings, monkeypatch):
_add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3)
def _hits(score, **kw):
return lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0, **{kw["key"]: score})]
# Strong dense hit -> injected.
monkeypatch.setattr(retrieval, "retrieve_hybrid", _hits(0.8, key = "dense_score"))
found = tool.search_for_autoinject(query = "q", scope_kb_id = "a", min_dense_score = 0.55)
assert found is not None
text, sources = found
assert '<chunk id="1"' in text and sources[0]["chunkId"] == "d1:0"
# Dense below floor -> nothing injected.
monkeypatch.setattr(retrieval, "retrieve_hybrid", _hits(0.30, key = "dense_score"))
assert tool.search_for_autoinject(query = "q", scope_kb_id = "a", min_dense_score = 0.55) is None
# Lexical-only hit (no dense score) does not auto-inject.
monkeypatch.setattr(retrieval, "retrieve_hybrid", _hits(1.0, key = "lexical_score"))
assert tool.search_for_autoinject(query = "q", scope_kb_id = "a", min_dense_score = 0.55) is None
def test_search_for_autoinject_bm25_gates_on_dense_probe(rag_conn, bow_embeddings, monkeypatch):
# BM25 hits carry no cosine, so the gate uses a dense 1-NN probe (#5).
_add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", "body text here", page = 3)
monkeypatch.setattr(
retrieval,
"retrieve_hybrid",
lambda conn, scope, q, **k: [retrieval.Hit("d1:0", 1.0, lexical_score = 2.5)],
)
monkeypatch.setattr(
retrieval,
"retrieve_dense",
lambda conn, scope, q, k = None, **kw: [retrieval.Hit("d1:0", 0.82, dense_score = 0.82)],
)
found = tool.search_for_autoinject(
query = "q", scope_kb_id = "a", mode = "lexical", min_dense_score = 0.70
)
assert found is not None and found[1][0]["chunkId"] == "d1:0"
monkeypatch.setattr(
retrieval,
"retrieve_dense",
lambda conn, scope, q, k = None, **kw: [retrieval.Hit("d1:0", 0.40, dense_score = 0.40)],
)
assert (
tool.search_for_autoinject(query = "q", scope_kb_id = "a", mode = "lexical", min_dense_score = 0.70)
is None
)
def test_search_for_autoinject_empty_query_or_scope(rag_home):
assert tool.search_for_autoinject(query = " ", scope_kb_id = "a") is None
assert tool.search_for_autoinject(query = "hello") is None # no scope
def test_build_rag_autoinject_emits_pipeline(monkeypatch):
# Auto-inject yields the same tool card + source-map a real call would.
from core.inference import tools
from storage import rag_db
monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False)
monkeypatch.setattr(
tool,
"search_for_autoinject",
lambda **k: (
'<chunk id="1" source="d.pdf">hi</chunk>',
[{"citationId": 1, "filename": "d.pdf"}],
),
)
conv = [{"role": "user", "content": "When was DeepSeek V4 released?"}]
out = tools.build_rag_autoinject(conv, {"thread_id": "t1"})
assert out is not None
kinds = [e["type"] for e in out["events"]]
assert "tool_start" in kinds and "tool_end" in kinds
te = next(e for e in out["events"] if e["type"] == "tool_end")
assert te["tool_name"] == "search_knowledge_base"
assert tools.RAG_SOURCES_SENTINEL in te["result"]
assert out["messages"][0]["tool_calls"][0]["function"]["name"] == "search_knowledge_base"
assert "__RAG_SOURCES__" not in out["messages"][1]["content"]
def test_build_rag_autoinject_skips_without_hit(monkeypatch):
from core.inference import tools
from storage import rag_db
monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False)
monkeypatch.setattr(tool, "search_for_autoinject", lambda **k: None)
assert (
tools.build_rag_autoinject([{"role": "user", "content": "hi"}], {"thread_id": "t1"}) is None
)
def test_build_rag_autoinject_enabled_by_default(monkeypatch):
from core.inference import tools
from storage import rag_db
monkeypatch.delenv("RAG_AUTOINJECT", raising = False)
monkeypatch.delenv("RAG_AUTOINJECT_MIN_SCORE", raising = False)
monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False)
seen: dict = {}
def fake(**k):
seen.update(k)
return ("x", [{"citationId": 1}])
monkeypatch.setattr(tool, "search_for_autoinject", fake)
out = tools.build_rag_autoinject([{"role": "user", "content": "hi"}], {"thread_id": "t1"})
assert out is not None
assert seen["min_dense_score"] == 0.70 # high-precision floor by default
def test_build_rag_autoinject_caps_top_k(monkeypatch):
from core.inference import tools
from storage import rag_db
monkeypatch.setenv("RAG_AUTOINJECT", "1")
monkeypatch.setenv("RAG_AUTOINJECT_TOP_K", "4")
monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False)
seen: dict = {}
def fake(**k):
seen.update(k)
return ("x", [{"citationId": 1}])
monkeypatch.setattr(tool, "search_for_autoinject", fake)
conv = [{"role": "user", "content": "q"}]
tools.build_rag_autoinject(conv, {"thread_id": "t1"})
assert seen["top_k"] == 4 # lean default
tools.build_rag_autoinject(conv, {"thread_id": "t1", "default_top_k": 2})
assert seen["top_k"] == 2 # lower user setting wins
def test_build_rag_autoinject_disabled_by_env(monkeypatch):
from core.inference import tools
monkeypatch.setenv("RAG_AUTOINJECT", "0")
assert (
tools.build_rag_autoinject([{"role": "user", "content": "hi"}], {"thread_id": "t1"}) is None
)
# No scope -> also a no-op.
monkeypatch.delenv("RAG_AUTOINJECT", raising = False)
assert tools.build_rag_autoinject([{"role": "user", "content": "hi"}], None) is None
def test_retrieve_hybrid_mode_selects_backend(monkeypatch):
# ``mode`` runs only the chosen backend; hybrid uses config counts + rrf_k.
calls: list = []
monkeypatch.setattr(
retrieval,
"retrieve_lexical",
lambda c, s, q, k = None: calls.append(("lex", k)) or [],
)
monkeypatch.setattr(
retrieval,
"retrieve_dense",
lambda c, s, q, k = None, *, model_name = None: calls.append(("dense", k)) or [],
)
monkeypatch.setattr(
retrieval,
"_rrf",
lambda rankings, rrf_k, top_k: calls.append(("rrf", rrf_k, top_k)) or [],
)
calls.clear()
retrieval.retrieve_hybrid(None, "kb_a", "q", k = 5, mode = "lexical")
assert [c[0] for c in calls] == ["lex"] # dense + rrf skipped
calls.clear()
retrieval.retrieve_hybrid(None, "kb_a", "q", k = 5, mode = "dense")
assert [c[0] for c in calls] == ["dense"]
calls.clear()
retrieval.retrieve_hybrid(None, "kb_a", "q", k = 5, mode = "hybrid")
# Candidate pools + rrf_k come from config (no per-request override).
assert ("lex", config.TOP_K_LEXICAL) in calls
assert ("dense", config.TOP_K_DENSE) in calls
rrf = next(c for c in calls if c[0] == "rrf")
assert rrf[1] == config.RRF_K and rrf[2] == 5 # config rrf_k + final top_k
def test_scope_overrides_reach_retrieval(monkeypatch):
from core.inference import tools
from storage import rag_db
monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False)
seen: dict = {}
def fake_search(**kw):
seen.update(kw)
return ("text", [])
monkeypatch.setattr(tool, "search_knowledge_base_with_sources", fake_search)
tools._search_knowledge_base(
{"query": "q"},
{"kb_id": "a", "mode": "dense", "default_top_k": 11},
)
assert seen["mode"] == "dense"
assert seen["top_k"] == 11
# Unknown mode falls back to hybrid.
seen.clear()
tools._search_knowledge_base({"query": "q"}, {"kb_id": "a", "mode": "bogus"})
assert seen["mode"] == "hybrid"
def test_build_rag_autoinject_scope_overrides_env(monkeypatch):
from core.inference import tools
from storage import rag_db
monkeypatch.setattr(rag_db, "RAG_AVAILABLE", True, raising = False)
seen: dict = {}
def fake_autoinject(**k):
seen.update(k)
return ('<chunk id="1" source="d.pdf">hi</chunk>', [{"citationId": 1}])
monkeypatch.setattr(tool, "search_for_autoinject", fake_autoinject)
conv = [{"role": "user", "content": "q"}]
# Scope enables + overrides the floor though env says off.
monkeypatch.setenv("RAG_AUTOINJECT", "0")
out = tools.build_rag_autoinject(
conv,
{
"thread_id": "t1",
"autoinject": True,
"autoinject_min_score": 0.8,
"mode": "dense",
},
)
assert out is not None
assert seen["min_dense_score"] == 0.8
assert seen["mode"] == "dense"
# Explicit False disables even with the env default on.
monkeypatch.setenv("RAG_AUTOINJECT", "1")
assert tools.build_rag_autoinject(conv, {"thread_id": "t1", "autoinject": False}) is None

View file

@ -0,0 +1,125 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Store tests: incremental writes, dedupe, delete, scope, dense + lexical."""
import math
from core.rag import store
from core.rag.chunking import Chunk
VOCAB = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"]
def embed(text):
v = [float(text.lower().count(w)) for w in VOCAB]
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]
def _chunk(
text,
index = 0,
page = None,
):
return Chunk(
text = text,
token_count = len(text.split()),
page_number = page,
source_page_index = 0,
chunk_index = index,
page_char_start = 0,
page_char_end = len(text),
)
def _add_doc(conn, scope, doc_id, filename, sha, texts):
chunks = [_chunk(t, i) for i, t in enumerate(texts)]
vectors = [embed(t) for t in texts]
store.create_document(conn, scope = scope, filename = filename, sha256 = sha, document_id = doc_id)
store.add_chunks(conn, scope, doc_id, chunks, vectors)
def test_lexical_returns_only_matching_docs(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "d1.txt", "h1", ["alpha bravo charlie"])
_add_doc(rag_conn, "kb_a", "d2", "d2.txt", "h2", ["golf hotel india"])
hits = store.search_lexical(rag_conn, "kb_a", "alpha", 10)
assert [cid for cid, _ in hits] == ["d1:0"] # d2 not returned (score 0)
def test_scope_isolation(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "f", "h1", ["alpha bravo"])
_add_doc(rag_conn, "kb_b", "d2", "f", "h2", ["alpha bravo"])
assert [cid for cid, _ in store.search_lexical(rag_conn, "kb_b", "alpha", 10)] == ["d2:0"]
def test_match_query_sanitizes_special_chars():
assert store._match_query('AND OR "quote" (paren) -dash') != ""
def test_lexical_does_not_crash_on_punctuation(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "f", "h1", ["alpha bravo"])
# Must not raise on FTS operators in the query.
store.search_lexical(rag_conn, "kb_a", 'NEAR("x" AND', 5)
def test_dense_ranks_by_cosine(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "f", "h1", ["alpha alpha"])
_add_doc(rag_conn, "kb_a", "d2", "f", "h2", ["hotel golf"])
ranked = store.search_dense(rag_conn, "kb_a", embed("alpha"), 10)
assert ranked[0][0] == "d1:0" and ranked[0][1] > 0.99
def test_dense_empty_before_any_ingest(rag_conn):
# No chunks_vec table yet -> [], no crash.
assert store.search_dense(rag_conn, "kb_a", embed("alpha"), 10) == []
def test_dedupe_by_hash(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "f", "SHA", ["alpha"])
assert store.document_by_hash(rag_conn, "kb_a", "SHA") == "d1"
assert store.document_by_hash(rag_conn, "kb_a", "OTHER") is None
def test_delete_document_purges_all_tables(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "f", "h1", ["alpha bravo"])
store.delete_document(rag_conn, "d1")
assert store.search_lexical(rag_conn, "kb_a", "alpha", 10) == []
assert store.search_dense(rag_conn, "kb_a", embed("alpha"), 10) == []
assert store.chunks_by_id(rag_conn, ["d1:0"]) == {}
assert store.get_document(rag_conn, "d1") is None
def test_incremental_add_is_flat(rag_conn):
# Adding doc2 must not touch doc1's fts rowids (append, not rebuild).
_add_doc(rag_conn, "kb_a", "d1", "f", "h1", ["alpha bravo charlie"])
before = rag_conn.execute(
"SELECT rowid, chunk_id FROM chunks_fts WHERE scope='kb_a'"
).fetchall()
_add_doc(rag_conn, "kb_a", "d2", "f", "h2", ["delta echo foxtrot"])
after = rag_conn.execute(
"SELECT rowid, chunk_id FROM chunks_fts WHERE scope='kb_a' AND chunk_id LIKE 'd1:%'"
).fetchall()
before_d1 = [(r["rowid"], r["chunk_id"]) for r in before if r["chunk_id"].startswith("d1:")]
after_d1 = [(r["rowid"], r["chunk_id"]) for r in after]
assert before_d1 == after_d1
def test_chunks_by_id_joins_filename(rag_conn):
_add_doc(rag_conn, "kb_a", "d1", "paper.pdf", "h1", ["body text here"])
rows = store.chunks_by_id(rag_conn, ["d1:0"])
assert rows["d1:0"]["filename"] == "paper.pdf"
assert rows["d1:0"]["text"] == "body text here"
def test_kb_crud_and_delete_cascades(rag_conn):
kb_id = store.create_kb(rag_conn, name = "My KB", description = "d", kb_id = "K1")
assert store.get_kb(rag_conn, kb_id)["name"] == "My KB"
assert [k["id"] for k in store.list_kbs(rag_conn)] == ["K1"]
scope = store.kb_scope("K1")
_add_doc(rag_conn, scope, "doc1", "f", "h1", ["alpha bravo"])
store.delete_kb(rag_conn, "K1")
assert store.get_kb(rag_conn, "K1") is None
assert store.list_documents(rag_conn, scope) == []
assert store.search_lexical(rag_conn, scope, "alpha", 10) == []

View file

@ -23,6 +23,7 @@ from core.inference.safetensors_agentic import (
strip_tool_markup_streaming,
)
from core.inference.tool_call_parser import (
RAG_MAX_SEARCHES_PER_TURN,
has_tool_signal,
parse_tool_calls_from_text,
strip_tool_markup,
@ -205,6 +206,7 @@ class FakeExecuteTool:
cancel_event = None,
timeout = None,
session_id = None,
rag_scope = None,
):
self.calls.append((name, arguments))
result = self.results.pop(0) if self.results else "OK"
@ -625,6 +627,44 @@ class TestLoopBehaviour:
for event in events
)
def test_kb_search_capped_per_turn(self):
# Paraphrased KB searches differ by args (dup guard misses them); the
# per-turn cap stops the runaway re-search loop.
n = RAG_MAX_SEARCHES_PER_TURN
queries = [f"paraphrase {i}" for i in range(n + 1)]
turns = [
[
'<tool_call>{"name":"search_knowledge_base",'
f'"arguments":{{"query":"{q}"}}}}</tool_call>'
]
for q in queries
] + [["final answer"]]
turn_iter = iter(turns)
def _gen(_messages):
try:
chunks = next(turn_iter)
except StopIteration:
return
acc = ""
for c in chunks:
acc += c
yield acc
exec_fn = FakeExecuteTool([f"chunk-{i}" for i in range(n)])
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "hi"}],
tools = [{"type": "function", "function": {"name": "search_knowledge_base"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
assert len(exec_fn.calls) == n
assert all(c[0] == "search_knowledge_base" for c in exec_fn.calls)
tool_end_events = [e for e in events if e["type"] == "tool_end"]
assert len(tool_end_events) == n + 1
assert "do not search again" in tool_end_events[n]["result"].lower()
def test_image_sentinel_stripped_from_model_feed(self):
# The image sentinel is stripped before the next turn, but tool_end still
# carries the raw result for the UI.

View file

@ -23,6 +23,9 @@ from .storage_roots import (
auth_root,
auth_db_path,
studio_db_path,
rag_root,
rag_db_path,
rag_uploads_root,
documents_root,
project_workspaces_root,
tmp_root,
@ -66,6 +69,9 @@ __all__ = [
"auth_root",
"auth_db_path",
"studio_db_path",
"rag_root",
"rag_db_path",
"rag_uploads_root",
"documents_root",
"project_workspaces_root",
"tmp_root",
@ -86,3 +92,6 @@ __all__ = [
"resolve_tensorboard_dir",
"resolve_dataset_path",
]
# Bind the re-exports so the import-hoist verifier counts them as used.
_ = (rag_root, rag_db_path, rag_uploads_root)

View file

@ -96,6 +96,21 @@ def studio_db_path() -> Path:
return studio_root() / "studio.db"
def rag_root() -> Path:
"""Root directory for retrieval-augmented-generation state (db + uploads)."""
return studio_root() / "rag"
def rag_db_path() -> Path:
"""SQLite file holding RAG documents, chunks, FTS5 + sqlite-vec indexes."""
return rag_root() / "rag.db"
def rag_uploads_root() -> Path:
"""Directory where uploaded source documents are stored for ingestion."""
return rag_root() / "uploads"
def _xdg_user_dir(key: str) -> Path | None:
config = Path.home() / ".config" / "user-dirs.dirs"
try:

View file

@ -59,6 +59,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",
@ -1661,6 +1662,256 @@
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@ -10959,6 +11210,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
@ -10997,6 +11260,24 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
@ -11383,6 +11664,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -12727,6 +13025,18 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -13196,6 +13506,35 @@
"license": "MIT",
"peer": true
},
"node_modules/react-pdf": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz",
"integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@ -15253,6 +15592,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",

View file

@ -68,6 +68,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",

View file

@ -49,7 +49,9 @@ import {
CursorInfo02Icon,
DashboardCircleIcon,
Delete02Icon,
Download01Icon,
DownloadSquare01Icon,
Upload01Icon,
Edit03Icon,
FolderAddIcon,
FolderExportIcon,
@ -66,6 +68,17 @@ import {
TestTube01Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import {
exportConversationRawJsonl,
exportConversationCsv,
exportConversationShareGPT,
exportBulkConversationsMerged,
exportBulkConversationsSeparate,
importConversationsFromFile,
EXPORT_FORMATS_LIST,
type ConvExportFormat,
} from "@/features/chat/prompt-storage/prompt-storage-dialog";
import { listStoredChatThreads } from "@/features/chat/utils/chat-history-storage";
import {
Tooltip,
TooltipContent,
@ -245,6 +258,41 @@ export function AppSidebar() {
const isChatRoute = pathname.startsWith("/chat");
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
const [chatOpen, setChatOpen] = useState(true);
const recentsImportInputRef = useRef<HTMLInputElement>(null);
async function handleImportToRecents(file: File) {
try {
const count = await importConversationsFromFile(file, null);
if (count === 0) {
toast.info("No conversations found in file.");
} else {
toast.success(`Imported ${count} conversation${count === 1 ? "" : "s"} to Recents.`);
}
} catch {
toast.error("Import failed.");
}
}
async function handleBulkExport(scope: "recents" | "all", fmt: ConvExportFormat, merged: boolean) {
try {
const threads = await listStoredChatThreads({
includeArchived: false,
...(scope === "recents" ? { projectId: null } : {}),
});
const ids = [...new Set(threads.map((t) => t.id))];
if (ids.length === 0) { toast.info("No conversations to export."); return; }
const ts = new Date().toISOString().slice(0, 10);
const basename = scope === "all" ? `all-chats-${ts}` : `recents-${ts}`;
if (merged) {
await exportBulkConversationsMerged(ids, fmt, basename);
} else {
await exportBulkConversationsSeparate(ids, fmt, basename);
}
} catch {
toast.error("Export failed.");
}
}
const [trainOpen, setTrainOpen] = useState(true);
const [runsOpen, setRunsOpen] = useState(true);
@ -644,6 +692,35 @@ export function AppSidebar() {
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon" />
<span>Export</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent sideOffset={8} alignOffset={-4} className="unsloth-plus-menu w-52">
{[
{ label: "Raw JSONL", fn: exportConversationRawJsonl },
{ label: "CSV", fn: exportConversationCsv },
{ label: "ShareGPT JSONL", fn: exportConversationShareGPT },
].map(({ label, fn }) => (
<DropdownMenuItem
key={label}
onSelect={async () => {
try {
const ids = item.type === "single"
? [item.id]
: (await listStoredChatThreads({ pairId: item.id })).map((t) => t.id);
await Promise.all(ids.map((id) => fn(id)));
} catch {
toast.error("Export failed.");
}
}}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
@ -659,6 +736,18 @@ export function AppSidebar() {
return (
<>
{/* Hidden file inputs for chat import */}
<input
ref={recentsImportInputRef}
type="file"
accept=".jsonl,.ndjson,.csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImportToRecents(file);
e.target.value = "";
}}
/>
<Sidebar
collapsible="icon"
variant="sidebar"
@ -872,10 +961,68 @@ export function AppSidebar() {
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
<div className="flex w-full items-center group/sb-collap">
<CollapsibleTrigger className="cursor-pointer flex flex-1 items-center gap-1 min-w-0">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="ml-auto flex items-center justify-center rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-0"
title="Export recents"
>
<MoreHorizontalIcon className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-56">
<DropdownMenuItem onSelect={() => recentsImportInputRef.current?.click()}>
<HugeiconsIcon icon={Upload01Icon} strokeWidth={1.75} className="size-icon mr-1" />
Import chats
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon mr-1" />
Export Recents
</DropdownMenuSubTrigger>
<DropdownMenuSubContent avoidCollisions={false} className="unsloth-plus-menu w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`r-m-${fmt}`} onSelect={() => void handleBulkExport("recents", fmt, true)}>
{label} combined
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`r-s-${fmt}`} onSelect={() => void handleBulkExport("recents", fmt, false)}>
{label} per chat
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon mr-1" />
Export Recents + Projects
</DropdownMenuSubTrigger>
<DropdownMenuSubContent avoidCollisions={false} className="unsloth-plus-menu w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`a-m-${fmt}`} onSelect={() => void handleBulkExport("all", fmt, true)}>
{label} combined
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`a-s-${fmt}`} onSelect={() => void handleBulkExport("all", fmt, false)}>
{label} per chat
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-2">

View file

@ -0,0 +1,105 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const RAG_SOURCES_SENTINEL = "__RAG_SOURCES__:";
export interface Citation {
id: string;
filename: string;
page?: number | null;
score?: number | null;
text: string;
documentId?: string | null;
chunkId?: string | null;
}
function asNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
// null if absent so callers fall back to generic JSON shapes.
function parseSentinelSources(result: unknown): Citation[] | null {
if (typeof result !== "string") return null;
const idx = result.indexOf(RAG_SOURCES_SENTINEL);
if (idx < 0) return null;
const payload = result.slice(idx + RAG_SOURCES_SENTINEL.length).trim();
let rows: unknown;
try {
rows = JSON.parse(payload);
} catch {
return [];
}
if (!Array.isArray(rows)) return [];
return rows.map((row, i) => {
const r = (row ?? {}) as Record<string, unknown>;
const documentId = typeof r.documentId === "string" ? r.documentId : null;
const chunkId = typeof r.chunkId === "string" ? r.chunkId : null;
const filename =
typeof r.filename === "string" ? r.filename : `Source ${i + 1}`;
return {
id: chunkId ?? `${filename}-${i}`,
filename,
page: asNumber(r.page),
score: asNumber(r.score),
text: typeof r.text === "string" ? r.text : "",
documentId,
chunkId,
};
});
}
export function parseCitations(result: unknown): Citation[] {
const sentinel = parseSentinelSources(result);
if (sentinel !== null) return sentinel;
let rows: unknown[] | null = null;
if (Array.isArray(result)) {
rows = result;
} else if (typeof result === "string") {
const trimmed = result.trim();
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) rows = parsed;
else if (parsed && Array.isArray((parsed as { results?: unknown }).results)) {
rows = (parsed as { results: unknown[] }).results;
}
} catch {
rows = null;
}
}
} else if (result && Array.isArray((result as { results?: unknown }).results)) {
rows = (result as { results: unknown[] }).results;
}
if (!rows) return [];
const citations: Citation[] = [];
rows.forEach((row, i) => {
if (!row || typeof row !== "object") return;
const r = row as Record<string, unknown>;
const text =
typeof r.text === "string"
? r.text
: typeof r.chunk === "string"
? r.chunk
: typeof r.content === "string"
? r.content
: "";
const filename =
typeof r.filename === "string"
? r.filename
: typeof r.documentId === "string"
? r.documentId
: `Source ${i + 1}`;
const chunkId =
typeof r.chunkId === "string" ? r.chunkId : `${filename}-${i}`;
citations.push({
id: chunkId,
filename,
page: asNumber(r.page),
score: asNumber(r.score),
text,
});
});
return citations;
}

View file

@ -35,6 +35,7 @@ import {
useInfiniteScroll,
useRecommendedModelVram,
} from "@/hooks";
import { extractParamLabel } from "@/lib/model-size";
import { cn, formatCompact } from "@/lib/utils";
import type { VramFitStatus } from "@/lib/vram";
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
@ -120,7 +121,7 @@ function ModelRow({
tooltipText,
}: {
label: string;
meta?: string;
meta?: string | null;
selected?: boolean;
onClick: () => void;
vramStatus?: VramFitStatus | null;
@ -458,14 +459,6 @@ function isGgufRepo(id: string, hintedIsGguf?: boolean): boolean {
return Boolean(hintedIsGguf) || hasGgufSuffix(id);
}
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
function extractParamLabel(id: string): string | undefined {
const name = id.split("/").pop() ?? id;
// Match patterns like "0.6B", "1B", "4B", "3.5B", "70B", "1.5B" etc.
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
return match ? `${match[1]}B` : undefined;
}
// Module-level caches so re-mounting the popover shows results instantly
let _cachedGgufCache: CachedGgufRepo[] = [];
let _cachedModelsCache: CachedModelRepo[] = [];

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { useMessage } from "@assistant-ui/react";
import type { FC } from "react";
import { type Citation, parseCitations } from "./citation-utils";
import { CitationBadge } from "./tool-ui-knowledge-base";
export const RagSourcesGroup: FC = () => {
const message = useMessage();
const all: Citation[] = [];
for (const part of message.content ?? []) {
if (part.type === "tool-call" && part.toolName === "search_knowledge_base") {
all.push(...parseCitations(part.result));
}
}
// Map updates keep first-seen order, so dedup to best-scoring chunk per doc.
const byDoc = new Map<string, Citation>();
for (const c of all) {
const key = c.documentId ?? c.filename;
const prev = byDoc.get(key);
if (!prev || (c.score ?? -Infinity) > (prev.score ?? -Infinity)) {
byDoc.set(key, c);
}
}
const sources = Array.from(byDoc.values());
if (sources.length === 0) return null;
return (
<div className="mt-2 mb-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">
Document Sources
</div>
<div className="flex flex-wrap gap-1.5">
{sources.map((citation, i) => (
<CitationBadge key={citation.id} citation={citation} index={i} />
))}
</div>
</div>
);
};

View file

@ -13,6 +13,7 @@ import { downloadImagePart } from "@/components/assistant-ui/image";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources";
import { Sources, SourcesGroup } from "@/components/assistant-ui/sources";
import {
thinkEffortAriaLabel,
@ -22,6 +23,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
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 { KnowledgeBaseToolUI } from "@/components/assistant-ui/tool-ui-knowledge-base";
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
@ -34,6 +36,7 @@ import {
useScrollThreadToBottom,
} from "@/components/assistant-ui/use-intent-aware-autoscroll";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
DropdownMenu,
DropdownMenuContent,
@ -46,14 +49,28 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import {
PromptStorageDialog,
exportConversationShareGPT,
exportConversationRawJsonl,
exportConversationCsv,
} from "@/features/chat/prompt-storage/prompt-storage-dialog";
import {
listPromptEntries,
type PromptEntry,
} from "@/features/chat/api/prompts-api";
import { useChatProjects } from "@/features/chat/hooks/use-chat-projects";
import { NewProjectDialog } from "@/features/chat/components/new-project-dialog";
import { parseExternalModelId } from "@/features/chat/external-providers";
import { McpComposerButton } from "@/features/chat/mcp-composer-button";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useRagToolAvailable } from "@/features/chat/hooks/use-rag-tool-available";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
import { ThreadDocumentsBar } from "@/features/rag/components/thread-documents-bar";
import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button";
import { DocumentPreviewMount } from "@/features/rag/components/document-preview-mount";
import { useUserProfileStore } from "@/features/profile/stores/user-profile-store";
import { applyQwenThinkingParams } from "@/features/chat/utils/qwen-params";
import { isTauri } from "@/lib/api-base";
@ -77,11 +94,13 @@ import {
import { flushResourcesSync } from "@assistant-ui/tap";
import {
AttachmentIcon,
Bookmark02Icon,
CodeIcon,
Copy01Icon,
Delete02Icon,
Download01Icon,
Edit03Icon,
FileDatabaseIcon,
Folder01Icon,
FolderAddIcon,
Image03Icon,
@ -94,7 +113,6 @@ import { useNavigate } from "@tanstack/react-router";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
Columns2Icon,
@ -128,6 +146,83 @@ import {
// can show its "Drop files here" affordance.
const PageDragContext = createContext(false);
// Single-chat prompt queue. State lives at module level so it survives the
// Composer remount when the first queued message creates a new thread, and
// detection subscribes to the store's runningByThreadId rather than
// aui.thread() (unbound on the welcome screen).
import { create as _createZustand } from "zustand";
// Module-level Zustand so ComposerRightControls re-renders across Composer mounts.
interface _QueueUIState { isRunning: boolean; current: number; total: number; }
const _useQueueUI = _createZustand<_QueueUIState>(() => ({
isRunning: false, current: 0, total: 0,
}));
let _qItems: string[] = [];
let _qIndex = 0;
let _qIsRunning = false;
let _qPrevStoreRunning = false;
let _qStoreUnsub: (() => void) | null = null;
// Points to the current Composer's aui (updated every render), so it stays valid
// after a remount.
let _qGetAui: () => ReturnType<typeof useAui> = () => {
throw new Error("aui not initialised");
};
function _qStopSubscription() {
if (_qStoreUnsub) { _qStoreUnsub(); _qStoreUnsub = null; }
_qPrevStoreRunning = false;
}
function _qAdvance() {
const nextIndex = _qIndex + 1;
if (nextIndex >= _qItems.length) {
_qIsRunning = false;
_qItems = [];
_qIndex = 0;
_qStopSubscription();
_useQueueUI.setState({ isRunning: false, current: 0, total: 0 });
toast.success("Prompt queue complete");
return;
}
_qIndex = nextIndex;
_useQueueUI.setState({ current: nextIndex + 1, total: _qItems.length });
const next = _qItems[nextIndex];
toast(`Prompt ${nextIndex + 1} / ${_qItems.length}`, {
description: next.length > 80 ? next.slice(0, 80) + "…" : next,
});
_qPrevStoreRunning = false; // catch the next run
setTimeout(() => {
_qGetAui().thread().append({
role: "user",
content: [{ type: "text", text: next }],
createdAt: new Date(),
} as never);
}, 100);
}
function _qStartSubscription() {
_qStopSubscription();
// runningByThreadId tracks the actual thread (not aui.thread()), so detection
// survives navigation.
_qStoreUnsub = useChatRuntimeStore.subscribe((state) => {
if (!_qIsRunning) { _qStopSubscription(); return; }
const isRunning = Object.keys(state.runningByThreadId).length > 0;
const wasRunning = _qPrevStoreRunning;
_qPrevStoreRunning = isRunning;
if (wasRunning && !isRunning) {
_qAdvance();
}
});
}
interface _QueueCallbacks { startQueue: (items: string[]) => void; stopQueue: () => void; }
const PromptQueueContext = createContext<_QueueCallbacks>({
startQueue: () => {}, stopQueue: () => {},
});
// Gap (px) between last message and floating composer; bottom spacer tracks
// composer height plus this gap so chat can scroll fully above the composer.
const COMPOSER_SCROLL_GAP_PX = 24;
@ -443,6 +538,8 @@ export const Thread: FC<{
)}
</IntentAwareScrollProvider>
</ThreadPrimitive.Root>
{/* Document preview, opened by citation badges. */}
<DocumentPreviewMount />
</PageDragContext.Provider>
</GeneratedImageOverlayProvider>
);
@ -778,10 +875,13 @@ const Composer: FC<{
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const ragToolAvailable = useRagToolAvailable();
// More than 4 pills: collapse to icons only. Search and Code always show;
// Images, Canvas and MCP are conditional.
const pillsCompact =
2 +
(ragEnabled && ragToolAvailable ? 1 : 0) +
(supportsBuiltinImageGeneration ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) >
@ -847,6 +947,7 @@ const Composer: FC<{
toolsEnabled ||
codeToolsEnabled ||
imageToolsEnabled ||
ragEnabled ||
artifactsEnabled ||
mcpEnabledForChat;
// react-textarea-autosize re-measures only on value change or window resize,
@ -890,18 +991,88 @@ const Composer: FC<{
// Docked composer opens upward; the welcome composer opens downward by
// default and only flips up via collision detection when it won't fit.
const effectiveMenuSide = menuSide ?? "bottom";
// While this thread's docs index, hold the send and fire it once they finish so
// retrieval covers all of them.
const [indexingActive, setIndexingActive] = useState(false);
const [pendingSend, setPendingSend] = useState(false);
const pendingSendRef = useRef(false);
const waitToastRef = useRef<string | number | null>(null);
const dismissWaitToast = useCallback(() => {
if (waitToastRef.current !== null) {
toast.dismiss(waitToastRef.current);
waitToastRef.current = null;
}
}, []);
const cancelQueuedSend = useCallback(() => {
pendingSendRef.current = false;
setPendingSend(false);
dismissWaitToast();
}, [dismissWaitToast]);
const enqueueSend = useCallback(() => {
if (pendingSendRef.current) return;
pendingSendRef.current = true;
setPendingSend(true);
waitToastRef.current = toast("Waiting for documents to finish indexing", {
description:
"Your message will send automatically once indexing finishes.",
duration: Infinity,
cancel: { label: "Cancel", onClick: cancelQueuedSend },
});
}, [cancelQueuedSend]);
const shouldBlockSend = useCallback(
() =>
!hasSendableContent || isComposingRef.current || hasPendingAttachments,
[hasPendingAttachments, hasSendableContent, isComposingRef],
);
const handleSubmit = useCallback(
(event: Parameters<NonNullable<ComponentProps<"form">["onSubmit"]>>[0]) => {
// Gate for both form submit and the Send button. Returns true when it handled
// the event (blocked or queued) so callers stop.
const interceptSend = useCallback(
(event: { preventDefault: () => void }) => {
if (disabled || shouldBlockSend()) {
event.preventDefault();
return;
return true;
}
if (indexingActive && !overlay) {
event.preventDefault();
enqueueSend();
return true;
}
return false;
},
[disabled, shouldBlockSend, indexingActive, overlay, enqueueSend],
);
// Fire the parked send once indexing clears, unless the user emptied the
// composer while waiting (then drop it quietly).
useEffect(() => {
if (!pendingSend || indexingActive) return;
const { text, attachments } = aui.composer().getState();
pendingSendRef.current = false;
setPendingSend(false);
dismissWaitToast();
if (text.trim().length > 0 || attachments.length > 0) {
aui.composer().send();
}
}, [pendingSend, indexingActive, aui, dismissWaitToast]);
// Drop any queued send + toast on unmount (e.g. thread switch).
useEffect(
() => () => {
pendingSendRef.current = false;
if (waitToastRef.current !== null) toast.dismiss(waitToastRef.current);
},
[],
);
const handleSubmit = useCallback(
(event: Parameters<NonNullable<ComponentProps<"form">["onSubmit"]>>[0]) => {
if (interceptSend(event)) return;
if (overlay) {
const trimmed = composerText.trim();
@ -949,19 +1120,58 @@ const Composer: FC<{
aui,
closeOverlay,
composerText,
disabled,
interceptSend,
overlay,
referenceThreadId,
setImageToolsEnabled,
setPendingImageEditReference,
shouldBlockSend,
],
);
// Update the getter every render so the queue always calls the current
// Composer's aui (post-remount).
_qGetAui = () => aui;
const stopQueue = useCallback(() => {
_qIsRunning = false;
_qStopSubscription();
_useQueueUI.setState({ isRunning: false, current: 0, total: 0 });
_qItems = [];
_qIndex = 0;
try { _qGetAui().thread().cancelRun(); } catch {}
}, []);
const startQueue = useCallback((items: string[]) => {
const filtered = items.filter((p) => p.trim());
if (!filtered.length) return;
_qItems = filtered;
_qIndex = 0;
_qIsRunning = true;
_useQueueUI.setState({ isRunning: true, current: 1, total: filtered.length });
toast(`Prompt 1 / ${filtered.length}`, {
description: filtered[0].length > 80 ? filtered[0].slice(0, 80) + "…" : filtered[0],
});
// Subscribe BEFORE appending so we don't miss a very fast completion.
_qStartSubscription();
setTimeout(() => {
_qGetAui().thread().append({
role: "user",
content: [{ type: "text", text: filtered[0] }],
createdAt: new Date(),
} as never);
}, 50);
}, []);
const queueContextValue: _QueueCallbacks = { startQueue, stopQueue };
const composerContent = (
<>
<ComposerAttachments />
<PendingAudioChip />
<ThreadDocumentsBar
threadId={referenceThreadId}
onIndexingChange={setIndexingActive}
/>
<ToolStatusDisplay />
<div
className="unsloth-composer-line"
@ -977,6 +1187,7 @@ const Composer: FC<{
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<KnowledgeBaseComposerButton side={effectiveMenuSide} />
{artifactsEnabled ? <ArtifactsToggle /> : null}
{mcpEnabledForChat ? (
<McpComposerButton side={effectiveMenuSide} />
@ -1007,7 +1218,8 @@ const Composer: FC<{
isComposing ||
hasPendingAttachments
}
shouldBlockSend={shouldBlockSend}
onSendClick={interceptSend}
pendingSend={pendingSend}
menuSide={effectiveMenuSide}
/>
</div>
@ -1015,6 +1227,7 @@ const Composer: FC<{
);
return (
<PromptQueueContext.Provider value={queueContextValue}>
<ComposerPrimitive.Root
className="aui-composer-root relative flex w-full flex-col"
aria-disabled={disabled}
@ -1050,6 +1263,7 @@ const Composer: FC<{
</ComposerPrimitive.AttachmentDropzone>
)}
</ComposerPrimitive.Root>
</PromptQueueContext.Provider>
);
};
@ -1409,7 +1623,7 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
side={side}
align="end"
avoidCollisions={true}
className="unsloth-plus-menu unsloth-thinking-menu min-w-0 w-[160px]"
className="unsloth-plus-menu unsloth-thinking-menu min-w-0 w-[176px]"
>
{isEffort ? (
<>
@ -1422,7 +1636,9 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
setPreserveThinking(false);
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
effectiveReasoningVisualEnabled && "opacity-0",
@ -1447,7 +1663,9 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
}
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
!(
@ -1475,7 +1693,9 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
}
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
!effectiveReasoningEnabled && "opacity-0",
@ -1499,7 +1719,9 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({
}
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
!preserveThinking && "opacity-0",
@ -1793,6 +2015,10 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const setMcpEnabledForChat = useChatRuntimeStore(
(s) => s.setMcpEnabledForChat,
);
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
// Shared gate so the menu row agrees with the RAG pill and Add Files bar.
const ragAvailable = useRagToolAvailable();
// Capability gating mirrors the visible pills so menu and pills agree on
// what a loaded model supports (a tool the backend drops must not look on).
const modelLoaded = useChatRuntimeStore(
@ -1857,10 +2083,42 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
}, [navigate]);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [promptStorageOpen, setPromptStorageOpen] = useState(false);
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const aui = useAui();
// Disable Export chat until the thread has content.
const messageCount = useAuiState(({ thread }) => thread.messages.length);
const { startQueue } = useContext(PromptQueueContext);
const [recentPrompts, setRecentPrompts] = useState<PromptEntry[]>([]);
const refreshRecentPrompts = useCallback(async () => {
try {
const rows = await listPromptEntries();
setRecentPrompts(
[...rows].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3),
);
} catch {
}
}, []);
return (
<>
<DropdownMenu>
<PromptStorageDialog
open={promptStorageOpen}
onOpenChange={setPromptStorageOpen}
onUse={(text) => {
aui.composer().setText(text);
}}
onRunList={(items) => {
setPromptStorageOpen(false);
startQueue(items);
}}
/>
<DropdownMenu
onOpenChange={(open) => {
if (open) void refreshRecentPrompts();
}}
>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
@ -1906,7 +2164,11 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
<GlobeIcon />
Web search
{toolsEnabled && !searchDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
@ -1925,7 +2187,11 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
/>
Code
{codeToolsEnabled && !codeDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
{supportsBuiltinImageGeneration && (
@ -1941,18 +2207,31 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
<HugeiconsIcon icon={Image03Icon} strokeWidth={2} />
Images
{imageToolsEnabled && !imageDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className={artifactsEnabled ? "text-primary font-medium" : undefined}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
disabled={!ragAvailable}
className={
ragEnabled && ragAvailable ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? <CheckIcon className="ml-auto" /> : null}
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
RAG
{ragEnabled && ragAvailable ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
disabled={mcpDisabled}
@ -1966,14 +2245,105 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
MCP
{mcpEnabledForChat && !mcpDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
{/* RAG hidden temporarily */}
{/* Top-level so it stays one click away (not buried in More). */}
<DropdownMenuItem onSelect={() => startCompare()}>
<Columns2Icon />
Compare chat
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[176px]">
{recentPrompts.map((p) => (
<DropdownMenuItem
key={p.id}
onSelect={() => aui.composer().setText(p.text)}
>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{recentPrompts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={() => setPromptStorageOpen(true)}>
All saved prompts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Top-level: a third-level submenu collision-flips at narrow widths
and is awkward to reach. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!activeThreadId || messageCount === 0}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationRawJsonl(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
Raw JSONL
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationCsv(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
CSV
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (!activeThreadId) return;
exportConversationShareGPT(activeThreadId).catch(() =>
toast.error("Export failed."),
);
}}
>
ShareGPT JSONL
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-[200px]">
<DropdownMenuItem
className={
artifactsEnabled ? "text-primary font-medium" : undefined
}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@ -2015,9 +2385,14 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
const ComposerRightControls: FC<{
disabled?: boolean;
shouldBlockSend?: () => boolean;
onSendClick?: (event: { preventDefault: () => void }) => void;
pendingSend?: boolean;
menuSide?: "top" | "bottom";
}> = ({ disabled, shouldBlockSend, menuSide }) => {
}> = ({ disabled, onSendClick, pendingSend, menuSide }) => {
const isQueueRunning = _useQueueUI((s) => s.isRunning);
const queueCurrent = _useQueueUI((s) => s.current);
const queueTotal = _useQueueUI((s) => s.total);
const { stopQueue } = useContext(PromptQueueContext);
return (
<div className="aui-composer-action-wrapper flex shrink-0 items-center gap-1.5">
<ReasoningToggle side={menuSide} />
@ -2045,40 +2420,58 @@ const ComposerRightControls: FC<{
</TooltipIconButton>
</ComposerPrimitive.StopDictation>
</ComposerPrimitive.If>
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild={true}>
<TooltipIconButton
tooltip="Send message"
side="bottom"
type="submit"
variant="default"
size="icon"
disabled={disabled}
onClick={(event) => {
if (shouldBlockSend?.()) {
event.preventDefault();
}
}}
className="aui-composer-send ml-1.5 size-8 rounded-full"
aria-label="Send message"
>
<ArrowUpIcon className="aui-composer-send-icon size-[21px] stroke-2" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild={true}>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel ml-1.5 size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
{isQueueRunning ? (
<button
type="button"
onClick={stopQueue}
aria-label="Stop prompt queue"
className="ml-1.5 flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/60 px-2.5 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<SquareIcon className="size-2.5 shrink-0 fill-current" />
<span className="tabular-nums">
Stop queue {queueCurrent}/{queueTotal}
</span>
</button>
) : (
<>
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild={true}>
<TooltipIconButton
tooltip={pendingSend ? "Waiting for documents…" : "Send message"}
side="bottom"
type="submit"
variant="default"
size="icon"
// Stay clickable while docs index so a click can queue the send;
// disabled only once a send is parked.
disabled={disabled || pendingSend}
onClick={(event) => onSendClick?.(event)}
className="aui-composer-send ml-1.5 size-8 rounded-full"
aria-label="Send message"
>
{pendingSend ? (
<Spinner className="size-[18px]" />
) : (
<ArrowUpIcon className="aui-composer-send-icon size-[21px] stroke-2" />
)}
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild={true}>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel ml-1.5 size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</>
)}
</div>
);
};
@ -2141,6 +2534,7 @@ const AssistantMessage: FC = () => {
tools: {
by_name: {
web_search: WebSearchToolUI,
search_knowledge_base: KnowledgeBaseToolUI,
python: PythonToolUI,
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
@ -2152,6 +2546,7 @@ const AssistantMessage: FC = () => {
}}
/>
<SourcesGroup />
<RagSourcesGroup />
<MessageError />
</div>

View file

@ -0,0 +1,141 @@
// 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 {
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { FileTextIcon, LibraryBigIcon, LoaderIcon } from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import { Badge } from "./badge";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
import { useDocumentPreviewStore } from "@/features/rag/components/preview-store";
import { type Citation, parseCitations } from "./citation-utils";
export function CitationBadge({
citation,
index,
}: {
citation: Citation;
index: number;
}) {
const openPreview = useDocumentPreviewStore((s) => s.openPreview);
const clickable = Boolean(citation.documentId);
const label =
citation.page != null
? `${citation.filename} · p.${citation.page}`
: citation.filename;
const open = () => {
if (!citation.documentId) return;
openPreview({
documentId: citation.documentId,
chunkId: citation.chunkId,
filename: citation.filename,
page: citation.page,
});
};
const badge = (
<Badge
variant="outline"
size="sm"
className={`rounded-full inline-flex items-center gap-1.5 max-w-[15rem] ${
clickable
? "cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
: "cursor-default"
}`}
>
<span className="tabular-nums text-muted-foreground">{index + 1}</span>
<FileTextIcon className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</Badge>
);
return clickable ? (
<button type="button" onClick={open} className="inline-block">
{badge}
</button>
) : (
<span className="inline-block">{badge}</span>
);
}
const KnowledgeBaseToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const query = (args as { query?: string })?.query ?? "";
const isRunning = status?.type === "running";
const citations = useMemo(() => parseCitations(result), [result]);
// Citations render in RagSourcesGroup; this block keeps a one-line summary.
const docCount = useMemo(
() => new Set(citations.map((c) => c.documentId ?? c.filename)).size,
[citations],
);
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]);
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={query ? `Searched documents for "${query}"` : "Knowledge search"}
status={status}
icon={LibraryBigIcon}
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>
{query ? (
<>Searching documents for &ldquo;{query}&rdquo;&hellip;</>
) : (
<>Searching documents&hellip;</>
)}
</span>
</div>
) : citations.length > 0 ? (
<div className="text-sm text-muted-foreground">
Retrieved {citations.length} passage
{citations.length === 1 ? "" : "s"} from {docCount} document
{docCount === 1 ? "" : "s"}. See Document Sources below.
</div>
) : result ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
) : (
<div className="text-sm text-muted-foreground">No matching passages.</div>
)}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const KnowledgeBaseToolUI = memo(
KnowledgeBaseToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
KnowledgeBaseToolUI.displayName = "KnowledgeBaseToolUI";

View file

@ -221,7 +221,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-pointer items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}

View file

@ -3,6 +3,7 @@
import { getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { parseParamCountB } from "@/lib/model-size";
import { toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
import type { ChatModelAdapter } from "@assistant-ui/react";
@ -33,6 +34,7 @@ import {
} from "../provider-capabilities";
import {
type PendingImageEditReference,
type RagAutoInject,
resolveToolsEnabledOnLoad,
useChatRuntimeStore,
} from "../stores/chat-runtime-store";
@ -74,6 +76,18 @@ import {
isProviderKeyRotationError,
} from "./providers-api";
// Small models (<=9B) answer from memory instead of calling search, so "auto"
// forces retrieval for them and leaves it to larger ones.
const AUTOINJECT_AUTO_MAX_SIZE_B = 9;
function resolveAutoInject(mode: RagAutoInject, checkpoint: string): boolean {
if (mode === "on") return true;
if (mode === "off") return false;
const size = parseParamCountB(checkpoint);
// Unknown size -> enable.
return size === null || size <= AUTOINJECT_AUTO_MAX_SIZE_B;
}
/** Server-side usage data from llama-server (via stream_options.include_usage). */
interface ServerUsage {
prompt_tokens: number;
@ -1467,6 +1481,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
artifactsEnabled,
mcpEnabledForChat,
webFetchToolsEnabled,
ragEnabled,
ragSource,
ragMode,
ragTopK,
ragAutoInject,
ragAutoInjectMinScore,
} = runtime;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
@ -2368,10 +2388,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
(toolsEnabled ||
codeToolsEnabled ||
renderHtmlToolEnabledForThisTurn ||
mcpEnabledForChat)
mcpEnabledForChat ||
ragEnabled)
? {
enable_tools: true,
enabled_tools: [
// First so retrieval is the primary tool when Docs is on.
...(ragEnabled ? ["search_knowledge_base"] : []),
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(renderHtmlToolEnabledForThisTurn
@ -2379,6 +2402,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
: []),
],
mcp_enabled: mcpEnabledForChat,
// Scope: thread_id = this thread's docs, kb_id = a KB.
...(ragEnabled
? {
rag_scope: {
...(ragSource.type === "kb"
? { kb_id: ragSource.kbId }
: resolvedThreadId
? { thread_id: resolvedThreadId }
: {}),
default_top_k: ragTopK,
mode: ragMode,
autoinject: resolveAutoInject(
ragAutoInject,
params.checkpoint,
),
autoinject_min_score: ragAutoInjectMinScore,
},
}
: {}),
auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls,
max_tool_calls_per_message:

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
export interface PromptEntry {
id: string;
name: string;
text: string;
createdAt: number;
updatedAt: number;
}
export interface PromptListEntry {
id: string;
name: string;
items: string[];
createdAt: number;
updatedAt: number;
}
async function parseJsonOrThrow<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => null);
if (!res.ok) {
const detail = (body as { detail?: string } | null)?.detail;
throw new Error(detail ?? `Request failed (${res.status})`);
}
return body as T;
}
export async function listPromptEntries(): Promise<PromptEntry[]> {
const res = await authFetch("/api/prompts/entries");
const data = await parseJsonOrThrow<{ entries: PromptEntry[] }>(res);
return data.entries;
}
export async function savePromptEntry(entry: PromptEntry): Promise<PromptEntry> {
const res = await authFetch(`/api/prompts/entries/${entry.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry),
});
return parseJsonOrThrow<PromptEntry>(res);
}
export async function deletePromptEntry(id: string): Promise<void> {
const res = await authFetch(`/api/prompts/entries/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed (${res.status})`);
}
export async function bulkSavePromptEntries(entries: PromptEntry[]): Promise<number> {
if (!entries.length) return 0;
const res = await authFetch("/api/prompts/entries/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entries }),
});
const data = await parseJsonOrThrow<{ count: number }>(res);
return data.count;
}
export async function listPromptLists(): Promise<PromptListEntry[]> {
const res = await authFetch("/api/prompts/lists");
const data = await parseJsonOrThrow<{ lists: PromptListEntry[] }>(res);
return data.lists;
}
export async function savePromptList(list: PromptListEntry): Promise<PromptListEntry> {
const res = await authFetch(`/api/prompts/lists/${list.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(list),
});
return parseJsonOrThrow<PromptListEntry>(res);
}
export async function deletePromptList(id: string): Promise<void> {
const res = await authFetch(`/api/prompts/lists/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed (${res.status})`);
}
export async function bulkSavePromptLists(lists: PromptListEntry[]): Promise<number> {
if (!lists.length) return 0;
const res = await authFetch("/api/prompts/lists/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lists }),
});
const data = await parseJsonOrThrow<{ count: number }>(res);
return data.count;
}

View file

@ -523,7 +523,12 @@ const LoraCompareContent = memo(function LoraCompareContent({
const [baseThreadId, setBaseThreadId] = useState<string>();
const [loraThreadId, setLoraThreadId] = useState<string>();
const compareRunning = useChatRuntimeStore(
(s) => Object.keys(s.runningByThreadId).length > 0,
);
useEffect(() => {
if (compareRunning) return;
let isActive = true;
listStoredChatThreads({ pairId })
.then((threads) => {
@ -539,7 +544,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
return () => {
isActive = false;
};
}, [pairId]);
}, [pairId, compareRunning]);
return (
<CompareShell
@ -548,6 +553,8 @@ const LoraCompareContent = memo(function LoraCompareContent({
<SharedComposer
handlesRef={handlesRef}
onExitCompare={onExitCompare}
model1ThreadId={baseThreadId}
model2ThreadId={loraThreadId}
/>
}
>
@ -666,6 +673,9 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
const globalCheckpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const globalGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
const compareRunning = useChatRuntimeStore(
(s) => Object.keys(s.runningByThreadId).length > 0,
);
const [model1, setModel1] = useState<CompareModelSelection>({
id: globalCheckpoint || "",
isLora: false,
@ -690,6 +700,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
);
useEffect(() => {
if (compareRunning) return;
let isActive = true;
listStoredChatThreads({ pairId })
.then((threads) => {
@ -713,7 +724,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
return () => {
isActive = false;
};
}, [pairId]);
}, [pairId, compareRunning]);
return (
<CompareShell
@ -724,6 +735,8 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
model1={model1}
model2={model2}
onExitCompare={onExitCompare}
model1ThreadId={model1ThreadId}
model2ThreadId={model2ThreadId}
/>
}
>

View file

@ -90,6 +90,7 @@ import {
providerSupportsFastMode,
} from "./provider-capabilities";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { RetrievalSettingsSection } from "@/features/rag/components/retrieval-settings-section";
import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
@ -1362,6 +1363,12 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
) : null}
{!isExternalModel ? (
<CollapsibleSection label="Retrieval">
<RetrievalSettingsSection />
</CollapsibleSection>
) : null}
</div>
</div>
</div>

View file

@ -0,0 +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 { parseExternalModelId } from "../external-providers";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
// Single source of truth for the RAG pill's disabled gate and the Add Files bar's
// visibility so the bar never shows while the pill is inert.
export function useRagToolAvailable(): boolean {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
return (
modelLoaded && parseExternalModelId(checkpoint) === null && supportsTools
);
}

View file

@ -1,9 +1,8 @@
// 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 { McpServerIcon } from "@hugeicons/core-free-icons";
import { McpServerIcon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { CheckIcon } from "lucide-react";
import { type FC, useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
@ -215,7 +214,9 @@ export function McpComposerButton({
}
>
<span className="truncate">{opts.label}</span>
{opts.enabled ? <CheckIcon className="ml-auto" /> : null}
{opts.enabled ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
{opts.hint ? (
<Tooltip open={hintKey === opts.key}>
<TooltipTrigger asChild={true}>

View file

@ -13,6 +13,10 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@ -35,14 +39,27 @@ import {
} from "@/features/chat";
import {
Delete02Icon,
Download01Icon,
Edit03Icon,
FolderAddIcon,
Search01Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { MoreHorizontalIcon } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
exportProjectConversations,
exportBulkConversationsMerged,
exportBulkConversationsSeparate,
importConversationsFromFile,
EXPORT_FORMATS_LIST,
type ConvExportFormat,
} from "./prompt-storage/prompt-storage-dialog";
import {
listStoredChatThreads,
} from "./utils/chat-history-storage";
type SortMode = "activity" | "name";
@ -76,6 +93,36 @@ export function ProjectsPage() {
const [renameDraft, setRenameDraft] = useState("");
const [deleting, setDeleting] = useState<ProjectRecord | null>(null);
const globalImportRef = useRef<HTMLInputElement>(null);
const projectImportRefs = useRef<Map<string, HTMLInputElement>>(new Map());
const [importFile, setImportFile] = useState<File | null>(null);
// null = Recents
const [importTargetId, setImportTargetId] = useState<string | null>(null);
async function handleImport(file: File, projectId: string | null) {
try {
const count = await importConversationsFromFile(file, projectId);
if (count === 0) {
toast.info("No conversations found in file.");
} else {
const dest = projectId
? (projects.find((p) => p.id === projectId)?.name ?? "project")
: "Recents";
toast.success(`Imported ${count} conversation${count === 1 ? "" : "s"} to ${dest}.`);
}
} catch {
toast.error("Import failed.");
}
}
async function commitImport() {
if (!importFile) return;
const file = importFile;
const target = importTargetId;
setImportFile(null);
await handleImport(file, target);
}
const visibleProjects = useMemo(() => {
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
@ -128,6 +175,48 @@ export function ProjectsPage() {
}
}
async function handleProjectExport(project: ProjectRecord, fmt: ConvExportFormat) {
try {
const threads = await listStoredChatThreads({ projectId: project.id, includeArchived: false });
const ids = [...new Set(threads.map((t) => t.id))];
await exportProjectConversations(ids, fmt, project.name);
} catch {
toast.error("Export failed.");
}
}
async function handleBulkProjectExport(
scope: "projects" | "all",
fmt: ConvExportFormat,
merged: boolean,
) {
try {
let threads;
if (scope === "projects") {
threads = (
await Promise.all(
projects.map((p) =>
listStoredChatThreads({ projectId: p.id, includeArchived: false }),
),
)
).flat();
} else {
threads = await listStoredChatThreads({ includeArchived: false });
}
const ids = [...new Set(threads.map((t) => t.id))];
if (ids.length === 0) { toast.info("No conversations to export."); return; }
const ts = new Date().toISOString().slice(0, 10);
const basename = `${scope === "all" ? "all-chats" : "all-projects"}-${ts}`;
if (merged) {
await exportBulkConversationsMerged(ids, fmt, basename);
} else {
await exportBulkConversationsSeparate(ids, fmt, basename);
}
} catch {
toast.error("Export failed.");
}
}
async function commitDelete() {
const target = deleting;
if (!target) return;
@ -143,6 +232,21 @@ export function ProjectsPage() {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 font-heading sm:px-6">
{/* Global import file input */}
<input
ref={globalImportRef}
type="file"
accept=".jsonl,.ndjson,.csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setImportTargetId(projects[0]?.id ?? null);
setImportFile(file);
}
e.target.value = "";
}}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Projects
@ -163,6 +267,52 @@ export function ProjectsPage() {
</SelectContent>
</Select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" title="Import / Export projects">
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => globalImportRef.current?.click()}>
<HugeiconsIcon icon={Upload01Icon} strokeWidth={1.75} className="size-icon" />
Import chats
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Export All Projects</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`ap-m-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, true)}>
{label} combined
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`ap-s-${fmt}`} onSelect={() => void handleBulkProjectExport("projects", fmt, false)}>
{label} per chat
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Export Projects + Recents</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`all-m-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, true)}>
{label} combined
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`all-s-${fmt}`} onSelect={() => void handleBulkProjectExport("all", fmt, false)}>
{label} per chat
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={() => {
setNameDraft("");
@ -225,6 +375,22 @@ export function ProjectsPage() {
) : (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visibleProjects.map((project) => (
<div key={`wrap-${project.id}`} className="contents">
<input
key={`import-${project.id}`}
type="file"
accept=".jsonl,.ndjson,.csv"
className="hidden"
ref={(el) => {
if (el) projectImportRefs.current.set(project.id, el);
else projectImportRefs.current.delete(project.id);
}}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImport(file, project.id);
e.target.value = "";
}}
/>
<div
key={project.id}
role="button"
@ -270,6 +436,35 @@ export function ProjectsPage() {
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.stopPropagation();
projectImportRefs.current.get(project.id)?.click();
}}
>
<HugeiconsIcon icon={Upload01Icon} strokeWidth={1.75} className="size-icon" />
<span>Import chats</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon mr-1" />
<span>Export</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem
key={fmt}
onSelect={(e) => {
e.stopPropagation();
void handleProjectExport(project, fmt);
}}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleting(project)}
@ -289,6 +484,7 @@ export function ProjectsPage() {
Updated {formatUpdatedAgo(project.updatedAt)}
</span>
</div>
</div>
))}
</div>
)}
@ -371,6 +567,36 @@ export function ProjectsPage() {
</DialogContent>
</Dialog>
{/* Import destination picker */}
<Dialog open={importFile !== null} onOpenChange={(open) => { if (!open) setImportFile(null); }}>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>Import chats</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{importFile?.name}</span> choose where to import:
</p>
<Select
value={importTargetId ?? "__recents__"}
onValueChange={(v) => setImportTargetId(v === "__recents__" ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select destination" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__recents__">Recents</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setImportFile(null)}>Cancel</Button>
<Button type="button" onClick={() => void commitImport()}>Import</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete project */}
<Dialog
open={deleting !== null}

File diff suppressed because it is too large Load diff

View file

@ -27,28 +27,39 @@ import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
CheckIcon,
Columns2Icon,
GlobeIcon,
HeadphonesIcon,
MoreHorizontalIcon,
PlusIcon,
SquareIcon,
XIcon,
} from "lucide-react";
import {
AttachmentIcon,
Bookmark02Icon,
CodeIcon,
Download01Icon,
FileDatabaseIcon,
Folder01Icon,
FolderAddIcon,
Image03Icon,
McpServerIcon,
PencilRulerIcon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { useNavigate } from "@tanstack/react-router";
import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "@/lib/toast";
import {
PromptStorageDialog,
exportConversationShareGPT,
exportConversationRawJsonl,
exportConversationCsv,
} from "./prompt-storage/prompt-storage-dialog";
import { listPromptEntries, type PromptEntry } from "./api/prompts-api";
import { McpComposerButton } from "./mcp-composer-button";
import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button";
import { NewProjectDialog } from "./components/new-project-dialog";
import { useChatProjects } from "./hooks/use-chat-projects";
import { loadModel, validateModel } from "./api/chat-api";
@ -389,11 +400,15 @@ export function SharedComposer({
model1,
model2,
onExitCompare,
model1ThreadId,
model2ThreadId,
}: {
handlesRef: CompareHandles;
model1?: CompareModelSelection;
model2?: CompareModelSelection;
onExitCompare?: () => void;
model1ThreadId?: string;
model2ThreadId?: string;
}): ReactElement {
const navigate = useNavigate();
// Exit compare: parent's restore handler, or fresh chat if opened by URL.
@ -415,6 +430,26 @@ export function SharedComposer({
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const [newProjectOpen, setNewProjectOpen] = useState(false);
const [promptStorageOpen, setPromptStorageOpen] = useState(false);
const [recentPrompts, setRecentPrompts] = useState<PromptEntry[]>([]);
const refreshRecentPrompts = useCallback(async () => {
try {
const rows = await listPromptEntries();
setRecentPrompts(
[...rows].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3),
);
} catch {
}
}, []);
const [isQueueRunning, setIsQueueRunning] = useState(false);
const [queueProgress, setQueueProgress] = useState({ current: 0, total: 0 });
const queueRef = useRef<string[]>([]);
const queueIndexRef = useRef(0);
const isQueueRunningRef = useRef(false);
const prevRunningRef = useRef(false);
const prevComparingRef = useRef(false);
const compareStepSucceededRef = useRef(false);
const sendRef = useRef<(() => void) | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -486,6 +521,13 @@ export function SharedComposer({
const setWebFetchToolsEnabled = useChatRuntimeStore(
(s) => s.setWebFetchToolsEnabled,
);
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
// Empty until a compare run; gates Export chat off.
const exportThreadIds = [model1ThreadId, model2ThreadId, activeThreadId].filter(
(id): id is string => Boolean(id),
);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
@ -614,11 +656,15 @@ export function SharedComposer({
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
// Docs (RAG) pill is local-only: search_knowledge_base needs the local runtime.
const ragDisabled = !modelLoaded || isExternalModel || !supportsTools;
const showRagPill = !isExternalModel;
// Above 4 pills, collapse to icons only to cut clutter. Compare, Search and
// Code always show; the rest are conditional.
const pillsCompact =
3 +
(showImagePill ? 1 : 0) +
(showRagPill && ragEnabled && !ragDisabled ? 1 : 0) +
(showWebFetchPill ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
(mcpEnabledForChat ? 1 : 0) >
@ -647,6 +693,55 @@ export function SharedComposer({
return () => clearInterval(id);
}, [handlesRef]);
function advanceQueue() {
const nextIndex = queueIndexRef.current + 1;
if (nextIndex >= queueRef.current.length) {
isQueueRunningRef.current = false;
setIsQueueRunning(false);
queueRef.current = [];
queueIndexRef.current = 0;
setQueueProgress({ current: 0, total: 0 });
toast.success("Prompt queue complete");
return;
}
queueIndexRef.current = nextIndex;
setQueueProgress({ current: nextIndex + 1, total: queueRef.current.length });
const next = queueRef.current[nextIndex];
toast(`Prompt ${nextIndex + 1} / ${queueRef.current.length}`, {
description: next.length > 80 ? next.slice(0, 80) + "…" : next,
});
setText(next);
setTimeout(() => { sendRef.current?.(); }, 100);
}
// Compare mode: advance the queue on cycle end, but stop on a failed step so we
// don't burn prompts on incomplete results.
useEffect(() => {
const wasComparing = prevComparingRef.current;
prevComparingRef.current = comparing;
if (!isQueueRunningRef.current || !wasComparing || comparing) return;
if (!compareStepSucceededRef.current) {
isQueueRunningRef.current = false;
setIsQueueRunning(false);
queueRef.current = [];
queueIndexRef.current = 0;
setQueueProgress({ current: 0, total: 0 });
toast.error("Prompt queue stopped", {
description: "A compare step failed — remaining prompts were not sent.",
});
return;
}
prevRunningRef.current = false;
advanceQueue();
}, [comparing]);
useEffect(() => {
const wasRunning = prevRunningRef.current;
prevRunningRef.current = running;
if (!isQueueRunningRef.current || !wasRunning || running || comparing) return;
advanceQueue();
}, [running, comparing]);
// Auto-expand textarea up to 6 rows, then scroll (matches regular chat composer).
useEffect(() => {
const ta = textareaRef.current;
@ -946,8 +1041,10 @@ export function SharedComposer({
await done;
}
compareStepSucceededRef.current = true;
toast.success("Compare complete", { id: toastId, duration: 2000 });
} catch (err) {
compareStepSucceededRef.current = false;
toast.error("Compare failed", {
id: toastId,
description: err instanceof Error ? err.message : "Unknown error",
@ -963,6 +1060,7 @@ export function SharedComposer({
}
}
}
sendRef.current = send;
function stop() {
if (isDictating) stopDictation();
@ -1018,6 +1116,41 @@ export function SharedComposer({
addFiles(e.dataTransfer.files);
}}
>
<PromptStorageDialog
open={promptStorageOpen}
onOpenChange={setPromptStorageOpen}
onUse={(t) => {
setText(t);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
onRunList={(items) => {
const filtered = items.filter((p) => p.trim());
if (!filtered.length) return;
const hasCompareHandles = Boolean(
handlesRef.current["model1"] || handlesRef.current["model2"],
);
const isGeneralizedCompare =
hasCompareHandles && Boolean(model1?.id && model2?.id);
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
description:
"Use the model dropdown above each pane, then send your prompt.",
});
return;
}
setPromptStorageOpen(false);
queueRef.current = filtered;
queueIndexRef.current = 0;
isQueueRunningRef.current = true;
setIsQueueRunning(true);
setQueueProgress({ current: 1, total: filtered.length });
toast(`Prompt 1 / ${filtered.length}`, {
description: filtered[0].length > 80 ? filtered[0].slice(0, 80) + "…" : filtered[0],
});
setText(filtered[0]);
setTimeout(() => { sendRef.current?.(); }, 100);
}}
/>
{/* Gemini-style drop affordance, mirrored from the single composer. */}
<div
className={`pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[32px] bg-background/90 backdrop-blur-sm transition-opacity duration-150 dark:bg-card/90 ${dragging ? "opacity-100" : "opacity-0"}`}
@ -1120,7 +1253,11 @@ export function SharedComposer({
/>
{/* Same + menu as single-chat (ComposerToolsMenu), wired to the
compare composer's own file/audio inputs and tools. */}
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
if (open) void refreshRecentPrompts();
}}
>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
@ -1170,7 +1307,11 @@ export function SharedComposer({
<GlobeIcon />
Web search
{toolsEnabled && !searchDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
@ -1189,7 +1330,11 @@ export function SharedComposer({
/>
Code
{codeToolsEnabled && !codeDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
{showImagePill && (
@ -1205,20 +1350,33 @@ export function SharedComposer({
<HugeiconsIcon icon={Image03Icon} strokeWidth={2} />
Images
{imageToolsEnabled && !imageDisabled ? (
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={ragDisabled}
className={
artifactsEnabled ? "text-primary font-medium" : undefined
ragEnabled && !ragDisabled
? "text-primary font-medium"
: undefined
}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
onSelect={() => setRagEnabled(!ragEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? <CheckIcon className="ml-auto" /> : null}
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
RAG
{ragEnabled && !ragDisabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!supportsTools}
@ -1229,7 +1387,13 @@ export function SharedComposer({
>
<HugeiconsIcon icon={McpServerIcon} strokeWidth={2} />
MCP
{mcpEnabledForChat ? <CheckIcon className="ml-auto" /> : null}
{mcpEnabledForChat ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
{/* RAG hidden temporarily */}
{/* Always active: this menu only renders in compare mode. Ticked
@ -1240,8 +1404,98 @@ export function SharedComposer({
>
<Columns2Icon />
Compare chat
<CheckIcon className="ml-auto" />
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Bookmark02Icon} strokeWidth={2} />
Saved prompts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[176px]">
{recentPrompts.map((p) => (
<DropdownMenuItem
key={p.id}
onSelect={() => {
setText(p.text);
requestAnimationFrame(() =>
textareaRef.current?.focus(),
);
}}
>
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
{recentPrompts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={() => setPromptStorageOpen(true)}>
All saved prompts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Top-level: a third-level submenu collision-flips at narrow
widths and is awkward to reach. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={exportThreadIds.length === 0}>
<HugeiconsIcon icon={Download01Icon} strokeWidth={2} />
Export chat
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={16}
className="unsloth-plus-menu w-[176px]"
>
{[
{ label: "Raw JSONL", fn: exportConversationRawJsonl },
{ label: "CSV", fn: exportConversationCsv },
{
label: "ShareGPT JSONL",
fn: exportConversationShareGPT,
},
].map(({ label, fn }) => (
<DropdownMenuItem
key={label}
disabled={exportThreadIds.length === 0}
onSelect={() => {
if (!exportThreadIds.length) {
toast.error("No conversation to export yet.");
return;
}
Promise.all(exportThreadIds.map((id) => fn(id))).catch(
() => toast.error("Export failed."),
);
}}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
More
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[200px]">
<DropdownMenuItem
className={
artifactsEnabled ? "text-primary font-medium" : undefined
}
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
>
<HugeiconsIcon icon={PencilRulerIcon} strokeWidth={2} />
Canvas
{artifactsEnabled ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@ -1358,6 +1612,7 @@ export function SharedComposer({
<span>Images</span>
</button>
)}
{showRagPill && <KnowledgeBaseComposerButton side="top" />}
{showWebFetchPill && (
<button
type="button"
@ -1445,7 +1700,9 @@ export function SharedComposer({
setPreserveThinking(false);
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
effectiveReasoningVisualEnabled && "opacity-0",
@ -1474,7 +1731,9 @@ export function SharedComposer({
}
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
!(
@ -1505,7 +1764,9 @@ export function SharedComposer({
}
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
!effectiveReasoningEnabled && "opacity-0",
@ -1529,7 +1790,9 @@ export function SharedComposer({
}
}}
>
<CheckIcon
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className={cn(
"unsloth-tick size-4",
!preserveThinking && "opacity-0",
@ -1606,7 +1869,26 @@ export function SharedComposer({
)}
</>
)}
{busy ? (
{isQueueRunning ? (
<button
type="button"
onClick={() => {
isQueueRunningRef.current = false;
setIsQueueRunning(false);
queueRef.current = [];
queueIndexRef.current = 0;
setQueueProgress({ current: 0, total: 0 });
stop();
}}
aria-label="Stop prompt queue"
className="ml-1.5 flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/60 px-2.5 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<SquareIcon className="size-2.5 shrink-0 fill-current" />
<span className="tabular-nums">
Stop queue {queueProgress.current}/{queueProgress.total}
</span>
</button>
) : busy ? (
<Button
type="button"
variant="default"

View file

@ -36,6 +36,93 @@ export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
export const CHAT_RAG_ENABLED_KEY = "unsloth_chat_rag_enabled";
export const CHAT_RAG_SOURCE_KEY = "unsloth_chat_rag_source";
export const CHAT_RAG_MODE_KEY = "unsloth_chat_rag_mode";
export const CHAT_RAG_TOP_K_KEY = "unsloth_chat_rag_top_k";
export const CHAT_RAG_AUTOINJECT_KEY = "unsloth_chat_rag_autoinject";
export const CHAT_RAG_AUTOINJECT_MIN_SCORE_KEY =
"unsloth_chat_rag_autoinject_min_score";
export type RagSource =
| { type: "thread" }
| { type: "kb"; kbId: string };
export type RagMode = "hybrid" | "lexical" | "dense";
export const DEFAULT_RAG_SOURCE: RagSource = { type: "thread" };
export const DEFAULT_RAG_MODE: RagMode = "hybrid";
export const DEFAULT_RAG_TOP_K = 5;
// `auto` forces retrieval for smaller models (<=9B); `on`/`off` force it.
export type RagAutoInject = "auto" | "on" | "off";
export const DEFAULT_RAG_AUTOINJECT: RagAutoInject = "auto";
export const DEFAULT_RAG_AUTOINJECT_MIN_SCORE = 0.7;
function loadRagSource(): RagSource {
if (typeof window === "undefined") return DEFAULT_RAG_SOURCE;
try {
const raw = window.localStorage.getItem(CHAT_RAG_SOURCE_KEY);
if (!raw) return DEFAULT_RAG_SOURCE;
const parsed = JSON.parse(raw) as RagSource;
if (parsed?.type === "kb" && typeof parsed.kbId === "string") {
return { type: "kb", kbId: parsed.kbId };
}
if (parsed?.type === "thread") return { type: "thread" };
return DEFAULT_RAG_SOURCE;
} catch {
return DEFAULT_RAG_SOURCE;
}
}
function saveRagSource(value: RagSource): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CHAT_RAG_SOURCE_KEY, JSON.stringify(value));
} catch {
}
}
function loadRagMode(): RagMode {
const raw = loadString(CHAT_RAG_MODE_KEY, DEFAULT_RAG_MODE);
return raw === "lexical" || raw === "dense" ? raw : "hybrid";
}
function loadRagAutoInject(): RagAutoInject {
const raw = loadString(CHAT_RAG_AUTOINJECT_KEY, DEFAULT_RAG_AUTOINJECT);
if (raw === "auto" || raw === "on" || raw === "off") return raw;
// Legacy boolean migration: false -> Off, else Auto.
return raw === "false" ? "off" : "auto";
}
function loadRagTopK(): number {
if (typeof window === "undefined") return DEFAULT_RAG_TOP_K;
try {
const raw = window.localStorage.getItem(CHAT_RAG_TOP_K_KEY);
if (raw === null) return DEFAULT_RAG_TOP_K;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_RAG_TOP_K;
} catch {
return DEFAULT_RAG_TOP_K;
}
}
// Preserves a stored 0 (score floors can legitimately be 0).
function loadRagNumber(
key: string,
fallback: number,
{ min, max, integer = false }: { min: number; max: number; integer?: boolean },
): number {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return fallback;
const parsed = integer ? Number.parseInt(raw, 10) : Number.parseFloat(raw);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
} catch {
return fallback;
}
}
// External provider selection is encoded into `params.checkpoint` as
// `external::<providerId>::<modelId>`. PersistedChatSettings omits `checkpoint`
@ -309,6 +396,13 @@ type ChatRuntimeStore = {
collapseHtmlArtifacts: boolean;
allowArtifactNetworkAccess: boolean;
mcpEnabledForChat: boolean;
ragEnabled: boolean;
ragSource: RagSource;
ragMode: RagMode;
ragTopK: number;
// autoInject = forced first-pass retrieval before answering.
ragAutoInject: RagAutoInject;
ragAutoInjectMinScore: number;
/**
* Fetch pill state, independent of `toolsEnabled` (Search). Only
* consulted when `providerSupportsBuiltinWebFetch` is true.
@ -386,6 +480,12 @@ type ChatRuntimeStore = {
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
setMcpEnabledForChat: (enabled: boolean) => void;
setWebFetchToolsEnabled: (enabled: boolean) => void;
setRagEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setRagSource: (source: RagSource) => void;
setRagMode: (mode: RagMode) => void;
setRagTopK: (topK: number) => void;
setRagAutoInject: (value: RagAutoInject) => void;
setRagAutoInjectMinScore: (score: number) => void;
setToolStatus: (status: string | null) => void;
setGeneratingStatus: (status: string | null) => void;
setAutoHealToolCalls: (enabled: boolean) => void;
@ -646,6 +746,16 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
),
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
ragEnabled: loadBool(CHAT_RAG_ENABLED_KEY, false),
ragSource: loadRagSource(),
ragMode: loadRagMode(),
ragTopK: loadRagTopK(),
ragAutoInject: loadRagAutoInject(),
ragAutoInjectMinScore: loadRagNumber(
CHAT_RAG_AUTOINJECT_MIN_SCORE_KEY,
DEFAULT_RAG_AUTOINJECT_MIN_SCORE,
{ min: 0, max: 1 },
),
toolStatus: null,
generatingStatus: null,
autoHealToolCalls: true,
@ -860,6 +970,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
artifactsEnabled: false,
mcpEnabledForChat: false,
webFetchToolsEnabled: false,
// Only the per-session enable pill resets; source/mode/top_k persist.
ragEnabled: false,
toolStatus: null,
kvCacheDtype: null,
loadedKvCacheDtype: null,
@ -960,6 +1072,41 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, webFetchToolsEnabled);
return { webFetchToolsEnabled };
}),
setRagEnabled: (ragEnabled, options) =>
set(() => {
if (options?.persist !== false) {
saveBool(CHAT_RAG_ENABLED_KEY, ragEnabled);
}
return { ragEnabled };
}),
setRagSource: (ragSource) =>
set(() => {
saveRagSource(ragSource);
return { ragSource };
}),
setRagMode: (ragMode) =>
set(() => {
saveString(CHAT_RAG_MODE_KEY, ragMode);
return { ragMode };
}),
setRagTopK: (ragTopK) =>
set(() => {
saveString(CHAT_RAG_TOP_K_KEY, String(ragTopK));
return { ragTopK };
}),
setRagAutoInject: (ragAutoInject) =>
set(() => {
saveString(CHAT_RAG_AUTOINJECT_KEY, ragAutoInject);
return { ragAutoInject };
}),
setRagAutoInjectMinScore: (ragAutoInjectMinScore) =>
set(() => {
saveString(
CHAT_RAG_AUTOINJECT_MIN_SCORE_KEY,
String(ragAutoInjectMinScore),
);
return { ragAutoInjectMinScore };
}),
setToolStatus: (toolStatus) => set({ toolStatus }),
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),
setAutoHealToolCalls: (autoHealToolCalls) =>

View file

@ -1,30 +1,80 @@
// 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 { useState } from "react";
import {
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
BookOpen02Icon,
ColumnInsertIcon,
Delete02Icon,
Download01Icon,
MoreHorizontalIcon,
NewReleasesIcon,
PencilEdit02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "sonner";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { ChatView } from "./types";
import { deleteChatItem, useChatSidebarItems } from "./hooks/use-chat-sidebar-items";
import {
deleteChatItem,
renameChatItem,
useChatSidebarItems,
} from "./hooks/use-chat-sidebar-items";
import type { SidebarItem } from "./hooks/use-chat-sidebar-items";
import {
exportConversationRawJsonl,
exportConversationCsv,
exportConversationShareGPT,
exportBulkConversationsMerged,
exportBulkConversationsSeparate,
EXPORT_FORMATS_LIST,
type ConvExportFormat,
} from "./prompt-storage/prompt-storage-dialog";
import {
listStoredChatThreads,
} from "./utils/chat-history-storage";
const EXPORT_FORMATS = [
{ label: "Raw JSONL", fn: exportConversationRawJsonl },
{ label: "CSV", fn: exportConversationCsv },
{ label: "ShareGPT JSONL", fn: exportConversationShareGPT },
] as const;
async function getThreadIdsForItem(item: SidebarItem): Promise<string[]> {
if (item.type === "single") return [item.id];
const threads = await listStoredChatThreads({ pairId: item.id });
return threads.map((t) => t.id);
}
export function ThreadSidebar({
view,
@ -48,6 +98,9 @@ export function ThreadSidebar({
? view.pairId
: view.projectId;
const [renamingItem, setRenamingItem] = useState<SidebarItem | null>(null);
const [renameDraft, setRenameDraft] = useState("");
function viewForItem(item: SidebarItem): ChatView {
return item.type === "single"
? { mode: "single", threadId: item.id }
@ -55,12 +108,65 @@ export function ThreadSidebar({
}
async function handleDelete(item: SidebarItem) {
// Directly set a new view with a nonce rather than going through
// onNewThread(), which may return early if the guard sees no
// threadId and no activeThreadId (after we just cleared it).
await deleteChatItem(item, activeId ?? undefined, onSelect);
}
function openRename(item: SidebarItem) {
setRenameDraft(item.title);
setRenamingItem(item);
}
async function commitRename() {
if (!renamingItem) return;
try {
await renameChatItem(renamingItem, renameDraft);
} catch {
toast.error("Failed to rename chat.");
} finally {
setRenamingItem(null);
}
}
async function handleExport(
item: SidebarItem,
fn: (threadId: string) => Promise<void>,
) {
try {
const ids = await getThreadIdsForItem(item);
await Promise.all(ids.map((id) => fn(id)));
} catch {
toast.error("Export failed.");
}
}
async function getBulkThreadIds(scope: "recents" | "all"): Promise<string[]> {
const threads = await listStoredChatThreads({
includeArchived: false,
...(scope === "recents" ? { projectId: null } : {}),
});
return [...new Set(threads.map((t) => t.id))];
}
async function handleBulkExport(
scope: "recents" | "all",
fmt: ConvExportFormat,
merged: boolean,
) {
try {
const ids = await getBulkThreadIds(scope);
if (ids.length === 0) { toast.info("No conversations to export."); return; }
const ts = new Date().toISOString().slice(0, 10);
const basename = `${scope === "all" ? "all-chats" : "recents"}-${ts}`;
if (merged) {
await exportBulkConversationsMerged(ids, fmt, basename);
} else {
await exportBulkConversationsSeparate(ids, fmt, basename);
}
} catch {
toast.error("Export failed.");
}
}
return (
<>
<SidebarHeader className="px-4 py-3">
@ -88,7 +194,61 @@ export function ThreadSidebar({
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="flex-1 px-4">
<SidebarGroupLabel className="text-xs font-medium text-muted-foreground/80">Your Chats</SidebarGroupLabel>
{/* Recents label with export-all menu */}
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground/80">Recents</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center justify-center rounded-sm p-0.5 text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-0"
title="Export options"
>
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-56">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} className="mr-2 size-4" />
Export Recents
</DropdownMenuSubTrigger>
<DropdownMenuSubContent avoidCollisions={false} className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`r-m-${fmt}`} onSelect={() => void handleBulkExport("recents", fmt, true)}>
{label} combined
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`r-s-${fmt}`} onSelect={() => void handleBulkExport("recents", fmt, false)}>
{label} per chat
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} className="mr-2 size-4" />
Export Recents + Projects
</DropdownMenuSubTrigger>
<DropdownMenuSubContent avoidCollisions={false} className="w-52">
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`a-m-${fmt}`} onSelect={() => void handleBulkExport("all", fmt, true)}>
{label} combined
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
<DropdownMenuItem key={`a-s-${fmt}`} onSelect={() => void handleBulkExport("all", fmt, false)}>
{label} per chat
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</div>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
@ -99,13 +259,44 @@ export function ThreadSidebar({
>
<span>{item.title}</span>
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={true}
onClick={() => handleDelete(item)}
title="Delete"
>
<HugeiconsIcon icon={Delete02Icon} />
</SidebarMenuAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover className="focus:outline-none focus-visible:ring-0" onClick={(e) => e.stopPropagation()}>
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-4" />
<span className="sr-only">More options</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuItem onSelect={() => openRename(item)}>
<HugeiconsIcon icon={PencilEdit02Icon} className="mr-2 size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Download01Icon} className="mr-2 size-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent avoidCollisions={false} className="w-52">
{EXPORT_FORMATS.map(({ label, fn }) => (
<DropdownMenuItem
key={label}
onSelect={() => handleExport(item, fn)}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => void handleDelete(item)}
>
<HugeiconsIcon icon={Delete02Icon} className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
@ -137,6 +328,30 @@ export function ThreadSidebar({
<span>What&apos;s new</span>
</a>
</SidebarFooter>
{/* Rename dialog */}
<Dialog open={renamingItem !== null} onOpenChange={(open) => { if (!open) setRenamingItem(null); }}>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-sm">
<DialogHeader>
<DialogTitle>Rename chat</DialogTitle>
</DialogHeader>
<Input
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void commitRename(); }}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setRenamingItem(null)}>Cancel</Button>
<Button
onClick={() => void commitRename()}
disabled={!renameDraft.trim() || renameDraft.trim() === renamingItem?.title}
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -274,6 +274,17 @@ export interface OpenAIChatCompletionsRequest {
preserve_thinking?: boolean | null;
enable_tools?: boolean | null;
enabled_tools?: string[];
/** Local models + enable_tools only. */
mcp_enabled?: boolean;
/** Exactly one of `kb_id` (a KB) or `thread_id` (thread docs). */
rag_scope?: {
kb_id?: string;
thread_id?: string;
default_top_k: number;
mode: "hybrid" | "lexical" | "dense";
autoinject?: boolean;
autoinject_min_score?: number;
};
auto_heal_tool_calls?: boolean;
max_tool_calls_per_message?: number;
tool_call_timeout?: number;

View file

@ -41,6 +41,7 @@ import {
useHfTokenValidation,
useInfiniteScroll,
} from "@/hooks";
import { extractParamLabel } from "@/lib/model-size";
import { formatCompact } from "@/lib/utils";
import {
type TrainingMethod as VramTrainingMethod,
@ -58,13 +59,6 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
function extractParamLabel(id: string): string | null {
const name = id.split("/").pop() ?? id;
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
return match ? `${match[1]}B` : null;
}
export function ModelSelectionStep() {
const gpu = useGpuInfo();
const {

View file

@ -0,0 +1,203 @@
// 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 } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
import type {
DocumentUploadResult,
IndexJob,
JobEvent,
KnowledgeBase,
PreviewTarget,
RagDocument,
} from "../types/rag";
const RAG_BASE = "/api/rag";
function parseErrorText(status: number, body: unknown): string {
if (body && typeof body === "object") {
const { detail, message } = body as { detail?: unknown; message?: unknown };
const formatted = formatFastApiDetail(detail);
if (formatted) return formatted;
if (typeof message === "string" && message) return message;
}
return `Request failed (${status})`;
}
async function ragRequest<T>(
path: string,
init?: { method?: string; body?: object },
): Promise<T> {
const response = await authFetch(`${RAG_BASE}${path}`, {
method: init?.method,
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
body: init?.body ? JSON.stringify(init.body) : undefined,
});
if (response.status === 204) return undefined as T;
const json = await response.json().catch(() => null);
if (!response.ok) throw new Error(parseErrorText(response.status, json));
return json as T;
}
async function ragUpload(path: string, file: File): Promise<DocumentUploadResult> {
const form = new FormData();
form.append("file", file);
// No Content-Type: let the browser set the multipart boundary.
const response = await authFetch(`${RAG_BASE}${path}`, {
method: "POST",
body: form,
});
const json = await response.json().catch(() => null);
if (!response.ok) throw new Error(parseErrorText(response.status, json));
return json as DocumentUploadResult;
}
export async function listKnowledgeBases(): Promise<KnowledgeBase[]> {
const data = await ragRequest<{ knowledgeBases: KnowledgeBase[] }>(
"/knowledge-bases",
);
return data.knowledgeBases ?? [];
}
export function createKnowledgeBase(payload: {
name: string;
description?: string;
}): Promise<{ id: string; name: string }> {
return ragRequest("/knowledge-bases", {
method: "POST",
body: {
name: payload.name,
...(payload.description ? { description: payload.description } : {}),
},
});
}
export function updateKnowledgeBase(
kbId: string,
payload: { name?: string; description?: string },
): Promise<{ ok: boolean }> {
const body: Record<string, unknown> = {};
if (payload.name !== undefined) body.name = payload.name;
if (payload.description !== undefined) body.description = payload.description;
return ragRequest(`/knowledge-bases/${encodeURIComponent(kbId)}`, {
method: "PATCH",
body,
});
}
export function deleteKnowledgeBase(kbId: string): Promise<{ ok: boolean }> {
return ragRequest(`/knowledge-bases/${encodeURIComponent(kbId)}`, {
method: "DELETE",
});
}
export async function listKnowledgeBaseDocuments(
kbId: string,
): Promise<RagDocument[]> {
const data = await ragRequest<{ documents: RagDocument[] }>(
`/knowledge-bases/${encodeURIComponent(kbId)}/documents`,
);
return data.documents ?? [];
}
export function uploadKnowledgeBaseDocument(
kbId: string,
file: File,
): Promise<DocumentUploadResult> {
return ragUpload(
`/knowledge-bases/${encodeURIComponent(kbId)}/documents`,
file,
);
}
export async function listThreadDocuments(
threadId: string,
): Promise<RagDocument[]> {
const data = await ragRequest<{ documents: RagDocument[] }>(
`/threads/${encodeURIComponent(threadId)}/documents`,
);
return data.documents ?? [];
}
export function uploadThreadDocument(
threadId: string,
file: File,
): Promise<DocumentUploadResult> {
return ragUpload(`/threads/${encodeURIComponent(threadId)}/documents`, file);
}
export function deleteDocument(documentId: string): Promise<{ ok: boolean }> {
return ragRequest(`/documents/${encodeURIComponent(documentId)}`, {
method: "DELETE",
});
}
export function getJob(jobId: string): Promise<IndexJob> {
return ragRequest(`/jobs/${encodeURIComponent(jobId)}`);
}
// SSE; returns on [DONE]. Transport errors propagate so callers can poll getJob.
export async function* streamJobEvents(
jobId: string,
signal?: AbortSignal,
): AsyncGenerator<JobEvent> {
const response = await authFetch(
`${RAG_BASE}/jobs/${encodeURIComponent(jobId)}/events`,
signal ? { signal } : undefined,
);
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(parseErrorText(response.status, body));
}
if (!response.body) throw new Error("Stream response missing body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.search(/\r?\n\r?\n/);
while (separatorIndex >= 0) {
const rawEvent = buffer.slice(0, separatorIndex);
const separatorLength = buffer[separatorIndex] === "\r" ? 4 : 2;
buffer = buffer.slice(separatorIndex + separatorLength);
const dataLines: string[] = [];
for (const line of rawEvent.split(/\r?\n/)) {
if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length > 0) {
const dataText = dataLines.join("\n");
if (dataText === "[DONE]") return;
try {
yield JSON.parse(dataText) as JobEvent;
} catch {
// Ignore unparseable frames; [DONE] still ends the loop.
}
}
separatorIndex = buffer.search(/\r?\n\r?\n/);
}
}
}
export function getPreviewTarget(
documentId: string,
chunkId?: string,
): Promise<PreviewTarget> {
const qs = chunkId ? `?chunk_id=${encodeURIComponent(chunkId)}` : "";
return ragRequest(
`/documents/${encodeURIComponent(documentId)}/preview-target${qs}`,
);
}
// Signed URL (no bearer) so pdf.js can issue Range requests.
export async function getDocumentFileUrl(documentId: string): Promise<string> {
const data = await ragRequest<{ url: string }>(
`/documents/${encodeURIComponent(documentId)}/file-url`,
);
return data.url;
}

View file

@ -0,0 +1,32 @@
// 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 { Suspense, lazy, useEffect, useState } from "react";
import { useDocumentPreviewStore } from "./preview-store";
// pdf.js / react-pdf are heavy (~0.5 MB gzip): defer until the first citation
// click, then keep mounted for open/close anims.
const DocumentPreviewSheet = lazy(() =>
import("./document-preview-sheet").then((m) => ({
default: m.DocumentPreviewSheet,
})),
);
export function DocumentPreviewMount() {
const open = useDocumentPreviewStore((s) => s.open);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (open) setMounted(true);
}, [open]);
if (!mounted) return null;
return (
<Suspense fallback={null}>
<DocumentPreviewSheet />
</Suspense>
);
}

View file

@ -0,0 +1,474 @@
// 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 { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import {
ChevronLeftIcon,
ChevronRightIcon,
FileTextIcon,
LoaderIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getDocumentFileUrl, getPreviewTarget } from "../api/rag-api";
import type { PdfRegion, PreviewTarget } from "../types/rag";
import { useDocumentPreviewStore } from "./preview-store";
// Serve the pdf.js worker from the app origin.
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
// Highlight rects; coords are 0..1 of the page box.
function RegionOverlay({ regions }: { regions: PdfRegion[] }) {
if (regions.length === 0) return null;
return (
<>
{regions.map((r, i) => (
<div
key={i}
className="pointer-events-none absolute rounded-sm bg-amber-300/35 ring-1 ring-amber-500/70 mix-blend-multiply"
style={{
left: `${r.x * 100}%`,
top: `${r.y * 100}%`,
width: `${r.width * 100}%`,
height: `${r.height * 100}%`,
}}
/>
))}
</>
);
}
// Zoom multiplies fit-to-panel width: 1 = fit.
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3;
const ZOOM_STEP = 0.25;
const clampZoom = (z: number) =>
Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Number(z.toFixed(2))));
function PdfPreview({
fileUrl,
initialPage,
regions,
}: {
fileUrl: string;
initialPage: number;
regions: PdfRegion[];
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
const [page, setPage] = useState(initialPage);
const [scale, setScale] = useState(1);
const [error, setError] = useState<string | null>(null);
const [grabbing, setGrabbing] = useState(false);
const [scrollable, setScrollable] = useState(false);
const panRef = useRef<{
x: number;
y: number;
left: number;
top: number;
} | null>(null);
// Reset page/zoom when a new citation reuses this viewer.
useEffect(() => setPage(initialPage), [initialPage, fileUrl]);
useEffect(() => setScale(1), [fileUrl]);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => setWidth(el.clientWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Non-passive wheel listener so preventDefault can stop the panel scrolling.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
setScale((s) => clampZoom(s - Math.sign(e.deltaY) * ZOOM_STEP));
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const onLoad = useCallback(
({ numPages: n }: { numPages: number }) => {
setNumPages(n);
setPage((p) => Math.min(Math.max(p, 1), n));
},
[],
);
const zoomBy = useCallback(
(delta: number) => setScale((s) => clampZoom(s + delta)),
[],
);
// Whether the page overflows the panel (so panning matters).
const recheckScrollable = useCallback(() => {
const el = containerRef.current;
if (!el) return;
setScrollable(
el.scrollWidth > el.clientWidth + 1 ||
el.scrollHeight > el.clientHeight + 1,
);
}, []);
useEffect(() => {
recheckScrollable();
}, [recheckScrollable, width, scale, page, numPages]);
// Grab-to-pan; listen on window so the drag tracks past the panel edge.
useEffect(() => {
if (!grabbing) return;
const onMove = (e: MouseEvent) => {
const el = containerRef.current;
const start = panRef.current;
if (!el || !start) return;
el.scrollLeft = start.left - (e.clientX - start.x);
el.scrollTop = start.top - (e.clientY - start.y);
};
const onUp = () => {
setGrabbing(false);
panRef.current = null;
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [grabbing]);
const onPanStart = useCallback(
(e: React.MouseEvent) => {
const el = containerRef.current;
if (!el || e.button !== 0 || !scrollable) return;
panRef.current = {
x: e.clientX,
y: e.clientY,
left: el.scrollLeft,
top: el.scrollTop,
};
setGrabbing(true);
e.preventDefault(); // stop canvas image-drag / selection
},
[scrollable],
);
const pageRegions = regions.filter(
(r) => r.pageNumber === page || r.pageIndex === page - 1,
);
if (error) {
return (
<div className="p-6 text-sm text-muted-foreground">
Could not render this PDF ({error}).
</div>
);
}
return (
<div className="flex h-full flex-col">
<div
ref={containerRef}
onMouseDown={onPanStart}
className={cn(
"flex-1 overflow-auto bg-muted/30 px-4 py-3",
grabbing
? "cursor-grabbing select-none"
: scrollable
? "cursor-grab"
: "",
)}
>
<Document
file={fileUrl}
onLoadSuccess={onLoad}
onLoadError={(e) => setError(e.message)}
loading={
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" /> Loading PDF
</div>
}
>
{width > 0 && (
// min-w-fit lets the zoomed row grow past the panel so the page stays
// centered and reachable on both sides.
<div className="flex min-w-fit justify-center">
<div className="relative w-fit shadow-sm">
<Page
pageNumber={page}
width={(width - 8) * scale}
renderTextLayer={false}
renderAnnotationLayer={false}
onRenderSuccess={recheckScrollable}
/>
<RegionOverlay regions={pageRegions} />
</div>
</div>
)}
</Document>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center border-t px-3 py-2 text-xs">
<div className="flex items-center gap-0.5 justify-self-start">
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={scale <= ZOOM_MIN}
onClick={() => zoomBy(-ZOOM_STEP)}
aria-label="Zoom out"
>
<ZoomOutIcon className="size-4" />
</Button>
<button
type="button"
onClick={() => setScale(1)}
className="w-11 text-center tabular-nums text-muted-foreground hover:text-foreground"
aria-label="Reset zoom"
>
{Math.round(scale * 100)}%
</button>
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={scale >= ZOOM_MAX}
onClick={() => zoomBy(ZOOM_STEP)}
aria-label="Zoom in"
>
<ZoomInIcon className="size-4" />
</Button>
</div>
{numPages > 1 ? (
<div className="flex items-center gap-3 justify-self-center">
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
aria-label="Previous page"
>
<ChevronLeftIcon className="size-4" />
</Button>
<span className="tabular-nums text-muted-foreground">
Page {page} / {numPages}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={page >= numPages}
onClick={() => setPage((p) => Math.min(numPages, p + 1))}
aria-label="Next page"
>
<ChevronRightIcon className="size-4" />
</Button>
</div>
) : (
<span />
)}
<span aria-hidden />
</div>
</div>
);
}
// Resizable preview width (px). Default matches the prior fixed 44rem; drag the
// left edge to widen. Persisted so it survives reopen.
const PREVIEW_WIDTH_KEY = "unsloth-rag-preview-width";
const MIN_PREVIEW_WIDTH = 384;
const DEFAULT_PREVIEW_WIDTH = 704;
const maxPreviewWidth = () =>
typeof window === "undefined"
? DEFAULT_PREVIEW_WIDTH
: Math.round(window.innerWidth * 0.95);
const clampPreviewWidth = (w: number) =>
Math.min(maxPreviewWidth(), Math.max(MIN_PREVIEW_WIDTH, Math.round(w)));
function readStoredPreviewWidth(): number {
if (typeof window === "undefined") return DEFAULT_PREVIEW_WIDTH;
const raw = Number(window.localStorage.getItem(PREVIEW_WIDTH_KEY));
return Number.isFinite(raw) && raw > 0
? clampPreviewWidth(raw)
: DEFAULT_PREVIEW_WIDTH;
}
function persistPreviewWidth(w: number) {
try {
window.localStorage.setItem(PREVIEW_WIDTH_KEY, String(Math.round(w)));
} catch {
// ignore storage errors (private mode, quota, etc.)
}
}
export function DocumentPreviewSheet() {
const { open, documentId, chunkId, filename, page, closePreview } =
useDocumentPreviewStore();
const [target, setTarget] = useState<PreviewTarget | null>(null);
const [fileUrl, setFileUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Left-edge drag resizing of the panel.
const [previewWidth, setPreviewWidth] = useState<number>(
readStoredPreviewWidth,
);
const [resizing, setResizing] = useState(false);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
const widthRef = useRef(previewWidth);
widthRef.current = previewWidth;
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
resizeRef.current = { startX: e.clientX, startWidth: widthRef.current };
setResizing(true);
}, []);
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
const start = resizeRef.current;
if (!start) return;
// Right-anchored panel: dragging left (smaller clientX) widens it.
setPreviewWidth(
clampPreviewWidth(start.startWidth + (start.startX - e.clientX)),
);
};
const onUp = () => {
setResizing(false);
resizeRef.current = null;
persistPreviewWidth(widthRef.current);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizing]);
useEffect(() => {
if (!open || !documentId) return;
let cancelled = false;
setLoading(true);
setError(null);
setTarget(null);
setFileUrl(null);
(async () => {
try {
const t = await getPreviewTarget(documentId, chunkId ?? undefined);
if (cancelled) return;
setTarget(t);
if (t.mediaKind === "pdf") {
const url = await getDocumentFileUrl(documentId);
if (!cancelled) setFileUrl(url);
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, documentId, chunkId]);
const headerName = target?.filename ?? filename ?? "Document";
const headerPage = target?.targetPage ?? page ?? null;
return (
<Sheet open={open} onOpenChange={(o) => !o && closePreview()}>
<SheetContent
side="right"
style={{ width: previewWidth, maxWidth: "95vw" }}
className={cn(
"flex w-full flex-col gap-0 p-0",
resizing && "select-none",
)}
>
{/* Drag the left edge to widen the preview; double-click to reset. */}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize document preview"
onMouseDown={onResizeStart}
onDoubleClick={() => {
setPreviewWidth(DEFAULT_PREVIEW_WIDTH);
persistPreviewWidth(DEFAULT_PREVIEW_WIDTH);
}}
className={cn(
"absolute inset-y-0 left-0 z-20 w-2 cursor-col-resize transition-colors hover:bg-primary/25",
resizing && "bg-primary/40",
)}
/>
<SheetHeader className="gap-1 border-b p-4">
{/* pr-10 reserves room for the absolute close button. */}
<SheetTitle className="flex items-center gap-2 pr-10 text-sm">
<FileTextIcon className="size-4 shrink-0" />
<span className="min-w-0 truncate">{headerName}</span>
{headerPage != null && (
<span className="shrink-0 text-muted-foreground">
· page {headerPage}
</span>
)}
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1">
{loading ? (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" /> Resolving source
</div>
) : error ? (
<div className="p-6 text-sm text-muted-foreground">
Could not open this document ({error}).
</div>
) : target && target.mediaKind === "pdf" && fileUrl ? (
<PdfPreview
fileUrl={fileUrl}
initialPage={target.targetPage ?? 1}
regions={target.pdfRegions ?? []}
/>
) : target?.text ? (
<div className="h-full overflow-auto p-5">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/90">
{target.text}
</p>
</div>
) : (
<div className="p-6 text-sm text-muted-foreground">
No preview available for this source.
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,60 @@
// 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 { LoaderCircleIcon, XIcon } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { File02Icon } from "@hugeicons/core-free-icons";
import { Badge } from "@/components/assistant-ui/badge";
import { cn } from "@/lib/utils";
import type { DocumentStatus } from "../types/rag";
export function DocumentStatusChip({
filename,
status,
error,
onRemove,
}: {
filename: string;
status: DocumentStatus;
progress?: number | null;
error?: string | null;
onRemove?: () => void;
}) {
const processing = status === "pending" || status === "running";
return (
<Badge
variant="outline"
size="sm"
title={error ?? filename}
className={cn(
"rounded-full inline-flex items-center gap-1.5 max-w-[16rem]",
status === "failed" && "border-destructive/40 text-destructive",
)}
>
{/* file */}
<HugeiconsIcon
icon={File02Icon}
strokeWidth={2}
className="size-3 shrink-0"
/>
<span className="truncate">{filename}</span>
{/* spinner while indexing, else close button */}
{processing ? (
<LoaderCircleIcon
className="shrink-0 animate-spin size-3.5 text-muted-foreground"
role="status"
aria-label="Loading"
/>
) : onRemove ? (
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${filename}`}
className="shrink-0 rounded-full text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3" />
</button>
) : null}
</Badge>
);
}

View file

@ -0,0 +1,198 @@
// 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 { XIcon } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { FileDatabaseIcon, Tick02Icon } from "@hugeicons/core-free-icons";
import { type FC, useCallback, useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useRagToolAvailable } from "@/features/chat/hooks/use-rag-tool-available";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { listKnowledgeBases } from "../api/rag-api";
import type { KnowledgeBase } from "../types/rag";
import { KnowledgeBaseDialog } from "./knowledge-base-dialog";
// Matches the Thinking/MCP pill chevron.
const ArrowDownStandardIcon: FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={true}
>
<path d="M5.99977 9.00005L11.9998 15L17.9998 9" />
</svg>
);
// Picks the retrieval source. Only rendered when retrieval is on and the loaded
// model can run search_knowledge_base.
export function KnowledgeBaseComposerButton({
side = "bottom",
}: {
side?: "top" | "bottom";
} = {}) {
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
const ragAvailable = useRagToolAvailable();
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [kbsLoaded, setKbsLoaded] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const refresh = useCallback(async () => {
try {
const rows = await listKnowledgeBases();
setKbs(rows);
} catch {
// Keep prior state on failure.
} finally {
setKbsLoaded(true);
}
}, []);
// Load on mount so newly created KBs show up.
useEffect(() => {
void refresh();
}, [refresh]);
// If the selected KB was deleted, fall back to thread source so we never send a
// stale kb_id. Gate on kbsLoaded, not kbs.length: deleting the last KB empties the
// list, so a length>0 guard would skip the reset and stick on a ghost KB.
useEffect(() => {
if (
kbsLoaded &&
ragSource.type === "kb" &&
!kbs.some((kb) => kb.id === ragSource.kbId)
) {
setRagSource({ type: "thread" });
}
}, [kbs, kbsLoaded, ragSource, setRagSource]);
if (!ragEnabled || !ragAvailable) return null;
return (
<>
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) void refresh();
}}
>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
className="composer-pill-btn"
data-active="true"
aria-label="Retrieval source"
>
{/* Icon doubles as an off switch: hover swaps to an X; clicking it
turns RAG off without opening the menu. */}
<span
role="button"
aria-label="Turn off retrieval"
tabIndex={-1}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setRagEnabled(false);
}}
className="composer-pill-glyph cursor-pointer"
>
<HugeiconsIcon
icon={FileDatabaseIcon}
strokeWidth={2}
className="size-[15px]"
/>
<XIcon className="composer-pill-x" />
</span>
<span>RAG</span>
<ArrowDownStandardIcon className="composer-pill-caret size-[15px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side={side}
align="start"
sideOffset={2}
avoidCollisions={true}
className="unsloth-plus-menu mcp-menu w-[232px]"
>
<DropdownMenuLabel>Retrieve from</DropdownMenuLabel>
<DropdownMenuItem
onSelect={() => setRagSource({ type: "thread" })}
className={
ragSource.type === "thread"
? "relative text-primary font-medium"
: "relative"
}
>
<span className="truncate">This thread's documents</span>
{ragSource.type === "thread" ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
{kbs.length > 0 ? <DropdownMenuSeparator /> : null}
{kbs.map((kb) => {
const selected =
ragSource.type === "kb" && ragSource.kbId === kb.id;
return (
<DropdownMenuItem
key={kb.id}
onSelect={() => setRagSource({ type: "kb", kbId: kb.id })}
className={
selected ? "relative text-primary font-medium" : "relative"
}
>
<span className="truncate">{kb.name}</span>
{selected ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="ml-auto"
/>
) : null}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
setDialogOpen(true);
}}
>
Manage knowledge bases
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<KnowledgeBaseDialog
open={dialogOpen}
onOpenChange={(next) => {
setDialogOpen(next);
if (!next) void refresh();
}}
/>
</>
);
}

View file

@ -0,0 +1,334 @@
// 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 { useCallback, useEffect, useRef, useState } from "react";
import {
Delete02Icon,
Edit03Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronLeftIcon, UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/lib/toast";
import {
createKnowledgeBase,
deleteKnowledgeBase,
listKnowledgeBaseDocuments,
listKnowledgeBases,
updateKnowledgeBase,
} from "../api/rag-api";
import { RAG_UPLOAD_ACCEPT, type KnowledgeBase } from "../types/rag";
import { DocumentStatusChip } from "./document-status-chip";
import { useRagDocuments } from "./use-rag-documents";
type View =
| { kind: "list" }
| { kind: "create" }
| { kind: "edit"; kb: KnowledgeBase }
| { kind: "documents"; kb: KnowledgeBase };
export interface KnowledgeBaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function KnowledgeBaseDialog({
open,
onOpenChange,
}: KnowledgeBaseDialogProps) {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [loading, setLoading] = useState(false);
const [view, setView] = useState<View>({ kind: "list" });
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
setKbs(await listKnowledgeBases());
} catch (err) {
toast.error("Failed to load knowledge bases", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
setView({ kind: "list" });
void refresh();
}, [open, refresh]);
function startCreate() {
setName("");
setDescription("");
setView({ kind: "create" });
}
function startEdit(kb: KnowledgeBase) {
setName(kb.name);
setDescription(kb.description ?? "");
setView({ kind: "edit", kb });
}
function backToList() {
setView({ kind: "list" });
}
async function submitForm() {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Name is required");
return;
}
setSaving(true);
try {
if (view.kind === "edit") {
await updateKnowledgeBase(view.kb.id, {
name: trimmed,
description: description.trim(),
});
toast.success("Knowledge base updated");
} else {
await createKnowledgeBase({
name: trimmed,
description: description.trim() || undefined,
});
toast.success("Knowledge base created");
}
backToList();
await refresh();
} catch (err) {
toast.error("Save failed", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setSaving(false);
}
}
async function removeKb(kb: KnowledgeBase) {
if (
!window.confirm(
`Delete knowledge base "${kb.name}" and all its documents?`,
)
) {
return;
}
try {
await deleteKnowledgeBase(kb.id);
await refresh();
} catch (err) {
toast.error("Delete failed", {
description: err instanceof Error ? err.message : String(err),
});
}
}
const showForm = view.kind === "create" || view.kind === "edit";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{view.kind === "documents"
? view.kb.name
: "Knowledge bases"}
</DialogTitle>
<DialogDescription>
{view.kind === "documents"
? "Upload documents to index for retrieval in chat."
: "Group documents into a reusable knowledge base for chat retrieval."}
</DialogDescription>
</DialogHeader>
{view.kind === "documents" ? (
<KnowledgeBaseDocuments kb={view.kb} onBack={backToList} />
) : showForm ? (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="kb-name">Name</Label>
<Input
id="kb-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Product docs"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="kb-description">Description</Label>
<Textarea
id="kb-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional. What this knowledge base contains."
rows={3}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={backToList} disabled={saving}>
Cancel
</Button>
<Button onClick={submitForm} disabled={saving}>
{saving ? <Spinner /> : null}
{view.kind === "edit" ? "Save changes" : "Create"}
</Button>
</div>
</div>
) : (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex justify-end">
<Button size="sm" onClick={startCreate}>
<HugeiconsIcon icon={PlusSignIcon} size={14} />
New knowledge base
</Button>
</div>
{loading ? (
<div className="flex justify-center py-6">
<Spinner />
</div>
) : kbs.length === 0 ? (
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
No knowledge bases yet.
</div>
) : (
<ul className="flex max-h-[60vh] flex-col divide-y overflow-y-auto rounded-md border">
{kbs.map((kb) => (
<li
key={kb.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<button
type="button"
onClick={() => setView({ kind: "documents", kb })}
className="min-w-0 flex-1 text-left"
>
<div className="truncate font-medium">{kb.name}</div>
<div className="truncate text-xs text-muted-foreground">
{kb.documentCount ?? 0} document
{(kb.documentCount ?? 0) === 1 ? "" : "s"}
{kb.description ? ` · ${kb.description}` : ""}
</div>
</button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => startEdit(kb)}
aria-label="Rename knowledge base"
>
<HugeiconsIcon icon={Edit03Icon} size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeKb(kb)}
aria-label="Delete knowledge base"
>
<HugeiconsIcon icon={Delete02Icon} size={14} />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
function KnowledgeBaseDocuments({
kb,
onBack,
}: {
kb: KnowledgeBase;
onBack: () => void;
}) {
const lister = useCallback(
() => listKnowledgeBaseDocuments(kb.id),
[kb.id],
);
const { documents, loading, uploading, upload, remove } = useRagDocuments(
{ type: "kb", kbId: kb.id },
lister,
);
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={onBack}>
<ChevronLeftIcon className="size-4" />
All knowledge bases
</Button>
<Button
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? <Spinner /> : <UploadIcon className="size-3.5" />}
Upload
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept={RAG_UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void upload(e.target.files);
e.target.value = "";
}}
/>
</div>
{loading && documents.length === 0 ? (
<div className="flex justify-center py-6">
<Spinner />
</div>
) : documents.length === 0 ? (
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
No documents yet. Upload a PDF, Markdown, DOCX, HTML, or text file.
</div>
) : (
<div className="flex max-h-[55vh] flex-wrap gap-1.5 overflow-y-auto pr-0.5">
{documents.map((doc) => (
<DocumentStatusChip
key={doc.id}
filename={doc.filename}
status={doc.status}
progress={doc.progress}
error={doc.error}
onRemove={
doc.id.startsWith("pending_")
? undefined
: () => void remove(doc.id)
}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,39 @@
// 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";
// Global store for the shared preview Sheet, so any citation drives the one viewer
// without prop-drilling.
interface DocumentPreviewState {
open: boolean;
documentId: string | null;
/** Chunk to highlight; null opens at page 1. */
chunkId: string | null;
filename: string | null;
page: number | null;
openPreview: (args: {
documentId: string;
chunkId?: string | null;
filename?: string | null;
page?: number | null;
}) => void;
closePreview: () => void;
}
export const useDocumentPreviewStore = create<DocumentPreviewState>((set) => ({
open: false,
documentId: null,
chunkId: null,
filename: null,
page: null,
openPreview: ({ documentId, chunkId, filename, page }) =>
set({
open: true,
documentId,
chunkId: chunkId ?? null,
filename: filename ?? null,
page: page ?? null,
}),
closePreview: () => set({ open: false }),
}));

View file

@ -0,0 +1,206 @@
// 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 { ReactNode } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InfoIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
type RagAutoInject,
type RagMode,
useChatRuntimeStore,
} from "@/features/chat/stores/chat-runtime-store";
const MODE_LABEL: Record<RagMode, string> = {
hybrid: "Hybrid",
dense: "Semantic only",
lexical: "BM25 only",
};
function InfoHint({ children }: { children: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
aria-label="More info"
className="text-muted-foreground/50 hover:text-muted-foreground"
>
<InfoIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{children}</TooltipContent>
</Tooltip>
);
}
function SliderRow({
label,
value,
min,
max,
step,
onChange,
disabled = false,
format = (v: number) => String(v),
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
disabled?: boolean;
format?: (v: number) => string;
}) {
return (
<div
className={cn(
"flex flex-col gap-2",
disabled && "pointer-events-none opacity-50",
)}
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
{label}
</span>
<span className="text-[13px] tabular-nums text-muted-foreground">
{format(value)}
</span>
</div>
<Slider
value={[value]}
min={min}
max={max}
step={step}
disabled={disabled}
onValueChange={([v]) => onChange(v)}
aria-label={label}
/>
</div>
);
}
// Retrieval settings; the source itself is picked from the composer dropdown.
export function RetrievalSettingsSection() {
const ragMode = useChatRuntimeStore((s) => s.ragMode);
const setRagMode = useChatRuntimeStore((s) => s.setRagMode);
const ragTopK = useChatRuntimeStore((s) => s.ragTopK);
const setRagTopK = useChatRuntimeStore((s) => s.setRagTopK);
const ragAutoInject = useChatRuntimeStore((s) => s.ragAutoInject);
const setRagAutoInject = useChatRuntimeStore((s) => s.setRagAutoInject);
const ragAutoInjectMinScore = useChatRuntimeStore(
(s) => s.ragAutoInjectMinScore,
);
const setRagAutoInjectMinScore = useChatRuntimeStore(
(s) => s.setRagAutoInjectMinScore,
);
return (
<div className="flex flex-col gap-5 pt-1">
<div className="flex flex-col gap-2">
<span className="text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Search mode
</span>
<Select
value={ragMode}
onValueChange={(value) => setRagMode(value as RagMode)}
>
<SelectTrigger
className="panel-select-trigger h-8 w-full"
aria-label="Search mode"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hybrid">{MODE_LABEL.hybrid}</SelectItem>
<SelectItem value="dense">{MODE_LABEL.dense}</SelectItem>
<SelectItem value="lexical">{MODE_LABEL.lexical}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Passages (top K)
</span>
<span className="text-[13px] tabular-nums text-muted-foreground">
{ragTopK}
</span>
</div>
<Slider
value={[ragTopK]}
min={1}
max={20}
step={1}
onValueChange={([value]) => setRagTopK(value)}
aria-label="Number of passages to retrieve"
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col">
<span className="flex items-center gap-1.5 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Auto-retrieve documents
<InfoHint>
Auto turns retrieval on for smaller models (9B and below), which
tend to answer from memory instead of searching, and leaves it to
larger ones. On and Off force it either way.
</InfoHint>
</span>
<span className="text-[12px] leading-[1.3] text-muted-foreground">
Search attached documents before answering.
</span>
</div>
<ToggleGroup
type="single"
variant="outline"
value={ragAutoInject}
onValueChange={(value) => {
// Radix clears on re-click; ignore empty so one stays selected.
if (value) setRagAutoInject(value as RagAutoInject);
}}
className="w-full"
aria-label="Auto-retrieve documents"
>
<ToggleGroupItem value="auto" className="flex-1">
Auto
</ToggleGroupItem>
<ToggleGroupItem value="on" className="flex-1">
On
</ToggleGroupItem>
<ToggleGroupItem value="off" className="flex-1">
Off
</ToggleGroupItem>
</ToggleGroup>
<SliderRow
label="Auto-retrieve threshold"
value={ragAutoInjectMinScore}
min={0}
max={1}
step={0.05}
disabled={ragAutoInject === "off"}
onChange={setRagAutoInjectMinScore}
format={(v) => v.toFixed(2)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,215 @@
// 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 { useCallback, useEffect, useRef, useState } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { AttachmentIcon, FileDatabaseIcon } from "@hugeicons/core-free-icons";
import { useAui } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useRagToolAvailable } from "@/features/chat/hooks/use-rag-tool-available";
import { toast } from "@/lib/toast";
import { listKnowledgeBases, listThreadDocuments } from "../api/rag-api";
import { RAG_UPLOAD_ACCEPT } from "../types/rag";
import { DocumentStatusChip } from "./document-status-chip";
import { useRagDocuments } from "./use-rag-documents";
// Read-only chip shown when retrieval comes from a KB, so the source isn't invisible.
function KnowledgeBaseSourceChip({ kbId }: { kbId: string }) {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
listKnowledgeBases()
.then((rows) => {
if (!cancelled) setName(rows.find((kb) => kb.id === kbId)?.name ?? null);
})
.catch(() => {
if (!cancelled) setName(null);
});
return () => {
cancelled = true;
};
}, [kbId]);
return (
<div className="mb-2 flex w-full flex-row items-center gap-1.5 pl-0.5 pr-1.5 pt-0.5 pb-1">
<span
className="composer-pill-btn shrink-0 cursor-default"
title="This chat retrieves from a knowledge base. Change the source in RAG retrieval settings."
>
<HugeiconsIcon
icon={FileDatabaseIcon}
strokeWidth={2}
className="size-3.5"
/>
<span>{name ? `Knowledge base: ${name}` : "Knowledge base"}</span>
</span>
</div>
);
}
export function ThreadDocumentsBar({
threadId,
onIndexingChange,
}: {
threadId: string | null;
onIndexingChange?: (active: boolean) => void;
}) {
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const ragAvailable = useRagToolAvailable();
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const aui = useAui();
const fileInputRef = useRef<HTMLInputElement>(null);
// A fresh chat has no thread id until the first message; materialize one on demand
// so docs can attach (append() in runtime-provider reuses it). Track it locally:
// pushing to global activeThreadId would, in a project, remount this bar mid-upload
// (ProjectLanding's pendingNewThreadId branch) and drop the just-attached chips.
const [materializedId, setMaterializedId] = useState<string | null>(null);
const effectiveThreadId = threadId ?? materializedId;
useEffect(() => {
if (threadId) setMaterializedId(null);
}, [threadId]);
const lister = useCallback(
() =>
effectiveThreadId
? listThreadDocuments(effectiveThreadId)
: Promise.resolve([]),
[effectiveThreadId],
);
const { documents, uploading, upload, remove } = useRagDocuments(
effectiveThreadId && ragEnabled && ragSource.type === "thread"
? { type: "thread", threadId: effectiveThreadId }
: null,
lister,
);
// Tell the composer whether any doc is still indexing, so it can hold a queued
// send until retrieval covers them (Composer.enqueueSend). For KB / RAG-off scope
// is null, so `documents` is empty and this reads false.
const hasIndexing = documents.some(
(d) => d.status === "pending" || d.status === "running",
);
useEffect(() => {
onIndexingChange?.(hasIndexing);
}, [hasIndexing, onIndexingChange]);
useEffect(() => () => onIndexingChange?.(false), [onIndexingChange]);
// Materialize the thread id on first use; ref-deduped so a double-click can't
// start two threads.
const initPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureThreadId = useCallback((): Promise<string | null> => {
if (effectiveThreadId) return Promise.resolve(effectiveThreadId);
if (initPromiseRef.current) return initPromiseRef.current;
const pending = aui
.threadListItem()
.initialize()
.then(({ remoteId }) => {
setMaterializedId(remoteId);
return remoteId;
})
.catch(() => {
toast.error("Couldn't start a chat for these documents");
return null;
})
.finally(() => {
initPromiseRef.current = null;
});
initPromiseRef.current = pending;
return pending;
}, [aui, effectiveThreadId]);
const chipScrollRef = useRef<HTMLDivElement>(null);
const [chipsOverflow, setChipsOverflow] = useState(false);
const updateChipFade = useCallback(() => {
const el = chipScrollRef.current;
if (!el) return;
setChipsOverflow(el.scrollHeight - el.scrollTop - el.clientHeight > 1);
}, []);
useEffect(() => {
updateChipFade();
}, [documents, updateChipFade]);
// Open the picker synchronously to keep the click's user activation. Do NOT
// materialize here: setActiveThreadId while the native dialog is open can remount
// the composer and orphan this <input>. Materialize in onChange instead.
const handleAddDocs = useCallback(() => {
fileInputRef.current?.click();
}, []);
// Only when the RAG pill is on: enabled AND a tool-capable model loaded.
if (!ragEnabled || !ragAvailable) return null;
// A KB source uploads via the KB dialog, not here; show which KB is active.
if (ragSource.type === "kb") {
return <KnowledgeBaseSourceChip kbId={ragSource.kbId} />;
}
return (
<div className="mb-2 flex w-full flex-row items-start gap-1.5 pl-0.5 pr-1.5 pt-0.5 pb-1">
<button
type="button"
onClick={handleAddDocs}
disabled={uploading}
className={cn(
"composer-pill-btn shrink-0 -translate-y-px !text-foreground/80",
// Square button so the rounded-full hover reads as a circle.
documents.length > 0 && "size-8 justify-center px-0",
)}
aria-label="Attach documents to this thread"
title="Attach documents for retrieval"
>
<HugeiconsIcon
icon={AttachmentIcon}
strokeWidth={2}
className="size-3.5"
/>
{/* Icon-only once documents are attached. */}
{documents.length === 0 && <span>Add files to chat with</span>}
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept={RAG_UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length === 0) return;
// Pass the id as a promise so upload() flips its in-flight guard before
// materialization re-renders us; on the first click `scope` is still null.
void upload(
files,
ensureThreadId().then((id) =>
id ? ({ type: "thread", threadId: id } as const) : null,
),
);
}}
/>
{/* Cap height so a large set scrolls; fade the cut-off row. */}
<div
ref={chipScrollRef}
onScroll={updateChipFade}
className={cn(
"flex max-h-24 flex-1 flex-row flex-wrap items-center gap-1.5 overflow-y-auto",
chipsOverflow && "rag-docs-bottom-fade",
)}
>
{documents.map((doc) => (
<DocumentStatusChip
key={doc.id}
filename={doc.filename}
status={doc.status}
progress={doc.progress}
error={doc.error}
onRemove={
doc.id.startsWith("pending_")
? undefined
: () => void remove(doc.id)
}
/>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,352 @@
// 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 { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "@/lib/toast";
import {
deleteDocument,
getJob,
streamJobEvents,
uploadKnowledgeBaseDocument,
uploadThreadDocument,
} from "../api/rag-api";
import type { DocumentStatus, RagDocument } from "../types/rag";
export interface TrackedDocument extends RagDocument {
progress?: number | null;
}
// Client-side dedup key; backend dedups authoritatively by content hash.
function fileSignature(file: File): string {
return `${file.name}|${file.size}|${file.lastModified}`;
}
export type RagDocumentScope =
| { type: "kb"; kbId: string }
| { type: "thread"; threadId: string };
type Lister = () => Promise<RagDocument[]>;
export function useRagDocuments(
scope: RagDocumentScope | null,
lister: Lister,
) {
const [documents, setDocuments] = useState<TrackedDocument[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
// jobId -> abort, to avoid double-subscribing.
const trackedJobs = useRef<Map<string, AbortController>>(new Map());
// Live mirror of `documents` for synchronous dedup in upload().
const documentsRef = useRef<TrackedDocument[]>([]);
useEffect(() => {
documentsRef.current = documents;
}, [documents]);
// documentId -> signature; forgotten on delete, cleared on scope change.
const sigByDocId = useRef<Map<string, string>>(new Map());
const sigAttached = useCallback(
(sig: string) => {
for (const s of sigByDocId.current.values()) if (s === sig) return true;
return false;
},
[],
);
// True while upload() runs, so the scope-change effect can tell a real switch
// from lazy thread materialization mid-upload (which must not reset).
const uploadInFlightRef = useRef(false);
const scopeKey = scope
? scope.type === "kb"
? `kb:${scope.kbId}`
: `thread:${scope.threadId}`
: null;
const prevScopeKeyRef = useRef<string | null>(null);
const patchDoc = useCallback(
(documentId: string, patch: Partial<TrackedDocument>) => {
setDocuments((rows) =>
rows.map((row) =>
row.id === documentId ? { ...row, ...patch } : row,
),
);
},
[],
);
const trackJob = useCallback(
(jobId: string, documentId: string, filename: string) => {
if (trackedJobs.current.has(jobId)) return;
const controller = new AbortController();
trackedJobs.current.set(jobId, controller);
const finish = (status: DocumentStatus, error?: string | null) => {
if (status === "failed") {
// Drop the chip rather than show "Failed"; warn via toast.
setDocuments((rows) => rows.filter((row) => row.id !== documentId));
toast.error(`Couldn't index ${filename}`, {
description: error ?? "Indexing failed",
});
} else {
patchDoc(documentId, { status, error: null, progress: 1 });
}
trackedJobs.current.delete(jobId);
};
(async () => {
try {
for await (const ev of streamJobEvents(jobId, controller.signal)) {
if (ev.type === "progress") {
patchDoc(documentId, {
status: "running",
progress: ev.progress ?? null,
});
} else if (ev.type === "complete") {
finish("completed");
return;
} else if (ev.type === "error") {
finish("failed", ev.error ?? "Indexing failed");
return;
}
}
// Stream ended with no terminal frame: reconcile.
const job = await getJob(jobId);
finish(
job.status === "completed"
? "completed"
: job.status === "failed"
? "failed"
: "completed",
job.error,
);
} catch {
if (controller.signal.aborted) {
trackedJobs.current.delete(jobId);
return;
}
// SSE unavailable: poll to a terminal state.
try {
for (let i = 0; i < 600; i++) {
if (controller.signal.aborted) break;
const job = await getJob(jobId);
if (job.status === "completed") return finish("completed");
if (job.status === "failed") {
return finish("failed", job.error ?? "Indexing failed");
}
patchDoc(documentId, {
status: job.status === "running" ? "running" : "pending",
progress: job.progress ?? null,
});
await new Promise((r) => setTimeout(r, 1500));
}
} catch {
trackedJobs.current.delete(jobId);
}
}
})();
},
[patchDoc],
);
const refresh = useCallback(async (opts?: { quiet?: boolean }) => {
if (!scope) return;
if (!opts?.quiet) setLoading(true);
try {
// Merge server truth with local progress so a refresh mid-index keeps a
// live "running %" chip. Failed docs hidden (toast warned at upload).
const rows = (await lister()).filter((row) => row.status !== "failed");
setDocuments((prev) => {
const merged = rows.map((row) => {
const tracked = prev.find((p) => p.id === row.id);
return tracked && tracked.progress != null && row.status !== "completed"
? { ...row, progress: tracked.progress }
: row;
});
// Keep optimistic chips (not yet listed) so a refresh racing an upload
// can't make them vanish.
const serverIds = new Set(rows.map((row) => row.id));
const pendingLocal = prev.filter(
(row) => row.id.startsWith("pending_") && !serverIds.has(row.id),
);
return [...merged, ...pendingLocal];
});
} catch (err) {
toast.error("Failed to load documents", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
if (!opts?.quiet) setLoading(false);
}
}, [scope, lister]);
// A real switch (thread/KB swap) resets + reloads; first acquiring a scope just
// loads. Skip both during materialization mid-upload (scope null -> new thread
// while upload() runs) so we don't abort tracking or wipe optimistic chips.
useEffect(() => {
const prev = prevScopeKeyRef.current;
prevScopeKeyRef.current = scopeKey;
if (prev !== null && prev !== scopeKey) {
for (const controller of trackedJobs.current.values()) controller.abort();
trackedJobs.current.clear();
sigByDocId.current.clear();
setDocuments([]);
if (scope) void refresh();
} else if (prev === null && scope && !uploadInFlightRef.current) {
void refresh();
}
return () => {
// Preserve in-flight tracking when cleanup is the materialization flip,
// not a real switch/unmount.
if (uploadInFlightRef.current) return;
for (const controller of trackedJobs.current.values()) controller.abort();
trackedJobs.current.clear();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeKey]);
// Safety net: a big upload opens one SSE stream per doc, but HTTP/1.1 caps
// concurrent connections, so streams past the cap may never deliver a terminal
// frame and leave a chip spinning. While anything is indexing, reconcile against
// the document list (one request covers every doc) so chips always resolve.
const hasIndexing = documents.some(
(d) => d.status === "pending" || d.status === "running",
);
useEffect(() => {
if (!scopeKey || !hasIndexing) return;
const id = setInterval(() => void refresh({ quiet: true }), 4000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeKey, hasIndexing]);
// POST one file, then swap its optimistic chip (`tempId`) to the real id; drop
// the chip if the backend deduped. `seenIds` holds ids present/added this batch.
const uploadOne = useCallback(
async (
file: File,
seenIds: Set<string>,
activeScope: RagDocumentScope,
tempId: string,
) => {
try {
const result =
activeScope.type === "kb"
? await uploadKnowledgeBaseDocument(activeScope.kbId, file)
: await uploadThreadDocument(activeScope.threadId, file);
sigByDocId.current.set(result.documentId, fileSignature(file));
if (seenIds.has(result.documentId)) {
setDocuments((rows) => rows.filter((row) => row.id !== tempId));
toast.info(`${result.filename || file.name} is already indexed - skipping`);
return;
}
seenIds.add(result.documentId);
setDocuments((rows) =>
rows.map((row) =>
row.id === tempId
? {
...row,
id: result.documentId,
filename: result.filename || row.filename,
status: "running",
}
: row,
),
);
trackJob(result.jobId, result.documentId, result.filename || file.name);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Drop the chip rather than show "Failed"; warn via toast.
setDocuments((rows) => rows.filter((row) => row.id !== tempId));
toast.error(`Couldn't upload ${file.name}`, { description: message });
}
},
[trackJob],
);
// `overrideScope` lets a caller pass a freshly-resolved scope (or a promise of
// one), since the thread bar's id is still null on the first click; falls back to
// the hook scope.
const upload = useCallback(
async (
files: FileList | File[],
overrideScope?: RagDocumentScope | Promise<RagDocumentScope | null>,
) => {
// Flip the in-flight guard synchronously, before awaiting a thread id that
// may still be materializing, so the scope-change effect reads it and leaves
// job tracking and optimistic chips alone.
uploadInFlightRef.current = true;
setUploading(true);
try {
// Show an optimistic chip per file before awaiting the thread id;
// materialization is a round-trip and gating chips behind it makes a slow
// one look like nothing happened. Dedup re-selections up front.
const fresh: Array<{ tempId: string; file: File }> = [];
for (const file of Array.from(files)) {
if (sigAttached(fileSignature(file))) {
toast.info(`${file.name} is already indexed - skipping`);
continue;
}
fresh.push({
tempId: `pending_${Math.random().toString(36).slice(2)}`,
file,
});
}
if (fresh.length === 0) return;
setDocuments((rows) => [
...rows,
...fresh.map(({ tempId, file }) => ({
id: tempId,
filename: file.name,
status: "pending" as const,
progress: null,
})),
]);
const resolved =
overrideScope instanceof Promise ? await overrideScope : overrideScope;
const activeScope = resolved ?? scope;
if (!activeScope) {
// Materialization failed: drop the chips so they don't hang "pending".
const tempIds = new Set(fresh.map((f) => f.tempId));
setDocuments((rows) => rows.filter((row) => !tempIds.has(row.id)));
toast.error("Couldn't attach documents", {
description: "Could not start a chat to attach them to.",
});
return;
}
const seenIds = new Set(
documentsRef.current
.filter((d) => !d.id.startsWith("pending_"))
.map((d) => d.id),
);
for (const { tempId, file } of fresh) {
await uploadOne(file, seenIds, activeScope, tempId);
}
} finally {
setUploading(false);
uploadInFlightRef.current = false;
}
},
[scope, uploadOne, sigAttached],
);
const remove = useCallback(
async (documentId: string) => {
const prev = documents;
setDocuments((rows) => rows.filter((row) => row.id !== documentId));
// Forget the dedup signature so re-uploading re-indexes.
const prevSig = sigByDocId.current.get(documentId);
sigByDocId.current.delete(documentId);
try {
await deleteDocument(documentId);
} catch (err) {
setDocuments(prev);
if (prevSig !== undefined) sigByDocId.current.set(documentId, prevSig);
toast.error("Delete failed", {
description: err instanceof Error ? err.message : String(err),
});
}
},
[documents],
);
return { documents, loading, uploading, refresh, upload, remove };
}

View file

@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { KnowledgeBaseComposerButton } from "./components/knowledge-base-composer-button";
export { KnowledgeBaseDialog } from "./components/knowledge-base-dialog";
export { RetrievalSettingsSection } from "./components/retrieval-settings-section";
export { ThreadDocumentsBar } from "./components/thread-documents-bar";
export type { KnowledgeBase, RagDocument } from "./types/rag";

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export interface KnowledgeBase {
id: string;
name: string;
description?: string | null;
createdAt?: string | null;
documentCount?: number;
}
/** Index status: pending -> running -> completed | failed. */
export type DocumentStatus = "pending" | "running" | "completed" | "failed";
export interface RagDocument {
id: string;
filename: string;
status: DocumentStatus;
error?: string | null;
numChunks?: number | null;
kbId?: string | null;
threadId?: string | null;
createdAt?: string | null;
}
export interface DocumentUploadResult {
documentId: string;
jobId: string;
filename: string;
}
export type JobStatus = "pending" | "running" | "completed" | "failed";
export interface IndexJob {
id: string;
documentId: string;
status: JobStatus;
stage?: string | null;
progress?: number | null;
error?: string | null;
}
/** One SSE frame from /jobs/{jobId}/events. */
export interface JobEvent {
type: "progress" | "complete" | "error";
stage?: string | null;
progress?: number | null;
error?: string | null;
}
/** Coords 0..1, top-left origin. */
export interface PdfRegion {
pageIndex: number;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
}
export interface PreviewTarget {
documentId: string;
filename: string;
mediaKind: "pdf" | "text";
targetPage?: number | null;
pdfRegions: PdfRegion[];
text?: string | null;
}
export const RAG_UPLOAD_ACCEPT = ".pdf,.txt,.md,.markdown,.docx,.html,.htm";

View file

@ -45,6 +45,7 @@ import {
useHfTokenValidation,
useInfiniteScroll,
} from "@/hooks";
import { extractParamLabel } from "@/lib/model-size";
import { formatCompact } from "@/lib/utils";
import {
type VramFitStatus,
@ -78,13 +79,6 @@ const DARK_CONTENT =
const DARK_COMBOBOX_CONTENT =
"bg-foreground text-background shadow-xl border-background/10 dark:[--accent:rgba(2,6,23,0.08)] dark:[--accent-foreground:rgb(2,6,23)] dark:[&_[data-slot=combobox-item]]:text-slate-900 dark:[&_.text-muted-foreground]:text-slate-500";
/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */
function extractParamLabel(id: string): string | null {
const name = id.split("/").pop() ?? id;
const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/);
return match ? `${match[1]}B` : null;
}
export function ModelSection() {
const t = useT();
const gpu = useGpuInfo();

View file

@ -45,6 +45,16 @@
height: 40px;
background: linear-gradient(to top, var(--sidebar), transparent);
}
/* RAG chip strip: sidebar-style bottom fade while more chips sit below
the scroll fold (JS-toggled). */
.rag-docs-bottom-fade {
-webkit-mask-image: linear-gradient(
to bottom,
#000 calc(100% - 16px),
transparent
);
mask-image: linear-gradient(to bottom, #000 calc(100% - 16px), transparent);
}
}
@layer base {

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// The leading boundary skips family-version digits ("Qwen3-") and MoE active-param
// notation ("A3B"), so "Qwen3-30B-A3B" reads as 30B total, not 3B active.
const PARAM_COUNT_RE = /(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/;
function matchParamCount(id: string): RegExpMatchArray | null {
const name = id.split("/").pop() ?? id;
return name.match(PARAM_COUNT_RE);
}
export function extractParamLabel(id: string): string | null {
const m = matchParamCount(id);
return m ? `${m[1]}B` : null;
}
export function parseParamCountB(id: string): number | null {
const m = matchParamCount(id);
if (!m) return null;
const v = Number.parseFloat(m[1]);
return Number.isFinite(v) ? v : null;
}

View file

@ -0,0 +1,433 @@
"""Text-only FastLanguageModel routing for vision-capable configs."""
import ast
import copy
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
LOADER_PATH = REPO_ROOT / "unsloth" / "models" / "loader.py"
VISION_PATH = REPO_ROOT / "unsloth" / "models" / "vision.py"
UTILS_PATH = REPO_ROOT / "unsloth" / "models" / "_utils.py"
def _source(path):
return path.read_text()
def _class_method(tree, class_name, method_name):
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == class_name:
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == method_name:
return item
raise AssertionError(f"{class_name}.{method_name} not found")
def _assigns_name(method, target_name, predicate):
"""True when the method contains `target_name = <value>` and predicate(value)."""
for node in ast.walk(method):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id == target_name:
if predicate(node.value):
return True
return False
def _calls_function(method, func_name):
"""True when the method calls `func_name(...)` (bare name, not attribute)."""
for node in ast.walk(method):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == func_name
):
return True
return False
def _names_in(node):
return {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}
def _param_default(method, name):
# Default-value AST node for a named parameter, or None.
args = method.args
params = list(args.args) + list(args.kwonlyargs)
defaults = list(args.defaults) + list(args.kw_defaults)
return dict(zip([p.arg for p in params][-len(defaults) :], defaults)).get(name)
def _load_text_only_namespace():
# Exec the text-only helpers from _utils into one namespace (no unsloth import),
# in dependency order so cross-references resolve.
source = _source(UTILS_PATH)
import transformers
from packaging.version import Version
ns = {
"copy": copy,
"Version": Version,
"transformers_version": transformers.__version__,
}
funcs = {
node.name: ast.get_source_segment(source, node)
for node in ast.parse(source).body
if isinstance(node, ast.FunctionDef)
}
for name in (
"resolve_model_class",
"_is_family_text_decoder",
"_remap_text_only_skip_modules",
"_get_text_only_config",
"_get_text_only_key_mapping",
"_apply_text_only_key_mapping",
):
if name in funcs:
exec(funcs[name], ns)
return ns
def _load_text_only_helper():
return _load_text_only_namespace()["_get_text_only_config"]
def test_gemma3_vision_config_resolves_to_text_config():
transformers = pytest.importorskip("transformers")
helper = _load_text_only_helper()
config = transformers.Gemma3Config()
text_config = helper(config, "google/gemma-3-27b-it")
assert isinstance(text_config, transformers.Gemma3TextConfig)
assert text_config.model_type == "gemma3_text"
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
assert model_class.__name__ == "Gemma3ForCausalLM"
def test_text_only_helper_rejects_configs_without_text_submodel():
helper = _load_text_only_helper()
class VisionOnlyConfig:
vision_config = object()
with pytest.raises(ValueError, match = "Cannot load vision-only as text-only"):
helper(VisionOnlyConfig(), "vision-only")
def test_fast_language_model_forwards_text_only_to_fast_model():
source = _source(LOADER_PATH)
method = _class_method(ast.parse(source), "FastLanguageModel", "from_pretrained")
# text_only defaults False (opt-in, not forced True), and both FastModel
# delegations forward it.
text_only_default = _param_default(method, "text_only")
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
fast_model_calls = [
node
for node in ast.walk(method)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "from_pretrained"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "FastModel"
]
assert len(fast_model_calls) == 2
for call in fast_model_calls:
kw = [k for k in call.keywords if k.arg == "text_only"]
assert len(kw) == 1
assert isinstance(kw[0].value, ast.Name) and kw[0].value.id == "text_only"
def test_fast_model_text_only_does_not_override_explicit_auto_model():
# AST-based so formatting/refactors that keep the structure do not break it.
source = _source(LOADER_PATH)
method = _class_method(ast.parse(source), "FastModel", "from_pretrained")
text_only_default = _param_default(method, "text_only")
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
# load_text_only is text_only AND a check that the caller did not pass auto_model.
def _is_guarded_bool(value):
names = _names_in(value)
has_none_check = any(
isinstance(n, ast.Compare) and any(isinstance(op, (ast.Is, ast.IsNot)) for op in n.ops)
for n in ast.walk(value)
)
return "text_only" in names and "auto_model" in names and has_none_check
assert _assigns_name(method, "load_text_only", _is_guarded_bool)
assert _calls_function(method, "_get_text_only_config")
def _forwards_kwarg(node):
return any(
isinstance(n, ast.Call)
and any(
kw.arg == "text_only"
and isinstance(kw.value, ast.Name)
and kw.value.id == "load_text_only"
for kw in n.keywords
)
for n in ast.walk(node)
)
assert _forwards_kwarg(method)
# Falls back to the full model unless the family has its own text decoder.
assert _calls_function(method, "_is_family_text_decoder")
assert _assigns_name(
method,
"load_text_only",
lambda v: isinstance(v, ast.Constant) and v.value is False,
)
def test_fast_base_model_text_only_bypasses_vision_auto_model():
source = _source(VISION_PATH)
method = _class_method(ast.parse(source), "FastBaseModel", "from_pretrained")
text_only_default = _param_default(method, "text_only")
assert isinstance(text_only_default, ast.Constant) and text_only_default.value is False
assert _assigns_name(
method,
"auto_model",
lambda v: isinstance(v, ast.Name) and v.id == "AutoModelForCausalLM",
)
# Text-only path: strip config, apply the family guard, inject the key remap.
assert _calls_function(method, "_get_text_only_config")
assert _calls_function(method, "_is_family_text_decoder")
assert _calls_function(method, "_apply_text_only_key_mapping")
def test_gemma3_text_only_model_class_resolves_and_has_no_vision_tower():
"""Tiny end-to-end: build a Gemma3 text-only config, instantiate the
matching model class with shrunken hidden sizes, assert it has the
text language model attributes and no vision tower attribute.
This is the integration check the AST-only tests were missing -- it
proves the text-only routing actually produces a model that can be
instantiated and that the resulting model is purely text. We use
shrunken hidden sizes so the test is fast and CPU-only.
"""
transformers = pytest.importorskip("transformers")
helper = _load_text_only_helper()
full_config = transformers.Gemma3Config()
text_config = helper(full_config, "google/gemma-3-27b-it")
# Shrink for a cheap CPU instantiation; keep the shape attrs read at construction.
text_config.num_hidden_layers = 1
text_config.hidden_size = 32
text_config.intermediate_size = 32
text_config.num_attention_heads = 2
text_config.num_key_value_heads = 1
text_config.head_dim = 16
text_config.vocab_size = 128
model_class = transformers.AutoModelForCausalLM._model_mapping[type(text_config)]
model = model_class(text_config)
# Positive checks: text language model surface is present.
assert hasattr(model, "lm_head"), "text-only Gemma3 model should expose lm_head"
# Negative checks: no vision tower / multimodal projector remains.
assert not hasattr(
model, "vision_tower"
), "text-only Gemma3 model should not have a vision_tower"
assert not hasattr(
model, "multi_modal_projector"
), "text-only Gemma3 model should not have a multi_modal_projector"
def test_helper_defined_once_in_utils_and_imported():
# _get_text_only_config is defined only in _utils and imported by loader + vision.
def _defines(path):
return any(
isinstance(n, ast.FunctionDef) and n.name == "_get_text_only_config"
for n in ast.parse(_source(path)).body
)
def _imports(path):
return any(
isinstance(n, ast.ImportFrom)
and n.module == "_utils"
and any(a.name == "_get_text_only_config" for a in n.names)
for n in ast.walk(ast.parse(_source(path)))
)
assert _defines(UTILS_PATH)
assert not _defines(LOADER_PATH) and _imports(LOADER_PATH)
assert not _defines(VISION_PATH) and _imports(VISION_PATH)
def _load_util_func(name):
ns = _load_text_only_namespace()
if name not in ns:
raise AssertionError(f"{name} not found")
return ns[name]
def test_text_only_guard_predicate_across_vlm_families():
# Text-only is taken only when the resolved class remaps VLM weights.
transformers = pytest.importorskip("transformers")
from transformers import AutoModelForCausalLM
resolve = _load_util_func("resolve_model_class")
is_family = _load_util_func("_is_family_text_decoder")
helper = _load_text_only_helper()
def takes_text_only(cfg):
text = helper(cfg, "x")
return resolve(AutoModelForCausalLM, text) is not None and is_family(
getattr(cfg, "model_type", ""), getattr(text, "model_type", "")
)
# Dedicated text decoder remaps language_model.* -> strip vision.
assert takes_text_only(transformers.Gemma3Config()) is True
# No text class (Qwen2-VL/Mllama) or a generic reused decoder that would
# load random weights (Llava/PaliGemma/Idefics3/InternVL) -> keep full model.
for name in [
"Qwen2VLConfig",
"Qwen2_5_VLConfig",
"MllamaConfig",
"LlavaConfig",
"PaliGemmaConfig",
"Idefics3Config",
"InternVLConfig",
]:
cfg_cls = getattr(transformers, name, None)
if cfg_cls is None:
continue
assert takes_text_only(cfg_cls()) is False, name
def test_text_only_helper_preserves_quantization_config():
# quantization_config must survive the strip so pre-quantized repos still load. A
# sentinel object avoids a bitsandbytes dependency on transformers 4.51.3.
transformers = pytest.importorskip("transformers")
helper = _load_text_only_helper()
config = transformers.Gemma3Config()
sentinel = object()
config.quantization_config = sentinel
text_config = helper(config, "google/gemma-3-27b-it")
assert getattr(text_config, "quantization_config", None) is sentinel
# The parent's shared text sub-config must not be mutated by the carry-over.
assert getattr(config.get_text_config(), "quantization_config", None) is None
def test_text_only_key_mapping_targets_published_prefixes():
# The mapping must remap the published VLM decoder prefixes and only apply on
# transformers >=5 (on 4.x base_model_prefix handles it and a mapping hurts).
transformers = pytest.importorskip("transformers")
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
mapping = get_key_mapping(transformers.Gemma3Config(), transformers.Gemma3TextConfig())
if int(transformers.__version__.split(".")[0]) < 5:
assert mapping is None
else:
assert isinstance(mapping, dict)
assert mapping.get(r"^language_model\.model\.") == "model." # gemma3
assert mapping.get(r"^model\.language_model\.") == "model." # gemma3n
assert mapping.get(r"^language_model\.lm_head\.") == "lm_head."
def test_gemma3_text_only_loads_real_language_weights_from_vlm_checkpoint(tmp_path):
# Regression for PR #5816: text-only loading of a Gemma 3 VLM checkpoint must load the
# real language weights, not random ones. Fails on tf >=5 without the key_mapping fix.
transformers = pytest.importorskip("transformers")
torch = pytest.importorskip("torch")
import shutil
from safetensors.torch import load_file, save_file
get_text_config = _load_text_only_helper()
get_key_mapping = _load_util_func("_get_text_only_key_mapping")
sentinel = 0.1234
text_cfg = transformers.Gemma3TextConfig(
hidden_size = 32,
intermediate_size = 64,
num_hidden_layers = 1,
num_attention_heads = 2,
num_key_value_heads = 1,
head_dim = 16,
vocab_size = 128,
max_position_embeddings = 128,
sliding_window = 64,
)
vision_cfg = transformers.SiglipVisionConfig(
hidden_size = 32,
intermediate_size = 64,
num_hidden_layers = 1,
num_attention_heads = 2,
image_size = 16,
patch_size = 8,
num_channels = 3,
)
full_config = transformers.Gemma3Config(
text_config = text_cfg.to_dict(),
vision_config = vision_cfg.to_dict(),
)
full_model = transformers.Gemma3ForConditionalGeneration(full_config)
state = full_model.state_dict()
text_q = [
k
for k in state
if "language_model" in k
and "vision" not in k
and k.endswith("layers.0.self_attn.q_proj.weight")
]
assert text_q, [k for k in state if "q_proj" in k][:5]
with torch.no_grad():
for k in text_q:
state[k].fill_(sentinel)
save_dir = tmp_path / "vlm"
full_model.save_pretrained(save_dir, safe_serialization = True)
# tf >=5 saves under an outer "model." prefix; strip it to reproduce the real
# language_model.model.* layout the published Gemma 3 checkpoints use.
real_dir = tmp_path / "real"
real_dir.mkdir()
weights = {}
for f in save_dir.glob("*.safetensors"):
weights.update(load_file(str(f)))
for f in save_dir.glob("*.bin"):
weights.update(torch.load(f, map_location = "cpu", weights_only = True))
weights = {
(k[len("model.") :] if k.startswith("model.") else k): v.contiguous()
for k, v in weights.items()
}
for p in save_dir.iterdir():
if not p.name.endswith((".safetensors", ".bin", ".index.json")):
shutil.copy(p, real_dir / p.name)
save_file(weights, str(real_dir / "model.safetensors"))
text_config = get_text_config(full_config, "google/gemma-3-27b-it")
load_kwargs = {}
key_mapping = get_key_mapping(full_config, text_config)
if key_mapping is not None:
load_kwargs["key_mapping"] = key_mapping
model = transformers.AutoModelForCausalLM.from_pretrained(
real_dir,
config = text_config,
dtype = torch.float32,
local_files_only = True,
**load_kwargs,
)
loaded = model.state_dict()
q_key = [k for k in loaded if k.endswith("model.layers.0.self_attn.q_proj.weight")]
assert q_key, "text decoder q_proj weight missing from the loaded model"
assert float(loaded[q_key[0]].flatten()[0]) == pytest.approx(
sentinel
), "text weights were randomly initialized instead of loaded from the checkpoint"
assert not any(
"vision_tower" in n for n, _ in model.named_modules()
), "vision tower should be skipped on the text-only path"

View file

@ -92,6 +92,7 @@ from platform import system as platform_system
platform_system = platform_system()
import numpy as np
import contextlib
import copy
import re
from dataclasses import dataclass, field
import functools
@ -472,6 +473,98 @@ def resolve_model_class(auto_model, config):
return result[0] if isinstance(result, (list, tuple)) else result
def _is_family_text_decoder(parent_model_type, text_model_type):
# True only for the family's own text variant (gemma3 -> gemma3_text); a generic
# reused decoder (llava -> llama) would load random weights, so keep the full model.
return bool(parent_model_type) and str(text_model_type).startswith(parent_model_type)
def _get_text_only_config(model_config, model_name):
# Text sub-config of a vision-language config so FastLanguageModel skips the vision tower (PR #5816).
text_config = None
if hasattr(model_config, "get_text_config"):
text_config = model_config.get_text_config()
if text_config is None:
text_config = getattr(model_config, "text_config", None)
if text_config is None:
raise ValueError(f"Cannot load {model_name} as text-only; use FastVisionModel")
# Carry over quantization_config; copy first since get_text_config() shares the parent's object.
qc = getattr(model_config, "quantization_config", None)
if qc is not None and getattr(text_config, "quantization_config", None) is None:
text_config = copy.copy(text_config)
text_config.quantization_config = _remap_text_only_skip_modules(qc)
return text_config
def _remap_text_only_skip_modules(qc):
# Remap llm_int8_skip_modules off the VLM wrapper prefix (language_model.model.* ->
# model.*) after text-only stripping, and drop vision/audio entries. See PR #5816.
is_dict = isinstance(qc, dict)
skip = (
qc.get("llm_int8_skip_modules") if is_dict else getattr(qc, "llm_int8_skip_modules", None)
)
if not skip:
return qc
remapped = []
for name in skip:
for pref in (
"language_model.model.",
"model.language_model.",
"language_model.",
):
if name.startswith(pref):
name = (
("model." + name[len(pref) :])
if pref != "language_model."
else name[len(pref) :]
)
break
if name.startswith(
(
"vision_tower",
"multi_modal_projector",
"audio_tower",
"modality_projection",
)
):
continue
remapped.append(name)
remapped = list(dict.fromkeys(remapped))
qc = dict(qc) if is_dict else copy.copy(qc)
if is_dict:
qc["llm_int8_skip_modules"] = remapped
else:
qc.llm_int8_skip_modules = remapped
return qc
def _get_text_only_key_mapping(parent_config, text_config):
# transformers >=5 stopped auto-stripping the VLM wrapper prefix (base_model_prefix
# changed language_model -> model), so remap the text weights onto the decoder keys.
# None on tf <5 (still strips; a mapping would break the load) or non-family. See PR #5816.
if Version(transformers_version) < Version("5.0.0"):
return None
if not _is_family_text_decoder(
getattr(parent_config, "model_type", ""),
getattr(text_config, "model_type", ""),
):
return None
return {
r"^language_model\.model\.": "model.",
r"^model\.language_model\.": "model.",
r"^language_model\.lm_head\.": "lm_head.",
}
def _apply_text_only_key_mapping(kwargs, parent_config, text_config):
# Add the text-only key_mapping to from_pretrained kwargs, under any user mapping.
mapping = _get_text_only_key_mapping(parent_config, text_config)
if not mapping:
return
user_mapping = kwargs.get("key_mapping", None)
kwargs["key_mapping"] = {**mapping, **user_mapping} if user_mapping else mapping
def resolve_attention_implementation(
model_class,
config,

View file

@ -98,6 +98,10 @@ from ._utils import (
process_vision_info,
unsloth_compile_transformers,
fast_inference_setup,
_get_text_only_config,
resolve_model_class,
_is_family_text_decoder,
_apply_text_only_key_mapping,
)
# Single source of truth is unsloth_zoo.model_lists. Re-exported so callers
@ -257,6 +261,7 @@ class FastLanguageModel(FastLlamaModel):
qat_scheme = None,
load_in_fp8 = False, # fp8 LoRA (True, False, 'block')
unsloth_tiled_mlp = False,
text_only = False, # Skip vision/audio towers and load only the text decoder
*args,
**kwargs,
):
@ -343,6 +348,7 @@ class FastLanguageModel(FastLlamaModel):
qat_scheme = qat_scheme,
load_in_fp8 = load_in_fp8,
unsloth_tiled_mlp = unsloth_tiled_mlp,
text_only = text_only,
*args,
**kwargs,
)
@ -401,7 +407,7 @@ class FastLanguageModel(FastLlamaModel):
load_in_8bit,
load_in_16bit,
)
model_name = _offline_quantize_to_fp8(model_name, fp8_mode)
model_name = _offline_quantize_to_fp8(model_name, fp8_mode, text_only = text_only)
else:
assert new_model_name is not None
model_name = new_model_name
@ -688,6 +694,7 @@ class FastLanguageModel(FastLlamaModel):
qat_scheme = qat_scheme,
load_in_fp8 = load_in_fp8,
unsloth_tiled_mlp = unsloth_tiled_mlp,
text_only = text_only,
*args,
**kwargs,
)
@ -869,6 +876,7 @@ class FastModel(FastBaseModel):
load_in_fp8 = False, # fp8 LoRA (True, False, 'block')
unsloth_tiled_mlp = False,
target_parameters = None, # For MoE expert parameters
text_only = False, # Skip vision/audio towers and load only the text decoder
*args,
**kwargs,
):
@ -998,7 +1006,7 @@ class FastModel(FastBaseModel):
load_in_8bit,
load_in_16bit,
)
model_name = _offline_quantize_to_fp8(model_name, fp8_mode)
model_name = _offline_quantize_to_fp8(model_name, fp8_mode, text_only = text_only)
else:
assert new_model_name is not None
model_name = new_model_name
@ -1409,6 +1417,29 @@ class FastModel(FastBaseModel):
architectures = []
is_vlm = any(x.endswith("ForConditionalGeneration") for x in architectures)
is_vlm = is_vlm or hasattr(model_config, "vision_config")
load_text_only = text_only and auto_model is None
if load_text_only:
if hasattr(model_config, "vision_config"):
text_config = _get_text_only_config(model_config, old_model_name)
# Skip the vision tower only for families with their own text decoder (Gemma 3);
# others would load random weights, so keep the full model (use FastVisionModel).
text_class = resolve_model_class(AutoModelForCausalLM, text_config)
if text_class is None or not _is_family_text_decoder(
getattr(model_config, "model_type", ""),
getattr(text_config, "model_type", ""),
):
load_text_only = False
else:
logger.warning_once(
f"Loading {old_model_name} as text-only; vision/audio towers skipped. "
"Use FastVisionModel for multimodal inputs."
)
# Remap VLM text weights (tf >=5) while model_config is still the parent. #5816
_apply_text_only_key_mapping(kwargs, model_config, text_config)
model_config = text_config
is_vlm = False
else:
is_vlm = False
# If num_labels is set, use AutoModelForSequenceClassification
_num_labels = kwargs.get("num_labels", None)
if auto_model is None:
@ -1464,6 +1495,7 @@ class FastModel(FastBaseModel):
max_lora_rank = max_lora_rank,
disable_log_stats = disable_log_stats,
load_in_fp8 = load_in_fp8,
text_only = load_text_only,
*args,
**kwargs,
)

View file

@ -264,42 +264,73 @@ def get_model_name(
return new_model_name
def _offline_quantize_to_fp8(model_name: str, fp8_mode: str) -> str:
def _offline_quantize_to_fp8(
model_name: str,
fp8_mode: str,
*,
text_only: bool = False,
) -> str:
"""Quantize the model to fp8 via torchao, save to a temp dir, return its path.
For vllm >= 0.12.0, prefer dynamic quantization in vllm instead (via
hf_overrides={"quantization_config_file": "torchao_config.json"}).
"""
from transformers import (
AutoModelForCausalLM,
AutoModelForImageTextToText,
AutoTokenizer,
AutoProcessor,
TorchAoConfig,
AutoConfig,
)
config = AutoConfig.from_pretrained(model_name)
is_vlm = any(
x.endswith(("ForConditionalGeneration", "ForVisionText2Text"))
for x in (getattr(config, "architectures", None) or [])
)
is_vlm = is_vlm or hasattr(config, "vision_config")
# Decide text-only before the cache name so the fp8 artifact and its path stay in sync. #5816
text_config = None
if text_only and hasattr(config, "vision_config"):
from ._utils import (
_get_text_only_config,
resolve_model_class,
_is_family_text_decoder,
)
candidate = _get_text_only_config(config, model_name)
text_class = resolve_model_class(AutoModelForCausalLM, candidate)
if text_class is not None and _is_family_text_decoder(
getattr(config, "model_type", ""),
getattr(candidate, "model_type", ""),
):
text_config = candidate
is_vlm = False
temp_dir = tempfile.gettempdir()
new_model_name = model_name.split("/")[-1] + "-fp8-" + fp8_mode
new_model_name = os.path.join(temp_dir, new_model_name)
# Cache text-only and full-VLM artifacts separately so neither reuses the other. #5816
cache_name = model_name.split("/")[-1] + "-fp8-" + fp8_mode
if text_config is not None:
cache_name += "-text-only"
new_model_name = os.path.join(temp_dir, cache_name)
print(f"Unsloth: Quantizing '{model_name}' to fp8, using model_name='{new_model_name}' instead")
if not os.path.isdir(new_model_name):
from transformers import (
AutoModelForCausalLM,
AutoModelForImageTextToText,
AutoTokenizer,
AutoProcessor,
TorchAoConfig,
AutoConfig,
)
from ._utils import _apply_text_only_key_mapping
qconfig = _get_torchao_fp8_config(fp8_mode)
qconfig = TorchAoConfig(qconfig)
config = AutoConfig.from_pretrained(model_name)
is_vlm = any(
x.endswith(("ForConditionalGeneration", "ForVisionText2Text"))
for x in config.architectures
)
is_vlm = is_vlm or hasattr(config, "vision_config")
load_kwargs = dict(torch_dtype = "auto", device_map = "auto", quantization_config = qconfig)
if text_config is not None:
_apply_text_only_key_mapping(load_kwargs, config, text_config)
config = text_config
auto_model = AutoModelForImageTextToText if is_vlm else AutoModelForCausalLM
auto_processor = AutoProcessor if is_vlm else AutoTokenizer
model = auto_model.from_pretrained(
model_name,
torch_dtype = "auto",
device_map = "auto",
quantization_config = qconfig,
config = config,
**load_kwargs,
)
tokenizer = auto_processor.from_pretrained(model_name)
model.save_pretrained(new_model_name, safe_serialization = False)

View file

@ -34,6 +34,9 @@ from ._utils import (
_prepare_model_for_qat,
resolve_model_class,
resolve_attention_implementation,
_get_text_only_config,
_is_family_text_decoder,
_apply_text_only_key_mapping,
)
from ._utils import *
from .loader_utils import _get_fp8_mode_and_check_settings
@ -583,6 +586,7 @@ class FastBaseModel:
disable_log_stats = False,
unsloth_vllm_standby = False,
load_in_fp8 = False, # fp8 LoRA (True, False, 'block')
text_only = False,
**kwargs,
):
if unsloth_vllm_standby and os.environ.get("UNSLOTH_VLLM_STANDBY", "0") != "1":
@ -597,6 +601,31 @@ class FastBaseModel:
if os.environ.get("UNSLOTH_MODEL_NAME", "") == "":
os.environ["UNSLOTH_MODEL_NAME"] = model_name.lower()
# Resolve text-only before the is_vlm / vLLM checks so is_vlm stays consistent;
# skip the vision tower only for families with their own text decoder (Gemma 3). #5816
if text_only and auto_config is None:
auto_config = AutoConfig.from_pretrained(
model_name,
token = token,
trust_remote_code = trust_remote_code,
)
if text_only and hasattr(auto_config, "vision_config"):
parent_config = auto_config
text_config = _get_text_only_config(parent_config, model_name)
text_class = resolve_model_class(AutoModelForCausalLM, text_config)
if text_class is not None and _is_family_text_decoder(
getattr(parent_config, "model_type", ""),
getattr(text_config, "model_type", ""),
):
auto_config = text_config
auto_model = AutoModelForCausalLM
_apply_text_only_key_mapping(kwargs, parent_config, text_config)
elif text_only and auto_model in [
AutoModelForVision2Seq,
AutoModelForImageTextToText,
]:
# Pure text model requested text-only with a VLM auto class.
auto_model = AutoModelForCausalLM
is_vlm = auto_model in [AutoModelForVision2Seq, AutoModelForImageTextToText]
is_whisper = whisper_language is not None and whisper_task is not None
auto_processor = AutoProcessor if (is_vlm or is_whisper) else AutoTokenizer