diff --git a/studio/backend/routes/rag.py b/studio/backend/routes/rag.py index dc0122f44a..88ce2d84b2 100644 --- a/studio/backend/routes/rag.py +++ b/studio/backend/routes/rag.py @@ -480,6 +480,89 @@ def set_rag_defaults( ) +class ThreadRagSettings(BaseModel): + chunking_strategy: ChunkingStrategy = "standard" + mode: KBMode = "text" + embedding_model: str | None = None + + +class UpdateThreadRagSettingsRequest(BaseModel): + chunking_strategy: ChunkingStrategy | None = None + mode: KBMode | None = None + embedding_model: str | None = None + + +def _thread_settings_key(thread_id: str) -> str: + return f"thread:{thread_id}:rag" + + +def _load_thread_settings(thread_id: str) -> ThreadRagSettings: + """Per-thread RAG settings, falling back to app-level defaults. + + Stored in chat_settings under "thread::rag" as a nested JSON + dict — same shape as RagDefaults. + """ + settings = list_chat_settings() + raw = settings.get(_thread_settings_key(thread_id)) or {} + if not isinstance(raw, dict): + raw = {} + fallback = _load_rag_defaults() + return ThreadRagSettings( + chunking_strategy = ( + raw.get("chunking_strategy") or fallback.chunking_strategy + ), + mode = raw.get("mode") or fallback.mode, + embedding_model = raw.get("embedding_model") or fallback.embedding_model, + ) + + +@router.get( + "/threads/{thread_id}/settings", + response_model = ThreadRagSettings, +) +def get_thread_rag_settings( + thread_id: str, + current_subject: str = Depends(get_current_subject), +) -> ThreadRagSettings: + return _load_thread_settings(thread_id) + + +@router.put( + "/threads/{thread_id}/settings", + response_model = ThreadRagSettings, +) +def set_thread_rag_settings( + thread_id: str, + payload: UpdateThreadRagSettingsRequest, + current_subject: str = Depends(get_current_subject), +) -> ThreadRagSettings: + current = _load_thread_settings(thread_id) + new_strategy = payload.chunking_strategy or current.chunking_strategy + new_mode = payload.mode or current.mode + 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( + { + _thread_settings_key(thread_id): { + "chunking_strategy": new_strategy, + "mode": new_mode, + "embedding_model": new_embedder, + } + } + ) + return ThreadRagSettings( + 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 @@ -619,20 +702,45 @@ def reingest_knowledge_base( ) def reingest_thread_documents( thread_id: str, + payload: UpdateThreadRagSettingsRequest | None = None, current_subject: str = Depends(get_current_subject), ) -> ReingestResponse: - """Rebuild a thread's RAG index using the current defaults. + """Rebuild a thread's RAG index. - No body — per-thread strategy/mode overrides aren't exposed in v1. + Optional body lets the caller change the thread's chunking + strategy / mode / embedder at the same time — persisted into + chat_settings before re-ingestion so subsequent uploads pick up + the new values too. With an empty body, current settings are + reused. """ - from utils.rag.config import RAG_EMBEDDING_MODEL + from utils.rag.config import resolve_embedder + if payload is None: + payload = UpdateThreadRagSettingsRequest() + if ( + payload.chunking_strategy is not None + or payload.mode is not None + or payload.embedding_model is not None + ): + # set_thread_rag_settings handles validation + persistence. + settings = set_thread_rag_settings( + thread_id, + payload, + current_subject = current_subject, + ) + else: + settings = _load_thread_settings(thread_id) + + embedder = settings.embedding_model or resolve_embedder( + settings.mode, + settings.chunking_strategy, + ) return _reingest_scope( kb_id = None, thread_id = thread_id, - chunking_strategy = "standard", - mode = "text", - embedding_model = RAG_EMBEDDING_MODEL, + chunking_strategy = settings.chunking_strategy, + mode = settings.mode, + embedding_model = embedder, ) @@ -696,12 +804,19 @@ async def upload_thread_document( file: UploadFile, current_subject: str = Depends(get_current_subject), ) -> UploadResponse: - from utils.rag.config import RAG_EMBEDDING_MODEL + from utils.rag.config import resolve_embedder # Don't validate against chat_threads — a brand-new chat won't be # persisted there until after the first runStart/runEnd. Users who # attach a document on a fresh thread would otherwise hit a 404. stored_path, filename, byte_size = await _save_upload(file) + # Per-thread settings fall back to app-level defaults inside the + # helper, so first-time-uploaded threads inherit user preferences. + settings = _load_thread_settings(thread_id) + embedder = settings.embedding_model or resolve_embedder( + settings.mode, + settings.chunking_strategy, + ) return _start_ingestion( filename = filename, stored_path = stored_path, @@ -709,7 +824,9 @@ async def upload_thread_document( content_type = file.content_type, kb_id = None, thread_id = thread_id, - embedding_model = RAG_EMBEDDING_MODEL, + embedding_model = embedder, + chunking_strategy = settings.chunking_strategy, + mode = settings.mode, ) diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index f6f0100d4d..cf5fb78677 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -96,6 +96,10 @@ import { KBCreateDialog } from "@/features/rag/components/kb-create-dialog"; import { useKnowledgeBases } from "@/features/rag/hooks/use-knowledge-bases"; import { useThreadDocuments } from "@/features/rag/hooks/use-kb-documents"; import { useRagStore } from "@/features/rag/stores/rag-store"; +import type { + ChunkingStrategy as RagChunkingStrategy, + KBMode, +} from "@/features/rag/api/rag-api"; import { Add01Icon, Delete02Icon } from "@hugeicons/core-free-icons"; function ragSourceLabel( @@ -448,6 +452,49 @@ export function ChatSettingsPanel({ ); const clearThreadIndex = useRagStore((s) => s.clearThreadIndex); const reingestThread = useRagStore((s) => s.reingestThread); + const threadSettings = useRagStore((s) => + activeThreadId ? s.threadSettings[activeThreadId] : undefined, + ); + const loadThreadSettings = useRagStore((s) => s.loadThreadSettings); + const updateThreadSettings = useRagStore((s) => s.updateThreadSettings); + const ragDefaults = useRagStore((s) => s.defaults); + + // Load this thread's RAG settings once when the sheet sees a thread + // for the first time. Updates re-render automatically via the store. + useEffect(() => { + if (ragSource.kind === "thread" && activeThreadId && !threadSettings) { + void loadThreadSettings(activeThreadId); + } + }, [ragSource.kind, activeThreadId, threadSettings, loadThreadSettings]); + + const effectiveThreadChunking: RagChunkingStrategy = + threadSettings?.chunking_strategy ?? + ragDefaults?.chunking_strategy ?? + "standard"; + const effectiveThreadMode: KBMode = + threadSettings?.mode ?? ragDefaults?.mode ?? "text"; + + const applyThreadSettingChange = ( + patch: { chunking_strategy?: RagChunkingStrategy; mode?: KBMode }, + ) => { + if (!activeThreadId) return; + if (threadDocs.length === 0) { + // No existing chunks to invalidate — just persist. + void updateThreadSettings(activeThreadId, patch); + return; + } + const ok = window.confirm( + `Re-index ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} ` + + `with the new settings? Existing chunks will be deleted and rebuilt.`, + ); + if (ok) { + void reingestThread(activeThreadId, patch); + } else { + // User declined — refresh the store so the select snaps back + // to the unchanged settings. + void loadThreadSettings(activeThreadId); + } + }; const [kbCreateOpen, setKbCreateOpen] = useState(false); const ragEnabled = ragSource.kind !== "off"; const activeKbId = ragSource.kind === "kb" ? ragSource.kbId : null; @@ -1311,7 +1358,76 @@ export function ChatSettingsPanel({ onCreated={(kb) => setRagSource({ kind: "kb", kbId: kb.id })} /> {ragSource.kind === "thread" && activeThreadId ? ( -
+ <> +
+
+ + +
+
+ + +
+
+

+ Changing either setting will re-index this thread's existing + documents. +

+
@@ -1368,7 +1484,8 @@ export function ChatSettingsPanel({
)} -
+ + ) : null}
diff --git a/studio/frontend/src/features/rag/api/rag-api.ts b/studio/frontend/src/features/rag/api/rag-api.ts index 121d7494bb..e0ae25607d 100644 --- a/studio/frontend/src/features/rag/api/rag-api.ts +++ b/studio/frontend/src/features/rag/api/rag-api.ts @@ -232,15 +232,52 @@ export async function reingestKnowledgeBase( return parseJsonOrThrow(response); } +export interface ThreadRagSettings { + chunking_strategy: ChunkingStrategy; + mode: KBMode; + embedding_model: string | null; +} + +export interface UpdateThreadRagSettingsRequest { + chunking_strategy?: ChunkingStrategy; + mode?: KBMode; + embedding_model?: string | null; +} + +export async function getThreadRagSettings( + threadId: string, +): Promise { + const response = await authFetch( + `/api/rag/threads/${encodeURIComponent(threadId)}/settings`, + ); + return parseJsonOrThrow(response); +} + +export async function setThreadRagSettings( + threadId: string, + payload: UpdateThreadRagSettingsRequest, +): Promise { + const response = await authFetch( + `/api/rag/threads/${encodeURIComponent(threadId)}/settings`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + return parseJsonOrThrow(response); +} + export async function reingestThreadDocuments( threadId: string, + opts: UpdateThreadRagSettingsRequest = {}, ): Promise { const response = await authFetch( `/api/rag/threads/${encodeURIComponent(threadId)}/reingest`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: "{}", + body: JSON.stringify(opts), }, ); return parseJsonOrThrow(response); diff --git a/studio/frontend/src/features/rag/stores/rag-store.ts b/studio/frontend/src/features/rag/stores/rag-store.ts index 09a740fbe7..02d6422748 100644 --- a/studio/frontend/src/features/rag/stores/rag-store.ts +++ b/studio/frontend/src/features/rag/stores/rag-store.ts @@ -9,6 +9,7 @@ import { deleteDocument as apiDeleteDocument, deleteKnowledgeBase as apiDeleteKB, getRagDefaults as apiGetRagDefaults, + getThreadRagSettings as apiGetThreadSettings, type JobEvent, type KnowledgeBase, listKBDocuments, @@ -21,9 +22,12 @@ import { reingestKnowledgeBase as apiReingestKB, reingestThreadDocuments as apiReingestThread, setRagDefaults as apiSetRagDefaults, + setThreadRagSettings as apiSetThreadSettings, subscribeToJobEvents, type ThreadIndexSummary, + type ThreadRagSettings, type UpdateRagDefaultsRequest, + type UpdateThreadRagSettingsRequest, uploadKBDocument, uploadThreadDocument, } from "../api/rag-api"; @@ -59,12 +63,22 @@ interface RagStoreState { clearThreadIndex: (threadId: string) => Promise; reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise; - reingestThread: (threadId: string) => Promise; + reingestThread: ( + threadId: string, + opts?: UpdateThreadRagSettingsRequest, + ) => Promise; defaults: RagDefaults | null; loadDefaults: () => Promise; updateDefaults: (patch: UpdateRagDefaultsRequest) => Promise; + threadSettings: Record; + loadThreadSettings: (threadId: string) => Promise; + updateThreadSettings: ( + threadId: string, + patch: UpdateThreadRagSettingsRequest, + ) => Promise; + subscribeJob: (jobId: string, onComplete?: () => void) => void; } @@ -92,6 +106,8 @@ export const useRagStore = create((set, get) => ({ defaults: null, + threadSettings: {}, + async loadKnowledgeBases() { set({ kbsLoading: true, kbsError: null }); try { @@ -269,10 +285,15 @@ export const useRagStore = create((set, get) => ({ set({ defaults }); }, - async reingestThread(threadId) { - const response = await apiReingestThread(threadId); + async reingestThread(threadId, opts) { + const response = await apiReingestThread(threadId, opts ?? {}); void get().loadThreadDocuments(threadId); void get().loadThreadIndexes(); + if (opts) { + // The reingest endpoint persists the new settings as a side + // effect; refresh the local copy so the UI reflects them. + void get().loadThreadSettings(threadId); + } for (const jobId of response.job_ids) { get().subscribeJob(jobId, () => { void get().loadThreadDocuments(threadId); @@ -281,6 +302,25 @@ export const useRagStore = create((set, get) => ({ return response.job_ids; }, + async loadThreadSettings(threadId) { + try { + const settings = await apiGetThreadSettings(threadId); + set((state) => ({ + threadSettings: { ...state.threadSettings, [threadId]: settings }, + })); + } catch { + // Best-effort — falls back to defaults UI-side when missing. + } + }, + + async updateThreadSettings(threadId, patch) { + const settings = await apiSetThreadSettings(threadId, patch); + set((state) => ({ + threadSettings: { ...state.threadSettings, [threadId]: settings }, + })); + return settings; + }, + subscribeJob(jobId, onComplete) { const existing = get().jobUnsubscribers[jobId]; if (existing) return;