diff --git a/studio/backend/routes/rag.py b/studio/backend/routes/rag.py index 392a4e0d02..ae65712146 100644 --- a/studio/backend/routes/rag.py +++ b/studio/backend/routes/rag.py @@ -47,7 +47,14 @@ _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] + if len(base) <= 200: + return base + # Trim the stem, not the extension: _save_upload gates on the extension, so + # a plain truncation would reject a long-named .txt as "unsupported". + stem, ext = os.path.splitext(base) + if not ext or len(ext) > 32: + return base[:200] + return stem[: 200 - len(ext)] + ext def _save_upload(file: UploadFile) -> tuple[str, str]: diff --git a/studio/backend/tests/test_rag_project_source_upload.py b/studio/backend/tests/test_rag_project_source_upload.py new file mode 100644 index 0000000000..fd20816b56 --- /dev/null +++ b/studio/backend/tests/test_rag_project_source_upload.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Project sources upload: the path the create-project dialog drives.""" + +import os + +import pytest + +from core.rag import ingestion, store +from routes.rag import _sanitize_filename +from storage import rag_db + + +def _wait(job_id, timeout = 30.0): + import time + + deadline = time.time() + timeout + while time.time() < deadline: + status = ingestion.get_job_status(job_id) + if status and status["status"] in ("completed", "failed"): + return status + time.sleep(0.05) + raise AssertionError("ingestion did not finish in time") + + +def _ingest(project_id, filename, path): + return ingestion.start_ingestion( + store.project_scope(project_id), None, None, filename, path, project_id = project_id + ) + + +def test_project_document_persists_under_its_scope(rag_home, stub_embeddings, tmp_path): + path = tmp_path / "notes.txt" + path.write_text("alpha bravo charlie " * 50, encoding = "utf-8") + _, job_id = _ingest("P1", "notes.txt", str(path)) + assert _wait(job_id)["status"] == "completed" + + conn = rag_db.get_connection() + try: + docs = store.list_documents(conn, store.project_scope("P1")) + assert [d["filename"] for d in docs] == ["notes.txt"] + # Scoped: a sibling project cannot see it. + assert store.list_documents(conn, store.project_scope("P2")) == [] + assert store.search_lexical(conn, store.project_scope("P1"), "bravo", 5) + finally: + conn.close() + + +@pytest.mark.parametrize( + "raw", + [ + "x" * 300 + ".txt", + "y" * 512 + ".PDF", + "../" * 80 + "deep.md", + ], +) +def test_long_filenames_keep_their_extension(raw): + # _save_upload gates on the extension, so trimming it would reject the file. + out = _sanitize_filename(raw) + assert len(out) <= 200 + assert os.path.splitext(out)[1].lower() == os.path.splitext(raw)[1].lower() + + +@pytest.mark.parametrize( + "raw", + [ + "../../etc/passwd.txt", + "..\\..\\windows\\evil.txt", + "/absolute/notes.txt", + "C:\\Users\\me\\notes.txt", + ], +) +def test_sanitized_filenames_carry_no_path(raw): + out = _sanitize_filename(raw) + assert "/" not in out and "\\" not in out + + +@pytest.mark.parametrize("raw", ["." * 300, "noext" * 100, "a" * 100 + "." + "e" * 250]) +def test_sanitizer_degrades_safely(raw): + assert 0 < len(_sanitize_filename(raw)) <= 200 diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 3cda2a8690..a31d9b6ced 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -105,7 +105,6 @@ import { archiveChatItem, ChatSearchDialog, clearNewChatDraft, - createChatProject, deleteChatProject, deleteChatItem, listStoredChatThreads, @@ -123,6 +122,7 @@ import { type ProjectRecord, type SidebarItem, } from "@/features/chat"; +import { NewProjectDialog } from "@/features/chat/components/new-project-dialog"; import { useAppearanceCustomStore, useSettingsDialogStore, @@ -696,7 +696,6 @@ export function AppSidebar() { }); }, [allChatItems, pendingRename]); const [creatingProject, setCreatingProject] = useState(false); - const [projectNameDraft, setProjectNameDraft] = useState(""); const [projectCreateMoveTarget, setProjectCreateMoveTarget] = useState(null); const renameTrimmed = renameDraft.trim(); @@ -849,28 +848,26 @@ export function AppSidebar() { } } - async function commitCreateProject() { - const name = projectNameDraft.trim(); - if (!name) return; + // "New project" from a chat's menu moves that chat in and stays put; + // otherwise open the project, unless a slow upload outlasted the route the + // user was on when they hit create. + async function afterCreateProject( + project: ProjectRecord, + { stayedOnRoute }: { stayedOnRoute: boolean }, + ) { const moveTarget = projectCreateMoveTarget; + setProjectCreateMoveTarget(null); + if (!moveTarget) { + if (stayedOnRoute) openProject(project.id); + return; + } try { - const project = await createChatProject(name); - if (moveTarget) { - await moveChatItemToProject(moveTarget, project.id); - if (activeThreadId === moveTarget.id) { - useChatRuntimeStore.getState().setActiveProjectId(project.id); - } - } - setCreatingProject(false); - setProjectNameDraft(""); - setProjectCreateMoveTarget(null); - if (moveTarget) { - return; - } else { - openProject(project.id); + await moveChatItemToProject(moveTarget, project.id); + if (activeThreadId === moveTarget.id) { + useChatRuntimeStore.getState().setActiveProjectId(project.id); } } catch (err) { - toast.error(moveTarget ? "Failed to create and move chat" : "Failed to create project", { + toast.error("Failed to move chat to the new project", { description: err instanceof Error ? err.message : undefined, }); } @@ -1050,7 +1047,6 @@ export function AppSidebar() { { setProjectCreateMoveTarget(item); - setProjectNameDraft(""); setCreatingProject(true); }} > @@ -1393,7 +1389,6 @@ export function AppSidebar() { onClick={(e) => { e.stopPropagation(); setProjectCreateMoveTarget(null); - setProjectNameDraft(""); setCreatingProject(true); }} className="sidebar-row-action group-hover/projects-item:opacity-100 group-hover/projects-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto group-data-[collapsible=icon]:hidden" @@ -2172,58 +2167,18 @@ export function AppSidebar() { - { setCreatingProject(open); - if (!open) { - setProjectNameDraft(""); - setProjectCreateMoveTarget(null); - } + if (!open) setProjectCreateMoveTarget(null); }} - > - - - - {projectCreateMoveTarget ? "Move to new project" : "New project"} - - - setProjectNameDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void commitCreateProject(); - } - }} - autoFocus - maxLength={120} - placeholder="Project name" - aria-label="Project name" - className="focus-visible:border-input focus-visible:ring-0" - /> - - - - - - + title={ + projectCreateMoveTarget ? "Move to new project" : "Create project" + } + submitLabel={projectCreateMoveTarget ? "Create and move" : "Create project"} + onCreated={afterCreateProject} + /> ); } diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index a439a91239..7e0544e2d1 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -185,6 +185,10 @@ import { listStoredChatThreads, } from "./utils/chat-history-storage"; import { isAssistantLocalThreadId } from "./utils/thread-ids"; +import { + consumeProjectSourcesPending, + hasProjectSourcesPending, +} from "@/features/rag/components/project-source-dropzone"; const ProjectSourcesPanel = lazy(() => @@ -998,7 +1002,14 @@ function ProjectLanding({ const active = useChatActive(); const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const initialActiveThreadRef = useRef(null); - const [projectTab, setProjectTab] = useState<"chats" | "sources">("chats"); + // Land on Sources when the project was just created with dropped files. + const [projectTab, setProjectTab] = useState<"chats" | "sources">(() => + hasProjectSourcesPending(projectId) ? "sources" : "chats", + ); + // Drop the marker once committed: React may replay the initializer above. + useEffect(() => { + consumeProjectSourcesPending(projectId); + }, [projectId]); const [pendingNewThreadId, setPendingNewThreadId] = useState( null, ); diff --git a/studio/frontend/src/features/chat/components/new-project-dialog.tsx b/studio/frontend/src/features/chat/components/new-project-dialog.tsx index 880129ac6c..6aca3d36c3 100644 --- a/studio/frontend/src/features/chat/components/new-project-dialog.tsx +++ b/studio/frontend/src/features/chat/components/new-project-dialog.tsx @@ -2,7 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -12,31 +12,92 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; +import { + ProjectSourceDropzone, + type StagedSource, + uploadStagedSources, +} from "@/features/rag/components/project-source-dropzone"; import { toast } from "@/lib/toast"; +import { Folder02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { createChatProject } from "../hooks/use-chat-projects"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; +import type { ProjectRecord } from "../types"; -// Create-project dialog usable from the composer + menu. Creating opens the new -// project straight away rather than dropping the user on the projects list. +function currentRoute(): string { + if (typeof window === "undefined") return ""; + return window.location.pathname + window.location.search; +} + +// Create-project dialog for the composer, sidebar, and projects page. Creating +// opens the new project; `onCreated` overrides that for callers with their own +// follow-up (the sidebar's "move this chat to a new project"). export function NewProjectDialog({ open, onOpenChange, + title = "Create project", + submitLabel = "Create project", + onCreated, }: { open: boolean; onOpenChange: (open: boolean) => void; + title?: string; + submitLabel?: string; + onCreated?: ( + project: ProjectRecord, + context: { stayedOnRoute: boolean }, + ) => void | Promise; }) { const navigate = useNavigate(); const [name, setName] = useState(""); + const [staged, setStaged] = useState([]); + const [busy, setBusy] = useState(false); + // Uploads outlive this component, so a slow one must not yank the user to the + // new project after they have navigated away. + const mounted = useRef(true); + useEffect(() => { + // Set on setup, not just cleared on cleanup: StrictMode replays + // setup/cleanup/setup, which would otherwise leave this false forever. + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + function reset() { + setName(""); + setStaged([]); + } + + // Every close path routes through here: callers keep this mounted, so a draft + // left behind would resurface (and upload) on the next project. + function close() { + if (busy) return; + reset(); + onOpenChange(false); + } async function commitCreate() { const trimmed = name.trim(); - if (!trimmed) return; + if (!trimmed || busy) return; + setBusy(true); + // Sidebar callers keep this mounted across routes, so unmounting alone + // cannot tell whether the user has moved on during a slow upload. + const origin = currentRoute(); try { const project = await createChatProject(trimmed); + // Upload before closing so the Sources panel lists them on first fetch. + await uploadStagedSources(project.id, staged); + if (!mounted.current) return; + const stayedOnRoute = currentRoute() === origin; onOpenChange(false); - setName(""); + reset(); + if (onCreated) { + await onCreated(project, { stayedOnRoute }); + return; + } + if (!stayedOnRoute) return; const runtime = useChatRuntimeStore.getState(); runtime.setActiveThreadId(null); runtime.setActiveProjectId(project.id); @@ -45,6 +106,8 @@ export function NewProjectDialog({ toast.error("Failed to create project", { description: err instanceof Error ? err.message : undefined, }); + } finally { + setBusy(false); } } @@ -52,43 +115,59 @@ export function NewProjectDialog({ { - if (!next) setName(""); - onOpenChange(next); + if (next) { + onOpenChange(true); + return; + } + close(); }} > - + - New project + {title} - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void commitCreate(); - } - }} - autoFocus={true} - maxLength={120} - placeholder="Project name" - aria-label="Project name" - className="focus-visible:border-input focus-visible:ring-0" + {/* Name field: folder glyph in its own cell, divided from the input. */} +
+ + + +
+ -
diff --git a/studio/frontend/src/features/chat/projects-page.tsx b/studio/frontend/src/features/chat/projects-page.tsx index e20e517787..192c4e2331 100644 --- a/studio/frontend/src/features/chat/projects-page.tsx +++ b/studio/frontend/src/features/chat/projects-page.tsx @@ -34,7 +34,6 @@ import { isTauri } from "@/lib/api-base"; import { isDownloadCancelled, pickNativeChatImport } from "@/lib/native-files"; import { toast } from "@/lib/toast"; import { - createChatProject, deleteChatProject, renameChatProject, useChatProjects, @@ -42,6 +41,7 @@ import { usePinnedProjectsStore, type ProjectRecord, } from "@/features/chat"; +import { NewProjectDialog } from "./components/new-project-dialog"; import { Delete02Icon, Download01Icon, @@ -124,7 +124,6 @@ export function ProjectsPage() { ); const [creating, setCreating] = useState(false); - const [nameDraft, setNameDraft] = useState(""); const [renaming, setRenaming] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const [deleting, setDeleting] = useState(null); @@ -258,21 +257,6 @@ export function ProjectsPage() { navigate({ to: "/chat", search: { project: projectId } }); } - async function commitCreate() { - const name = nameDraft.trim(); - if (!name) return; - try { - const project = await createChatProject(name); - setCreating(false); - setNameDraft(""); - openProject(project.id); - } catch (err) { - toast.error("Failed to create project", { - description: err instanceof Error ? err.message : undefined, - }); - } - } - async function commitRename() { const target = renaming; const name = renameDraft.trim(); @@ -469,14 +453,7 @@ export function ProjectsPage() { - + @@ -511,10 +488,7 @@ export function ProjectsPage() { - - -
-
+ {/* Create project (name + drag-and-drop sources) */} + {/* Rename project */} = 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const shown = + value >= 10 || unit === 0 + ? String(Math.round(value)) + : value.toFixed(1).replace(/\.0$/, ""); + return `${shown} ${units[unit]}`; +} + +const ACCEPTED_EXTS = new Set( + RAG_UPLOAD_ACCEPT.split(",").map((ext) => ext.trim().toLowerCase()), +); + +// `accept` only filters the picker, so a drop can carry anything. A folder +// arrives as an extension-less entry, which this rejects along with the types +// the backend would 400 on. +function isSupported(file: File): boolean { + const dot = file.name.lastIndexOf("."); + if (dot <= 0) return false; + return ACCEPTED_EXTS.has(file.name.slice(dot).toLowerCase()); +} + +/** Merge a selection into the staged list. Returns the names it would not take, + * so the caller can say so once instead of dropping them silently. */ +function addStagedSources( + staged: StagedSource[], + incoming: FileList | File[], +): { next: StagedSource[]; unsupported: string[]; duplicates: string[] } { + const seen = new Set(staged.map((entry) => fileSignature(entry.file))); + const next = [...staged]; + const unsupported: string[] = []; + const duplicates: string[] = []; + for (const file of Array.from(incoming)) { + if (!isSupported(file)) { + unsupported.push(file.name); + continue; + } + const signature = fileSignature(file); + if (seen.has(signature)) { + duplicates.push(file.name); + continue; + } + seen.add(signature); + next.push({ + id: `staged_${Math.random().toString(36).slice(2)}`, + file, + }); + } + return { next, unsupported, duplicates }; +} + +// Projects created with staged files, so the landing can open on Sources. +const projectsWithPendingSources = new Set(); + +function markProjectSourcesPending(projectId: string): void { + projectsWithPendingSources.add(projectId); +} + +/** Whether this project was just created with staged sources. Read-only, so it + * is safe in a render pass that React may replay. */ +export function hasProjectSourcesPending(projectId: string): boolean { + return projectsWithPendingSources.has(projectId); +} + +/** Drop the marker once the landing has committed. */ +export function consumeProjectSourcesPending(projectId: string): void { + projectsWithPendingSources.delete(projectId); +} + +/** Upload staged files to a new project. Indexing runs in the background; a + * per-file failure toasts and never blocks project creation. */ +export async function uploadStagedSources( + projectId: string, + staged: StagedSource[], +): Promise { + if (staged.length === 0) return; + invalidateProjectSources(projectId); + markProjectSourcesPending(projectId); + const { ocr, caption } = resolveVisionOverrides(); + const documentIds = new Set(); + const merged: string[] = []; + for (const { file } of staged) { + try { + const result = await uploadProjectDocument(projectId, file, ocr, caption); + // Same bytes under another name: the backend hashes content, so this is + // the document already uploaded. Say so rather than imply a new source. + if (documentIds.has(result.documentId)) merged.push(file.name); + else documentIds.add(result.documentId); + } catch (error) { + toast.error(`Couldn't upload ${file.name}`, { + description: error instanceof Error ? error.message : String(error), + }); + } + } + if (merged.length > 0) { + toast.info( + merged.length === 1 + ? `${merged[0]} matched a file already added` + : `${merged.length} files matched files already added`, + { description: "Identical contents are stored once." }, + ); + } + invalidateProjectSources(projectId); +} + +/** Create-project drop area: stages files until the project exists. */ +export function ProjectSourceDropzone({ + staged, + onChange, + disabled = false, +}: { + staged: StagedSource[]; + onChange: (next: StagedSource[]) => void; + disabled?: boolean; +}) { + const inputRef = useRef(null); + // Count enter/leave pairs: children fire dragleave on the parent. + const dragDepth = useRef(0); + const [dragging, setDragging] = useState(false); + + const addFiles = useCallback( + (files: FileList | File[]) => { + const { next, unsupported, duplicates } = addStagedSources(staged, files); + if (next.length !== staged.length) onChange(next); + if (unsupported.length > 0) { + toast.info( + unsupported.length === 1 + ? `Can't add ${unsupported[0]}` + : `Can't add ${unsupported.length} files`, + { description: `Supported types: ${RAG_UPLOAD_ACCEPT}` }, + ); + } + // Name, size and mtime can in principle match for two different files, so + // never drop one without saying so. + if (duplicates.length > 0) { + toast.info( + duplicates.length === 1 + ? `${duplicates[0]} is already added` + : `${duplicates.length} files were already added`, + ); + } + }, + [staged, onChange], + ); + + const endDrag = useCallback(() => { + dragDepth.current = 0; + setDragging(false); + }, []); + + return ( +
+

Sources

+ {/* Panel is the drop target; the inner button owns the click so staged + rows can carry their own remove buttons. */} +
{ + e.preventDefault(); + if (disabled) return; + dragDepth.current += 1; + setDragging(true); + }} + onDragOver={(e) => { + e.preventDefault(); + if (disabled) return; + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={() => { + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setDragging(false); + }} + onDrop={(e) => { + e.preventDefault(); + if (disabled) return; + endDrag(); + addFiles(Array.from(e.dataTransfer.files ?? [])); + }} + className={cn( + "rounded-[22px] border border-border transition-colors dark:border-white/10", + dragging && "border-primary/60 bg-primary/5", + disabled && "opacity-60", + )} + > + { + const files = Array.from(e.target.files ?? []); + e.target.value = ""; + addFiles(files); + }} + /> + {staged.length === 0 ? ( + + ) : ( +
+
    + {staged.map((entry) => ( +
  • + + + {entry.file.name} + + + {formatSize(entry.file.size)} + + +
  • + ))} +
+ +
+ )} +
+
+ ); +} diff --git a/studio/frontend/src/features/rag/components/use-rag-documents.ts b/studio/frontend/src/features/rag/components/use-rag-documents.ts index 8d6433d8c3..bdab7b0518 100644 --- a/studio/frontend/src/features/rag/components/use-rag-documents.ts +++ b/studio/frontend/src/features/rag/components/use-rag-documents.ts @@ -1,13 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { useCallback, useEffect, useRef, useState } from "react"; -import { - CHAT_RAG_CAPTION_KEY, - CHAT_RAG_OCR_KEY, - useChatRuntimeStore, -} from "@/features/chat"; import { toast } from "@/lib/toast"; +import { useCallback, useEffect, useRef, useState } from "react"; import { deleteDocument, getJob, @@ -17,6 +12,7 @@ import { uploadThreadDocument, } from "../api/rag-api"; import type { DocumentStatus, RagDocument } from "../types/rag"; +import { resolveVisionOverrides } from "./vision-overrides"; export interface TrackedDocument extends RagDocument { progress?: number | null; @@ -263,18 +259,7 @@ export function useRagDocuments( tempId: string, ) => { try { - // Send vision-pass overrides only after the user has explicitly set them; - // otherwise backend env defaults own the ingest policy. - const state = useChatRuntimeStore.getState(); - const hasLocal = (key: string) => - typeof window !== "undefined" && - window.localStorage.getItem(key) !== null; - const ocr = hasLocal(CHAT_RAG_OCR_KEY) - ? state.ragOcrScanned - : undefined; - const caption = hasLocal(CHAT_RAG_CAPTION_KEY) - ? state.ragCaptionFigures - : undefined; + const { ocr, caption } = resolveVisionOverrides(); const result = activeScope.type === "kb" ? await uploadKnowledgeBaseDocument( diff --git a/studio/frontend/src/features/rag/components/vision-overrides.ts b/studio/frontend/src/features/rag/components/vision-overrides.ts new file mode 100644 index 0000000000..674484970b --- /dev/null +++ b/studio/frontend/src/features/rag/components/vision-overrides.ts @@ -0,0 +1,35 @@ +// 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 { + CHAT_RAG_CAPTION_KEY, + CHAT_RAG_OCR_KEY, + useChatRuntimeStore, +} from "@/features/chat"; + +function hasLocal(key: string): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(key) !== null; + } catch { + // Storage can be blocked outright (sandboxed context). These overrides are + // optional, so fall back to the backend defaults rather than failing the + // upload that asked for them. + return false; + } +} + +/** Ingest-time vision-pass overrides, sent only once the user has set them; + * otherwise backend env defaults own the policy. Shared by every upload path. */ +export function resolveVisionOverrides(): { + ocr: boolean | undefined; + caption: boolean | undefined; +} { + const state = useChatRuntimeStore.getState(); + return { + ocr: hasLocal(CHAT_RAG_OCR_KEY) ? state.ragOcrScanned : undefined, + caption: hasLocal(CHAT_RAG_CAPTION_KEY) + ? state.ragCaptionFigures + : undefined, + }; +}