From ca83bea538b53e069454644fcc984969d71e71b0 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sun, 24 May 2026 12:47:16 +0400 Subject: [PATCH] Studio: app-level RAG defaults for new KBs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Power users can now set their preferred chunking strategy / mode / embedder once in Settings → Knowledge Bases and have new KBs use those values by default, instead of toggling on every create. Backend - routes/rag.py: - GET /api/rag/defaults returns the stored RagDefaults (or sensible fallbacks when nothing is set: standard / text / null embedder). - PUT /api/rag/defaults is PATCH-style — only fields present in the body overwrite. The (multimodal, late) constraint is enforced here too, so users can't poison the defaults with a combination the create path would reject. - Persistence reuses the existing chat_settings store via upsert_chat_settings_merge; the values live under a single rag.defaults key as a nested JSON dict. Frontend - rag-api.ts: getRagDefaults / setRagDefaults wrappers + RagDefaults + UpdateRagDefaultsRequest types. - rag-store.ts: defaults state, loadDefaults / updateDefaults actions. loadDefaults swallows errors so a missing endpoint just leaves defaults null. - rag-defaults-section.tsx (new): self-contained mode + strategy + embedding-model controls, persists on change. Used in the Settings KB tab below the ThreadIndexList section. - knowledge-bases-tab.tsx: mounts RagDefaultsSection below thread indexes with a separator. - kb-create-dialog.tsx: loads defaults on open and prefills the form with them (falls back to hard-coded standard / text when defaults haven't loaded yet). reset() returns to the latest defaults rather than the hard-coded ones. --- studio/backend/routes/rag.py | 75 ++++++++- .../frontend/src/features/rag/api/rag-api.ts | 28 ++++ .../rag/components/kb-create-dialog.tsx | 42 ++++- .../rag/components/rag-defaults-section.tsx | 148 ++++++++++++++++++ .../src/features/rag/stores/rag-store.ts | 26 +++ .../settings/tabs/knowledge-bases-tab.tsx | 3 + 6 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 studio/frontend/src/features/rag/components/rag-defaults-section.tsx diff --git a/studio/backend/routes/rag.py b/studio/backend/routes/rag.py index dddb6c66b5..dc0122f44a 100644 --- a/studio/backend/routes/rag.py +++ b/studio/backend/routes/rag.py @@ -43,7 +43,11 @@ async def _sse_auth( from core.rag import embeddings, ingestion, reranker, retrieval, vector_store from core.rag.vector_store import kb_scope, thread_scope from loggers import get_logger -from storage.studio_db import get_connection +from storage.studio_db import ( + get_connection, + list_chat_settings, + upsert_chat_settings_merge, +) from utils.paths.storage_roots import ensure_dir, rag_uploads_root from utils.rag.config import ( RAG_MAX_UPLOAD_MB, @@ -407,6 +411,75 @@ def list_knowledge_bases( return KBListResponse(knowledge_bases = [_row_to_kb(r) for r in rows]) +class RagDefaults(BaseModel): + chunking_strategy: ChunkingStrategy = "standard" + mode: KBMode = "text" + embedding_model: str | None = None + + +class UpdateRagDefaultsRequest(BaseModel): + """Patch shape — only fields present overwrite stored values.""" + chunking_strategy: ChunkingStrategy | None = None + mode: KBMode | None = None + embedding_model: str | None = None + + +_DEFAULTS_KEY = "rag.defaults" + + +def _load_rag_defaults() -> RagDefaults: + settings = list_chat_settings() + raw = settings.get(_DEFAULTS_KEY) or {} + if not isinstance(raw, dict): + raw = {} + return RagDefaults( + chunking_strategy = raw.get("chunking_strategy") or "standard", + mode = raw.get("mode") or "text", + embedding_model = raw.get("embedding_model"), + ) + + +@router.get("/defaults", response_model = RagDefaults) +def get_rag_defaults( + current_subject: str = Depends(get_current_subject), +) -> RagDefaults: + return _load_rag_defaults() + + +@router.put("/defaults", response_model = RagDefaults) +def set_rag_defaults( + payload: UpdateRagDefaultsRequest, + current_subject: str = Depends(get_current_subject), +) -> RagDefaults: + current = _load_rag_defaults() + new_strategy = payload.chunking_strategy or current.chunking_strategy + new_mode = payload.mode or current.mode + # PATCH-style — passing an empty string clears the override; a + # null/missing field keeps the current value. + if payload.embedding_model is None: + new_embedder = current.embedding_model + elif payload.embedding_model.strip() == "": + new_embedder = None + else: + new_embedder = payload.embedding_model.strip() + _validate_mode_combo(new_mode, new_strategy) + + upsert_chat_settings_merge( + { + _DEFAULTS_KEY: { + "chunking_strategy": new_strategy, + "mode": new_mode, + "embedding_model": new_embedder, + } + } + ) + return RagDefaults( + chunking_strategy = new_strategy, + mode = new_mode, + embedding_model = new_embedder, + ) + + class ReingestKBRequest(BaseModel): """All fields optional — omitting one keeps the KB's current value.""" chunking_strategy: ChunkingStrategy | None = None diff --git a/studio/frontend/src/features/rag/api/rag-api.ts b/studio/frontend/src/features/rag/api/rag-api.ts index d6c18faf3b..121d7494bb 100644 --- a/studio/frontend/src/features/rag/api/rag-api.ts +++ b/studio/frontend/src/features/rag/api/rag-api.ts @@ -246,6 +246,34 @@ export async function reingestThreadDocuments( return parseJsonOrThrow(response); } +export interface RagDefaults { + chunking_strategy: ChunkingStrategy; + mode: KBMode; + embedding_model: string | null; +} + +export async function getRagDefaults(): Promise { + const response = await authFetch("/api/rag/defaults"); + return parseJsonOrThrow(response); +} + +export interface UpdateRagDefaultsRequest { + chunking_strategy?: ChunkingStrategy; + mode?: KBMode; + embedding_model?: string | null; +} + +export async function setRagDefaults( + payload: UpdateRagDefaultsRequest, +): Promise { + const response = await authFetch("/api/rag/defaults", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return parseJsonOrThrow(response); +} + // ------------------------------------------------------------------ // Search // ------------------------------------------------------------------ diff --git a/studio/frontend/src/features/rag/components/kb-create-dialog.tsx b/studio/frontend/src/features/rag/components/kb-create-dialog.tsx index c56b8b38f3..22e73afc77 100644 --- a/studio/frontend/src/features/rag/components/kb-create-dialog.tsx +++ b/studio/frontend/src/features/rag/components/kb-create-dialog.tsx @@ -19,13 +19,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ChunkingStrategy, KBMode, KnowledgeBase, } from "../api/rag-api"; import { useKnowledgeBases } from "../hooks/use-knowledge-bases"; +import { useRagStore } from "../stores/rag-store"; export function KBCreateDialog({ open, @@ -37,21 +38,48 @@ export function KBCreateDialog({ onCreated?: (kb: KnowledgeBase) => void; }) { const { createKB } = useKnowledgeBases(); + const defaults = useRagStore((s) => s.defaults); + const loadDefaults = useRagStore((s) => s.loadDefaults); + + // Cache the initial values so reset() puts us back to the latest + // saved defaults rather than the hard-coded ones. + const initialStrategy: ChunkingStrategy = + defaults?.chunking_strategy ?? "standard"; + const initialMode: KBMode = defaults?.mode ?? "text"; + const initialEmbedder = defaults?.embedding_model ?? ""; + const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const [embeddingModel, setEmbeddingModel] = useState(""); + const [embeddingModel, setEmbeddingModel] = useState(initialEmbedder); const [chunkingStrategy, setChunkingStrategy] = - useState("standard"); - const [mode, setMode] = useState("text"); + useState(initialStrategy); + const [mode, setMode] = useState(initialMode); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // Fetch defaults on first open and re-sync the form when they arrive. + useEffect(() => { + if (open && !defaults) { + void loadDefaults(); + } + }, [open, defaults, loadDefaults]); + + useEffect(() => { + if (open && defaults) { + setChunkingStrategy(defaults.chunking_strategy); + setMode(defaults.mode); + setEmbeddingModel(defaults.embedding_model ?? ""); + } + // Intentional: only when `open` flips, not on every defaults change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + const reset = () => { setName(""); setDescription(""); - setEmbeddingModel(""); - setChunkingStrategy("standard"); - setMode("text"); + setEmbeddingModel(defaults?.embedding_model ?? ""); + setChunkingStrategy(defaults?.chunking_strategy ?? "standard"); + setMode(defaults?.mode ?? "text"); setError(null); setSubmitting(false); }; diff --git a/studio/frontend/src/features/rag/components/rag-defaults-section.tsx b/studio/frontend/src/features/rag/components/rag-defaults-section.tsx new file mode 100644 index 0000000000..fd3be615e9 --- /dev/null +++ b/studio/frontend/src/features/rag/components/rag-defaults-section.tsx @@ -0,0 +1,148 @@ +// 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 { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useEffect, useState } from "react"; +import type { ChunkingStrategy, KBMode } from "../api/rag-api"; +import { useRagStore } from "../stores/rag-store"; + +/** + * Defaults for newly-created KBs, surfaced in the Settings → Knowledge + * Bases tab. Values pre-fill the KB create dialog so power users can + * pick their preferred strategy/mode once instead of toggling per KB. + * + * Backed by chat_settings via PUT /api/rag/defaults. The selects + * enforce the same (multimodal, late) constraint as the create + * dialog. + */ +export function RagDefaultsSection() { + const defaults = useRagStore((s) => s.defaults); + const loadDefaults = useRagStore((s) => s.loadDefaults); + const updateDefaults = useRagStore((s) => s.updateDefaults); + + const [chunkingStrategy, setChunkingStrategy] = + useState("standard"); + const [mode, setMode] = useState("text"); + const [embeddingModel, setEmbeddingModel] = useState(""); + const [error, setError] = useState(null); + + // Load defaults on mount; reflect them in local state. + useEffect(() => { + void loadDefaults(); + }, [loadDefaults]); + + useEffect(() => { + if (defaults) { + setChunkingStrategy(defaults.chunking_strategy); + setMode(defaults.mode); + setEmbeddingModel(defaults.embedding_model ?? ""); + } + }, [defaults]); + + const lateDisabled = mode === "multimodal"; + const multimodalDisabled = chunkingStrategy === "late"; + + const persist = (patch: { + chunking_strategy?: ChunkingStrategy; + mode?: KBMode; + embedding_model?: string | null; + }) => { + setError(null); + void updateDefaults(patch).catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }); + }; + + return ( +
+
+

Defaults for new knowledge bases

+

+ Pre-fills the KB create dialog. Existing KBs keep their own + settings — use the Reconfigure button to change those. +

+
+
+
+ + +
+
+ + +
+
+
+ + setEmbeddingModel(e.target.value)} + onBlur={() => persist({ embedding_model: embeddingModel })} + placeholder="Leave blank to use the matrix default" + /> +
+ {error ?
{error}
: null} +
+ ); +} diff --git a/studio/frontend/src/features/rag/stores/rag-store.ts b/studio/frontend/src/features/rag/stores/rag-store.ts index 3cf9d98d66..09a740fbe7 100644 --- a/studio/frontend/src/features/rag/stores/rag-store.ts +++ b/studio/frontend/src/features/rag/stores/rag-store.ts @@ -8,18 +8,22 @@ import { type CreateKnowledgeBaseRequest, deleteDocument as apiDeleteDocument, deleteKnowledgeBase as apiDeleteKB, + getRagDefaults as apiGetRagDefaults, type JobEvent, type KnowledgeBase, listKBDocuments, listKnowledgeBases, listThreadDocuments, listThreadIndexes, + type RagDefaults, type RagDocument, type ReingestKBOptions, reingestKnowledgeBase as apiReingestKB, reingestThreadDocuments as apiReingestThread, + setRagDefaults as apiSetRagDefaults, subscribeToJobEvents, type ThreadIndexSummary, + type UpdateRagDefaultsRequest, uploadKBDocument, uploadThreadDocument, } from "../api/rag-api"; @@ -57,6 +61,10 @@ interface RagStoreState { reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise; reingestThread: (threadId: string) => Promise; + defaults: RagDefaults | null; + loadDefaults: () => Promise; + updateDefaults: (patch: UpdateRagDefaultsRequest) => Promise; + subscribeJob: (jobId: string, onComplete?: () => void) => void; } @@ -82,6 +90,8 @@ export const useRagStore = create((set, get) => ({ threadIndexes: [], threadIndexesLoading: false, + defaults: null, + async loadKnowledgeBases() { set({ kbsLoading: true, kbsError: null }); try { @@ -243,6 +253,22 @@ export const useRagStore = create((set, get) => ({ return response.job_ids; }, + async loadDefaults() { + try { + const defaults = await apiGetRagDefaults(); + set({ defaults }); + } catch { + // Defaults endpoint is best-effort; a 401 / network blip just + // leaves defaults null and dialogs fall back to hard-coded + // ('standard', 'text', no embedder override). + } + }, + + async updateDefaults(patch) { + const defaults = await apiSetRagDefaults(patch); + set({ defaults }); + }, + async reingestThread(threadId) { const response = await apiReingestThread(threadId); void get().loadThreadDocuments(threadId); diff --git a/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx b/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx index 94cb90959e..0f5a3c290c 100644 --- a/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx @@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator"; import type { KnowledgeBase } from "@/features/rag/api/rag-api"; import { KBDetailPanel } from "@/features/rag/components/kb-detail-panel"; import { KBList } from "@/features/rag/components/kb-list"; +import { RagDefaultsSection } from "@/features/rag/components/rag-defaults-section"; import { ThreadIndexList } from "@/features/rag/components/thread-index-list"; import { useState } from "react"; @@ -38,6 +39,8 @@ export function KnowledgeBasesTab() { + + ); }