Merge remote-tracking branch 'origin/main' into woa-nvidia-wsl-fallback
This commit is contained in:
commit
ccd5b30b7e
85 changed files with 12669 additions and 176 deletions
14
.github/workflows/consolidated-tests-ci.yml
vendored
14
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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*\{")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
studio/backend/core/rag/__init__.py
Normal file
16
studio/backend/core/rag/__init__.py
Normal 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",
|
||||
]
|
||||
107
studio/backend/core/rag/captioner.py
Normal file
107
studio/backend/core/rag/captioner.py
Normal 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
|
||||
121
studio/backend/core/rag/chunking.py
Normal file
121
studio/backend/core/rag/chunking.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""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
|
||||
41
studio/backend/core/rag/config.py
Normal file
41
studio/backend/core/rag/config.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""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"))
|
||||
448
studio/backend/core/rag/embed_llama_server.py
Normal file
448
studio/backend/core/rag/embed_llama_server.py
Normal 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
|
||||
297
studio/backend/core/rag/embeddings.py
Normal file
297
studio/backend/core/rag/embeddings.py
Normal 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)
|
||||
251
studio/backend/core/rag/ingestion.py
Normal file
251
studio/backend/core/rag/ingestion.py
Normal 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()
|
||||
182
studio/backend/core/rag/locators.py
Normal file
182
studio/backend/core/rag/locators.py
Normal 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()
|
||||
216
studio/backend/core/rag/parsers.py
Normal file
216
studio/backend/core/rag/parsers.py
Normal 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)]
|
||||
96
studio/backend/core/rag/retrieval.py
Normal file
96
studio/backend/core/rag/retrieval.py
Normal 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]
|
||||
261
studio/backend/core/rag/store.py
Normal file
261
studio/backend/core/rag/store.py
Normal 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}
|
||||
193
studio/backend/core/rag/tool.py
Normal file
193
studio/backend/core/rag/tool.py
Normal 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
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
101
studio/backend/routes/prompts.py
Normal file
101
studio/backend/routes/prompts.py
Normal 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}
|
||||
456
studio/backend/routes/rag.py
Normal file
456
studio/backend/routes/rag.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""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"],
|
||||
)
|
||||
153
studio/backend/storage/rag_db.py
Normal file
153
studio/backend/storage/rag_db.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
105
studio/backend/tests/test_rag_captioning.py
Normal file
105
studio/backend/tests/test_rag_captioning.py
Normal 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"
|
||||
69
studio/backend/tests/test_rag_chunking.py
Normal file
69
studio/backend/tests/test_rag_chunking.py
Normal 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")
|
||||
448
studio/backend/tests/test_rag_embed_llama_server.py
Normal file
448
studio/backend/tests/test_rag_embed_llama_server.py
Normal 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
|
||||
222
studio/backend/tests/test_rag_embeddings.py
Normal file
222
studio/backend/tests/test_rag_embeddings.py
Normal 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"])
|
||||
139
studio/backend/tests/test_rag_ingestion.py
Normal file
139
studio/backend/tests/test_rag_ingestion.py
Normal 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()
|
||||
174
studio/backend/tests/test_rag_preview.py
Normal file
174
studio/backend/tests/test_rag_preview.py
Normal 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
|
||||
432
studio/backend/tests/test_rag_retrieval.py
Normal file
432
studio/backend/tests/test_rag_retrieval.py
Normal 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
|
||||
125
studio/backend/tests/test_rag_store.py
Normal file
125
studio/backend/tests/test_rag_store.py
Normal 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) == []
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
348
studio/frontend/package-lock.json
generated
348
studio/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
105
studio/frontend/src/components/assistant-ui/citation-utils.ts
Normal file
105
studio/frontend/src/components/assistant-ui/citation-utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
46
studio/frontend/src/components/assistant-ui/rag-sources.tsx
Normal file
46
studio/frontend/src/components/assistant-ui/rag-sources.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 “{query}”…</>
|
||||
) : (
|
||||
<>Searching documents…</>
|
||||
)}
|
||||
</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";
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
91
studio/frontend/src/features/chat/api/prompts-api.ts
Normal file
91
studio/frontend/src/features/chat/api/prompts-api.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
203
studio/frontend/src/features/rag/api/rag-api.ts
Normal file
203
studio/frontend/src/features/rag/api/rag-api.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
39
studio/frontend/src/features/rag/components/preview-store.ts
Normal file
39
studio/frontend/src/features/rag/components/preview-store.ts
Normal 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 }),
|
||||
}));
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
352
studio/frontend/src/features/rag/components/use-rag-documents.ts
Normal file
352
studio/frontend/src/features/rag/components/use-rag-documents.ts
Normal 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 };
|
||||
}
|
||||
8
studio/frontend/src/features/rag/index.ts
Normal file
8
studio/frontend/src/features/rag/index.ts
Normal 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";
|
||||
70
studio/frontend/src/features/rag/types/rag.ts
Normal file
70
studio/frontend/src/features/rag/types/rag.ts
Normal 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";
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
23
studio/frontend/src/lib/model-size.ts
Normal file
23
studio/frontend/src/lib/model-size.ts
Normal 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;
|
||||
}
|
||||
433
tests/python/test_fast_language_model_text_only.py
Normal file
433
tests/python/test_fast_language_model_text_only.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue