Studio: make tab navigation feel immediate (#7271)
* Studio: make repeated tab switches feel immediate * Keep cached Studio navigation data fresh * Make first Studio tab visits responsive * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Serve range requests uncompressed for immutable assets (PR #7271) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: test <test@test.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
parent
3d379cdb81
commit
27f3473c7e
18 changed files with 516 additions and 107 deletions
|
|
@ -289,6 +289,7 @@ from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
|
@ -1509,6 +1510,34 @@ def _should_inject_bootstrap(request: Request) -> bool:
|
|||
return _is_local_bootstrap_request(request)
|
||||
|
||||
|
||||
_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
class ImmutableStaticFiles(StaticFiles):
|
||||
"""Serve Vite's content-hashed assets without browser revalidation."""
|
||||
|
||||
def file_response(
|
||||
self,
|
||||
full_path,
|
||||
stat_result,
|
||||
scope,
|
||||
status_code = 200,
|
||||
):
|
||||
response = super().file_response(full_path, stat_result, scope, status_code)
|
||||
response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
|
||||
class _AssetGZipMiddleware(GZipMiddleware):
|
||||
"""Serve range requests uncompressed; gzip + 206 mislabels Content-Range."""
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http" and any(key == b"range" for key, _ in scope["headers"]):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
def setup_frontend(app: FastAPI, build_path: Path):
|
||||
"""Mount frontend static files (optional)"""
|
||||
if not build_path.exists():
|
||||
|
|
@ -1516,7 +1545,12 @@ def setup_frontend(app: FastAPI, build_path: Path):
|
|||
|
||||
assets_dir = build_path / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
|
||||
assets_app = _AssetGZipMiddleware(
|
||||
ImmutableStaticFiles(directory = assets_dir),
|
||||
minimum_size = 1024,
|
||||
compresslevel = 6,
|
||||
)
|
||||
app.mount("/assets", assets_app, name = "assets")
|
||||
|
||||
def _build_index_response(request: Request) -> Response:
|
||||
content = (build_path / "index.html").read_bytes()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import pytest
|
|||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -471,6 +472,71 @@ class TestSecurityHeadersMiddleware:
|
|||
assert b"server" in names
|
||||
|
||||
|
||||
class TestFrontendAssets:
|
||||
def test_hashed_assets_are_compressed_and_cached(self, tmp_path, main_module):
|
||||
content = b"export const value = 'responsive';\n" * 200
|
||||
(tmp_path / "page-abc123.js").write_bytes(content)
|
||||
app = FastAPI()
|
||||
assets_app = GZipMiddleware(
|
||||
main_module.ImmutableStaticFiles(directory = tmp_path),
|
||||
minimum_size = 1024,
|
||||
compresslevel = 6,
|
||||
)
|
||||
app.mount("/assets", assets_app, name = "assets")
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/assets/page-abc123.js",
|
||||
headers = {"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == content
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL)
|
||||
assert "accept-encoding" in response.headers["vary"].lower()
|
||||
|
||||
def test_asset_revalidation_keeps_immutable_cache_header(self, tmp_path, main_module):
|
||||
(tmp_path / "page-abc123.js").write_text("export {};", encoding = "utf-8")
|
||||
app = FastAPI()
|
||||
app.mount(
|
||||
"/assets",
|
||||
main_module.ImmutableStaticFiles(directory = tmp_path),
|
||||
name = "assets",
|
||||
)
|
||||
client = TestClient(app)
|
||||
first = client.get("/assets/page-abc123.js")
|
||||
|
||||
response = client.get(
|
||||
"/assets/page-abc123.js",
|
||||
headers = {"If-None-Match": first.headers["etag"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 304
|
||||
assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL)
|
||||
|
||||
def test_range_request_is_not_compressed(self, tmp_path, main_module):
|
||||
content = b"export const value = 'responsive';\n" * 200
|
||||
(tmp_path / "page-abc123.js").write_bytes(content)
|
||||
app = FastAPI()
|
||||
assets_app = main_module._AssetGZipMiddleware(
|
||||
main_module.ImmutableStaticFiles(directory = tmp_path),
|
||||
minimum_size = 1024,
|
||||
compresslevel = 6,
|
||||
)
|
||||
app.mount("/assets", assets_app, name = "assets")
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/assets/page-abc123.js",
|
||||
headers = {"Accept-Encoding": "gzip", "Range": "bytes=0-99"},
|
||||
)
|
||||
|
||||
assert response.status_code == 206
|
||||
assert response.headers.get("content-encoding") != "gzip"
|
||||
assert response.headers["content-range"] == f"bytes 0-99/{len(content)}"
|
||||
assert response.content == content[:100]
|
||||
assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL)
|
||||
|
||||
|
||||
# /api/health auth gate
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,19 +23,47 @@ interface AuthStatus {
|
|||
requires_password_change: boolean;
|
||||
}
|
||||
|
||||
const AUTH_STATUS_TTL_MS = 30_000;
|
||||
let authStatusCheckedAt = 0;
|
||||
let authStatusRequest: Promise<AuthStatus> | null = null;
|
||||
|
||||
function hasFreshAuthStatus(): boolean {
|
||||
return (
|
||||
authStatusCheckedAt !== 0 &&
|
||||
Date.now() - authStatusCheckedAt < AUTH_STATUS_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/auth/status"));
|
||||
if (!res.ok) return { initialized: true, requires_password_change: mustChangePassword() };
|
||||
const status = (await res.json()) as AuthStatus;
|
||||
// Server truth wins; keep localStorage in sync both ways.
|
||||
if (status.requires_password_change !== mustChangePassword()) {
|
||||
setMustChangePassword(status.requires_password_change);
|
||||
if (authStatusRequest) return authStatusRequest;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/auth/status"));
|
||||
if (!res.ok) {
|
||||
return {
|
||||
initialized: true,
|
||||
requires_password_change: mustChangePassword(),
|
||||
};
|
||||
}
|
||||
const status = (await res.json()) as AuthStatus;
|
||||
authStatusCheckedAt = Date.now();
|
||||
// Server truth wins; keep localStorage in sync both ways.
|
||||
if (status.requires_password_change !== mustChangePassword()) {
|
||||
setMustChangePassword(status.requires_password_change);
|
||||
}
|
||||
return status;
|
||||
} catch {
|
||||
return {
|
||||
initialized: true,
|
||||
requires_password_change: mustChangePassword(),
|
||||
};
|
||||
}
|
||||
return status;
|
||||
} catch {
|
||||
return { initialized: true, requires_password_change: mustChangePassword() };
|
||||
}
|
||||
})().finally(() => {
|
||||
authStatusRequest = null;
|
||||
});
|
||||
authStatusRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
function authRedirect(to: "/login" | "/change-password"): never {
|
||||
|
|
@ -49,12 +77,17 @@ export async function requireAuth(): Promise<void> {
|
|||
}
|
||||
|
||||
if (await hasActiveSession()) {
|
||||
const { requires_password_change } = await fetchAuthStatus();
|
||||
if (requires_password_change || mustChangePassword()) {
|
||||
authRedirect("/change-password");
|
||||
// Reconcile periodically so local-only routes cannot outlive a server-side
|
||||
// password-change requirement, while nearby route switches stay local.
|
||||
if (mustChangePassword() || !hasFreshAuthStatus()) {
|
||||
const { requires_password_change } = await fetchAuthStatus();
|
||||
if (requires_password_change || mustChangePassword()) {
|
||||
authRedirect("/change-password");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await fetchAuthStatus();
|
||||
if (status.requires_password_change || mustChangePassword()) {
|
||||
authRedirect("/change-password");
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ function RootLayout() {
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
transition={{ duration: 0.06 }}
|
||||
className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-visible"
|
||||
>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// 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 { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const DataRecipesPage = lazy(() =>
|
||||
import("@/features/data-recipes").then((m) => ({
|
||||
default: m.DataRecipesPage,
|
||||
})),
|
||||
const DataRecipesPage = lazyRouteComponent(
|
||||
() => import("@/features/data-recipes"),
|
||||
"DataRecipesPage",
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// 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 { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const ExportPage = lazy(() =>
|
||||
import("@/features/export/export-page").then((m) => ({
|
||||
default: m.ExportPage,
|
||||
})),
|
||||
const ExportPage = lazyRouteComponent(
|
||||
() => import("@/features/export/export-page"),
|
||||
"ExportPage",
|
||||
);
|
||||
|
||||
export type ExportSearch = {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// 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 { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const ModelsPage = lazy(() =>
|
||||
import("@/features/hub/hub-page").then((m) => ({
|
||||
default: m.ModelsPage,
|
||||
})),
|
||||
const ModelsPage = lazyRouteComponent(
|
||||
() => import("@/features/hub/hub-page"),
|
||||
"ModelsPage",
|
||||
);
|
||||
|
||||
export interface ModelsSearch {
|
||||
|
|
@ -31,7 +29,11 @@ export const Route = createRoute({
|
|||
const model = search.model;
|
||||
if (typeof model === "string" && model.length > 0) next.model = model;
|
||||
const section = search.section;
|
||||
if (section === "trending" || section === "latest" || section === "finetune") {
|
||||
if (
|
||||
section === "trending" ||
|
||||
section === "latest" ||
|
||||
section === "finetune"
|
||||
) {
|
||||
next.section = section;
|
||||
}
|
||||
const kind = search.kind;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// 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 { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const ProjectsPage = lazy(() =>
|
||||
import("@/features/chat/projects-page").then((m) => ({
|
||||
default: m.ProjectsPage,
|
||||
})),
|
||||
const ProjectsPage = lazyRouteComponent(
|
||||
() => import("@/features/chat/projects-page"),
|
||||
"ProjectsPage",
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
// 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 { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const StudioPage = lazy(() =>
|
||||
import("@/features/studio/studio-page").then((m) => ({
|
||||
default: m.StudioPage,
|
||||
})),
|
||||
const StudioPage = lazyRouteComponent(
|
||||
() => import("@/features/studio/studio-page"),
|
||||
"StudioPage",
|
||||
);
|
||||
|
||||
export const Route = createRoute({
|
||||
|
|
|
|||
|
|
@ -93,7 +93,12 @@ import {
|
|||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ChevronDown, Moon } from "lucide-react";
|
||||
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import {
|
||||
archiveChatItem,
|
||||
ChatSearchDialog,
|
||||
|
|
@ -256,6 +261,10 @@ function createNavigationNonce(): string {
|
|||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function preloadSilently(request: Promise<unknown>): void {
|
||||
void request.catch(() => undefined);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon,
|
||||
label,
|
||||
|
|
@ -267,6 +276,7 @@ function NavItem({
|
|||
className,
|
||||
spinner,
|
||||
tooltip,
|
||||
onIntent,
|
||||
}: {
|
||||
icon: typeof ZapIcon;
|
||||
label: string;
|
||||
|
|
@ -277,6 +287,7 @@ function NavItem({
|
|||
dataTour?: string;
|
||||
className?: string;
|
||||
spinner?: boolean;
|
||||
onIntent?: () => void;
|
||||
// Overrides the hover tooltip (defaults to `label`). Used to explain why a
|
||||
// disabled item (e.g. Train/Export on a chat-only host) is greyed out.
|
||||
tooltip?: string;
|
||||
|
|
@ -288,6 +299,8 @@ function NavItem({
|
|||
tooltip={tooltip ?? label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onPointerEnter={disabled ? undefined : onIntent}
|
||||
onFocus={disabled ? undefined : onIntent}
|
||||
isActive={active}
|
||||
data-tour={dataTour}
|
||||
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:mx-auto"
|
||||
|
|
@ -324,6 +337,7 @@ export function AppSidebar() {
|
|||
});
|
||||
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
|
||||
// Web update detection: `webUpdate` is non-null only when the installed
|
||||
// (PyPI) version is behind the latest release, so the card is hidden by
|
||||
|
|
@ -1218,6 +1232,9 @@ export function AppSidebar() {
|
|||
navigate({ to: "/projects" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
onIntent={() => {
|
||||
preloadSilently(router.preloadRoute({ to: "/projects" }));
|
||||
}}
|
||||
className="group/projects-item relative"
|
||||
>
|
||||
<button
|
||||
|
|
@ -1248,6 +1265,9 @@ export function AppSidebar() {
|
|||
navigate({ to: "/hub" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
onIntent={() => {
|
||||
preloadSilently(router.preloadRoute({ to: "/hub" }));
|
||||
}}
|
||||
/>
|
||||
{/* Train has a labelled section when expanded; plain icon here only when collapsed. */}
|
||||
<NavItem
|
||||
|
|
@ -1264,6 +1284,9 @@ export function AppSidebar() {
|
|||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
onIntent={() => {
|
||||
preloadSilently(router.preloadRoute({ to: "/studio" }));
|
||||
}}
|
||||
className="hidden group-data-[collapsible=icon]:block"
|
||||
/>
|
||||
</SidebarMenu>
|
||||
|
|
@ -1293,6 +1316,9 @@ export function AppSidebar() {
|
|||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
onIntent={() => {
|
||||
preloadSilently(router.preloadRoute({ to: "/studio" }));
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
|
|
@ -1302,6 +1328,16 @@ export function AppSidebar() {
|
|||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
onIntent={() => {
|
||||
preloadSilently(
|
||||
router.preloadRoute({ to: "/data-recipes" }),
|
||||
);
|
||||
preloadSilently(
|
||||
import("@/features/data-recipes").then((module) =>
|
||||
module.preloadRecipes(),
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={DownloadSquare01Icon}
|
||||
|
|
@ -1312,6 +1348,14 @@ export function AppSidebar() {
|
|||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
onIntent={() => {
|
||||
preloadSilently(router.preloadRoute({ to: "/export" }));
|
||||
preloadSilently(
|
||||
import(
|
||||
"@/features/export/export-navigation-cache"
|
||||
).then((module) => module.preloadExportData()),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type {
|
|||
} from "../types/api";
|
||||
|
||||
export const CHAT_HISTORY_UPDATED_EVENT = "unsloth-chat-history-updated";
|
||||
export const CHAT_PROJECTS_UPDATED_EVENT = "unsloth-chat-projects-updated";
|
||||
|
||||
/**
|
||||
* Thrown when the chat SSE stream ends without a terminal signal (`[DONE]` or a
|
||||
|
|
@ -55,6 +56,13 @@ export function notifyChatHistoryUpdated(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function notifyChatProjectsUpdated(): void {
|
||||
notifyChatHistoryUpdated();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(CHAT_PROJECTS_UPDATED_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
function parseErrorText(status: number, body: unknown): string {
|
||||
if (body && typeof body === "object") {
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
|
|
@ -644,7 +652,7 @@ export async function saveChatProject(
|
|||
body: JSON.stringify(project),
|
||||
});
|
||||
const saved = await parseJsonOrThrow<ProjectRecord>(response);
|
||||
notifyChatHistoryUpdated();
|
||||
notifyChatProjectsUpdated();
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
|
@ -661,7 +669,7 @@ export async function updateChatProject(
|
|||
},
|
||||
);
|
||||
const project = await parseJsonOrThrow<ProjectRecord>(response);
|
||||
notifyChatHistoryUpdated();
|
||||
notifyChatProjectsUpdated();
|
||||
return project;
|
||||
}
|
||||
|
||||
|
|
@ -677,7 +685,7 @@ export async function deleteChatProject(
|
|||
{ method: "DELETE" },
|
||||
);
|
||||
await parseJsonOrThrow<ProjectRecord>(response);
|
||||
notifyChatHistoryUpdated();
|
||||
notifyChatProjectsUpdated();
|
||||
}
|
||||
|
||||
export async function listChatMessages(
|
||||
|
|
|
|||
|
|
@ -1,8 +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 { useEffect, useState } from "react";
|
||||
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { CHAT_PROJECTS_UPDATED_EVENT } from "../api/chat-api";
|
||||
import type { ProjectRecord } from "../types";
|
||||
import {
|
||||
createStoredChatProject,
|
||||
|
|
@ -15,32 +15,83 @@ import {
|
|||
import type { SidebarItem } from "./use-chat-sidebar-items";
|
||||
|
||||
let cachedProjects: ProjectRecord[] = [];
|
||||
let projectsLoaded = false;
|
||||
let projectsRequest: Promise<ProjectRecord[]> | null = null;
|
||||
let projectsRefreshPending = false;
|
||||
let lastProjectsUpdateEvent: Event | null = null;
|
||||
const projectSubscribers = new Set<() => void>();
|
||||
|
||||
function subscribeToProjects(onStoreChange: () => void): () => void {
|
||||
projectSubscribers.add(onStoreChange);
|
||||
return () => projectSubscribers.delete(onStoreChange);
|
||||
}
|
||||
|
||||
function getProjectsSnapshot(): ProjectRecord[] {
|
||||
return cachedProjects;
|
||||
}
|
||||
|
||||
function publishProjects(projects: ProjectRecord[]): void {
|
||||
cachedProjects = projects;
|
||||
projectsLoaded = true;
|
||||
for (const onStoreChange of projectSubscribers) onStoreChange();
|
||||
}
|
||||
|
||||
function loadProjects(
|
||||
force = false,
|
||||
followUpIfPending = false,
|
||||
): Promise<ProjectRecord[]> {
|
||||
if (projectsRequest) {
|
||||
if (followUpIfPending) projectsRefreshPending = true;
|
||||
return projectsRequest;
|
||||
}
|
||||
if (!force && projectsLoaded) {
|
||||
return Promise.resolve(cachedProjects);
|
||||
}
|
||||
|
||||
async function run(): Promise<ProjectRecord[]> {
|
||||
let nextProjects: ProjectRecord[] | null = null;
|
||||
do {
|
||||
projectsRefreshPending = false;
|
||||
try {
|
||||
const next = await listStoredChatProjects({ includeArchived: false });
|
||||
nextProjects = Array.isArray(next) ? next : [];
|
||||
} catch (error) {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) throw error;
|
||||
nextProjects = null;
|
||||
}
|
||||
} while (projectsRefreshPending);
|
||||
if (nextProjects !== null) publishProjects(nextProjects);
|
||||
return cachedProjects;
|
||||
}
|
||||
|
||||
const request = run().finally(() => {
|
||||
projectsRequest = null;
|
||||
});
|
||||
projectsRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useChatProjects(): {
|
||||
projects: ProjectRecord[];
|
||||
isLoading: boolean;
|
||||
hasLoaded: boolean;
|
||||
} {
|
||||
// Stay null-safe even if the cache was poisoned by a bad response.
|
||||
const cached = Array.isArray(cachedProjects) ? cachedProjects : [];
|
||||
const [projects, setProjects] = useState<ProjectRecord[]>(cached);
|
||||
const [isLoading, setIsLoading] = useState(cached.length === 0);
|
||||
const [hasLoaded, setHasLoaded] = useState(cached.length > 0);
|
||||
const projects = useSyncExternalStore(
|
||||
subscribeToProjects,
|
||||
getProjectsSnapshot,
|
||||
getProjectsSnapshot,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(!projectsLoaded);
|
||||
const [hasLoaded, setHasLoaded] = useState(projectsLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (!cancelled) setIsLoading(true);
|
||||
async function refresh(force = false, followUpIfPending = false) {
|
||||
if (!force && projectsLoaded) return;
|
||||
if (!cancelled && !projectsLoaded) setIsLoading(true);
|
||||
try {
|
||||
const next = await listStoredChatProjects({ includeArchived: false });
|
||||
cachedProjects = Array.isArray(next) ? next : [];
|
||||
if (!cancelled) setProjects(cachedProjects);
|
||||
} catch (error) {
|
||||
if (isExpectedBackgroundChatStorageError(error)) {
|
||||
return;
|
||||
}
|
||||
if (!cancelled) throw error;
|
||||
await loadProjects(force, followUpIfPending);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setHasLoaded(true);
|
||||
|
|
@ -49,15 +100,18 @@ export function useChatProjects(): {
|
|||
}
|
||||
}
|
||||
|
||||
const onHistoryUpdated = () => {
|
||||
void load();
|
||||
const onProjectsUpdated = (event: Event) => {
|
||||
const followUpIfPending = event !== lastProjectsUpdateEvent;
|
||||
lastProjectsUpdateEvent = event;
|
||||
void refresh(true, followUpIfPending);
|
||||
};
|
||||
|
||||
void load();
|
||||
window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated);
|
||||
// Cached rows render immediately, then one shared request reconciles
|
||||
// changes made by another browser tab or API client.
|
||||
void refresh(projectsLoaded);
|
||||
window.addEventListener(CHAT_PROJECTS_UPDATED_EVENT, onProjectsUpdated);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated);
|
||||
window.removeEventListener(CHAT_PROJECTS_UPDATED_EVENT, onProjectsUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,40 @@ db.version(1).stores({
|
|||
});
|
||||
|
||||
const recentRecipeCache = new Map<string, RecipeRecord>();
|
||||
let cachedRecipeList: RecipeRecord[] = [];
|
||||
let recipeListReady = false;
|
||||
let recipeListRequest: Promise<RecipeRecord[]> | null = null;
|
||||
|
||||
export function listRecipes(): Promise<RecipeRecord[]> {
|
||||
return db.recipes.orderBy("updatedAt").reverse().toArray();
|
||||
}
|
||||
|
||||
function cacheRecipeList(recipes: RecipeRecord[]): RecipeRecord[] {
|
||||
for (const recipe of recipes) {
|
||||
writeRecipeCache(recipe);
|
||||
}
|
||||
cachedRecipeList = recipes;
|
||||
recipeListReady = true;
|
||||
return recipes;
|
||||
}
|
||||
|
||||
export function preloadRecipes(): Promise<RecipeRecord[]> {
|
||||
if (recipeListReady) {
|
||||
return Promise.resolve(cachedRecipeList);
|
||||
}
|
||||
if (recipeListRequest) {
|
||||
return recipeListRequest;
|
||||
}
|
||||
|
||||
const request = listRecipes()
|
||||
.then(cacheRecipeList)
|
||||
.finally(() => {
|
||||
recipeListRequest = null;
|
||||
});
|
||||
recipeListRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
export function getRecipe(id: string): Promise<RecipeRecord | undefined> {
|
||||
return db.recipes.get(id);
|
||||
}
|
||||
|
|
@ -55,12 +84,21 @@ export async function saveRecipe(
|
|||
};
|
||||
await db.recipes.put(record);
|
||||
writeRecipeCache(record);
|
||||
if (recipeListReady) {
|
||||
cachedRecipeList = [
|
||||
record,
|
||||
...cachedRecipeList.filter((recipe) => recipe.id !== record.id),
|
||||
].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function deleteRecipe(id: string): Promise<void> {
|
||||
await db.recipes.delete(id);
|
||||
recentRecipeCache.delete(id);
|
||||
if (recipeListReady) {
|
||||
cachedRecipeList = cachedRecipeList.filter((recipe) => recipe.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRecipeDraft(): Promise<RecipeRecord> {
|
||||
|
|
@ -87,15 +125,13 @@ export function useRecipes(): {
|
|||
recipes: RecipeRecord[];
|
||||
ready: boolean;
|
||||
} {
|
||||
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [recipes, setRecipes] = useState<RecipeRecord[]>(cachedRecipeList);
|
||||
const [ready, setReady] = useState(recipeListReady);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = liveQuery(() => listRecipes()).subscribe({
|
||||
next: (value) => {
|
||||
for (const recipe of value) {
|
||||
writeRecipeCache(recipe);
|
||||
}
|
||||
cacheRecipeList(value);
|
||||
setRecipes(value);
|
||||
setReady(true);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
|
||||
export { DataRecipesPage } from "./pages/data-recipes-page";
|
||||
export { EditRecipePage } from "./pages/edit-recipe-page";
|
||||
export { preloadRecipes } from "./data/recipes-db";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
// 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 { type LocalModelInfo, listLocalModels } from "@/features/training";
|
||||
import { type ModelCheckpoints, fetchCheckpoints } from "./api/export-api";
|
||||
|
||||
let cachedCheckpoints: ModelCheckpoints[] | null = null;
|
||||
let checkpointsRequest: Promise<ModelCheckpoints[]> | null = null;
|
||||
let cachedLocalModels: LocalModelInfo[] | null = null;
|
||||
let localModelsRequest: Promise<LocalModelInfo[]> | null = null;
|
||||
|
||||
export function getCachedCheckpoints(): ModelCheckpoints[] | null {
|
||||
return cachedCheckpoints;
|
||||
}
|
||||
|
||||
export function getCachedLocalModels(): LocalModelInfo[] | null {
|
||||
return cachedLocalModels;
|
||||
}
|
||||
|
||||
export function refreshCheckpoints(): Promise<ModelCheckpoints[]> {
|
||||
if (checkpointsRequest) {
|
||||
return checkpointsRequest;
|
||||
}
|
||||
const request = fetchCheckpoints()
|
||||
.then((data) => {
|
||||
cachedCheckpoints = data.models;
|
||||
return data.models;
|
||||
})
|
||||
.finally(() => {
|
||||
checkpointsRequest = null;
|
||||
});
|
||||
checkpointsRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
export function refreshLocalModels(): Promise<LocalModelInfo[]> {
|
||||
if (localModelsRequest) {
|
||||
return localModelsRequest;
|
||||
}
|
||||
const request = listLocalModels()
|
||||
.then((models) => {
|
||||
cachedLocalModels = models;
|
||||
return models;
|
||||
})
|
||||
.finally(() => {
|
||||
localModelsRequest = null;
|
||||
});
|
||||
localModelsRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function preloadExportData(): Promise<void> {
|
||||
const requests: Promise<unknown>[] = [];
|
||||
if (cachedCheckpoints === null) {
|
||||
requests.push(refreshCheckpoints());
|
||||
}
|
||||
if (cachedLocalModels === null) {
|
||||
requests.push(refreshLocalModels());
|
||||
}
|
||||
await Promise.allSettled(requests);
|
||||
}
|
||||
|
|
@ -48,7 +48,6 @@ import { prepareHfTokenForUse } from "@/features/hf-auth";
|
|||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import {
|
||||
type LocalModelInfo,
|
||||
listLocalModels,
|
||||
useTrainingConfigStore,
|
||||
} from "@/features/training";
|
||||
import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
|
||||
|
|
@ -67,7 +66,6 @@ import { useSearch } from "@tanstack/react-router";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { ModelCheckpoints } from "./api/export-api";
|
||||
import { fetchCheckpoints } from "./api/export-api";
|
||||
import { ExportRunPanel } from "./components/export-run-panel";
|
||||
import { MethodPicker } from "./components/method-picker";
|
||||
import { QuantPicker } from "./components/quant-picker";
|
||||
|
|
@ -83,6 +81,12 @@ import {
|
|||
mergedFormatPayload,
|
||||
} from "./constants";
|
||||
import { useExportSizeEstimate } from "./hooks/use-export-size-estimate";
|
||||
import {
|
||||
getCachedCheckpoints,
|
||||
getCachedLocalModels,
|
||||
refreshCheckpoints,
|
||||
refreshLocalModels,
|
||||
} from "./export-navigation-cache";
|
||||
import {
|
||||
isExportPanelActive,
|
||||
useExportRuntimeStore,
|
||||
|
|
@ -172,8 +176,12 @@ export function ExportPage() {
|
|||
);
|
||||
|
||||
// ---- API-driven checkpoint state ----
|
||||
const [models, setModels] = useState<ModelCheckpoints[]>([]);
|
||||
const [loadingCheckpoints, setLoadingCheckpoints] = useState(true);
|
||||
const [models, setModels] = useState<ModelCheckpoints[]>(
|
||||
() => getCachedCheckpoints() ?? [],
|
||||
);
|
||||
const [loadingCheckpoints, setLoadingCheckpoints] = useState(
|
||||
getCachedCheckpoints() === null,
|
||||
);
|
||||
const [checkpointError, setCheckpointError] = useState<string | null>(null);
|
||||
|
||||
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
|
||||
|
|
@ -185,8 +193,12 @@ export function ExportPage() {
|
|||
null,
|
||||
);
|
||||
const [localModelInput, setLocalModelInput] = useState("");
|
||||
const [localModels, setLocalModels] = useState<LocalModelInfo[]>([]);
|
||||
const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(true);
|
||||
const [localModels, setLocalModels] = useState<LocalModelInfo[]>(
|
||||
() => getCachedLocalModels() ?? [],
|
||||
);
|
||||
const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(
|
||||
getCachedLocalModels() === null,
|
||||
);
|
||||
const [localModelsError, setLocalModelsError] = useState<string | null>(null);
|
||||
const debouncedModelQuery = useDebouncedValue(modelInput);
|
||||
const debouncedHfToken = useDebouncedValue(hfToken, 500);
|
||||
|
|
@ -295,16 +307,15 @@ export function ExportPage() {
|
|||
// ---- Fetch checkpoints on mount ----
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingCheckpoints(true);
|
||||
setCheckpointError(null);
|
||||
fetchCheckpoints()
|
||||
.then((data) => {
|
||||
const hadCache = getCachedCheckpoints() !== null;
|
||||
refreshCheckpoints()
|
||||
.then((models) => {
|
||||
if (!cancelled) {
|
||||
setModels(data.models);
|
||||
setModels(models);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && !hadCache) {
|
||||
setCheckpointError(
|
||||
err instanceof Error ? err.message : "Failed to load checkpoints",
|
||||
);
|
||||
|
|
@ -343,14 +354,15 @@ export function ExportPage() {
|
|||
|
||||
// ---- Fetch local models for direct export ----
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void listLocalModels(controller.signal)
|
||||
let cancelled = false;
|
||||
const hadCache = getCachedLocalModels() !== null;
|
||||
void refreshLocalModels()
|
||||
.then((models) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cancelled) return;
|
||||
setLocalModels(models);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cancelled || hadCache) return;
|
||||
setLocalModelsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
|
@ -358,10 +370,12 @@ export function ExportPage() {
|
|||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cancelled) return;
|
||||
setIsLoadingLocalModels(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---- Derived state ----
|
||||
|
|
|
|||
|
|
@ -1,7 +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
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface HfPaginatedState<T> {
|
||||
results: T[];
|
||||
|
|
@ -12,6 +12,10 @@ interface HfPaginatedState<T> {
|
|||
error: string | null;
|
||||
}
|
||||
|
||||
interface InternalPaginatedState<T> extends HfPaginatedState<T> {
|
||||
queryKey: object | null;
|
||||
}
|
||||
|
||||
const INITIAL: HfPaginatedState<never> = {
|
||||
results: [],
|
||||
scannedCount: 0,
|
||||
|
|
@ -74,10 +78,15 @@ export function useHubPaginatedSearch<T>(
|
|||
options?: { enabled?: boolean },
|
||||
): HfPaginatedState<T> & { fetchMore: () => boolean; retry: () => void } {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [state, setState] = useState<HfPaginatedState<T>>(
|
||||
INITIAL as HfPaginatedState<T>,
|
||||
);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const queryKey = useMemo(
|
||||
() => ({ createIter, mapItem, retryNonce }),
|
||||
[createIter, mapItem, retryNonce],
|
||||
);
|
||||
const [state, setState] = useState<InternalPaginatedState<T>>({
|
||||
...(INITIAL as HfPaginatedState<T>),
|
||||
queryKey: null,
|
||||
});
|
||||
const stateRef = useRef(state);
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
|
|
@ -187,6 +196,7 @@ export function useHubPaginatedSearch<T>(
|
|||
setState({
|
||||
...(INITIAL as HfPaginatedState<T>),
|
||||
isLoading: true,
|
||||
queryKey,
|
||||
});
|
||||
|
||||
const iter = createIter(controller.signal);
|
||||
|
|
@ -203,6 +213,7 @@ export function useHubPaginatedSearch<T>(
|
|||
isLoadingMore: false,
|
||||
hasMore: !done,
|
||||
error: null,
|
||||
queryKey,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -214,6 +225,7 @@ export function useHubPaginatedSearch<T>(
|
|||
isLoadingMore: false,
|
||||
hasMore: false,
|
||||
error: err instanceof Error ? err.message : "Search failed",
|
||||
queryKey,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -226,7 +238,14 @@ export function useHubPaginatedSearch<T>(
|
|||
return () => {
|
||||
clearDeferredFetch();
|
||||
};
|
||||
}, [createIter, mapItem, enabled, retryNonce, clearDeferredFetch]);
|
||||
}, [
|
||||
createIter,
|
||||
mapItem,
|
||||
enabled,
|
||||
retryNonce,
|
||||
queryKey,
|
||||
clearDeferredFetch,
|
||||
]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setRetryNonce((n) => n + 1);
|
||||
|
|
@ -358,5 +377,22 @@ export function useHubPaginatedSearch<T>(
|
|||
};
|
||||
}, [enabled, fetchMore]);
|
||||
|
||||
return { ...state, fetchMore, retry };
|
||||
const visibleState: InternalPaginatedState<T> =
|
||||
state.queryKey === queryKey
|
||||
? state
|
||||
: {
|
||||
...(INITIAL as HfPaginatedState<T>),
|
||||
isLoading: enabled,
|
||||
queryKey,
|
||||
};
|
||||
return {
|
||||
results: visibleState.results,
|
||||
scannedCount: visibleState.scannedCount,
|
||||
isLoading: visibleState.isLoading,
|
||||
isLoadingMore: visibleState.isLoadingMore,
|
||||
hasMore: visibleState.hasMore,
|
||||
error: visibleState.error,
|
||||
fetchMore,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import {
|
|||
hfApiToken,
|
||||
useHfTokenStore,
|
||||
} from "@/features/hub/stores/hf-token-store";
|
||||
import {
|
||||
isChannelEntryFresh,
|
||||
useHubFeedStore,
|
||||
} from "./stores/hub-feed-store";
|
||||
import {
|
||||
getInferenceStatus,
|
||||
isExternalModelId,
|
||||
|
|
@ -80,6 +84,7 @@ import {
|
|||
} from "./lib/hidden-models";
|
||||
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
|
||||
import { resolveOwnerProviderLogo } from "./lib/provider-logos";
|
||||
import { fingerprintToken } from "./lib/token-fingerprint";
|
||||
import {
|
||||
buildDiscoverRows,
|
||||
detectResultFormat,
|
||||
|
|
@ -569,6 +574,10 @@ export function ModelsPage() {
|
|||
const hfToken = useHfTokenStore((s) => s.token);
|
||||
const debouncedHfToken = useDebouncedValue(hfToken, 500);
|
||||
const apiHfToken = hfApiToken(debouncedHfToken);
|
||||
const tokenFingerprint = useMemo(
|
||||
() => fingerprintToken(apiHfToken),
|
||||
[apiHfToken],
|
||||
);
|
||||
const deferredFormatFilter = useDeferredValue(formatFilter);
|
||||
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
|
||||
|
||||
|
|
@ -633,8 +642,22 @@ export function ModelsPage() {
|
|||
online,
|
||||
});
|
||||
|
||||
const cachedListEntry = useHubFeedStore((state) =>
|
||||
liveListChannel ? state.channels[liveListChannel.id] : undefined,
|
||||
);
|
||||
const visibleResults =
|
||||
results.length === 0 &&
|
||||
liveListChannel &&
|
||||
isChannelEntryFresh(
|
||||
cachedListEntry,
|
||||
liveListChannel.id,
|
||||
tokenFingerprint,
|
||||
)
|
||||
? (cachedListEntry?.results ?? results)
|
||||
: results;
|
||||
|
||||
useFeedWriteBack({
|
||||
channelId: isChannelListMode ? activeChannelId : null,
|
||||
channelId: liveListChannel?.id ?? null,
|
||||
results,
|
||||
isLoading,
|
||||
accessToken: apiHfToken,
|
||||
|
|
@ -656,8 +679,13 @@ export function ModelsPage() {
|
|||
[effectiveCachedRows, effectiveLocalRows],
|
||||
);
|
||||
const modelDiscoverRows = useMemo<DiscoverRow[]>(
|
||||
() => buildDiscoverRows(results, effectiveCachedRows, effectiveLocalRows),
|
||||
[results, modelDiscoveryInventorySignature],
|
||||
() =>
|
||||
buildDiscoverRows(
|
||||
visibleResults,
|
||||
effectiveCachedRows,
|
||||
effectiveLocalRows,
|
||||
),
|
||||
[visibleResults, modelDiscoveryInventorySignature],
|
||||
);
|
||||
|
||||
const datasetDiscoverRows = useMemo<DiscoverRow[]>(() => {
|
||||
|
|
@ -784,7 +812,7 @@ export function ModelsPage() {
|
|||
const selectionFilteredDiscoverRows = isFeedMode
|
||||
? feedRows
|
||||
: filteredDiscoverRows;
|
||||
const selectionResults = isFeedMode ? feedResults : results;
|
||||
const selectionResults = isFeedMode ? feedResults : visibleResults;
|
||||
|
||||
const inventoryTokens = useMemo(
|
||||
() => (isDiscoverTab ? [] : tokenizeQuery(deferredDebouncedQuery)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue