* Studio: Data settings tab, uploaded files manager, quant pinning, image preview fix Settings - New Data tab in the settings sidebar, under Connections. Chat data management (archived chats, confirm before deleting, exports, import, clear all) moved there from the Chat tab. - New Archive all chats action with confirmation. Archives every chat in Recents and Projects; compare pairs count as one chat. - New Uploaded files manager listing RAG documents (chats, projects, knowledge bases) and chat message attachments with location, size and date. Files can be opened in a new tab or deleted. Deleting a chat attachment keeps the message text. Backend - GET /api/rag/documents lists all uploaded RAG documents with file size plus KB and project names. - GET /api/chat/attachments lists chat message attachments; per attachment file and delete endpoints included. Model selector - Downloaded GGUF quants can be pinned from the quant row (next to the settings and delete actions). Pinned quants show at the top of On Device under a Pinned heading as model name plus a grey quant chip and load directly with one click. Non GGUF cached repos pin as a whole. - Toned down the green of the downloaded label. Fix - Clicking an image attachment in chat now opens the preview overlay. The tooltip trigger wrapper called preventDefault before composed handlers ran, which made Radix DialogTrigger skip opening. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: image previews and file type chips in uploaded files list Image attachments now show a small thumbnail (lazy loaded from the stored bytes, object URL revoked on unmount) and every row shows a grey uppercase type chip derived from the extension or content type. Non image rows keep a file icon. Name cell floors its width and clips overflow so narrow dialogs stay aligned. * Harden attachment serving, add tests, and polish pinned rows and previews - Strict base64 decoding for attachment files: corrupt payloads now return 422 instead of silently serving empty or garbled bytes; whitespace, missing padding, the URL-safe alphabet, and RFC 2397 percent-encoded data URLs are all handled - New backend test suite covering attachment listing, size accounting, malformed rows, deletion semantics, and every file-serving edge case - Pinned quant rows show a Loaded tag when that exact quant is active, and reveal unpin, settings, and delete actions on hover - Uploaded files dialog is wider and chat locations link straight to the thread the attachment belongs to - Chat image preview is now a chrome-free lightbox: dimmed backdrop, rounded image, corner close button, click outside to dismiss - File opens go through a synchronous window.open so Safari and Firefox popup blockers do not eat them * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Uploaded files: click a file to jump to its chat, square thumbs, new Data icon - Clicking a file row (thumbnail or name) now goes straight to the chat it belongs to; files without a chat open directly as before - File thumbnails pin a small 7px radius: the theme scales rounded-md up to a near circle at this size - Settings Data tab now uses the database-setting icon * Uploaded files is now a Data tab subpage instead of a popup - Manage swaps the tab body for an inline Uploaded files page with a back header, matching the rest of settings navigation - Size column header and values are left aligned like the other columns - Column widths tightened so the table fits the settings panel * Lightbox polish and Data tab row order - Image preview close button is transparent until hovered - Preview image no longer rounds its corners - Import chats now sits below Clear all chats in the Data tab * Data tab: export chats as fine-tuning data and open them in Recipes - New Fine-tuning section in Settings > Data converts every chat into a JSONL dataset in the OpenAI messages format, one conversation per line with string-only system/user/assistant turns - The Train tab detects this file as chatml natively: no column mapping and no standardization pass, and it works with train on completions since every assistant turn sits behind the chat template response marker - Consecutive same-role turns merge, trailing turns without an assistant reply drop, and reasoning, tool calls, and images are excluded so chat templates format the data cleanly - Open in Recipes stages the JSONL as a local seed upload, creates a new Data Recipe with the seed block preconfigured, and jumps to the editor * Data tab: load chats straight into the Train tab, row moved to the top - New Load in Train tab button uploads the fine-tuning JSONL through the training dataset endpoint, selects it in the training config store, and opens the Train tab with the dataset loaded and format-checked - Use chats as training data now sits at the very top of the Data tab - The Chats subheading is gone; chat rows flow directly under it * Address review findings on the uploads manager and quant pins - Deleting the last attachment stores '[]' instead of NULL: a NULL reads back as a missing field and triggers the legacy IndexedDB backfill, which resurrected the deleted attachment on the next chat load - The attachment file endpoint now serves audio: adapter parts store {data, format} raw base64 and compare chats store a bare base64 string; media type comes from the attachment contentType or the format - Compare-chat uploads live in message content parts, not attachments; the uploads list now includes those blobs via synthetic content-part ids that the same get and delete routes resolve - Deleting a quant from the expanded repo row also unpins it so a pinned row cannot try to load a file that no longer exists - Thumbnails in the uploads list fetch their blob only once the row is visible, so a long screenshot history does not download everything - Nine new backend tests cover audio serving, content-part listing, serving, deletion, and the empty-list delete behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Data tab: single action dropdown with format choices for chat training data - The three fine-tune buttons collapse into one dropdown plus a run button; pick Load in Train tab, Open in Recipes, or Export JSONL, then click the arrow to run it - The dropdown's Format section adds ShareGPT and Alpaca alongside the default OpenAI messages format, ticked like a checklist; all three shapes are auto-detected by the Train tab's format check - Alpaca is single-turn, so each user to assistant pair becomes its own record with the system prompt and earlier turns carried in the input column - Shorter description on the training data row - Uploaded files rows show the size under the file name instead of a separate column, matching the tighter layout * Polish the training data action control - Run button is a true circle (icon-sm plus rounded-full) with a heavier arrow stroke - Dropdown trigger uses the shared standard chevron and a fixed width so switching actions no longer resizes the control * Shorten the training data row description * Use the standard chevron for the run button and enlarge the ticks - Run button uses the shared standard right chevron so it matches the dropdown chevron instead of the hugeicons arrow - Dropdown ticks bumped up a size for legibility * Reword the training data row description * Shorten Data Recipes to Recipes in the training data description * List Export JSONL first and rename the default format to Chat Completions * Handle legacy string content in fine-tune exports and gate Train on chat-only hosts - messageToPlainText now accepts plain-string message content, the shape legacy and imported histories store, so those conversations export instead of being skipped as having no exchange - The Load in Train tab action is disabled on chat-only hosts the same way the sidebar gates Train; the default action falls back to Export JSONL there so the run button never uploads a dataset that /studio would immediately redirect away from * Narrow the training data action dropdown slightly * Drop the format picker from the training data dropdown Chat Completions (OpenAI messages) is the only export format we ship, so the ShareGPT and Alpaca options and the Format section are removed. The export always uses the OpenAI messages shape. * Address the second round of review findings Security - Chat attachment data URLs no longer echo their embedded media type: anything that is not a plain raster image serves as octet-stream, so imported text/html or SVG payloads cannot render under the app origin - Uploaded .html/.htm RAG documents serve as text/plain for the same reason; the preview sheet only uses the file URL for PDFs Uploads manager - Remote image URLs in imported chats are no longer listed as stored uploads (nothing to serve, and delete would strip the chat reference); the delete guard mirrors the same data:-only rule - Deleting a content-part upload refetches the list since the remaining parts re-index, keeping sibling row ids current - Deleting a project document from the Data tab invalidates the project sources cache like the sources panel does - Data-tab deletions now patch the loaded thread's in-memory copy via a small event, so a later repo sync cannot write the attachment back Fine-tune export - Branch siblings from retries stay out of the exported conversation; only the selected chain converts (full exports still keep everything) - Assistant turns before the first user turn drop, preserving leading system prompts, so no unconditioned assistant targets are emitted Four new backend tests cover the media type clamp and remote-URL rows; two existing tests updated for the clamped types * Fix uploaded file lifecycle and model state * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make archived chats a Data settings subpage * Studio: fix attachment route tests and pinned quant edge cases - test_chat_attachments: drop asyncio.run around the synchronous /attachments routes (list/get/delete are plain def, so asyncio.run raised 'a coroutine was expected' and failed the Repo tests CI job). - test_chat_attachments: align compare-chat content-part assertions with the stable content-hash id scheme (content-part-sha256-...) instead of the removed array-index ids; resolve ids from the listing. - pickers: pass disabled={deleteDisabled} to the pinned-quant delete action so a quant cannot be deleted mid model-load, matching the expanded variant rows. - pickers: build the pinned-quant existence set from the query-unfiltered cached GGUF repos (format filter still applied) so a pinned quant stays findable when the search term matches only its quant name. * Fix Studio review regressions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard fine-tune export content blocks * Add Export button for archived chats Adds an Export action to the Archived chats view in Settings > Data that downloads only the archived chats as a JSON backup (their threads, messages and projects). The button sits in the archived header row and appears only when archived chats exist. * Refactor archived export into pure, testable units Split the archived-chats export into a dependency-free filter (archived-chat-export.ts) and a shared JSON download helper (download-json.ts). Skip the download when nothing is archived so a stray call never drops an empty file. No behavior change to the button. --------- Co-authored-by: shimmyshimmer <shimmyshimmer@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com> Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
573 lines
19 KiB
Python
573 lines
19 KiB
Python
# 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, Form, 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
|
|
cap = config.MAX_UPLOAD_BYTES
|
|
too_big = False
|
|
with open(stored_path, "wb") as out:
|
|
while True:
|
|
block = file.file.read(1 << 20)
|
|
if not block:
|
|
break
|
|
size += len(block)
|
|
if cap and size > cap:
|
|
too_big = True
|
|
break
|
|
out.write(block)
|
|
if too_big:
|
|
os.remove(stored_path)
|
|
raise HTTPException(
|
|
status_code = 413,
|
|
detail = f"File exceeds the {cap // (1024 * 1024)} MB upload limit.",
|
|
)
|
|
if size == 0:
|
|
os.remove(stored_path)
|
|
raise HTTPException(status_code = 400, detail = "Uploaded file is empty.")
|
|
return stored_path, filename
|
|
|
|
|
|
def _remove_stored_upload(stored_path: str | None) -> None:
|
|
"""Best-effort cleanup for files saved by _save_upload."""
|
|
if not stored_path:
|
|
return
|
|
try:
|
|
uploads = os.path.realpath(str(rag_uploads_root()))
|
|
target = os.path.realpath(stored_path)
|
|
if os.path.isfile(target) and os.path.commonpath([uploads, target]) == uploads:
|
|
os.remove(target)
|
|
except Exception: # noqa: BLE001 - DB/index deletion has already succeeded.
|
|
logger.warning("failed to remove RAG upload %s", stored_path, exc_info = True)
|
|
|
|
|
|
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"),
|
|
"projectId": row.get("project_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
|
|
project_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.effective_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(...),
|
|
ocr: bool | None = Form(None),
|
|
caption: bool | None = Form(None),
|
|
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, ocr = ocr, caption = caption
|
|
)
|
|
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(...),
|
|
ocr: bool | None = Form(None),
|
|
caption: bool | None = Form(None),
|
|
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,
|
|
ocr = ocr,
|
|
caption = caption,
|
|
)
|
|
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.post("/projects/{project_id}/documents")
|
|
async def upload_project_document(
|
|
project_id: str,
|
|
file: UploadFile = File(...),
|
|
ocr: bool | None = Form(None),
|
|
caption: bool | None = Form(None),
|
|
subject: str = Depends(get_current_subject),
|
|
) -> dict:
|
|
_require_rag()
|
|
from storage.studio_db import get_chat_project
|
|
|
|
if get_chat_project(project_id) is None:
|
|
raise HTTPException(status_code = 404, detail = "Project not found")
|
|
stored_path, filename = _save_upload(file)
|
|
document_id, job_id = ingestion.start_ingestion(
|
|
store.project_scope(project_id),
|
|
None,
|
|
None,
|
|
filename,
|
|
stored_path,
|
|
project_id = project_id,
|
|
ocr = ocr,
|
|
caption = caption,
|
|
)
|
|
return {"documentId": document_id, "jobId": job_id, "filename": filename}
|
|
|
|
|
|
@router.get("/projects/{project_id}/documents")
|
|
def list_project_documents(project_id: str, subject: str = Depends(get_current_subject)) -> dict:
|
|
_require_rag()
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
docs = store.list_documents(conn, store.project_scope(project_id))
|
|
return {"documents": [_doc_view(d) for d in docs]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/documents")
|
|
def list_all_uploaded_documents(subject: str = Depends(get_current_subject)) -> dict:
|
|
"""Every uploaded file across chats, projects, and knowledge bases (settings
|
|
Data tab)."""
|
|
_require_rag()
|
|
conn = rag_db.get_connection()
|
|
try:
|
|
docs = store.list_all_documents(conn)
|
|
kb_names = {kb["id"]: kb["name"] for kb in store.list_kbs(conn)}
|
|
finally:
|
|
conn.close()
|
|
|
|
from storage.studio_db import list_chat_projects
|
|
|
|
project_names = {p["id"]: p["name"] for p in list_chat_projects(include_archived = True)}
|
|
|
|
out = []
|
|
for doc in docs:
|
|
view = _doc_view(doc)
|
|
stored_path = doc.get("stored_path")
|
|
size = None
|
|
if stored_path:
|
|
try:
|
|
size = os.path.getsize(stored_path)
|
|
except OSError:
|
|
size = None
|
|
view["sizeBytes"] = size
|
|
view["kbName"] = kb_names.get(doc.get("kb_id"))
|
|
view["projectName"] = project_names.get(doc.get("project_id"))
|
|
out.append(view)
|
|
return {"documents": out}
|
|
|
|
|
|
@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:
|
|
doc = store.get_document(conn, document_id)
|
|
if doc is None:
|
|
raise HTTPException(status_code = 404, detail = "Document not found")
|
|
store.delete_document(conn, document_id)
|
|
_remove_stored_upload(doc.get("stored_path"))
|
|
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"),
|
|
"numChunks": row.get("num_chunks") or 0,
|
|
}
|
|
|
|
|
|
@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)
|
|
else:
|
|
scopes = []
|
|
if payload.project_id:
|
|
scopes.append(store.project_scope(payload.project_id))
|
|
if payload.thread_id:
|
|
scopes.append(store.thread_scope(payload.thread_id))
|
|
if not scopes:
|
|
raise HTTPException(status_code = 400, detail = "Provide kb_id, project_id, or thread_id")
|
|
scope = scopes[0] if len(scopes) == 1 else scopes
|
|
|
|
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",
|
|
# Served as plain text, never text/html: an uploaded HTML document rendered
|
|
# same-origin would execute its scripts with access to the app's storage.
|
|
".html": "text/plain; charset=utf-8",
|
|
".htm": "text/plain; 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"],
|
|
)
|