Studio: project sources backed by RAG (#6205)

* Studio: make project sources work with RAG and polish project UI

Projects had a disabled Sources tab with an Add sources placeholder.
This wires it up end to end on top of the RAG engine:

- Add a project scope to the RAG store, ingestion and retrieval
- New endpoints: POST/GET /api/rag/projects/{id}/documents
- search_knowledge_base resolves kb, project and thread scopes; an
  explicit KB stays exclusive, project and thread scopes combine
- Multi-scope search: FTS uses scope IN (...), vec0 KNN runs per
  scope and merges by cosine score
- Lazy ALTER TABLE adds documents.project_id on existing databases
- Deleting a project also removes its indexed sources
- Sources tab now uploads with progress chips and drag and drop
- Chats inside a project auto-enable retrieval over project sources
  when the project has indexed documents (cached probe, no Docs pill
  needed); external providers still never receive rag_scope

UI polish:
- Rounder project cards with folder icon chip and softer shadow
- Project header icon in a rounded chip
- Chats/Sources pills and Add sources button without borders

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: match Add sources button shadow to the chat composer in light mode

* Studio: round project switcher hover pill and pad the folder icon

* Studio: remove border from project sources box

* Studio: grey hover on project cards and menu, move search into header, widen page spacing

* Studio: shorten sources copy, white header pills with composer shadow, fixed-width search, hub-size page headings

* Studio: align project landing blocks to the composer width

* Studio: restore muted background and flat look on projects header controls

* Studio: darker grey hover on project cards in light mode

* Studio: soften project card hover grey

* Studio: keep project card menu button visible while its menu is open

* Studio: drop focus outlines and rings on buttons and clickable icons, keep input focus styles

* Studio: address review feedback on project sources

- Remove uploaded files from disk when a project is deleted, confined
  to the uploads root
- 404 project uploads when the project does not exist, matching the KB
  endpoint
- Guard lexical search against an empty scope list
- Re-invalidate the project sources probe after uploads and removals
  settle so a chat sent mid-upload cannot cache a stale negative
- Keep keyboard focus rings: only mouse focus drops the Tailwind ring,
  the browser default outline stays removed

* Studio: add a green New badge to the project Sources tab

* Studio: unify New pills, fully round with soft emerald fill and no border

* Studio: a touch more vertical padding on New pills

* Fix project RAG source edge cases for PR #6205

* Fix duplicate RAG upload cleanup for PR #6205

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
This commit is contained in:
Michael Han 2026-06-12 06:42:51 -07:00 committed by GitHub
parent ebe3bbd041
commit 99704ffe47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 633 additions and 119 deletions

View file

@ -806,6 +806,7 @@ def _search_knowledge_base(arguments: dict, rag_scope: dict | None) -> str:
query = str(query),
scope_kb_id = scope.get("kb_id"),
scope_thread_id = scope.get("thread_id"),
scope_project_id = scope.get("project_id"),
top_k = top_k,
**_scope_retrieval_kwargs(scope),
)
@ -913,6 +914,7 @@ def build_rag_autoinject(conversation: list[dict], rag_scope: dict | None) -> di
query = query,
scope_kb_id = rag_scope.get("kb_id"),
scope_thread_id = rag_scope.get("thread_id"),
scope_project_id = rag_scope.get("project_id"),
top_k = top_k,
min_dense_score = floor,
**_scope_retrieval_kwargs(rag_scope),

View file

@ -35,6 +35,22 @@ def _sha256_file(path: str) -> str:
return h.hexdigest()
def _remove_upload(stored_path: str | None, *, keep_path: str | None = None) -> None:
if not stored_path:
return
try:
target = os.path.realpath(stored_path)
if keep_path is not None and target == os.path.realpath(keep_path):
return
from utils.paths import rag_uploads_root
uploads = os.path.realpath(str(rag_uploads_root()))
if os.path.isfile(target) and os.path.commonpath([uploads, target]) == uploads:
os.remove(target)
except Exception: # noqa: BLE001 - upload cleanup must not block ingestion.
logger.warning("failed to remove RAG upload %s", stored_path, exc_info = True)
def _emit(job_id: str, event: dict) -> None:
with _jobs_lock:
q = _jobs.get(job_id)
@ -152,6 +168,7 @@ def start_ingestion(
filename: str,
stored_path: str,
*,
project_id: str | None = None,
model_name: str | None = None,
) -> tuple[str, str]:
"""Create the document + job rows and spawn the worker, returning
@ -167,11 +184,15 @@ def start_ingestion(
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)
_remove_upload(stored_path)
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
for failed in store.failed_documents_by_hash(conn, scope, sha):
store.delete_document(conn, failed["id"])
_remove_upload(failed.get("stored_path"), keep_path = stored_path)
document_id = store.create_document(
conn,
@ -180,6 +201,7 @@ def start_ingestion(
sha256 = sha,
kb_id = kb_id,
thread_id = thread_id,
project_id = project_id,
status = "pending",
stored_path = stored_path,
)

View file

@ -22,7 +22,7 @@ class Hit:
def retrieve_lexical(
conn: sqlite3.Connection,
scope: str,
scope: str | list[str],
query: str,
k: int | None = None,
) -> list[Hit]:
@ -32,7 +32,7 @@ def retrieve_lexical(
def retrieve_dense(
conn: sqlite3.Connection,
scope: str,
scope: str | list[str],
query: str,
k: int | None = None,
*,
@ -69,7 +69,7 @@ def _rrf(rankings: list[list[Hit]], rrf_k: int, top_k: int) -> list[Hit]:
def retrieve_hybrid(
conn: sqlite3.Connection,
scope: str,
scope: str | list[str],
query: str,
*,
k: int | None = None,

View file

@ -29,6 +29,15 @@ def thread_scope(thread_id: str) -> str:
return f"thread_{thread_id}"
def project_scope(project_id: str) -> str:
return f"project_{project_id}"
def _scopes(scope) -> list[str]:
"""Search helpers accept one scope or several (e.g. project + thread)."""
return [scope] if isinstance(scope, str) else list(scope)
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))
@ -96,19 +105,21 @@ def create_document(
sha256: str,
kb_id: str | None = None,
thread_id: str | None = None,
project_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(?,?,?,?,?,?,?,?,?)",
"INSERT INTO documents(id, scope, kb_id, thread_id, project_id, filename, sha256, "
"status, stored_path, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
(
document_id,
scope,
kb_id,
thread_id,
project_id,
filename,
sha256,
status,
@ -137,7 +148,8 @@ def set_document_status(
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 "
"SELECT id, scope, kb_id, thread_id, project_id, filename, sha256, status, error, "
"num_chunks, created_at "
"FROM documents WHERE scope=? ORDER BY created_at DESC",
(scope,),
).fetchall()
@ -151,11 +163,21 @@ def get_document(conn: sqlite3.Connection, document_id: str) -> dict | 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)
"SELECT id FROM documents WHERE scope=? AND sha256=? AND status!='failed' "
"ORDER BY created_at DESC LIMIT 1",
(scope, sha256),
).fetchone()
return row["id"] if row else None
def failed_documents_by_hash(conn: sqlite3.Connection, scope: str, sha256: str) -> list[dict]:
rows = conn.execute(
"SELECT id, stored_path FROM documents WHERE scope=? AND sha256=? AND status='failed'",
(scope, sha256),
).fetchall()
return [dict(r) for r in rows]
def add_chunks(
conn: sqlite3.Connection,
scope: str,
@ -220,30 +242,41 @@ def delete_document(conn: sqlite3.Connection, document_id: str) -> None:
conn.commit()
def search_lexical(conn: sqlite3.Connection, scope: str, query: str, k: int):
"""BM25 lexical search. Returns [(chunk_id, score)], higher = better."""
def search_lexical(conn: sqlite3.Connection, scope, query: str, k: int):
"""BM25 lexical search over one scope or several. Returns
[(chunk_id, score)], higher = better."""
mq = _match_query(query)
if not mq:
return []
scopes = _scopes(scope)
if not scopes:
return []
placeholders = ",".join("?" * len(scopes))
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),
f"SELECT chunk_id, bm25(chunks_fts) AS s FROM chunks_fts "
f"WHERE chunks_fts MATCH ? AND scope IN ({placeholders}) ORDER BY s LIMIT ?",
(mq, *scopes, 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)]."""
def search_dense(conn: sqlite3.Connection, scope, vector, k: int):
"""Cosine KNN over vec0 for one scope or several. Returns
[(chunk_id, 1 - distance)]. vec0 KNN constrains its partition key by
equality, so multi-scope runs one query per scope and merges by score."""
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]
out: list[tuple[str, float]] = []
for s in _scopes(scope):
rows = conn.execute(
"SELECT chunk_id, distance FROM chunks_vec "
"WHERE scope=? AND embedding MATCH ? ORDER BY distance LIMIT ?",
(s, _f32(vector), k),
).fetchall()
out.extend((r["chunk_id"], 1.0 - r["distance"]) for r in rows)
out.sort(key = lambda t: t[1], reverse = True)
return out[:k]
def chunks_by_id(conn: sqlite3.Connection, ids) -> dict:

View file

@ -3,7 +3,8 @@
"""``search_knowledge_base`` LLM tool: scope resolution + hit formatting.
KB scope wins over thread scope. Hits render as ``<chunk>`` blocks for the model,
KB scope wins; otherwise project and thread scopes combine so project chats also
see their own attachments. 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.
"""
@ -15,7 +16,7 @@ from xml.sax.saxutils import quoteattr
from storage import rag_db
from . import config, retrieval
from .store import kb_scope, thread_scope
from .store import kb_scope, project_scope, thread_scope
SEARCH_KNOWLEDGE_BASE_TOOL = {
"type": "function",
@ -42,12 +43,23 @@ SEARCH_KNOWLEDGE_BASE_TOOL = {
}
def _resolve_scope(scope_kb_id: str | None, scope_thread_id: str | None) -> str | None:
def _resolve_scope(
scope_kb_id: str | None,
scope_thread_id: str | None,
scope_project_id: str | None = None,
) -> str | list[str] | None:
"""KB (an explicit pick) is exclusive; project and thread scopes combine so a
project chat also retrieves from its own attached documents."""
if scope_kb_id:
return kb_scope(scope_kb_id)
scopes = []
if scope_project_id:
scopes.append(project_scope(scope_project_id))
if scope_thread_id:
return thread_scope(scope_thread_id)
return None
scopes.append(thread_scope(scope_thread_id))
if not scopes:
return None
return scopes[0] if len(scopes) == 1 else scopes
def _format(rows, hits) -> tuple[str, list[dict]]:
@ -83,6 +95,7 @@ def search_knowledge_base_with_sources(
query: str,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
scope_project_id: str | None = None,
top_k: int | None = None,
min_score: float = 0.0,
model_name: str | None = None,
@ -92,7 +105,7 @@ def search_knowledge_base_with_sources(
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)
scope = _resolve_scope(scope_kb_id, scope_thread_id, scope_project_id)
if scope is None:
return "No documents are attached to this chat.", []
@ -124,6 +137,7 @@ def search_for_autoinject(
query: str,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
scope_project_id: str | None = None,
top_k: int | None = None,
min_dense_score: float = 0.70,
model_name: str | None = None,
@ -138,7 +152,7 @@ def search_for_autoinject(
"""
if not query or not query.strip():
return None
scope = _resolve_scope(scope_kb_id, scope_thread_id)
scope = _resolve_scope(scope_kb_id, scope_thread_id, scope_project_id)
if scope is None:
return None
k = top_k or config.TOP_K_HYBRID
@ -177,6 +191,7 @@ def search_knowledge_base(
query: str,
scope_kb_id: str | None = None,
scope_thread_id: str | None = None,
scope_project_id: str | None = None,
top_k: int | None = None,
min_score: float = 0.0,
model_name: str | None = None,
@ -186,6 +201,7 @@ def search_knowledge_base(
query = query,
scope_kb_id = scope_kb_id,
scope_thread_id = scope_thread_id,
scope_project_id = scope_project_id,
top_k = top_k,
min_score = min_score,
model_name = model_name,

View file

@ -332,6 +332,35 @@ async def delete_project(
status_code = 404,
detail = f"Project {project_id} not found",
)
# Best-effort: drop the project's RAG sources (lazy import keeps RAG optional).
try:
import os
from storage import rag_db
if rag_db.RAG_AVAILABLE:
from core.rag import store as rag_store
from utils.paths import rag_uploads_root
uploads = os.path.realpath(str(rag_uploads_root()))
conn = rag_db.get_connection()
try:
scope = rag_store.project_scope(project_id)
for doc in rag_store.list_documents(conn, scope):
full = rag_store.get_document(conn, doc["id"]) or {}
rag_store.delete_document(conn, doc["id"])
stored = full.get("stored_path")
# Also remove the uploaded file; confined to the uploads root.
if stored:
target = os.path.realpath(stored)
if (
os.path.isfile(target)
and os.path.commonpath([uploads, target]) == uploads
):
os.remove(target)
finally:
conn.close()
except Exception: # noqa: BLE001 - source cleanup must not block project deletion
logger.warning("failed to delete RAG sources for project %s", project_id, exc_info = True)
return ChatProject(**project)

View file

@ -75,6 +75,19 @@ def _save_upload(file: UploadFile) -> tuple[str, str]:
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"],
@ -84,6 +97,7 @@ def _doc_view(row: dict) -> dict:
"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"),
}
@ -102,6 +116,7 @@ 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
@ -244,14 +259,50 @@ def list_thread_documents(thread_id: str, subject: str = Depends(get_current_sub
conn.close()
@router.post("/projects/{project_id}/documents")
async def upload_project_document(
project_id: str,
file: UploadFile = File(...),
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,
)
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.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:
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()
@ -297,10 +348,15 @@ def search(payload: SearchRequest, subject: str = Depends(get_current_subject))
_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")
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:

View file

@ -57,6 +57,7 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
scope TEXT NOT NULL,
kb_id TEXT,
thread_id TEXT,
project_id TEXT,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
@ -102,6 +103,10 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
);
"""
)
# Lazy upgrade for databases created before project sources existed.
cols = {r[1] for r in conn.execute("PRAGMA table_info(documents)").fetchall()}
if "project_id" not in cols:
conn.execute("ALTER TABLE documents ADD COLUMN project_id TEXT")
def get_connection() -> sqlite3.Connection:

View file

@ -39,7 +39,7 @@ def test_ingestion_lifecycle_pending_to_completed(rag_home, stub_embeddings, tmp
conn = rag_db.get_connection()
try:
assert store.get_document(conn, doc_id)["status"] == "pending"
assert store.get_document(conn, doc_id)["status"] in {"pending", "running", "completed"}
finally:
conn.close()
@ -83,6 +83,133 @@ def test_ingestion_dedupe_by_hash(rag_home, stub_embeddings, tmp_path):
conn.close()
def test_ingestion_dedupe_removes_duplicate_upload(rag_home, stub_embeddings):
from utils.paths import ensure_dir, rag_uploads_root
uploads = ensure_dir(rag_uploads_root())
first_path = uploads / "doc.txt"
duplicate_path = uploads / "copy.txt"
first_path.write_text("alpha bravo charlie", encoding = "utf-8")
duplicate_path.write_text("alpha bravo charlie", encoding = "utf-8")
scope = store.project_scope("P1")
doc_id, job_id = ingestion.start_ingestion(
scope,
None,
None,
"doc.txt",
str(first_path),
project_id = "P1",
)
_drain(job_id)
_wait_completed(job_id)
doc_id2, job_id2 = ingestion.start_ingestion(
scope,
None,
None,
"copy.txt",
str(duplicate_path),
project_id = "P1",
)
events = _drain(job_id2)
assert doc_id2 == doc_id
assert any(e.get("deduped") for e in events)
assert first_path.exists()
assert not duplicate_path.exists()
def test_ingestion_retry_replaces_failed_hash(rag_home, stub_embeddings):
from utils.paths import ensure_dir, rag_uploads_root
uploads = ensure_dir(rag_uploads_root())
old_path = uploads / "failed.txt"
retry_path = uploads / "retry.txt"
old_path.write_text("alpha bravo charlie", encoding = "utf-8")
retry_path.write_text("alpha bravo charlie", encoding = "utf-8")
scope = store.project_scope("P1")
sha = ingestion._sha256_file(str(old_path))
conn = rag_db.get_connection()
try:
failed_id = store.create_document(
conn,
scope = scope,
filename = "failed.txt",
sha256 = sha,
project_id = "P1",
status = "failed",
stored_path = str(old_path),
)
finally:
conn.close()
doc_id, job_id = ingestion.start_ingestion(
scope,
None,
None,
"retry.txt",
str(retry_path),
project_id = "P1",
)
events = _drain(job_id)
assert doc_id != failed_id
assert not any(e.get("deduped") for e in events)
assert not old_path.exists()
assert retry_path.exists()
status = _wait_completed(job_id)
assert status["status"] == "completed"
conn = rag_db.get_connection()
try:
assert store.get_document(conn, failed_id) is None
assert store.get_document(conn, doc_id)["status"] == "completed"
finally:
conn.close()
def test_delete_document_route_removes_stored_upload(rag_home):
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
upload = ensure_dir(rag_uploads_root()) / "delete-me.txt"
upload.write_text("alpha bravo", encoding = "utf-8")
scope = store.project_scope("P1")
conn = rag_db.get_connection()
try:
doc_id = store.create_document(
conn,
scope = scope,
filename = "delete-me.txt",
sha256 = "delete-route-sha",
project_id = "P1",
status = "completed",
stored_path = str(upload),
)
finally:
conn.close()
app = FastAPI()
app.include_router(router, prefix = "/api/rag")
app.dependency_overrides[get_current_subject] = lambda: "tester"
client = TestClient(app)
res = client.delete(f"/api/rag/documents/{doc_id}")
assert res.status_code == 200
assert not upload.exists()
conn = rag_db.get_connection()
try:
assert store.get_document(conn, doc_id) is None
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")

View file

@ -1231,7 +1231,7 @@ export function AppSidebar() {
>
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>{t("shell.navigation.api")}</span>
<span className="ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
<span className="ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t("common.new")}
</span>
</DropdownMenuItem>

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
import { projectHasSources } from "@/features/rag/api/rag-api";
import { apiUrl } from "@/lib/api-base";
import { parseParamCountB } from "@/lib/model-size";
import { toast } from "@/lib/toast";
@ -1097,14 +1098,11 @@ async function resolveProjectInstructions(
async function resolveProjectId(
threadId: string | undefined,
): Promise<string | null> {
let projectId: string | null | undefined;
if (threadId) {
const thread = await getStoredChatThread(threadId).catch(() => null);
projectId = thread?.projectId ?? null;
}
if (!projectId) {
projectId = useChatRuntimeStore.getState().activeProjectId;
return thread?.projectId ?? null;
}
const projectId = useChatRuntimeStore.getState().activeProjectId;
if (!projectId) {
return null;
}
@ -1561,6 +1559,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
ragAutoInject,
ragAutoInjectMinScore,
} = runtime;
// Project sources auto-scope: a chat inside a project retrieves from the
// project's indexed sources even when the Docs pill is off. The probe is
// cached, so this is one round trip per project every ~30s at most.
const ragProjectId = await resolveProjectId(resolvedThreadId);
const projectRagEnabled = ragProjectId
? await projectHasSources(ragProjectId)
: false;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
if (
@ -2462,12 +2467,15 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
codeToolsEnabled ||
renderHtmlToolEnabledForThisTurn ||
mcpEnabledForChat ||
ragEnabled)
ragEnabled ||
projectRagEnabled)
? {
enable_tools: true,
enabled_tools: [
// First so retrieval is the primary tool when Docs is on.
...(ragEnabled ? ["search_knowledge_base"] : []),
...(ragEnabled || projectRagEnabled
? ["search_knowledge_base"]
: []),
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(renderHtmlToolEnabledForThisTurn
@ -2476,15 +2484,22 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
],
mcp_enabled: mcpEnabledForChat,
confirm_tool_calls: confirmToolCalls,
// Scope: thread_id = this thread's docs, kb_id = a KB.
...(ragEnabled
// Scope: thread_id = this thread's docs, kb_id = a KB,
// project_id = the thread's project sources (auto-on whenever
// the project has indexed sources, no Docs pill needed).
...(ragEnabled || projectRagEnabled
? {
rag_scope: {
...(ragSource.type === "kb"
...(ragEnabled && ragSource.type === "kb"
? { kb_id: ragSource.kbId }
: resolvedThreadId
? { thread_id: resolvedThreadId }
: {}),
: {
...(ragEnabled && resolvedThreadId
? { thread_id: resolvedThreadId }
: {}),
...(projectRagEnabled && ragProjectId
? { project_id: ragProjectId }
: {}),
}),
default_top_k: ragTopK,
mode: ragMode,
autoinject: resolveAutoInject(

View file

@ -16,8 +16,8 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { ProjectSourcesPanel } from "@/features/rag/components/project-sources-panel";
import {
NativeModelChip,
NativeModelDropOverlay,
@ -32,7 +32,6 @@ import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import {
Folder02Icon,
FolderAddIcon,
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -933,13 +932,16 @@ function ProjectLanding({
} as CSSProperties
}
>
<div className="mx-auto flex w-full max-w-[48rem] flex-col pt-[120px] pb-14">
<div className="mb-12 flex items-center gap-3">
<HugeiconsIcon
icon={Folder02Icon}
strokeWidth={1.75}
className="size-9 shrink-0 text-foreground"
/>
{/* 46rem matches the composer so every block shares the same edges. */}
<div className="mx-auto flex w-full max-w-[46rem] flex-col pt-[120px] pb-14">
<div className="mb-12 flex items-center gap-4">
<span className="flex size-13 shrink-0 items-center justify-center rounded-[18px] bg-muted text-foreground/80">
<HugeiconsIcon
icon={Folder02Icon}
strokeWidth={1.75}
className="size-6.5"
/>
</span>
<h1 className="truncate font-sans text-[30px] font-medium leading-tight tracking-normal text-foreground">
{projectName}
</h1>
@ -955,7 +957,7 @@ function ProjectLanding({
type="button"
onClick={() => setProjectTab("chats")}
data-active={projectTab === "chats"}
className="h-10 rounded-full border px-5 text-[14px] font-semibold transition-colors data-[active=true]:border-border data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:border-transparent data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
className="h-10 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
>
Chats
</button>
@ -963,35 +965,17 @@ function ProjectLanding({
type="button"
onClick={() => setProjectTab("sources")}
data-active={projectTab === "sources"}
className="h-10 rounded-full border px-5 text-[14px] font-semibold transition-colors data-[active=true]:border-border data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:border-transparent data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
className="flex h-10 items-center gap-1.5 rounded-full px-5 text-[14px] font-semibold transition-colors data-[active=true]:bg-muted data-[active=true]:text-foreground data-[active=false]:text-muted-foreground data-[active=false]:hover:bg-nav-surface-hover"
>
Sources
<span className="rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] font-semibold leading-none text-emerald-700 dark:text-emerald-300">
New
</span>
</button>
</div>
{projectTab === "sources" ? (
<div className="mt-8 flex flex-col items-center justify-center gap-3 rounded-[16px] border border-dashed border-border/70 bg-muted/30 px-6 py-16 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon
icon={FolderAddIcon}
strokeWidth={1.75}
className="size-6"
/>
</span>
<div className="space-y-1">
<p className="text-[15px] font-semibold text-foreground">
Give this project context
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Upload PDFs, documents, or other text. The model can
reference them in every chat in this project.
</p>
</div>
<Button type="button" className="mt-1" disabled>
Add sources
</Button>
<p className="text-[11px] text-muted-foreground">Coming soon</p>
</div>
<ProjectSourcesPanel projectId={projectId} />
) : (
<div className="mt-8 flex flex-col gap-1">
{items.map((item) => {

View file

@ -46,7 +46,7 @@ export function ProjectSwitcher({
? "Loading project"
: "Pick a project"
}
className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-[10px] px-1.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-[#2d2e32]"
className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-full pl-3 pr-2.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-[#2d2e32]"
>
<HugeiconsIcon
icon={Folder01Icon}

View file

@ -41,6 +41,7 @@ import {
Delete02Icon,
Download01Icon,
Edit03Icon,
Folder02Icon,
FolderAddIcon,
Search01Icon,
Upload01Icon,
@ -231,7 +232,7 @@ export function ProjectsPage() {
}
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 font-heading sm:px-6">
<main className="mx-auto w-full max-w-6xl px-6 py-10 font-heading sm:px-10">
{/* Global import file input */}
<input
ref={globalImportRef}
@ -248,10 +249,22 @@ export function ProjectsPage() {
}}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">
Projects
</h1>
<div className="flex items-center gap-3">
<div className="relative">
<span className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground">
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-4" />
</span>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects"
className="h-9 w-52 rounded-full border-none bg-muted pl-10 pr-4 shadow-none dark:bg-card sm:w-64"
aria-label="Search projects"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Sort by</span>
<Select
@ -329,30 +342,17 @@ export function ProjectsPage() {
</div>
</div>
<div className="relative mx-auto mt-10 w-full max-w-[720px]">
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-muted-foreground">
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
</span>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects..."
className="h-14 rounded-full border-none bg-background pl-13 pr-5 shadow-[0_0_20px_0_rgba(0,0,0,0.07)] dark:bg-card dark:shadow-none"
aria-label="Search projects"
/>
</div>
{!hasLoaded ? (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-12 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[160px] rounded-[14px] bg-card p-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:shadow-none"
className="min-h-[172px] rounded-[26px] bg-card p-6 shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] dark:shadow-none"
>
<Skeleton className="h-5 w-2/3 rounded-[6px]" />
<Skeleton className="mt-3 h-4 w-full rounded-[6px]" />
<Skeleton className="mt-2 h-4 w-4/5 rounded-[6px]" />
<Skeleton className="mt-12 h-3 w-24 rounded-[6px]" />
<Skeleton className="size-10 rounded-[14px]" />
<Skeleton className="mt-4 h-5 w-2/3 rounded-[8px]" />
<Skeleton className="mt-2 h-4 w-4/5 rounded-[8px]" />
<Skeleton className="mt-8 h-3 w-24 rounded-[8px]" />
</div>
))}
</div>
@ -378,7 +378,7 @@ export function ProjectsPage() {
)}
</div>
) : (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-12 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{visibleProjects.map((project) => (
<div key={`wrap-${project.id}`} className="contents">
<input
@ -407,19 +407,23 @@ export function ProjectsPage() {
openProject(project.id);
}
}}
className="group/project-card relative flex min-h-[160px] cursor-pointer flex-col rounded-[14px] bg-card p-5 text-left shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-accent/40 dark:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group/project-card relative flex min-h-[172px] cursor-pointer flex-col rounded-[26px] bg-card p-6 text-left shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] transition-colors duration-150 hover:bg-[#f2f2f2] dark:shadow-none dark:hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-start justify-between gap-2">
<h2 className="truncate pr-2 text-[16px] font-semibold text-foreground">
{project.name}
</h2>
<span className="flex size-10 shrink-0 items-center justify-center rounded-[14px] bg-muted text-foreground/70 transition-colors group-hover/project-card:bg-primary/10 group-hover/project-card:text-primary">
<HugeiconsIcon
icon={Folder02Icon}
strokeWidth={1.75}
className="size-5"
/>
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Project options"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/project-card:opacity-100"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10 focus-visible:opacity-100 group-hover/project-card:opacity-100 data-[state=open]:bg-black/5 data-[state=open]:opacity-100 dark:data-[state=open]:bg-white/10"
>
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</button>
@ -480,12 +484,15 @@ export function ProjectsPage() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<h2 className="mt-4 truncate text-[16px] font-semibold text-foreground">
{project.name}
</h2>
{project.instructions ? (
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
<p className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{project.instructions}
</p>
) : null}
<span className="mt-auto pt-4 text-xs text-muted-foreground">
<span className="mt-auto pt-4 text-xs text-muted-foreground/80">
Updated {formatUpdatedAgo(project.updatedAt)}
</span>
</div>

View file

@ -295,9 +295,10 @@ export interface OpenAIChatCompletionsRequest {
mcp_enabled?: boolean;
/** Local models + enable_tools only. */
confirm_tool_calls?: boolean;
/** Exactly one of `kb_id` (a KB) or `thread_id` (thread docs). */
/** `kb_id` is exclusive; otherwise project and thread scopes may combine. */
rag_scope?: {
kb_id?: string;
project_id?: string;
thread_id?: string;
default_top_k: number;
mode: "hybrid" | "lexical" | "dense";

View file

@ -403,7 +403,7 @@ export function DataRecipesPage(): ReactElement {
<main className="mx-auto w-full max-w-7xl px-6 py-8">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">
Data Recipes
</h1>
<p className="mt-1 text-sm text-muted-foreground">

View file

@ -587,7 +587,7 @@ export function ExportPage() {
<GuidedTour {...tour.tourProps} />
<div className="mb-8 flex flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight">
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">
Export Model
</h1>
<p className="text-sm text-muted-foreground">

View file

@ -126,6 +126,48 @@ export function uploadThreadDocument(
return ragUpload(`/threads/${encodeURIComponent(threadId)}/documents`, file);
}
export async function listProjectDocuments(
projectId: string,
): Promise<RagDocument[]> {
const data = await ragRequest<{ documents: RagDocument[] }>(
`/projects/${encodeURIComponent(projectId)}/documents`,
);
return data.documents ?? [];
}
export function uploadProjectDocument(
projectId: string,
file: File,
): Promise<DocumentUploadResult> {
return ragUpload(`/projects/${encodeURIComponent(projectId)}/documents`, file);
}
// Cached "does this project have indexed sources?" probe so the chat adapter can
// auto-scope project chats without a round trip per message. The sources panel
// invalidates on upload/delete.
const projectSourcesCache = new Map<string, { has: boolean; at: number }>();
const PROJECT_SOURCES_TTL_MS = 30_000;
export async function projectHasSources(projectId: string): Promise<boolean> {
const cached = projectSourcesCache.get(projectId);
if (cached && Date.now() - cached.at < PROJECT_SOURCES_TTL_MS) {
return cached.has;
}
try {
const docs = await listProjectDocuments(projectId);
const has = docs.some((doc) => doc.status !== "failed");
projectSourcesCache.set(projectId, { has, at: Date.now() });
return has;
} catch {
// RAG unavailable or transient failure: don't cache, don't scope.
return false;
}
}
export function invalidateProjectSources(projectId: string): void {
projectSourcesCache.delete(projectId);
}
export function deleteDocument(documentId: string): Promise<{ ok: boolean }> {
return ragRequest(`/documents/${encodeURIComponent(documentId)}`, {
method: "DELETE",

View file

@ -0,0 +1,138 @@
// 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, useRef } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { FolderAddIcon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
invalidateProjectSources,
listProjectDocuments,
} from "../api/rag-api";
import { RAG_UPLOAD_ACCEPT } from "../types/rag";
import { DocumentStatusChip } from "./document-status-chip";
import { useRagDocuments } from "./use-rag-documents";
/** Project "Sources" tab: documents indexed for retrieval in every chat that
* belongs to the project. */
export function ProjectSourcesPanel({ projectId }: { projectId: string }) {
const fileInputRef = useRef<HTMLInputElement>(null);
const lister = useCallback(() => listProjectDocuments(projectId), [projectId]);
const { documents, loading, uploading, upload, remove } = useRagDocuments(
{ type: "project", projectId },
lister,
);
// Invalidate the sources probe before and after each mutation: a chat sent
// mid-upload must not cache "no sources" for the probe's TTL.
const handleFiles = useCallback(
async (files: File[]) => {
if (files.length === 0) return;
invalidateProjectSources(projectId);
await upload(files);
invalidateProjectSources(projectId);
},
[projectId, upload],
);
const handleRemove = useCallback(
async (documentId: string) => {
invalidateProjectSources(projectId);
await remove(documentId);
invalidateProjectSources(projectId);
},
[projectId, remove],
);
const empty = documents.length === 0;
return (
<div
className="mt-8"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
void handleFiles(Array.from(e.dataTransfer.files ?? []));
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={RAG_UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
void handleFiles(files);
}}
/>
{empty ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[26px] bg-muted/30 px-6 py-16 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon
icon={FolderAddIcon}
strokeWidth={1.75}
className="size-6"
/>
</span>
<div className="space-y-1">
<p className="text-[15px] font-semibold text-foreground">
Give this project context
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Upload PDFs, docs, or text. Every chat in this project can use
them.
</p>
</div>
<Button
type="button"
variant="outline"
className="mt-1 border-none bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] hover:bg-background/80 dark:bg-card dark:shadow-none dark:hover:bg-accent/50"
disabled={uploading || loading}
onClick={() => fileInputRef.current?.click()}
>
Add sources
</Button>
<p className="text-[11px] text-muted-foreground">Or drop files here</p>
</div>
) : (
<div className="flex flex-col gap-4 rounded-[26px] bg-muted/30 px-6 py-5">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{documents.length === 1
? "1 source"
: `${documents.length} sources`}
</p>
<Button
type="button"
size="sm"
variant="outline"
className="border-none bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] hover:bg-background/80 dark:bg-card dark:shadow-none dark:hover:bg-accent/50"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
Add sources
</Button>
</div>
<div className="flex flex-row flex-wrap items-center gap-1.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 handleRemove(doc.id)
}
/>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -8,6 +8,7 @@ import {
getJob,
streamJobEvents,
uploadKnowledgeBaseDocument,
uploadProjectDocument,
uploadThreadDocument,
} from "../api/rag-api";
import type { DocumentStatus, RagDocument } from "../types/rag";
@ -23,7 +24,8 @@ function fileSignature(file: File): string {
export type RagDocumentScope =
| { type: "kb"; kbId: string }
| { type: "thread"; threadId: string };
| { type: "thread"; threadId: string }
| { type: "project"; projectId: string };
type Lister = () => Promise<RagDocument[]>;
@ -57,7 +59,9 @@ export function useRagDocuments(
const scopeKey = scope
? scope.type === "kb"
? `kb:${scope.kbId}`
: `thread:${scope.threadId}`
: scope.type === "project"
? `project:${scope.projectId}`
: `thread:${scope.threadId}`
: null;
const prevScopeKeyRef = useRef<string | null>(null);
@ -81,6 +85,7 @@ export function useRagDocuments(
const finish = (status: DocumentStatus, error?: string | null) => {
if (status === "failed") {
// Drop the chip rather than show "Failed"; warn via toast.
sigByDocId.current.delete(documentId);
setDocuments((rows) => rows.filter((row) => row.id !== documentId));
toast.error(`Couldn't index ${filename}`, {
description: error ?? "Indexing failed",
@ -229,7 +234,9 @@ export function useRagDocuments(
const result =
activeScope.type === "kb"
? await uploadKnowledgeBaseDocument(activeScope.kbId, file)
: await uploadThreadDocument(activeScope.threadId, file);
: activeScope.type === "project"
? await uploadProjectDocument(activeScope.projectId, 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));

View file

@ -20,6 +20,7 @@ export interface RagDocument {
numChunks?: number | null;
kbId?: string | null;
threadId?: string | null;
projectId?: string | null;
createdAt?: string | null;
}

View file

@ -188,7 +188,7 @@ export function SettingsDialog() {
{t(tab.labelKey)}
</span>
{tab.badgeKey ? (
<span className="relative z-10 ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
<span className="relative z-10 ml-auto rounded-full bg-emerald-500/10 px-2 py-1 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{t(tab.badgeKey)}
</span>
) : null}

View file

@ -165,7 +165,7 @@ export function StudioPage(): ReactElement {
/>
<div className="mb-6 flex flex-col gap-0.5 sm:mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">
{t("studio.title")}
</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>

View file

@ -1260,6 +1260,25 @@
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 45%, transparent);
}
/* Clickable controls never show the browser's blue outline (Radix re-focuses
triggers on menu close, which lights them up). Mouse focus also drops the
Tailwind ring; keyboard focus (:focus-visible) keeps custom rings so the
focused control stays visible. Inputs keep their own focus styles. Zeroing
the ring vars leaves decorative shadow-* utilities intact. */
button:focus,
a:focus,
summary:focus,
[role="button"]:focus {
outline: none !important;
}
button:focus:not(:focus-visible),
a:focus:not(:focus-visible),
summary:focus:not(:focus-visible),
[role="button"]:focus:not(:focus-visible) {
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
}
/* Set Hellix explicitly; .aui-thread-root resets --font-heading to sans. */
.unsloth-welcome-title {
font-family: "Hellix", "Space Grotesk Variable", var(--font-sans);

View file

@ -4781,7 +4781,12 @@ def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
}:
return ["llama-server", "llama-quantize", "llama-diffusion-gemma-visual-server", "lib*.so*"]
if choice.install_kind in {"macos-arm64", "macos-x64"}:
return ["llama-server", "llama-quantize", "llama-diffusion-gemma-visual-server", "lib*.dylib"]
return [
"llama-server",
"llama-quantize",
"llama-diffusion-gemma-visual-server",
"lib*.dylib",
]
if choice.install_kind in {
"windows-cpu",
"windows-cuda",
@ -4789,7 +4794,12 @@ def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]:
"windows-rocm",
"windows-arm64",
}:
return ["llama-server.exe", "llama-quantize.exe", "llama-diffusion-gemma-visual-server.exe", "*.dll"]
return [
"llama-server.exe",
"llama-quantize.exe",
"llama-diffusion-gemma-visual-server.exe",
"*.dll",
]
raise PrebuiltFallback(f"unsupported install kind for runtime overlay: {choice.install_kind}")