Add Unsloth desktop deep links (#7560)
* Add Unsloth desktop deep links * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address deep-link review feedback --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
71f7e1087b
commit
65b4d9d9e7
21 changed files with 707 additions and 22 deletions
10
studio/frontend/package-lock.json
generated
10
studio/frontend/package-lock.json
generated
|
|
@ -34,6 +34,7 @@
|
|||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "2.4.9",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
|
|
@ -6451,6 +6452,15 @@
|
|||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-deep-link": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz",
|
||||
"integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "2.4.9",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||
import { WebUpdateBanner } from "@/components/web/update-banner";
|
||||
import { fetchDeviceType } from "@/config/env";
|
||||
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
|
||||
import { DeepLinkHandler } from "@/features/deep-links";
|
||||
import { DownloadManagerPanel } from "@/features/hub/download-manager";
|
||||
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
|
||||
import {
|
||||
|
|
@ -500,6 +501,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||
<MotionConfig reducedMotion={REDUCED_MOTION_MAP[reduceMotion]}>
|
||||
<TooltipProvider>
|
||||
<AppearanceCustomizationEffect />
|
||||
<DeepLinkHandler />
|
||||
<TauriWrapper>{children}</TauriWrapper>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ const ModelsPage = lazyRouteComponent(
|
|||
export interface ModelsSearch {
|
||||
tab?: "discover" | "downloaded";
|
||||
model?: string;
|
||||
file?: string;
|
||||
|
||||
intent?: number;
|
||||
section?: "trending" | "latest" | "finetune";
|
||||
kind?: "models" | "datasets";
|
||||
}
|
||||
|
|
@ -28,6 +31,18 @@ export const Route = createRoute({
|
|||
if (raw === "discover" || raw === "downloaded") next.tab = raw;
|
||||
const model = search.model;
|
||||
if (typeof model === "string" && model.length > 0) next.model = model;
|
||||
const file = search.file;
|
||||
if (next.model && typeof file === "string" && file.length > 0)
|
||||
next.file = file;
|
||||
|
||||
const intent = search.intent;
|
||||
if (
|
||||
next.file &&
|
||||
typeof intent === "number" &&
|
||||
Number.isSafeInteger(intent)
|
||||
) {
|
||||
next.intent = intent;
|
||||
}
|
||||
const section = search.section;
|
||||
if (
|
||||
section === "trending" ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
// 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 { isTauri } from "@/lib/api-base";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { createDeepLinkIntentGate } from "./deep-link-intent";
|
||||
import { parseUnslothDeepLink } from "./parse-deep-link";
|
||||
|
||||
const acceptIntent = createDeepLinkIntentGate(2_000);
|
||||
|
||||
async function restoreMainWindow(): Promise<void> {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
const window = getCurrentWindow();
|
||||
await window.show();
|
||||
await window.unminimize();
|
||||
await window.setFocus();
|
||||
}
|
||||
|
||||
export function DeepLinkHandler() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
let disposed = false;
|
||||
let receivedLiveIntent = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const handleUrls = (urls: string[]): boolean => {
|
||||
if (disposed) return false;
|
||||
|
||||
let hasValidIntent = false;
|
||||
let intent: ReturnType<typeof parseUnslothDeepLink> = null;
|
||||
|
||||
let intentSequence: number | null = null;
|
||||
for (const rawUrl of urls) {
|
||||
const parsed = parseUnslothDeepLink(rawUrl);
|
||||
if (!parsed) continue;
|
||||
hasValidIntent = true;
|
||||
const sequence = acceptIntent(parsed.model, parsed.file);
|
||||
if (sequence !== null) {
|
||||
intent = parsed;
|
||||
intentSequence = sequence;
|
||||
}
|
||||
}
|
||||
if (!intent || intentSequence === null) return hasValidIntent;
|
||||
|
||||
void restoreMainWindow().catch(() => undefined);
|
||||
void navigate({
|
||||
to: "/hub",
|
||||
search: {
|
||||
tab: "discover",
|
||||
kind: "models",
|
||||
model: intent.model,
|
||||
file: intent.file,
|
||||
|
||||
intent: intentSequence,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
async function subscribe() {
|
||||
const { getCurrent, onOpenUrl } =
|
||||
await import("@tauri-apps/plugin-deep-link");
|
||||
if (disposed) return;
|
||||
|
||||
const cleanup = await onOpenUrl((urls) => {
|
||||
if (handleUrls(urls)) receivedLiveIntent = true;
|
||||
});
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
|
||||
const currentUrls = await getCurrent();
|
||||
if (currentUrls && !receivedLiveIntent) handleUrls(currentUrls);
|
||||
}
|
||||
|
||||
void subscribe().catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
24
studio/frontend/src/features/deep-links/deep-link-intent.ts
Normal file
24
studio/frontend/src/features/deep-links/deep-link-intent.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export function createDeepLinkIntentGate(
|
||||
deduplicationWindowMs: number,
|
||||
now: () => number = Date.now,
|
||||
) {
|
||||
let lastIntent: { key: string; handledAt: number } | null = null;
|
||||
let sequence = 0;
|
||||
|
||||
return (model: string, file?: string): number | null => {
|
||||
const handledAt = now();
|
||||
const key = `${model}\0${file ?? ""}`;
|
||||
if (
|
||||
lastIntent?.key === key &&
|
||||
handledAt - lastIntent.handledAt < deduplicationWindowMs
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
lastIntent = { key, handledAt };
|
||||
sequence += 1;
|
||||
return sequence;
|
||||
};
|
||||
}
|
||||
4
studio/frontend/src/features/deep-links/index.ts
Normal file
4
studio/frontend/src/features/deep-links/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { DeepLinkHandler } from "./deep-link-handler";
|
||||
101
studio/frontend/src/features/deep-links/parse-deep-link.ts
Normal file
101
studio/frontend/src/features/deep-links/parse-deep-link.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
|
||||
|
||||
const MAX_REPO_ID_SEGMENT_LENGTH = 96;
|
||||
const MAX_GGUF_FILE_LENGTH = 512;
|
||||
const REPO_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
||||
function hasControlCharacters(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
export interface UnslothDeepLinkIntent {
|
||||
model: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
function isValidRepoSegment(segment: string): boolean {
|
||||
return (
|
||||
segment.length <= MAX_REPO_ID_SEGMENT_LENGTH &&
|
||||
REPO_SEGMENT.test(segment) &&
|
||||
!segment.includes("--") &&
|
||||
!segment.includes("..")
|
||||
);
|
||||
}
|
||||
|
||||
function isValidGgufFile(file: string): boolean {
|
||||
if (
|
||||
file.length === 0 ||
|
||||
file.length > MAX_GGUF_FILE_LENGTH ||
|
||||
file !== file.trim() ||
|
||||
hasControlCharacters(file) ||
|
||||
file.includes("\\") ||
|
||||
file.startsWith("/") ||
|
||||
!file.toLowerCase().endsWith(".gguf")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return file
|
||||
.split("/")
|
||||
.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
||||
}
|
||||
|
||||
export function parseUnslothDeepLink(
|
||||
rawUrl: string,
|
||||
): UnslothDeepLinkIntent | null {
|
||||
const queryIndex = rawUrl.indexOf("?");
|
||||
const target = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
|
||||
if (
|
||||
target !== "unsloth://open_from_hf" &&
|
||||
target !== "unsloth://open_from_hf/"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
url.protocol !== "unsloth:" ||
|
||||
url.hostname !== "open_from_hf" ||
|
||||
(url.pathname !== "" && url.pathname !== "/") ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.port !== "" ||
|
||||
url.hash !== ""
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = [...url.searchParams.keys()];
|
||||
if (
|
||||
keys.length < 1 ||
|
||||
keys.length > 2 ||
|
||||
!keys.includes("model") ||
|
||||
new Set(keys).size !== keys.length ||
|
||||
keys.some((key) => key !== "model" && key !== "file")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const model = url.searchParams.get("model") ?? "";
|
||||
const segments = model.split("/");
|
||||
if (
|
||||
model.endsWith(".git") ||
|
||||
segments.length !== 2 ||
|
||||
!segments.every(isValidRepoSegment)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = url.searchParams.get("file");
|
||||
if (file !== null && !isValidGgufFile(file)) return null;
|
||||
|
||||
return file === null ? { model } : { model, file };
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ export function DownloadSection({
|
|||
canRun = true,
|
||||
isActive,
|
||||
activeQuant,
|
||||
preferredGgufFile = null,
|
||||
|
||||
preferredGgufFileIntent = 0,
|
||||
isLoadingThisModel,
|
||||
gpuGb,
|
||||
systemRamGb,
|
||||
|
|
@ -35,6 +38,9 @@ export function DownloadSection({
|
|||
canRun?: boolean;
|
||||
isActive: boolean;
|
||||
activeQuant: string | null;
|
||||
preferredGgufFile?: string | null;
|
||||
|
||||
preferredGgufFileIntent?: number;
|
||||
isLoadingThisModel: boolean;
|
||||
gpuGb?: number;
|
||||
systemRamGb?: number;
|
||||
|
|
@ -46,12 +52,15 @@ export function DownloadSection({
|
|||
onTrain?: () => void;
|
||||
onChange?: () => void;
|
||||
}) {
|
||||
if (isGguf) {
|
||||
if (isGguf || preferredGgufFile) {
|
||||
return (
|
||||
<GgufDownloadCard
|
||||
repoId={repoId}
|
||||
isActive={isActive}
|
||||
activeQuant={activeQuant}
|
||||
preferredFile={preferredGgufFile}
|
||||
|
||||
preferredFileIntent={preferredGgufFileIntent}
|
||||
isLoadingThisModel={isLoadingThisModel}
|
||||
gpuGb={gpuGb}
|
||||
systemRamGb={systemRamGb}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ import { useOnlineStatus } from "../hooks/use-online-status";
|
|||
import { type GgufVariantDetail, deleteCachedModel } from "../inventory";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { type GgufFitClass, classifyGgufFit } from "../lib/gguf-fit";
|
||||
import {
|
||||
ggufFilenamesMatch,
|
||||
ggufSelectionOverrideMatchesIntent,
|
||||
} from "../lib/gguf-filename";
|
||||
import {
|
||||
ggufVariantDisplayLabel,
|
||||
ggufVariantDownloadSizeBytes,
|
||||
|
|
@ -540,6 +544,9 @@ export function GgufDownloadCard({
|
|||
repoId,
|
||||
isActive,
|
||||
activeQuant,
|
||||
preferredFile = null,
|
||||
|
||||
preferredFileIntent = 0,
|
||||
isLoadingThisModel,
|
||||
gpuGb,
|
||||
systemRamGb,
|
||||
|
|
@ -553,6 +560,9 @@ export function GgufDownloadCard({
|
|||
repoId: string;
|
||||
isActive: boolean;
|
||||
activeQuant: string | null;
|
||||
preferredFile?: string | null;
|
||||
|
||||
preferredFileIntent?: number;
|
||||
isLoadingThisModel: boolean;
|
||||
gpuGb?: number;
|
||||
systemRamGb?: number;
|
||||
|
|
@ -579,9 +589,25 @@ export function GgufDownloadCard({
|
|||
repoId: string;
|
||||
quant: string | null;
|
||||
userPicked?: boolean;
|
||||
preferredFile?: string | null;
|
||||
|
||||
preferredFileIntent?: number;
|
||||
}>(() => ({ repoId, quant: null }));
|
||||
const preferredQuant = preferredFile
|
||||
? (variants?.find((variant) =>
|
||||
ggufFilenamesMatch(variant.filename, preferredFile),
|
||||
)?.quant ?? null)
|
||||
: null;
|
||||
const selectedQuantOverride =
|
||||
selectedQuantState.repoId === repoId ? selectedQuantState.quant : null;
|
||||
selectedQuantState.repoId === repoId &&
|
||||
ggufSelectionOverrideMatchesIntent(
|
||||
preferredFile,
|
||||
preferredFileIntent,
|
||||
selectedQuantState.preferredFile,
|
||||
selectedQuantState.preferredFileIntent,
|
||||
)
|
||||
? selectedQuantState.quant
|
||||
: preferredQuant;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [updateTarget, setUpdateTarget] = useState<string | null>(null);
|
||||
|
|
@ -732,10 +758,13 @@ export function GgufDownloadCard({
|
|||
repoId,
|
||||
quant,
|
||||
userPicked: true,
|
||||
preferredFile,
|
||||
|
||||
preferredFileIntent,
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
[repoId],
|
||||
[preferredFile, preferredFileIntent, repoId],
|
||||
);
|
||||
const handleDeleteVariant = useCallback((quant: string) => {
|
||||
setDeleteTarget(quant);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import {
|
|||
deleteCachedModel,
|
||||
} from "../inventory";
|
||||
import { formatBytes } from "../lib/format";
|
||||
|
||||
import {
|
||||
ggufFilenamesMatch,
|
||||
ggufSelectionOverrideMatchesIntent,
|
||||
} from "../lib/gguf-filename";
|
||||
import {
|
||||
ggufVariantDisplayLabel,
|
||||
sortLocalGgufVariants,
|
||||
|
|
@ -87,6 +92,9 @@ interface LocalOnDeviceCardProps {
|
|||
activeGgufVariant?: string | null;
|
||||
isLoading: boolean;
|
||||
loadingPhase?: "downloading" | "starting";
|
||||
preferredFile?: string | null;
|
||||
preferredFileIntent?: number;
|
||||
|
||||
gpuGb?: number;
|
||||
systemRamGb?: number;
|
||||
unsupportedReason?: string | null;
|
||||
|
|
@ -207,6 +215,9 @@ export function LocalOnDeviceCard({
|
|||
activeGgufVariant = null,
|
||||
isLoading,
|
||||
loadingPhase,
|
||||
preferredFile = null,
|
||||
preferredFileIntent = 0,
|
||||
|
||||
gpuGb,
|
||||
systemRamGb,
|
||||
unsupportedReason,
|
||||
|
|
@ -281,6 +292,8 @@ export function LocalOnDeviceCard({
|
|||
const [selectedVariantState, setSelectedVariantState] = useState<{
|
||||
key: string;
|
||||
quant: string | null;
|
||||
preferredFile?: string | null;
|
||||
preferredFileIntent?: number;
|
||||
}>(() => ({
|
||||
key: variantKey,
|
||||
quant: null,
|
||||
|
|
@ -324,8 +337,21 @@ export function LocalOnDeviceCard({
|
|||
systemRamGb,
|
||||
],
|
||||
);
|
||||
const preferredQuant = preferredFile
|
||||
? (variants?.find((variant) =>
|
||||
ggufFilenamesMatch(variant.filename, preferredFile),
|
||||
)?.quant ?? null)
|
||||
: null;
|
||||
const selectedVariantOverride =
|
||||
selectedVariantState.key === variantKey ? selectedVariantState.quant : null;
|
||||
selectedVariantState.key === variantKey &&
|
||||
ggufSelectionOverrideMatchesIntent(
|
||||
preferredFile,
|
||||
preferredFileIntent,
|
||||
selectedVariantState.preferredFile,
|
||||
selectedVariantState.preferredFileIntent,
|
||||
)
|
||||
? selectedVariantState.quant
|
||||
: preferredQuant;
|
||||
const selectedQuant =
|
||||
selectedVariantOverride &&
|
||||
sortedVariants?.some((variant) =>
|
||||
|
|
@ -502,6 +528,9 @@ export function LocalOnDeviceCard({
|
|||
setSelectedVariantState({
|
||||
key: variantKey,
|
||||
quant: variant.quant,
|
||||
|
||||
preferredFile,
|
||||
preferredFileIntent,
|
||||
});
|
||||
setVariantOpen(false);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -409,6 +409,9 @@ export const ModelInspector = memo(function ModelInspector({
|
|||
model,
|
||||
runtime,
|
||||
actions,
|
||||
preferredGgufFile = null,
|
||||
|
||||
preferredGgufFileIntent = 0,
|
||||
isDataset = false,
|
||||
metadataUnavailable = false,
|
||||
selectionHiddenByFilters = false,
|
||||
|
|
@ -417,6 +420,9 @@ export const ModelInspector = memo(function ModelInspector({
|
|||
isDataset?: boolean;
|
||||
metadataUnavailable?: boolean;
|
||||
selectionHiddenByFilters?: boolean;
|
||||
preferredGgufFile?: string | null;
|
||||
|
||||
preferredGgufFileIntent?: number;
|
||||
runtime: ModelInspectorRuntime;
|
||||
actions: ModelInspectorActions;
|
||||
}) {
|
||||
|
|
@ -693,6 +699,9 @@ export const ModelInspector = memo(function ModelInspector({
|
|||
loadingPhase={loadingPhase}
|
||||
gpuGb={gpuGb}
|
||||
systemRamGb={systemRamGb}
|
||||
|
||||
preferredFile={preferredGgufFile}
|
||||
preferredFileIntent={preferredGgufFileIntent}
|
||||
unsupportedReason={
|
||||
unslothSupport.status === "unsupported"
|
||||
? (unslothSupport.reason ?? "Unsupported format")
|
||||
|
|
@ -717,6 +726,9 @@ export const ModelInspector = memo(function ModelInspector({
|
|||
canRun={canRunModel}
|
||||
isActive={isActive}
|
||||
activeQuant={isActive ? (activeGgufVariant ?? null) : null}
|
||||
preferredGgufFile={preferredGgufFile}
|
||||
|
||||
preferredGgufFileIntent={preferredGgufFileIntent}
|
||||
isLoadingThisModel={isLoadingThisModel}
|
||||
gpuGb={gpuGb}
|
||||
systemRamGb={systemRamGb}
|
||||
|
|
|
|||
|
|
@ -339,7 +339,9 @@ export function ModelsPage() {
|
|||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const hubSearch = useSearch({ from: "/hub" });
|
||||
const urlModel = hubSearch.model ?? null;
|
||||
const preferredGgufFile = hubSearch.file ?? null;
|
||||
|
||||
const preferredGgufFileIntent = hubSearch.intent ?? 0;
|
||||
const { selectModel, loadingModel, loadProgress, ejectModel } =
|
||||
useChatModelRuntime();
|
||||
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
|
||||
|
|
@ -1031,7 +1033,7 @@ export function ModelsPage() {
|
|||
setSelected(id);
|
||||
void navigate({
|
||||
to: "/hub",
|
||||
search: (prev) => ({ ...prev, model: id }),
|
||||
search: (prev) => ({ ...prev, model: id, file: undefined }),
|
||||
});
|
||||
},
|
||||
[setSelected, navigate],
|
||||
|
|
@ -1117,7 +1119,7 @@ export function ModelsPage() {
|
|||
setSelected(firstId);
|
||||
void navigate({
|
||||
to: "/hub",
|
||||
search: (prev) => ({ ...prev, model: firstId }),
|
||||
search: (prev) => ({ ...prev, model: firstId, file: undefined }),
|
||||
replace: true,
|
||||
});
|
||||
}, [
|
||||
|
|
@ -1604,6 +1606,9 @@ export function ModelsPage() {
|
|||
<div className="hub-canvas z-20 flex min-h-0 flex-col max-lg:absolute max-lg:inset-0 lg:relative lg:min-w-0 lg:flex-1">
|
||||
<HubDetailView
|
||||
model={selectedModel}
|
||||
preferredGgufFile={preferredGgufFile}
|
||||
|
||||
preferredGgufFileIntent={preferredGgufFileIntent}
|
||||
isDataset={isDatasetMode}
|
||||
metadataUnavailable={metadataUnavailable}
|
||||
selectionHiddenByFilters={selectionHiddenByFilters}
|
||||
|
|
@ -1623,6 +1628,9 @@ export function ModelsPage() {
|
|||
<div className="hub-canvas absolute inset-0 z-20 flex min-h-0 flex-col">
|
||||
<HubDetailView
|
||||
model={selectedModel}
|
||||
preferredGgufFile={preferredGgufFile}
|
||||
|
||||
preferredGgufFileIntent={preferredGgufFileIntent}
|
||||
isDataset={isDatasetMode}
|
||||
metadataUnavailable={metadataUnavailable}
|
||||
selectionHiddenByFilters={selectionHiddenByFilters}
|
||||
|
|
|
|||
33
studio/frontend/src/features/hub/lib/gguf-filename.ts
Normal file
33
studio/frontend/src/features/hub/lib/gguf-filename.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
const GGUF_SPLIT_SUFFIX = /-\d{3,}-of-\d{3,}(?=\.gguf$)/i;
|
||||
|
||||
function normalizeGgufFilename(filename: string): string {
|
||||
return filename
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(GGUF_SPLIT_SUFFIX, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function ggufFilenamesMatch(
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
): boolean {
|
||||
if (!(left && right)) return false;
|
||||
return normalizeGgufFilename(left) === normalizeGgufFilename(right);
|
||||
}
|
||||
|
||||
export function ggufSelectionOverrideMatchesIntent(
|
||||
preferredFile: string | null | undefined,
|
||||
preferredFileIntent: number,
|
||||
selectedPreferredFile: string | null | undefined,
|
||||
selectedPreferredFileIntent: number | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
!preferredFile ||
|
||||
(selectedPreferredFile === preferredFile &&
|
||||
selectedPreferredFileIntent === preferredFileIntent)
|
||||
);
|
||||
}
|
||||
126
studio/src-tauri/Cargo.lock
generated
126
studio/src-tauri/Cargo.lock
generated
|
|
@ -558,6 +558,26 @@ version = "0.10.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
|
|
@ -896,7 +916,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -945,6 +965,15 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
|
|
@ -1111,7 +1140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1738,6 +1767,12 @@ version = "0.12.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
|
|
@ -1941,7 +1976,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2531,7 +2566,7 @@ dependencies = [
|
|||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2944,6 +2979,16 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2961,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3824,6 +3869,16 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
|
|
@ -3849,7 +3904,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3905,7 +3960,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4347,7 +4402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4774,6 +4829,27 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry 0.5.3",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
|
|
@ -4876,6 +4952,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
|
|
@ -5014,7 +5091,7 @@ dependencies = [
|
|||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
|
|
@ -5051,10 +5128,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5174,6 +5251,15 @@ dependencies = [
|
|||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
|
@ -5460,7 +5546,7 @@ dependencies = [
|
|||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5500,7 +5586,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5590,6 +5676,7 @@ dependencies = [
|
|||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
|
|
@ -6069,7 +6156,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6257,6 +6344,17 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@
|
|||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:tray:default",
|
||||
"process:default",
|
||||
"deep-link:default",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
|
|
|
|||
12
studio/src-tauri/linux/unsloth.desktop
Normal file
12
studio/src-tauri/linux/unsloth.desktop
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[Desktop Entry]
|
||||
Categories={{categories}}
|
||||
{{#if comment}}
|
||||
Comment={{comment}}
|
||||
{{/if}}
|
||||
Exec={{exec}} %u
|
||||
StartupWMClass={{exec}}
|
||||
Icon={{icon}}
|
||||
Name={{name}}
|
||||
Terminal=false
|
||||
Type=Application
|
||||
MimeType=x-scheme-handler/unsloth;
|
||||
|
|
@ -17,7 +17,7 @@ mod process;
|
|||
mod update;
|
||||
mod windows_job;
|
||||
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
use process::new_backend_state;
|
||||
use simplelog::{
|
||||
CombinedLogger, Config, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger,
|
||||
|
|
@ -176,9 +176,11 @@ fn main() {
|
|||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
|
|
@ -234,6 +236,13 @@ fn main() {
|
|||
has_saved_window_state,
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
if let Err(error) = app.deep_link().register_all() {
|
||||
warn!("Failed to register deep-link handlers: {error}");
|
||||
}
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
setup_custom_titlebar(app)?;
|
||||
setup_tray(app)?;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@
|
|||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["unsloth"]
|
||||
}
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFBQzA4RjczODM0RjE1QjcKUldTM0ZVK0RjNC9BR2t4R0RVaFR5cTkyUlRVQ1FwaGV0Nk04eWNwWXBhZnlzalJydllmZm1QTS8K",
|
||||
"endpoints": [
|
||||
|
|
@ -80,6 +85,7 @@
|
|||
"bundleMediaFramework": false
|
||||
},
|
||||
"deb": {
|
||||
"desktopTemplate": "./linux/unsloth.desktop",
|
||||
"postRemoveScript": "./linux/postremove.sh"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue