feat: polish providers settings form UI
This commit is contained in:
parent
f345dc1908
commit
5816c409a1
7 changed files with 764 additions and 435 deletions
|
|
@ -15,6 +15,7 @@ import { useChooseNativeModel } from "@/features/native-intents/use-native-dialo
|
|||
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
|
||||
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
|
||||
import { useNativeIntentStore } from "@/features/native-intents/store";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import type { NativeIntent } from "@/features/native-intents/types";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -37,18 +38,11 @@ import {
|
|||
import { toast } from "sonner";
|
||||
import type { ChatSearch } from "@/app/routes/chat";
|
||||
import { listLocalModels } from "./api/chat-api";
|
||||
import { ChatProvidersDialog } from "./chat-providers-dialog";
|
||||
import { ChatSettingsPanel } from "./chat-settings-sheet";
|
||||
import { ContextUsageBar } from "./components/context-usage-bar";
|
||||
import { ModelLoadInlineStatus } from "./components/model-load-status";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
buildExternalModelId,
|
||||
isExternalModelId,
|
||||
loadExternalProviders,
|
||||
saveExternalProviders,
|
||||
type ExternalProviderConfig,
|
||||
} from "./external-providers";
|
||||
import { buildExternalModelId, isExternalModelId } from "./external-providers";
|
||||
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
||||
import {
|
||||
clearTrainingCompareHandoff,
|
||||
|
|
@ -63,6 +57,7 @@ import {
|
|||
SharedComposer,
|
||||
} from "./shared-composer";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import type { ChatView, MessageRecord } from "./types";
|
||||
|
||||
|
|
@ -545,7 +540,7 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
|
||||
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
|
||||
const [providersOpen, setProvidersOpen] = useState(false);
|
||||
const externalProviders = useExternalProvidersStore((s) => s.providers);
|
||||
|
||||
useEffect(() => {
|
||||
const threadId = search.thread;
|
||||
|
|
@ -576,12 +571,9 @@ export function ChatPage(): ReactElement {
|
|||
canceled = true;
|
||||
};
|
||||
}, [navigate, search.thread]);
|
||||
|
||||
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||
const [externalProviders, setExternalProviders] = useState<ExternalProviderConfig[]>(() =>
|
||||
loadExternalProviders(),
|
||||
);
|
||||
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
|
||||
const inferenceParams = useChatRuntimeStore((state) => state.params);
|
||||
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
|
||||
|
|
@ -609,7 +601,9 @@ export function ChatPage(): ReactElement {
|
|||
loadProgress,
|
||||
loadToastDismissed,
|
||||
} = useChatModelRuntime();
|
||||
const pendingNativeModelIntent = useNativeIntentStore((state) => state.pendingModelIntent);
|
||||
const pendingNativeModelIntent = useNativeIntentStore(
|
||||
(state) => state.pendingModelIntent,
|
||||
);
|
||||
const nativePathLeasesSupported = useNativePathLeasesSupported();
|
||||
const refreshRef = useRef(refresh);
|
||||
const selectModelRef = useRef(selectModel);
|
||||
|
|
@ -649,7 +643,8 @@ export function ChatPage(): ReactElement {
|
|||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
const loadNativeModelIntent = useCallback(
|
||||
async (intent: NativeIntent, loadingDescription: string) => {
|
||||
const label = intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
|
||||
const label =
|
||||
intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
|
||||
await selectModel({
|
||||
id: label,
|
||||
nativePathToken: intent.path.token,
|
||||
|
|
@ -885,20 +880,24 @@ export function ChatPage(): ReactElement {
|
|||
.catch(() => {});
|
||||
}, [navigate]);
|
||||
|
||||
const refreshModelLists = useCallback((deletedModel?: DeletedModelRef) => {
|
||||
const { checkpoint } = useChatRuntimeStore.getState().params;
|
||||
const activeGgufVariant = useChatRuntimeStore.getState().activeGgufVariant;
|
||||
if (
|
||||
modelMatchesDeleted(
|
||||
{ id: checkpoint, ggufVariant: activeGgufVariant },
|
||||
deletedModel,
|
||||
)
|
||||
) {
|
||||
useChatRuntimeStore.getState().clearCheckpoint();
|
||||
}
|
||||
void refresh();
|
||||
refreshLocalModels();
|
||||
}, [refresh, refreshLocalModels]);
|
||||
const refreshModelLists = useCallback(
|
||||
(deletedModel?: DeletedModelRef) => {
|
||||
const { checkpoint } = useChatRuntimeStore.getState().params;
|
||||
const activeGgufVariant =
|
||||
useChatRuntimeStore.getState().activeGgufVariant;
|
||||
if (
|
||||
modelMatchesDeleted(
|
||||
{ id: checkpoint, ggufVariant: activeGgufVariant },
|
||||
deletedModel,
|
||||
)
|
||||
) {
|
||||
useChatRuntimeStore.getState().clearCheckpoint();
|
||||
}
|
||||
void refresh();
|
||||
refreshLocalModels();
|
||||
},
|
||||
[refresh, refreshLocalModels],
|
||||
);
|
||||
|
||||
const loraModels = useMemo<LoraModelOption[]>(() => {
|
||||
const fromLoras = lorasFromStore.map((lora) => ({
|
||||
|
|
@ -918,10 +917,6 @@ export function ChatPage(): ReactElement {
|
|||
refreshLocalModels();
|
||||
}, [refresh, refreshLocalModels]);
|
||||
|
||||
useEffect(() => {
|
||||
void saveExternalProviders(externalProviders);
|
||||
}, [externalProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const handoff = getTrainingCompareHandoff();
|
||||
if (!handoff) return;
|
||||
|
|
@ -1115,7 +1110,11 @@ export function ChatPage(): ReactElement {
|
|||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvidersOpen(true)}
|
||||
onClick={() =>
|
||||
useSettingsDialogStore
|
||||
.getState()
|
||||
.openDialog("connections")
|
||||
}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] transition-colors hover:bg-[#ececec] hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-[#c7c7c4] dark:hover:bg-[#2e3035] dark:hover:text-white"
|
||||
aria-label="Open API providers"
|
||||
>
|
||||
|
|
@ -1186,12 +1185,6 @@ export function ChatPage(): ReactElement {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<ChatProvidersDialog
|
||||
open={providersOpen}
|
||||
onOpenChange={setProvidersOpen}
|
||||
providers={externalProviders}
|
||||
onProvidersChange={setExternalProviders}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -168,9 +168,9 @@ function saveRawKeyMap(map: Record<string, string>): void {
|
|||
}
|
||||
}
|
||||
|
||||
export async function saveExternalProviders(
|
||||
export function saveExternalProviders(
|
||||
providers: ExternalProviderConfig[],
|
||||
): Promise<void> {
|
||||
): void {
|
||||
if (!canUseStorage()) return;
|
||||
try {
|
||||
localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
|
||||
|
|
@ -223,4 +223,3 @@ export function removeExternalProviderApiKey(providerId: string): void {
|
|||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
loadExternalProviders,
|
||||
saveExternalProviders,
|
||||
type ExternalProviderConfig,
|
||||
} from "../external-providers";
|
||||
|
||||
interface ExternalProvidersState {
|
||||
providers: ExternalProviderConfig[];
|
||||
setProviders: (providers: ExternalProviderConfig[]) => void;
|
||||
}
|
||||
|
||||
export const useExternalProvidersStore = create<ExternalProvidersState>(
|
||||
(set) => ({
|
||||
providers: loadExternalProviders(),
|
||||
setProviders: (providers) => {
|
||||
set({ providers });
|
||||
saveExternalProviders(providers);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Cancel01Icon,
|
||||
CloudIcon,
|
||||
Globe02Icon,
|
||||
HelpCircleIcon,
|
||||
Message01Icon,
|
||||
|
|
@ -20,11 +21,15 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSettingsDialogStore, type SettingsTab } from "./stores/settings-dialog-store";
|
||||
import {
|
||||
useSettingsDialogStore,
|
||||
type SettingsTab,
|
||||
} from "./stores/settings-dialog-store";
|
||||
import { AboutTab } from "./tabs/about-tab";
|
||||
import { ApiKeysTab } from "./tabs/api-keys-tab";
|
||||
import { AppearanceTab } from "./tabs/appearance-tab";
|
||||
import { ChatTab } from "./tabs/chat-tab";
|
||||
import { ConnectionsTab } from "./tabs/connections-tab";
|
||||
import { GeneralTab } from "./tabs/general-tab";
|
||||
import { ProfileTab } from "./tabs/profile-tab";
|
||||
|
||||
|
|
@ -40,6 +45,7 @@ const TABS: TabDef[] = [
|
|||
{ id: "profile", label: "Profile", icon: UserIcon },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
|
||||
{ id: "chat", label: "Chat", icon: Message01Icon },
|
||||
{ id: "connections", label: "Providers", icon: CloudIcon },
|
||||
{ id: "api-keys", label: "API", icon: Globe02Icon, badge: "New" },
|
||||
{ id: "about", label: "Help", icon: HelpCircleIcon },
|
||||
];
|
||||
|
|
@ -54,6 +60,8 @@ function renderTab(tab: SettingsTab) {
|
|||
return <AppearanceTab />;
|
||||
case "chat":
|
||||
return <ChatTab />;
|
||||
case "connections":
|
||||
return <ConnectionsTab />;
|
||||
case "api-keys":
|
||||
return <ApiKeysTab />;
|
||||
case "about":
|
||||
|
|
@ -72,6 +80,7 @@ export function SettingsDialog() {
|
|||
profile: null,
|
||||
appearance: null,
|
||||
chat: null,
|
||||
connections: null,
|
||||
"api-keys": null,
|
||||
about: null,
|
||||
});
|
||||
|
|
@ -100,9 +109,9 @@ export function SettingsDialog() {
|
|||
<DialogDescription className="sr-only">
|
||||
Manage your Unsloth Studio preferences.
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
<div className="flex h-full min-h-0 max-sm:flex-col">
|
||||
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
|
||||
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
|
||||
{TABS.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
|
|
@ -115,6 +124,7 @@ export function SettingsDialog() {
|
|||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
|
||||
"max-sm:shrink-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
active
|
||||
? "text-black dark:text-white"
|
||||
|
|
@ -142,7 +152,9 @@ export function SettingsDialog() {
|
|||
strokeWidth={1.5}
|
||||
className="relative z-10 size-[18px]"
|
||||
/>
|
||||
<span className="relative z-10 min-w-0 truncate">{tab.label}</span>
|
||||
<span className="relative z-10 min-w-0 truncate">
|
||||
{tab.label}
|
||||
</span>
|
||||
{tab.badge ? (
|
||||
<span className="relative z-10 ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
{tab.badge}
|
||||
|
|
@ -154,7 +166,7 @@ export function SettingsDialog() {
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type SettingsTab =
|
|||
| "profile"
|
||||
| "appearance"
|
||||
| "chat"
|
||||
| "connections"
|
||||
| "api-keys"
|
||||
| "about";
|
||||
|
||||
|
|
@ -29,8 +30,18 @@ function loadInitialTab(): SettingsTab {
|
|||
} catch {
|
||||
return "general";
|
||||
}
|
||||
const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"];
|
||||
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
|
||||
const valid: SettingsTab[] = [
|
||||
"general",
|
||||
"profile",
|
||||
"appearance",
|
||||
"chat",
|
||||
"connections",
|
||||
"api-keys",
|
||||
"about",
|
||||
];
|
||||
return valid.includes(stored as SettingsTab)
|
||||
? (stored as SettingsTab)
|
||||
: "general";
|
||||
}
|
||||
|
||||
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
// 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 { ChatProvidersSettings } from "@/features/chat/chat-providers-dialog";
|
||||
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
|
||||
|
||||
export function ConnectionsTab() {
|
||||
const providers = useExternalProvidersStore((s) => s.providers);
|
||||
const setProviders = useExternalProvidersStore((s) => s.setProviders);
|
||||
|
||||
return (
|
||||
<ChatProvidersSettings
|
||||
providers={providers}
|
||||
onProvidersChange={setProviders}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue