Add drag and drop sources to the create project dialog (#7441)

* feat(studio): add drag and drop sources to create project

Files dropped on the create-project dialog upload to the new project's
sources as soon as it exists, so a project can start with context instead
of needing a second trip to the Sources tab.

The sidebar and projects page dialogs now reuse NewProjectDialog rather
than each keeping their own copy, and the OCR / caption ingest overrides
move to a shared helper so every upload path sends the same settings.

* fix(studio): harden project source drops

Drops are not filtered by the `accept` attribute the way the picker is, so a
folder or an image would stage and then fail server-side with a confusing
per-file error. Unsupported entries are now refused up front with one message.

Cancel bypassed the dialog's reset, so a discarded name and its staged files
came back on reopen and uploaded into the next project created. Every close
path now goes through one handler.

Long filenames lost their extension in _sanitize_filename and were then
rejected as an unsupported type; the stem is trimmed instead. Adds backend
tests for the project scope, the sanitizer and path stripping.

* fix(studio): address second review pass on source drops

A drop landing on the panel while uploads run was not cancelled, because
pointer-events-none took the panel out of hit testing and nothing else on the
page cancels a file drop. The browser would navigate to the file and kill the
uploads in flight. Drag defaults are now cancelled even while disabled, and the
files are ignored instead.

Name, size and mtime can match for two genuinely different files, so a skipped
duplicate now says so rather than disappearing.

A slow upload could resolve after the dialog unmounted and still navigate,
pulling the user off the page they had moved to. Post-upload work is gated on
the component still being mounted.

* fix(studio): make source drops safe under StrictMode replay

The mount sentinel was only cleared in effect cleanup, so StrictMode's
setup/cleanup/setup replay left it false for good and every create in a dev
build stopped short of closing the dialog or navigating. It is now set on
setup as well.

The pending-sources marker was consumed inside a useState initializer, which
React replays, so the discarded pass ate the flag and the project opened on
Chats. Reading is now a peek and the marker is dropped in an effect.

Identical bytes under two names collapse to one document server-side, which
looked like both files had been added. The upload loop now tracks returned
document ids and says when files were merged.

* fix(studio): guard the route and storage around staged uploads

The sidebar's dialog lives in the root layout and never unmounts on a route
change, so the mount check alone could not stop a slow upload from navigating
the user back to the new project. The route is captured when create is pressed
and compared afterwards, and callers get that answer so the sidebar can still
move a chat while leaving the user where they are.

Reading the vision-pass overrides went straight at localStorage, which throws
outright where storage is blocked. That happened before the upload loop, so a
project was created and every staged source was lost. It now falls back to the
backend defaults, matching loadOptionalBool in the chat runtime store.
This commit is contained in:
Michael Han 2026-07-25 23:54:48 -07:00 committed by GitHub
commit bac04ab577
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 583 additions and 186 deletions

View file

@ -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]:

View file

@ -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

View file

@ -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<SidebarItem | null>(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() {
<DropdownMenuItem
onSelect={() => {
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() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
<NewProjectDialog
open={creatingProject}
onOpenChange={(open) => {
setCreatingProject(open);
if (!open) {
setProjectNameDraft("");
setProjectCreateMoveTarget(null);
}
if (!open) setProjectCreateMoveTarget(null);
}}
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-md">
<DialogHeader>
<DialogTitle>
{projectCreateMoveTarget ? "Move to new project" : "New project"}
</DialogTitle>
</DialogHeader>
<Input
value={projectNameDraft}
onChange={(event) => 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"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setCreatingProject(false);
setProjectCreateMoveTarget(null);
}}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitCreateProject()}
disabled={!projectNameDraft.trim()}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
title={
projectCreateMoveTarget ? "Move to new project" : "Create project"
}
submitLabel={projectCreateMoveTarget ? "Create and move" : "Create project"}
onCreated={afterCreateProject}
/>
</>
);
}

View file

@ -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<string | null>(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<string | null>(
null,
);

View file

@ -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<void>;
}) {
const navigate = useNavigate();
const [name, setName] = useState("");
const [staged, setStaged] = useState<StagedSource[]>([]);
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({
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) setName("");
onOpenChange(next);
if (next) {
onOpenChange(true);
return;
}
close();
}}
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-md">
<DialogContent className="corner-squircle dialog-soft-surface gap-5 sm:max-w-lg">
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogTitle className="text-ui-21">{title}</DialogTitle>
</DialogHeader>
<Input
value={name}
onChange={(e) => 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. */}
<div className="flex items-stretch overflow-hidden rounded-[16px] border border-border bg-background transition-colors focus-within:border-ring has-[input:disabled]:opacity-50 dark:border-transparent dark:bg-white/[0.06]">
<span className="flex w-9 shrink-0 items-center justify-center text-muted-foreground">
<HugeiconsIcon
icon={Folder02Icon}
strokeWidth={1.75}
className="size-5"
/>
</span>
<span aria-hidden="true" className="my-3 w-px bg-border" />
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitCreate();
}
}}
autoFocus={true}
disabled={busy}
maxLength={120}
placeholder="Project name"
aria-label="Project name"
className="min-w-0 flex-1 bg-transparent py-4 pr-4 pl-2.5 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
</div>
<ProjectSourceDropzone
staged={staged}
onChange={setStaged}
disabled={busy}
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
<Button type="button" variant="ghost" disabled={busy} onClick={close}>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitCreate()}
disabled={!name.trim()}
disabled={!name.trim() || busy}
>
Create
{busy ? "Creating…" : submitLabel}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -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<ProjectRecord | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [deleting, setDeleting] = useState<ProjectRecord | null>(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() {
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={() => {
setNameDraft("");
setCreating(true);
}}
>
New project
</Button>
<Button onClick={() => setCreating(true)}>New project</Button>
</div>
</div>
@ -511,10 +488,7 @@ export function ProjectsPage() {
<Button
variant="outline"
className="mt-2 border-none bg-background shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-none"
onClick={() => {
setNameDraft("");
setCreating(true);
}}
onClick={() => setCreating(true)}
>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={1.75} className="size-icon" />
Create your first project
@ -674,42 +648,8 @@ export function ProjectsPage() {
</>
)}
{/* Create project */}
<Dialog
open={creating}
onOpenChange={(open) => {
if (!open) setCreating(false);
}}
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-md">
<DialogHeader>
<DialogTitle>New project</DialogTitle>
</DialogHeader>
<Input
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitCreate();
}
}}
autoFocus
maxLength={120}
placeholder="Project name"
aria-label="Project name"
className="focus-visible:border-input focus-visible:ring-0"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void commitCreate()} disabled={!nameDraft.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create project (name + drag-and-drop sources) */}
<NewProjectDialog open={creating} onOpenChange={setCreating} />
{/* Rename project */}
<Dialog

View file

@ -0,0 +1,304 @@
// 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 { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { File02Icon, FolderAddIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { XIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import {
invalidateProjectSources,
uploadProjectDocument,
} from "../api/rag-api";
import { RAG_UPLOAD_ACCEPT } from "../types/rag";
import { resolveVisionOverrides } from "./vision-overrides";
/** A file picked before the project exists, held until create commits. */
export interface StagedSource {
id: string;
file: File;
}
// Client-side dedup key; backend dedups authoritatively by content hash.
function fileSignature(file: File): string {
return `${file.name}|${file.size}|${file.lastModified}`;
}
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 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<string>();
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<void> {
if (staged.length === 0) return;
invalidateProjectSources(projectId);
markProjectSourcesPending(projectId);
const { ocr, caption } = resolveVisionOverrides();
const documentIds = new Set<string>();
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<HTMLInputElement>(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 (
<div className="space-y-2.5">
<p className="text-ui-15 font-medium text-foreground">Sources</p>
{/* Panel is the drop target; the inner button owns the click so staged
rows can carry their own remove buttons. */}
<div
// preventDefault runs even while disabled: nothing else on the page
// cancels a file drop, so the browser would navigate to the file and
// kill the uploads in flight.
onDragEnter={(e) => {
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",
)}
>
<input
ref={inputRef}
type="file"
multiple={true}
accept={RAG_UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
addFiles(files);
}}
/>
{staged.length === 0 ? (
<button
type="button"
aria-label="Add sources"
disabled={disabled}
onClick={() => inputRef.current?.click()}
className="flex w-full cursor-pointer flex-col items-center justify-center gap-3 rounded-[22px] px-6 py-12 text-center transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={FolderAddIcon}
strokeWidth={1.75}
className="size-6 text-muted-foreground"
/>
<span className="text-sm text-muted-foreground">
Add files every chat in this project can read
</span>
</button>
) : (
<div className="flex flex-col gap-1 p-2">
<ul className="max-h-52 space-y-0.5 overflow-y-auto">
{staged.map((entry) => (
<li
key={entry.id}
className="flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 hover:bg-muted/50"
>
<HugeiconsIcon
icon={File02Icon}
strokeWidth={1.75}
className="size-4 shrink-0 text-muted-foreground"
/>
<span
className="min-w-0 flex-1 truncate text-ui-14 text-foreground"
title={entry.file.name}
>
{entry.file.name}
</span>
<span className="shrink-0 text-ui-11 text-muted-foreground">
{formatSize(entry.file.size)}
</span>
<button
type="button"
aria-label={`Remove ${entry.file.name}`}
disabled={disabled}
onClick={() =>
onChange(staged.filter((row) => row.id !== entry.id))
}
className="shrink-0 rounded-full text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
<XIcon className="size-3.5" />
</button>
</li>
))}
</ul>
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
className="flex items-center justify-center gap-2 rounded-[10px] py-2 text-ui-13 font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={FolderAddIcon}
strokeWidth={1.75}
className="size-4"
/>
Add files
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -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(

View file

@ -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,
};
}