mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 00:49:01 +00:00
studio: restore file drops and composer drag feedback (#13387)
Some checks are pending
CodeQL / Languages to analyse (push) Waiting to run
CodeQL / Analyze () (push) Blocked by required conditions
Core / Resolve the unsloth_zoo revision (push) Waiting to run
Core / Core (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / Core zoo (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core zoo (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Interrupted install recovery / select legs (push) Waiting to run
Interrupted install recovery / kill@ (push) Blocked by required conditions
Interrupted install recovery / windows kill@ (push) Blocked by required conditions
Kaggle T4 Notebook CI / gate (push) Waiting to run
Kaggle T4 Notebook CI / T4 smoke (push) Blocked by required conditions
Kaggle T4 Unsloth GPU CI / gate (push) Waiting to run
Kaggle T4 Unsloth GPU CI / Unsloth GPU smoke (push) Blocked by required conditions
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / connection (claude, stable) (push) Waiting to run
Local Agent Guides CI / connection (codex, stable) (push) Waiting to run
Local Agent Guides CI / connection (dsh, stable) (push) Waiting to run
Local Agent Guides CI / connection (hermes, stable) (push) Waiting to run
Local Agent Guides CI / connection (openclaw, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, v2) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (dsh) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (vibe) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Local Agent Guides CI / connection (pi, stable) (push) Waiting to run
Local Agent Guides CI / connection (vibe, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: audio-torch280 (push) Waiting to run
Security audit / pip scan-packages :: audio-torch290 (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.11, floor) (push) Waiting to run
Backend CI / (Python 3.13, a-k) (push) Waiting to run
Backend CI / (Python 3.13, l-r) (push) Waiting to run
Backend CI / (Python 3.13, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, studio) (push) Waiting to run
Backend CI / Repo tests (CPU, studio-install) (push) Waiting to run
Backend CI / Shell installer tests (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Studio VAE seam guard / seam-guard (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Unsloth UI + API + Update + Inference CI / Chat UI, API, Update and GGUF inference (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (macOS) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (Windows) (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
studiobench CI / Selftests, lint and the single-file build (push) Waiting to run
studiobench CI / Real-path session, every action ran (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Windows ARM64 CI / woa (pwsh-7) (push) Waiting to run
Windows ARM64 CI / woa (windows-powershell-5.1) (push) Waiting to run
Windows No Compiler CI / the installer spawns no C# compiler (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Workflow trigger lint / security suites (torch-dependent) (push) Waiting to run
Some checks are pending
CodeQL / Languages to analyse (push) Waiting to run
CodeQL / Analyze () (push) Blocked by required conditions
Core / Resolve the unsloth_zoo revision (push) Waiting to run
Core / Core (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / Core zoo (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core zoo (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Interrupted install recovery / select legs (push) Waiting to run
Interrupted install recovery / kill@ (push) Blocked by required conditions
Interrupted install recovery / windows kill@ (push) Blocked by required conditions
Kaggle T4 Notebook CI / gate (push) Waiting to run
Kaggle T4 Notebook CI / T4 smoke (push) Blocked by required conditions
Kaggle T4 Unsloth GPU CI / gate (push) Waiting to run
Kaggle T4 Unsloth GPU CI / Unsloth GPU smoke (push) Blocked by required conditions
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / connection (claude, stable) (push) Waiting to run
Local Agent Guides CI / connection (codex, stable) (push) Waiting to run
Local Agent Guides CI / connection (dsh, stable) (push) Waiting to run
Local Agent Guides CI / connection (hermes, stable) (push) Waiting to run
Local Agent Guides CI / connection (openclaw, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, v2) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (dsh) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (vibe) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Local Agent Guides CI / connection (pi, stable) (push) Waiting to run
Local Agent Guides CI / connection (vibe, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: audio-torch280 (push) Waiting to run
Security audit / pip scan-packages :: audio-torch290 (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.11, floor) (push) Waiting to run
Backend CI / (Python 3.13, a-k) (push) Waiting to run
Backend CI / (Python 3.13, l-r) (push) Waiting to run
Backend CI / (Python 3.13, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, studio) (push) Waiting to run
Backend CI / Repo tests (CPU, studio-install) (push) Waiting to run
Backend CI / Shell installer tests (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Studio VAE seam guard / seam-guard (push) Waiting to run
Mac Unsloth Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Unsloth UI + API + Update + Inference CI / Chat UI, API, Update and GGUF inference (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (macOS) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (Windows) (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
studiobench CI / Selftests, lint and the single-file build (push) Waiting to run
studiobench CI / Real-path session, every action ran (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Windows ARM64 CI / woa (pwsh-7) (push) Waiting to run
Windows ARM64 CI / woa (windows-powershell-5.1) (push) Waiting to run
Windows No Compiler CI / the installer spawns no C# compiler (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Workflow trigger lint / security suites (torch-dependent) (push) Waiting to run
* studio: restore native file drops to the main webview * studio: attach dropped documents to the composer and reuse drag feedback * studio: remove unused attachment badge state * studio: tidy native drag context import and stale drop comment Import NativeAttachmentDragContext from the native-intents barrel like its siblings, indent the new provider in ChatPage, and update the OFFICE_ATTACHMENT_EXTS doc comment, which still pointed at the removed OFFICE_DOC_EXTS. --------- Co-authored-by: mahiatlinux <mahiatlinux@users.noreply.github.com> Co-authored-by: Michael Han <107991372+shimmyshimmer@users.noreply.github.com>
This commit is contained in:
parent
d8696f7573
commit
5f38afbc2d
16 changed files with 282 additions and 228 deletions
|
|
@ -172,6 +172,7 @@ import {
|
|||
DeepResearchWebsiteAccessDialog,
|
||||
} from "@/features/chat/components/deep-research-composer-button";
|
||||
import {
|
||||
NativeAttachmentDragContext,
|
||||
type NativeIntent,
|
||||
useNativeAttachmentTargetKey,
|
||||
useNativeIntentStore,
|
||||
|
|
@ -2782,6 +2783,7 @@ const Composer: FC<{
|
|||
const aui = useAui();
|
||||
const isDictating = useAuiState((s) => s.composer.dictation != null);
|
||||
const pageDragging = useContext(PageDragContext);
|
||||
const nativeDragging = useContext(NativeAttachmentDragContext);
|
||||
const { overlay, closeOverlay } = useGeneratedImageOverlay();
|
||||
const setImageToolsEnabled = useChatRuntimeStore(
|
||||
(s) => s.setImageToolsEnabled,
|
||||
|
|
@ -5693,6 +5695,24 @@ const Composer: FC<{
|
|||
</>
|
||||
);
|
||||
|
||||
const dropOverlay = (
|
||||
<div
|
||||
className={cn(
|
||||
"aui-composer-drop-overlay pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[inherit] bg-background/90 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-data-[dragging=true]/dropzone:opacity-100 dark:bg-card/90",
|
||||
(pageDragging || nativeDragging) && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={AttachmentIcon}
|
||||
strokeWidth={2}
|
||||
className="size-6 text-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-primary">
|
||||
Drop files here
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PromptQueueContext.Provider value={queueContextValue}>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
|
|
@ -5733,10 +5753,9 @@ const Composer: FC<{
|
|||
/>
|
||||
) : null}
|
||||
{isTauri ? (
|
||||
// Phase 1 native model owns Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri once Phase 1d adds token bridging.
|
||||
<div className="aui-composer-attachment-dropzone unsloth-composer-surface relative z-10">
|
||||
{composerContent}
|
||||
{dropOverlay}
|
||||
</div>
|
||||
) : (
|
||||
<ComposerPrimitive.AttachmentDropzone
|
||||
|
|
@ -5746,24 +5765,7 @@ const Composer: FC<{
|
|||
onDropCapture={claimPortaledDrop}
|
||||
>
|
||||
{composerContent}
|
||||
{/* Gemini-style drop affordance, shown while a file is dragged over
|
||||
the composer. Absolute + pointer-events-none so the outline adds
|
||||
no layout shift and the drop still lands. */}
|
||||
<div
|
||||
className={cn(
|
||||
"aui-composer-drop-overlay pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-[inherit] bg-background/90 opacity-0 backdrop-blur-sm transition-opacity duration-150 group-data-[dragging=true]/dropzone:opacity-100 dark:bg-card/90",
|
||||
pageDragging && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={AttachmentIcon}
|
||||
strokeWidth={2}
|
||||
className="size-6 text-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium text-primary">
|
||||
Drop files here
|
||||
</span>
|
||||
</div>
|
||||
{dropOverlay}
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
)}
|
||||
</ComposerPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ import { chatLocalModelOptions } from "./local-model-options";
|
|||
import {
|
||||
type NativeIntent,
|
||||
NativeAttachmentTargetContext,
|
||||
NativeAttachmentDragContext,
|
||||
NativeModelChip,
|
||||
NativeModelDropOverlay,
|
||||
useNativeIntentStore,
|
||||
|
|
@ -3720,21 +3721,13 @@ export function ChatPage({
|
|||
),
|
||||
[hasActiveModel, loadNativeModelIntent],
|
||||
);
|
||||
// Dropped documents go to the thread bar, which owns the RAG upload and can materialize a thread
|
||||
// id for a chat that has not been sent to yet.
|
||||
const handleNativeAttachmentDrop = useCallback(
|
||||
(intents: NativeIntent[]) => {
|
||||
useNativeIntentStore.getState().addAttachments(artifactViewKey, intents);
|
||||
},
|
||||
[artifactViewKey],
|
||||
);
|
||||
const handleNativeImageDrop = useCallback(
|
||||
(intents: NativeIntent[]) => {
|
||||
useNativeIntentStore.getState().addImageAttachments(artifactViewKey, intents);
|
||||
},
|
||||
[artifactViewKey],
|
||||
);
|
||||
const handleNativeOpenDocumentDrop = useCallback(
|
||||
const handleNativeDocumentDrop = useCallback(
|
||||
(intents: NativeIntent[]) => {
|
||||
useNativeIntentStore
|
||||
.getState()
|
||||
|
|
@ -3768,9 +3761,8 @@ export function ChatPage({
|
|||
hasActiveModel,
|
||||
isModelLoading: Boolean(loadingModel) || modelLoading,
|
||||
onAutoLoad: handleNativeModelDropAutoLoad,
|
||||
onAttach: handleNativeAttachmentDrop,
|
||||
onAttachImages: handleNativeImageDrop,
|
||||
onAttachOpenDocuments: handleNativeOpenDocumentDrop,
|
||||
onAttachDocuments: handleNativeDocumentDrop,
|
||||
onAttachAudio: handleNativeAudioDrop,
|
||||
onAttachVideo: handleNativeVideoDrop,
|
||||
});
|
||||
|
|
@ -4966,12 +4958,16 @@ export function ChatPage({
|
|||
runtimeReady={projectRuntimeReady}
|
||||
/>
|
||||
) : (
|
||||
<NativeAttachmentTargetContext.Provider
|
||||
value={baseAttachmentTargetKey}
|
||||
<NativeAttachmentDragContext.Provider
|
||||
value={nativeModelDropState.status === "attach"}
|
||||
>
|
||||
<TemporaryChatSaveBridge />
|
||||
<SingleContent threadId={baseView.threadId} />
|
||||
</NativeAttachmentTargetContext.Provider>
|
||||
<NativeAttachmentTargetContext.Provider
|
||||
value={baseAttachmentTargetKey}
|
||||
>
|
||||
<TemporaryChatSaveBridge />
|
||||
<SingleContent threadId={baseView.threadId} />
|
||||
</NativeAttachmentTargetContext.Provider>
|
||||
</NativeAttachmentDragContext.Provider>
|
||||
)}
|
||||
</ChatRuntimeProvider>
|
||||
</ChatActiveContext.Provider>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export const NativeAttachmentDragContext = createContext(false);
|
||||
|
||||
export const NativeAttachmentTargetContext = createContext<string | null>(null);
|
||||
|
||||
export function useNativeAttachmentTargetKey(): string | null {
|
||||
|
|
|
|||
|
|
@ -10,30 +10,6 @@ function overlayCopy(state: NativeModelDropState): { title: string; description:
|
|||
state.reason ?? "Drop a .gguf model, or documents to chat with.",
|
||||
};
|
||||
}
|
||||
if (state.status === "attach") {
|
||||
// Only documents are indexed; images, audio and video ride the next message.
|
||||
const description =
|
||||
state.kind === "images" || state.kind === "audio" || state.kind === "video"
|
||||
? "Attached to your next message."
|
||||
: state.kind === "mixed"
|
||||
? "Documents indexed, attachments sent with your next message."
|
||||
: "Indexed for this chat.";
|
||||
const noun =
|
||||
state.kind === "images"
|
||||
? "image"
|
||||
: state.kind === "audio"
|
||||
? "audio file"
|
||||
: state.kind === "video"
|
||||
? "video"
|
||||
: "file";
|
||||
return {
|
||||
title:
|
||||
state.count === 1
|
||||
? `Drop to attach ${noun}`
|
||||
: `Drop to attach ${state.count} ${noun}s`,
|
||||
description,
|
||||
};
|
||||
}
|
||||
if (state.status === "valid" && state.action === "replace") {
|
||||
return {
|
||||
title: "Drop to replace model",
|
||||
|
|
@ -53,9 +29,10 @@ function overlayCopy(state: NativeModelDropState): { title: string; description:
|
|||
}
|
||||
|
||||
export function NativeModelDropOverlay({ state }: { state: NativeModelDropState }) {
|
||||
if (state.status === "attach") return null;
|
||||
const isIdle = state.status === "idle";
|
||||
const isAutoLoad =
|
||||
(state.status === "valid" && state.action !== "chip") || state.status === "attach";
|
||||
state.status === "valid" && state.action !== "chip";
|
||||
const isInvalid = state.status === "invalid";
|
||||
const { title, description } = overlayCopy(state);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,24 +14,14 @@ import {
|
|||
TEXT_ATTACHMENT_EXTENSIONS,
|
||||
isTextAttachmentName,
|
||||
} from "../chat/text-attachment-accept.ts";
|
||||
import {
|
||||
RAG_DOCUMENT_UPLOAD_ACCEPT,
|
||||
RAG_UPLOAD_ACCEPT,
|
||||
} from "../rag/types/rag.ts";
|
||||
import { MAX_AUDIO_FILES } from "../../lib/audio-utils.ts";
|
||||
|
||||
const DOC_EXTS = RAG_UPLOAD_ACCEPT.split(",").map((ext) =>
|
||||
ext.trim().toLowerCase(),
|
||||
);
|
||||
const DOC_EXTS = [".pdf", ".html", ".htm", ".docx", ".xlsx", ".xlsm", ".pptx"];
|
||||
const TEXT_EXTS = TEXT_ATTACHMENT_EXTENSIONS.map((ext) => ext.toLowerCase());
|
||||
|
||||
// RAG types are filtered out, so a dropped .txt/.md keeps being indexed.
|
||||
const TEXT_EXTS = TEXT_ATTACHMENT_EXTENSIONS.map((ext) =>
|
||||
ext.toLowerCase(),
|
||||
).filter((ext) => !DOC_EXTS.includes(ext));
|
||||
|
||||
/** Dropped paths with an inline adapter but no RAG parser. */
|
||||
export function isComposerAttachmentName(path: string): boolean {
|
||||
return (
|
||||
DOC_EXTS.some((ext) => path.toLowerCase().endsWith(ext)) ||
|
||||
isOpenDocumentAttachmentName(path) ||
|
||||
isRtfAttachmentName(path) ||
|
||||
isToolOnlyAttachmentName(path) ||
|
||||
|
|
@ -39,13 +29,6 @@ export function isComposerAttachmentName(path: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/** Documents only chat with files reads; composer-handled types stay there. */
|
||||
const OFFICE_DOC_EXTS = RAG_DOCUMENT_UPLOAD_ACCEPT.split(",").filter(
|
||||
(ext) => !isComposerAttachmentName(`file${ext}`),
|
||||
);
|
||||
|
||||
const DROP_DOC_EXTS = [...DOC_EXTS, ...OFFICE_DOC_EXTS];
|
||||
|
||||
function isTextDropName(path: string): boolean {
|
||||
const name = nativeFileName(path).toLowerCase();
|
||||
if (!name.includes(".") && isTextAttachmentName(name)) {
|
||||
|
|
@ -81,7 +64,7 @@ const VIDEO_EXTS = CHAT_VIDEO_DROP_ACCEPT.split(",").map((ext) =>
|
|||
);
|
||||
|
||||
/** What the window actually takes, for the rejection toast and the overlay. */
|
||||
export const SUPPORTED_DROP_HINT = `Supported files: ${DROP_DOC_EXTS.join(",")}, ${OPEN_DOCUMENT_ATTACHMENT_EXTENSIONS}, ${RTF_ATTACHMENT_EXTENSIONS}, source and text files, ${TOOL_ONLY_ATTACHMENT_EXTENSIONS} with Code on, ${CHAT_IMAGE_DROP_ACCEPT}, up to ${MAX_AUDIO_FILES} of ${CHAT_AUDIO_DROP_ACCEPT}, one of ${CHAT_VIDEO_DROP_ACCEPT}, or a single .gguf model.`;
|
||||
export const SUPPORTED_DROP_HINT = `Supported files: ${DOC_EXTS.join(",")}, ${OPEN_DOCUMENT_ATTACHMENT_EXTENSIONS}, ${RTF_ATTACHMENT_EXTENSIONS}, source and text files, ${TOOL_ONLY_ATTACHMENT_EXTENSIONS} with Code on, ${CHAT_IMAGE_DROP_ACCEPT}, up to ${MAX_AUDIO_FILES} of ${CHAT_AUDIO_DROP_ACCEPT}, one of ${CHAT_VIDEO_DROP_ACCEPT}, or a single .gguf model.`;
|
||||
|
||||
/** Last path segment of a native path, for display and extension checks. */
|
||||
export function nativeFileName(path: string): string {
|
||||
|
|
@ -119,11 +102,7 @@ export function classifyDropPaths(paths: string[]): NativeDropClass {
|
|||
? { kind: "model", path: ggufs[0] }
|
||||
: { kind: "unsupported" };
|
||||
}
|
||||
const docs = paths.filter(
|
||||
(path) =>
|
||||
DROP_DOC_EXTS.some((ext) => hasExt(path, ext)) ||
|
||||
isComposerAttachmentName(path),
|
||||
);
|
||||
const docs = paths.filter(isComposerAttachmentName);
|
||||
const images = paths.filter((path) =>
|
||||
IMAGE_EXTS.some((ext) => hasExt(path, ext)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export { useNativeFileDrop } from "./use-native-file-drop";
|
|||
export type { NativeFileDrop, NativeFileDropOptions } from "./use-native-file-drop";
|
||||
export {
|
||||
NativeAttachmentTargetContext,
|
||||
NativeAttachmentDragContext,
|
||||
useNativeAttachmentTargetKey,
|
||||
} from "./attachment-target";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ function setHovered(next: HTMLElement | null): void {
|
|||
function listen(): void {
|
||||
if (listening || !isTauri) return;
|
||||
listening = true;
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(async ({ getCurrentWindow }) => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
await currentWindow.onDragDropEvent(({ payload }) => {
|
||||
void Promise.all([
|
||||
import("@tauri-apps/api/webview"),
|
||||
import("@tauri-apps/api/window"),
|
||||
])
|
||||
.then(async ([{ getCurrentWebview }, { getCurrentWindow }]) => {
|
||||
await getCurrentWebview().onDragDropEvent(({ payload }) => {
|
||||
if (payload.type === "leave") {
|
||||
setHovered(null);
|
||||
return;
|
||||
|
|
@ -70,6 +72,7 @@ function listen(): void {
|
|||
// Scale is a refinement; the devicePixelRatio seed holds until it lands.
|
||||
// Failing here must not reset `listening`, or the next registration would
|
||||
// stack a second drop listener and double every drop.
|
||||
const currentWindow = getCurrentWindow();
|
||||
let scaleReported = false;
|
||||
await currentWindow
|
||||
.onScaleChanged(({ payload }) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { registerNativeAttachmentPath, registerNativeModelPath } from "./api";
|
|||
import {
|
||||
SUPPORTED_DROP_HINT,
|
||||
classifyDropPaths,
|
||||
isComposerAttachmentName,
|
||||
} from "./drop-paths";
|
||||
import { nativeDropTargetAt } from "./native-drop-targets";
|
||||
import { useNativeIntentStore } from "./store";
|
||||
|
|
@ -14,11 +13,7 @@ import type { NativeIntent } from "./types";
|
|||
export type NativeModelDropState =
|
||||
| { status: "idle" }
|
||||
| { status: "valid"; action: "load" | "replace" | "chip" }
|
||||
| {
|
||||
status: "attach";
|
||||
count: number;
|
||||
kind: "docs" | "images" | "audio" | "video" | "mixed";
|
||||
}
|
||||
| { status: "attach" }
|
||||
// `reason` explains a refusal the file types alone do not. Absent means the
|
||||
// files themselves are the problem.
|
||||
| { status: "invalid"; reason?: string };
|
||||
|
|
@ -35,36 +30,20 @@ interface NativeModelDropOptions {
|
|||
hasActiveModel: boolean;
|
||||
isModelLoading: boolean;
|
||||
onAutoLoad?: (intent: NativeIntent) => Promise<void> | void;
|
||||
onAttach?: (intents: NativeIntent[]) => Promise<void> | void;
|
||||
onAttachImages?: (intents: NativeIntent[]) => Promise<void> | void;
|
||||
onAttachOpenDocuments?: (intents: NativeIntent[]) => Promise<void> | void;
|
||||
onAttachDocuments?: (intents: NativeIntent[]) => Promise<void> | void;
|
||||
onAttachAudio?: (intents: NativeIntent[]) => Promise<void> | void;
|
||||
onAttachVideo?: (intents: NativeIntent[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function canAttachDocs(options: NativeModelDropOptions): boolean {
|
||||
return options.nativePathLeasesSupported && Boolean(options.onAttach);
|
||||
return Boolean(options.onAttachDocuments);
|
||||
}
|
||||
|
||||
function canAttachImages(options: NativeModelDropOptions): boolean {
|
||||
return Boolean(options.onAttachImages);
|
||||
}
|
||||
|
||||
function canAttachOpenDocuments(options: NativeModelDropOptions): boolean {
|
||||
return Boolean(options.onAttachOpenDocuments);
|
||||
}
|
||||
|
||||
function canAttachDocumentPaths(
|
||||
paths: string[],
|
||||
options: NativeModelDropOptions,
|
||||
): boolean {
|
||||
return paths.every((path) =>
|
||||
isComposerAttachmentName(path)
|
||||
? canAttachOpenDocuments(options)
|
||||
: canAttachDocs(options),
|
||||
);
|
||||
}
|
||||
|
||||
function canAttachAudio(options: NativeModelDropOptions): boolean {
|
||||
return Boolean(options.onAttachAudio);
|
||||
}
|
||||
|
|
@ -81,28 +60,6 @@ function canAutoLoadModel(options: NativeModelDropOptions): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function attachmentCount(
|
||||
dropped: ReturnType<typeof classifyDropPaths>,
|
||||
): number {
|
||||
if (
|
||||
dropped.kind === "docs" ||
|
||||
dropped.kind === "images" ||
|
||||
dropped.kind === "audio" ||
|
||||
dropped.kind === "video"
|
||||
) {
|
||||
return dropped.paths.length;
|
||||
}
|
||||
if (dropped.kind === "attach") {
|
||||
return (
|
||||
dropped.docs.length +
|
||||
dropped.images.length +
|
||||
dropped.audio.length +
|
||||
dropped.video.length
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Anything this handler would otherwise act on. "none" and "unsupported"
|
||||
* already have their own answers. */
|
||||
function isActionableKind(
|
||||
|
|
@ -121,27 +78,27 @@ function dropStateForPaths(
|
|||
return { status: "invalid", reason: options.dropsUnsupportedReason };
|
||||
}
|
||||
if (dropped.kind === "docs") {
|
||||
return canAttachDocumentPaths(dropped.paths, options)
|
||||
? { status: "attach", count: dropped.paths.length, kind: "docs" }
|
||||
return canAttachDocs(options)
|
||||
? { status: "attach" }
|
||||
: { status: "invalid" };
|
||||
}
|
||||
if (dropped.kind === "images") {
|
||||
return canAttachImages(options)
|
||||
? { status: "attach", count: dropped.paths.length, kind: "images" }
|
||||
? { status: "attach" }
|
||||
: { status: "invalid" };
|
||||
}
|
||||
if (dropped.kind === "audio") {
|
||||
return canAttachAudio(options)
|
||||
? { status: "attach", count: dropped.paths.length, kind: "audio" }
|
||||
? { status: "attach" }
|
||||
: { status: "invalid" };
|
||||
}
|
||||
if (dropped.kind === "video") {
|
||||
return canAttachVideo(options)
|
||||
? { status: "attach", count: dropped.paths.length, kind: "video" }
|
||||
? { status: "attach" }
|
||||
: { status: "invalid" };
|
||||
}
|
||||
if (dropped.kind === "attach") {
|
||||
const docsSupported = canAttachDocumentPaths(dropped.docs, options);
|
||||
const docsSupported = dropped.docs.length === 0 || canAttachDocs(options);
|
||||
const imagesSupported =
|
||||
dropped.images.length === 0 || canAttachImages(options);
|
||||
const audioSupported =
|
||||
|
|
@ -149,7 +106,7 @@ function dropStateForPaths(
|
|||
const videoSupported =
|
||||
dropped.video.length === 0 || canAttachVideo(options);
|
||||
return docsSupported && imagesSupported && audioSupported && videoSupported
|
||||
? { status: "attach", count: attachmentCount(dropped), kind: "mixed" }
|
||||
? { status: "attach" }
|
||||
: { status: "invalid" };
|
||||
}
|
||||
if (dropped.kind === "unsupported") return { status: "invalid" };
|
||||
|
|
@ -164,7 +121,6 @@ function dropStateForPaths(
|
|||
|
||||
interface RegisteredDrop {
|
||||
docs: NativeIntent[];
|
||||
composerDocuments: NativeIntent[];
|
||||
images: NativeIntent[];
|
||||
audio: NativeIntent[];
|
||||
video: NativeIntent[];
|
||||
|
|
@ -209,10 +165,6 @@ async function registerDroppedAttachments(
|
|||
: dropped.kind === "attach"
|
||||
? dropped.docs
|
||||
: [];
|
||||
const composerDocumentPaths = docPaths.filter(isComposerAttachmentName);
|
||||
const ragDocumentPaths = docPaths.filter(
|
||||
(path) => !isComposerAttachmentName(path),
|
||||
);
|
||||
const imagePaths =
|
||||
dropped.kind === "images"
|
||||
? dropped.paths
|
||||
|
|
@ -231,26 +183,23 @@ async function registerDroppedAttachments(
|
|||
: dropped.kind === "attach"
|
||||
? dropped.video
|
||||
: [];
|
||||
const [docs, composerDocuments, images, audio, video] = await Promise.all([
|
||||
registerEach(ragDocumentPaths),
|
||||
registerEach(composerDocumentPaths),
|
||||
const [docs, images, audio, video] = await Promise.all([
|
||||
registerEach(docPaths),
|
||||
registerEach(imagePaths),
|
||||
registerEach(audioPaths),
|
||||
registerEach(videoPaths),
|
||||
]);
|
||||
return {
|
||||
docs: docs.intents,
|
||||
composerDocuments: composerDocuments.intents,
|
||||
images: images.intents,
|
||||
audio: audio.intents,
|
||||
video: video.intents,
|
||||
docsFailed: docs.failed + composerDocuments.failed,
|
||||
docsFailed: docs.failed,
|
||||
imagesFailed: images.failed,
|
||||
audioFailed: audio.failed,
|
||||
videoFailed: video.failed,
|
||||
error:
|
||||
docs.error ??
|
||||
composerDocuments.error ??
|
||||
images.error ??
|
||||
audio.error ??
|
||||
video.error,
|
||||
|
|
@ -264,8 +213,6 @@ function sameDropState(
|
|||
if (a.status !== b.status) return false;
|
||||
if (a.status === "valid" && b.status === "valid")
|
||||
return a.action === b.action;
|
||||
if (a.status === "attach" && b.status === "attach")
|
||||
return a.count === b.count && a.kind === b.kind;
|
||||
if (a.status === "invalid" && b.status === "invalid")
|
||||
return a.reason === b.reason;
|
||||
return true;
|
||||
|
|
@ -297,9 +244,9 @@ export function useNativeModelDrop(
|
|||
const publish = (next: NativeModelDropState) =>
|
||||
setDropState((prev) => (sameDropState(prev, next) ? prev : next));
|
||||
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(({ getCurrentWindow }) =>
|
||||
getCurrentWindow().onDragDropEvent(async (event) => {
|
||||
void import("@tauri-apps/api/webview")
|
||||
.then(({ getCurrentWebview }) =>
|
||||
getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
const currentOptions = optionsRef.current;
|
||||
if (event.payload.type === "leave") {
|
||||
draggedPaths = [];
|
||||
|
|
@ -351,37 +298,21 @@ export function useNativeModelDrop(
|
|||
: dropped.kind === "attach"
|
||||
? dropped.docs
|
||||
: [];
|
||||
const composerDocumentPaths = documentPaths.filter(
|
||||
isComposerAttachmentName,
|
||||
);
|
||||
const needsRagDocuments = documentPaths.some(
|
||||
(path) => !isComposerAttachmentName(path),
|
||||
);
|
||||
const needsComposerDocuments = composerDocumentPaths.length > 0;
|
||||
const needsComposerAttachments =
|
||||
needsImages || needsComposerDocuments;
|
||||
const needsDocuments = documentPaths.length > 0;
|
||||
const needsComposerAttachments = needsImages || needsDocuments;
|
||||
const needsAudio =
|
||||
dropped.kind === "audio" ||
|
||||
(dropped.kind === "attach" && dropped.audio.length > 0);
|
||||
const needsVideo =
|
||||
dropped.kind === "video" ||
|
||||
(dropped.kind === "attach" && dropped.video.length > 0);
|
||||
if (needsRagDocuments && !canAttachDocs(currentOptions)) {
|
||||
toast.error("Attaching files needs the desktop backend", {
|
||||
description: "Retry once Unsloth has finished starting up.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (needsImages && !canAttachImages(currentOptions)) {
|
||||
toast.error("Attaching images is unavailable right now", {
|
||||
description: "Retry once this chat is ready for attachments.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
needsComposerDocuments &&
|
||||
!canAttachOpenDocuments(currentOptions)
|
||||
) {
|
||||
if (needsDocuments && !canAttachDocs(currentOptions)) {
|
||||
toast.error("Attaching files is unavailable right now", {
|
||||
description: "Retry once this chat is ready for attachments.",
|
||||
});
|
||||
|
|
@ -416,19 +347,14 @@ export function useNativeModelDrop(
|
|||
latestOptions.attachmentScope === currentOptions.attachmentScope
|
||||
? latestOptions
|
||||
: currentOptions;
|
||||
if (registered.docs.length > 0) {
|
||||
await attachOptions.onAttach?.(registered.docs);
|
||||
}
|
||||
// Documents first: a vision-less model throws on the image and
|
||||
// aborts the batch, which would discard them.
|
||||
const composerAttachments = [
|
||||
...registered.composerDocuments,
|
||||
...registered.docs,
|
||||
...registered.images,
|
||||
];
|
||||
if (registered.composerDocuments.length > 0) {
|
||||
await attachOptions.onAttachOpenDocuments?.(
|
||||
composerAttachments,
|
||||
);
|
||||
if (registered.docs.length > 0) {
|
||||
await attachOptions.onAttachDocuments?.(composerAttachments);
|
||||
} else if (registered.images.length > 0) {
|
||||
await attachOptions.onAttachImages?.(registered.images);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,8 +332,11 @@ export function useDatasetUploads() {
|
|||
);
|
||||
};
|
||||
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(async ({ getCurrentWindow }) => {
|
||||
void Promise.all([
|
||||
import("@tauri-apps/api/window"),
|
||||
import("@tauri-apps/api/webview"),
|
||||
])
|
||||
.then(async ([{ getCurrentWindow }, { getCurrentWebview }]) => {
|
||||
const currentWindow = getCurrentWindow();
|
||||
const stopScaleChanged = await currentWindow.onScaleChanged(
|
||||
({ payload }) => {
|
||||
|
|
@ -359,7 +362,7 @@ export function useDatasetUploads() {
|
|||
scaleFactor = currentScaleFactor;
|
||||
}
|
||||
|
||||
const stopDragDrop = await currentWindow.onDragDropEvent((event) => {
|
||||
const stopDragDrop = await getCurrentWebview().onDragDropEvent((event) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// bundler-resolver's two rules, plus "@tauri-apps/api/window" -> a local stub,
|
||||
// because the real package only resolves inside a Tauri webview.
|
||||
// Hold native drop registration pending without replacing the bundler resolution rules.
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ function firstExisting(base) {
|
|||
}
|
||||
|
||||
export function resolve(specifier, context, next) {
|
||||
if (specifier === "@tauri-apps/api/window") {
|
||||
if (specifier === "@tauri-apps/api/window" || specifier === "@tauri-apps/api/webview") {
|
||||
return next(WINDOW_STUB, context);
|
||||
}
|
||||
if (specifier.startsWith("@/")) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Stands in for @tauri-apps/api/window, which only resolves inside a Tauri
|
||||
// webview. Control lives on globalThis so a test can hold the drag-drop install
|
||||
// unresolved and inspect the window before it completes.
|
||||
// Keep webview drop installation pending while window scale queries stay independent.
|
||||
const control = (globalThis.__TAURI_WINDOW_STUB__ ??= {});
|
||||
|
||||
export function getCurrentWindow() {
|
||||
export function getCurrentWebview() {
|
||||
return {
|
||||
onDragDropEvent: (handler) =>
|
||||
new Promise((resolve) => {
|
||||
control.deliver = handler;
|
||||
control.installed = () => resolve(() => undefined);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentWindow() {
|
||||
return {
|
||||
onScaleChanged: async () => () => undefined,
|
||||
scaleFactor: async () => 1,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -114,15 +114,15 @@ const TOOL_ONLY_ADAPTER_RE =
|
|||
const OPEN_DOCUMENT_ADAPTER_ACCEPT_RE =
|
||||
/class OpenDocumentAttachmentAdapter[^{]*\{[\s\S]*?accept = OPEN_DOCUMENT_ATTACHMENT_ACCEPT;/;
|
||||
const OPEN_DOCUMENT_DROP_TO_COMPOSER_RE =
|
||||
/const composerAttachments = \[[\s\S]*?\.\.\.registered\.composerDocuments,[\s\S]*?\.\.\.registered\.images,[\s\S]*?await attachOptions\.onAttachOpenDocuments\?\.\([\s\S]*?composerAttachments/;
|
||||
/const composerAttachments = \[[\s\S]*?\.\.\.registered\.docs,[\s\S]*?\.\.\.registered\.images,[\s\S]*?await attachOptions\.onAttachDocuments\?\.\([\s\S]*?composerAttachments/;
|
||||
const OPEN_DOCUMENT_REGISTRATION_CLASS_RE =
|
||||
/const composerDocumentPaths = docPaths\.filter\(isComposerAttachmentName\);[\s\S]*?const ragDocumentPaths = docPaths\.filter[\s\S]*?registerEach\(ragDocumentPaths\),[\s\S]*?registerEach\(composerDocumentPaths\),[\s\S]*?composerDocuments: composerDocuments\.intents/;
|
||||
/registerEach\(docPaths\),[\s\S]*?docs: docs\.intents/;
|
||||
const OPEN_DOCUMENT_IMAGE_REGISTRATION_RE =
|
||||
/const needsComposerDocuments = composerDocumentPaths\.length > 0;[\s\S]*?const needsComposerAttachments =\s*needsImages \|\| needsComposerDocuments;[\s\S]*?if \(needsComposerAttachments\) store\.beginImageDropRegistration\(\);[\s\S]*?finally \{[\s\S]*?if \(needsComposerAttachments\) store\.endImageDropRegistration\(\)/;
|
||||
/const needsDocuments = documentPaths\.length > 0;[\s\S]*?const needsComposerAttachments = needsImages \|\| needsDocuments;[\s\S]*?if \(needsComposerAttachments\) store\.beginImageDropRegistration\(\);[\s\S]*?finally \{[\s\S]*?if \(needsComposerAttachments\) store\.endImageDropRegistration\(\)/;
|
||||
const OPEN_DOCUMENT_BACKEND_GATE_RE =
|
||||
/function canAttachDocumentPaths[\s\S]*?isComposerAttachmentName\(path\)[\s\S]*?\? canAttachOpenDocuments\(options\)[\s\S]*?: canAttachDocs\(options\)[\s\S]*?const needsRagDocuments = documentPaths\.some[\s\S]*?if \(needsRagDocuments && !canAttachDocs\(currentOptions\)\)/;
|
||||
/function canAttachDocs[\s\S]*?return Boolean\(options\.onAttachDocuments\);/;
|
||||
const OPEN_DOCUMENT_CHAT_QUEUE_RE =
|
||||
/const handleNativeOpenDocumentDrop = useCallback\([\s\S]*?addOpenDocumentAttachments\(artifactViewKey, intents\)[\s\S]*?onAttachOpenDocuments: handleNativeOpenDocumentDrop/;
|
||||
/const handleNativeDocumentDrop = useCallback\([\s\S]*?addOpenDocumentAttachments\(artifactViewKey, intents\)[\s\S]*?onAttachDocuments: handleNativeDocumentDrop/;
|
||||
const OPEN_DOCUMENT_DRAIN_RE =
|
||||
/takeOpenDocumentAttachments\(targetKey\)[\s\S]*?nativeAttachmentIntentToFile\(intent\)[\s\S]*?await aui\.composer\(\)\.addAttachment\(file\)/;
|
||||
|
||||
|
|
@ -220,16 +220,16 @@ for (const [format, extensions, adapter, rustList] of [
|
|||
});
|
||||
}
|
||||
|
||||
test("Office, Outlook and web page drops index in chat with files and match the native allowlist", () => {
|
||||
test("native chat accepts Office types with a composer adapter; native intake retains explicit RAG formats", () => {
|
||||
const office =
|
||||
".doc .xls .xlsx .xlsm .xltx .xltm .ppt .pptx .pptm .ppsx .ott .ots .otp .msg .mht .mhtml .xhtml .xht".split(
|
||||
" ",
|
||||
);
|
||||
for (const extension of office) {
|
||||
const path = `/docs/Report${extension.toUpperCase()}`;
|
||||
assert.equal(classifyDropPaths([path]).kind, "docs", path);
|
||||
assert.ok(!isComposerAttachmentName(path), path);
|
||||
assert.ok(SUPPORTED_DROP_HINT.includes(extension));
|
||||
const supported = [".xlsx", ".xlsm", ".pptx"].includes(extension);
|
||||
assert.equal(classifyDropPaths([path]).kind, supported ? "docs" : "unsupported", path);
|
||||
assert.equal(isComposerAttachmentName(path), supported, path);
|
||||
}
|
||||
// Types with a composer adapter keep going to the composer.
|
||||
for (const ext of [".odt", ".rtf", ".epub", ".eml", ".docm"]) {
|
||||
|
|
@ -1558,10 +1558,10 @@ test("gettext catalogs use the charset declared by their header", async () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("RAG documents keep their existing route", () => {
|
||||
test("text and PDF drops attach to the composer", () => {
|
||||
for (const path of ["/docs/notes.txt", "/docs/notes.md", "/docs/paper.pdf"]) {
|
||||
assert.equal(classifyDropPaths([path]).kind, "docs", path);
|
||||
assert.equal(isComposerAttachmentName(path), false, path);
|
||||
assert.equal(isComposerAttachmentName(path), true, path);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
101
studio/frontend/tests/native-drop-webview-routing.test.ts
Normal file
101
studio/frontend/tests/native-drop-webview-routing.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// 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 assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
||||
import { readSrc, readText, registerBundlerResolver } from "./helpers/kit.ts";
|
||||
|
||||
registerBundlerResolver();
|
||||
|
||||
class ElementStub {
|
||||
parentElement = null;
|
||||
}
|
||||
const zone = new ElementStub();
|
||||
Object.assign(globalThis, {
|
||||
HTMLElement: ElementStub,
|
||||
window: { crypto, devicePixelRatio: 1, location: { protocol: "tauri:" } },
|
||||
document: { elementFromPoint: () => zone },
|
||||
});
|
||||
|
||||
type Registration = {
|
||||
event: string;
|
||||
target: { kind: string; label: string };
|
||||
handler: number;
|
||||
};
|
||||
const registrations: Registration[] = [];
|
||||
mockIPC((command, args) => {
|
||||
if (command === "plugin:event|listen") {
|
||||
registrations.push(args as unknown as Registration);
|
||||
return registrations.length;
|
||||
}
|
||||
if (command === "plugin:window|scale_factor") return 1;
|
||||
});
|
||||
mockWindows("main");
|
||||
|
||||
const { nativeDropTargetAt, registerNativeDropTarget } = await import(
|
||||
"../src/features/native-intents/native-drop-targets.ts"
|
||||
);
|
||||
|
||||
function emitFromWebview(label: string, event: string, payload: unknown) {
|
||||
const internals = (window as unknown as {
|
||||
__TAURI_INTERNALS__: { runCallback: (id: number, event: unknown) => void };
|
||||
}).__TAURI_INTERNALS__;
|
||||
for (const listener of registrations) {
|
||||
if (
|
||||
listener.event === event &&
|
||||
listener.target.kind === "Webview" &&
|
||||
listener.target.label === label
|
||||
) {
|
||||
internals.runCallback(listener.handler, { event, id: listener.handler, payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("native targets receive main-webview drops without claiming browser-page drops", async () => {
|
||||
const delivered: string[][] = [];
|
||||
const hovered: boolean[] = [];
|
||||
const unregister = registerNativeDropTarget(zone as unknown as HTMLElement, {
|
||||
onDrop: (paths) => delivered.push(paths),
|
||||
onDragOver: (over) => hovered.push(over),
|
||||
});
|
||||
try {
|
||||
for (let i = 0; i < 100 && !nativeDropTargetAt({ x: 10, y: 10 }); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(nativeDropTargetAt({ x: 10, y: 10 }), zone);
|
||||
const payload = { position: { x: 10, y: 10 }, paths: ["/tmp/probe.txt"] };
|
||||
emitFromWebview("browser-test", "tauri://drag-drop", payload);
|
||||
assert.deepEqual(delivered, []);
|
||||
emitFromWebview("main", "tauri://drag-enter", payload);
|
||||
assert.deepEqual(hovered, [true]);
|
||||
emitFromWebview("main", "tauri://drag-drop", payload);
|
||||
assert.deepEqual(delivered, [["/tmp/probe.txt"]]);
|
||||
assert.deepEqual(hovered, [true, false]);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("chat and training subscribe to the same webview event source", () => {
|
||||
for (const path of [
|
||||
"features/native-intents/use-native-drop.ts",
|
||||
"features/studio/sections/use-dataset-uploads.ts",
|
||||
]) {
|
||||
const source = readSrc(path);
|
||||
assert.match(source, /getCurrentWebview\(\)\.onDragDropEvent/);
|
||||
assert.doesNotMatch(source, /(?:getCurrentWindow\(\)|currentWindow)\.onDragDropEvent/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Rust authorizes only drops on the main webview", () => {
|
||||
const main = readText("../../src-tauri/src/main.rs");
|
||||
const handler = main.slice(
|
||||
main.indexOf(".on_webview_event("),
|
||||
main.indexOf(".on_window_event("),
|
||||
);
|
||||
assert.match(handler, /webview.label\(\) == "main"/);
|
||||
assert.match(handler, /tauri::WebviewEvent::DragDrop\(tauri::DragDropEvent::Drop/);
|
||||
assert.match(handler, /note_dropped_paths\(paths\)/);
|
||||
assert.doesNotMatch(main, /tauri::WindowEvent::DragDrop/);
|
||||
});
|
||||
|
|
@ -2396,6 +2396,19 @@ fn main() {
|
|||
setup_unix_termination_signals(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.on_webview_event(|webview, event| {
|
||||
// The multi-webview runtime delivers OS drops to the webview, not the window.
|
||||
// Browser page drops must not authorize paths for Studio's native intake.
|
||||
if webview.label() == "main" {
|
||||
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) =
|
||||
event
|
||||
{
|
||||
webview
|
||||
.state::<native_intents::NativeIntakeState>()
|
||||
.note_dropped_paths(paths);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if window.label() == "main" {
|
||||
match event {
|
||||
|
|
@ -2408,13 +2421,6 @@ fn main() {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// Record real drops here, in Rust, so the renderer can only register paths the
|
||||
// OS actually handed to the native intake commands.
|
||||
if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
|
||||
window
|
||||
.state::<native_intents::NativeIntakeState>()
|
||||
.note_dropped_paths(paths);
|
||||
}
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Never close directly: the only window, so closing exits before the reap.
|
||||
api.prevent_close();
|
||||
|
|
|
|||
|
|
@ -615,7 +615,7 @@ const MAX_NATIVE_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
|
|||
// Matches the clipboard reader, so a dropped source file and a pasted one
|
||||
// accept the same sizes.
|
||||
const MAX_NATIVE_TEXT_BYTES: u64 = 20 * 1024 * 1024;
|
||||
// OpenDocument archives use the composer's larger archive limit.
|
||||
// Documents use the composer's larger archive and PDF limit.
|
||||
const MAX_NATIVE_OPEN_DOCUMENT_BYTES: u64 = 50 * 1024 * 1024;
|
||||
// Images stop lower: the composer throws over 20 MB without a toast and the
|
||||
// drain swallows it, so a larger read loses them silently.
|
||||
|
|
@ -640,6 +640,14 @@ fn attachment_mime_type(path: &Path) -> Option<&'static str> {
|
|||
}
|
||||
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"txt" => Some("text/plain"),
|
||||
"md" | "markdown" => Some("text/markdown"),
|
||||
"html" | "htm" => Some("text/html"),
|
||||
"pdf" => Some("application/pdf"),
|
||||
"docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||
"xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||
"xlsm" => Some("application/vnd.ms-excel.sheet.macroEnabled.12"),
|
||||
"pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"png" => Some("image/png"),
|
||||
"webp" => Some("image/webp"),
|
||||
|
|
@ -770,13 +778,19 @@ fn read_attachment_payload(entry: &NativePathEntry) -> Result<NativeAttachmentFi
|
|||
// reapplied below once the bytes say it is TypeScript.
|
||||
let max_bytes = if has_transport_stream_extension(path) {
|
||||
MAX_NATIVE_VIDEO_BYTES
|
||||
} else if is_text_attachment {
|
||||
} else if is_text_attachment || mime_type.starts_with("text/") {
|
||||
MAX_NATIVE_TEXT_BYTES
|
||||
} else if mime_type.starts_with("image/") {
|
||||
MAX_NATIVE_IMAGE_BYTES
|
||||
} else if mime_type.starts_with("video/") {
|
||||
MAX_NATIVE_VIDEO_BYTES
|
||||
} else if mime_type.starts_with("application/vnd.oasis.opendocument.") {
|
||||
} else if mime_type.starts_with("application/vnd.oasis.opendocument.")
|
||||
|| mime_type.starts_with("application/vnd.openxmlformats-officedocument.")
|
||||
|| matches!(
|
||||
mime_type,
|
||||
"application/pdf" | "application/vnd.ms-excel.sheet.macroEnabled.12"
|
||||
)
|
||||
{
|
||||
MAX_NATIVE_OPEN_DOCUMENT_BYTES
|
||||
} else {
|
||||
MAX_NATIVE_ATTACHMENT_BYTES
|
||||
|
|
@ -1099,20 +1113,43 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn document_token_is_refused_by_the_image_reader() {
|
||||
let path = temp_path("note").with_extension("pdf");
|
||||
fs::write(&path, b"%PDF-1.4").unwrap();
|
||||
let (_state, entry) = attachment_entry(&path);
|
||||
let Err(err) = read_attachment_payload(&entry) else {
|
||||
panic!("expected the read to be refused");
|
||||
};
|
||||
assert!(err.contains("Only chat attachments can be read inline"));
|
||||
let _ = fs::remove_file(path);
|
||||
fn composer_documents_read_inline_with_adapter_mime_types() {
|
||||
for (ext, mime) in [
|
||||
("txt", "text/plain"),
|
||||
("md", "text/markdown"),
|
||||
("markdown", "text/markdown"),
|
||||
("html", "text/html"),
|
||||
("htm", "text/html"),
|
||||
("pdf", "application/pdf"),
|
||||
(
|
||||
"docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
),
|
||||
(
|
||||
"xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
("xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12"),
|
||||
(
|
||||
"pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
),
|
||||
] {
|
||||
let path = temp_path("composer-document").with_extension(ext);
|
||||
fs::write(&path, b"document bytes").unwrap();
|
||||
let (_state, entry) = attachment_entry(&path);
|
||||
let payload = read_attachment_payload(&entry).unwrap();
|
||||
assert_eq!(payload.mime_type, mime, "{ext}");
|
||||
assert_eq!(BASE64.decode(payload.base64).unwrap(), b"document bytes");
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_attachments_read_inline_with_their_own_cap() {
|
||||
for (ext, mime) in [
|
||||
("txt", "text/plain"),
|
||||
("md", "text/markdown"),
|
||||
("cs", "text/plain"),
|
||||
("php", "text/plain"),
|
||||
("js", "text/plain"),
|
||||
|
|
@ -1213,6 +1250,24 @@ mod tests {
|
|||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_documents_use_the_document_cap() {
|
||||
let path = temp_path("document-cap").with_extension("pdf");
|
||||
fs::write(&path, vec![0u8; MAX_NATIVE_ATTACHMENT_BYTES as usize + 1]).unwrap();
|
||||
let (_state, entry) = attachment_entry(&path);
|
||||
assert!(read_attachment_payload(&entry).is_ok());
|
||||
fs::File::create(&path)
|
||||
.unwrap()
|
||||
.set_len(MAX_NATIVE_OPEN_DOCUMENT_BYTES + 1)
|
||||
.unwrap();
|
||||
let (_state, entry) = attachment_entry(&path);
|
||||
let Err(error) = read_attachment_payload(&entry) else {
|
||||
panic!("expected the document cap to reject the read");
|
||||
};
|
||||
assert!(error.contains("too large"));
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_token_issues_distinct_validate_and_load_grants() {
|
||||
let state = new_native_intake_state();
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ pub fn classify_native_model_path(path: &Path) -> Result<ClassifiedPath, String>
|
|||
|
||||
/// Document types the RAG ingest accepts; keep in sync with `config.SUPPORTED_UPLOAD_EXTS`.
|
||||
pub const ATTACHMENT_EXTS: &[&str] = &["pdf", "txt", "md", "markdown", "docx", "html", "htm"];
|
||||
/// RAG-only document types; keep in sync with `OFFICE_DOC_EXTS` in `drop-paths.ts`.
|
||||
/// Office, Outlook and saved-page types RAG sources accept from a drop zone; chat drops take only the
|
||||
/// ones with a composer adapter (`DOC_EXTS` in `drop-paths.ts`).
|
||||
pub const OFFICE_ATTACHMENT_EXTS: &[&str] = &[
|
||||
"doc", "xls", "xlsx", "xlsm", "xltx", "xltm", "ppt", "pptx", "pptm", "ppsx", "ott", "ots",
|
||||
"otp", "msg", "mht", "mhtml", "xhtml", "xht",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue