studio: fix Connections dialog UX issues surfaced by image-gate probe (#5518)
* studio: register /settings route that opens the settings dialog Navigating to /settings used to render Not Found because the route was never registered. The settings dialog only opened via the user menu, so /settings was a broken deep link if shared. Add a route that calls useSettingsDialogStore.openDialog() and redirects to the post-auth landing page so the modal appears on top of the chat. * studio: harden Connections dialog provider sync and allow manual model IDs Two related fixes for the Connections panel. 1. Keep localStorage providers when the server returns an empty list. The dialog used to sync from /api/providers/ on mount and unconditionally overwrite the Zustand provider store with the server result. When the server had no enabled configs but the local store had entries (legacy users, fresh dev installs, or providers created via earlier paths), opening the dialog silently wiped them. The model picker reads from the same store, so the chat header reverted from 'gpt-4o . OpenAI' to the raw 'external::openai-1::gpt-4o' key. Treat the server as authoritative only when it actually has rows; otherwise keep the local view. 2. Accept manual model IDs alongside the live catalog for remote-mode providers (DeepSeek, OpenAI, etc.). Previously the only way to save was to load the available-models catalog via a live API call, which fails in air-gapped setups, behind 502s, or when the user already knows the exact model ID. Add a Textarea fallback in the same render block, and relax the validation to accept manual IDs even when availableModels is empty. The validation message now points users at the manual path. * studio: restrict manual model ID entry to openrouter among remote providers Address review feedback: major remote providers (openai, anthropic, gemini, mistral, cohere, deepseek, ...) expose large per-model parameter surfaces that differ across models, so accepting pasted model IDs leads to mismatched parameter expectations and frustrating runtime errors. Keep their catalog curated by hiding the manual textarea and falling back to the prior 'Load available models first' validation toast for them. OpenRouter drops unsupported parameters server-side, so manual entry remains useful there; keep the textarea and the union save path for it. Custom and curated backends already gated via isCustomProvider / isCuratedModelList and continue to require manual entry as before. * studio: shorten code comments in chat-providers-dialog.tsx Trim three multi-line comment blocks to single lines per review.
This commit is contained in:
parent
9b8ee6c773
commit
7214f59d57
3 changed files with 148 additions and 72 deletions
|
|
@ -12,6 +12,7 @@ import { Route as indexRoute } from "./routes/index";
|
|||
import { Route as loginRoute } from "./routes/login";
|
||||
import { Route as onboardingRoute } from "./routes/onboarding";
|
||||
import { Route as changePasswordRoute } from "./routes/change-password";
|
||||
import { Route as settingsRoute } from "./routes/settings";
|
||||
import { Route as studioRoute } from "./routes/studio";
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
|
|
@ -20,6 +21,7 @@ const routeTree = rootRoute.addChildren([
|
|||
loginRoute,
|
||||
changePasswordRoute,
|
||||
gridTestRoute,
|
||||
settingsRoute,
|
||||
studioRoute,
|
||||
chatRoute,
|
||||
exportRoute,
|
||||
|
|
|
|||
20
studio/frontend/src/app/routes/settings.tsx
Normal file
20
studio/frontend/src/app/routes/settings.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// 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, redirect } from "@tanstack/react-router";
|
||||
import { getPostAuthRoute } from "@/features/auth";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
// /settings is a deep link to the modal. Open it, then redirect home.
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/settings",
|
||||
beforeLoad: async () => {
|
||||
await requireAuth();
|
||||
useSettingsDialogStore.getState().openDialog();
|
||||
throw redirect({ to: getPostAuthRoute() });
|
||||
},
|
||||
component: () => null,
|
||||
});
|
||||
|
|
@ -135,6 +135,9 @@ function parseManualModelIds(text: string): string[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Remote providers safe for manual model IDs (openrouter drops unused params).
|
||||
const MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES = new Set<string>(["openrouter"]);
|
||||
|
||||
function pruneProviderModelIds(providerType: string, modelIds: string[]): string[] {
|
||||
if (providerType === "anthropic") {
|
||||
return modelIds.filter((id) => !ANTHROPIC_DATED_SNAPSHOT_SUFFIX.test(id));
|
||||
|
|
@ -330,6 +333,10 @@ export function ChatProvidersSettings({
|
|||
updatedAt,
|
||||
};
|
||||
});
|
||||
// Don't wipe localStorage providers when the server has no rows.
|
||||
if (syncedProviders.length === 0 && providersRef.current.length > 0) {
|
||||
return;
|
||||
}
|
||||
onProvidersChange(syncedProviders);
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
|
@ -501,19 +508,28 @@ export function ChatProvidersSettings({
|
|||
return;
|
||||
}
|
||||
const curated = selectedRegistryEntry?.model_list_mode === "curated";
|
||||
const manualModels = isCustomProvider || curated;
|
||||
const manualOnly = isCustomProvider || curated;
|
||||
const remoteAllowsManual =
|
||||
MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType);
|
||||
const manualIds = parseManualModelIds(manualModelIds);
|
||||
const allowManual = manualOnly || remoteAllowsManual;
|
||||
const modelsToSave = pruneProviderModelIds(
|
||||
providerType,
|
||||
manualModels
|
||||
allowManual
|
||||
? [
|
||||
...new Set([
|
||||
...selectedModelIds,
|
||||
...parseManualModelIds(manualModelIds),
|
||||
...manualIds,
|
||||
]),
|
||||
]
|
||||
: [...selectedModelIds],
|
||||
);
|
||||
if (manualModels) {
|
||||
if (manualOnly) {
|
||||
if (modelsToSave.length === 0) {
|
||||
toast.error("Add at least one model ID.");
|
||||
return;
|
||||
}
|
||||
} else if (remoteAllowsManual && manualIds.length > 0) {
|
||||
if (modelsToSave.length === 0) {
|
||||
toast.error("Add at least one model ID.");
|
||||
return;
|
||||
|
|
@ -553,7 +569,7 @@ export function ChatProvidersSettings({
|
|||
name: created.display_name,
|
||||
baseUrl: created.base_url ?? "",
|
||||
models: modelsToSave,
|
||||
availableModels: manualModels
|
||||
availableModels: manualOnly
|
||||
? []
|
||||
: pruneProviderModelIds(providerType, availableModels),
|
||||
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
||||
|
|
@ -597,19 +613,29 @@ export function ChatProvidersSettings({
|
|||
}
|
||||
const entry = registryByType.get(existing.providerType);
|
||||
const curated = entry?.model_list_mode === "curated";
|
||||
const manualModels = isEditingCustomProvider || curated;
|
||||
const manualOnly = isEditingCustomProvider || curated;
|
||||
const remoteAllowsManual = MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(
|
||||
existing.providerType,
|
||||
);
|
||||
const manualIds = parseManualModelIds(manualModelIds);
|
||||
const allowManual = manualOnly || remoteAllowsManual;
|
||||
const modelsToSave = pruneProviderModelIds(
|
||||
existing.providerType,
|
||||
manualModels
|
||||
allowManual
|
||||
? [
|
||||
...new Set([
|
||||
...selectedModelIds,
|
||||
...parseManualModelIds(manualModelIds),
|
||||
...manualIds,
|
||||
]),
|
||||
]
|
||||
: [...selectedModelIds],
|
||||
);
|
||||
if (manualModels) {
|
||||
if (manualOnly) {
|
||||
if (modelsToSave.length === 0) {
|
||||
toast.error("Add at least one model ID.");
|
||||
return;
|
||||
}
|
||||
} else if (remoteAllowsManual && manualIds.length > 0) {
|
||||
if (modelsToSave.length === 0) {
|
||||
toast.error("Add at least one model ID.");
|
||||
return;
|
||||
|
|
@ -655,7 +681,7 @@ export function ChatProvidersSettings({
|
|||
name: updated.display_name,
|
||||
baseUrl: updated.base_url ?? "",
|
||||
models: modelsToSave,
|
||||
availableModels: manualModels
|
||||
availableModels: manualOnly
|
||||
? []
|
||||
: pruneProviderModelIds(existing.providerType, availableModels),
|
||||
isReasoningModel: supportsProviderReasoningToggle(
|
||||
|
|
@ -1184,71 +1210,99 @@ export function ChatProvidersSettings({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : availableModels.length === 0 ? null : (
|
||||
) : availableModels.length === 0 &&
|
||||
!MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? null : (
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div className="grid grid-cols-[112px_minmax(220px,330px)_auto] items-center gap-3 max-sm:grid-cols-1">
|
||||
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
|
||||
{availableModelsLabel}
|
||||
</span>
|
||||
<Input
|
||||
id={`provider-model-search-${modelsPanelKey}`}
|
||||
type="search"
|
||||
value={modelSearchQuery}
|
||||
onChange={(event) =>
|
||||
setModelSearchQuery(event.target.value)
|
||||
}
|
||||
placeholder="Search"
|
||||
aria-label="Search models"
|
||||
className={modelSearchInputClassName}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
|
||||
onClick={selectAllModels}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
|
||||
onClick={clearModelSelection}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="max-h-56 overflow-y-auto rounded-[8px] border border-border/70 bg-background/50">
|
||||
{filteredAvailableModels.length === 0 ? (
|
||||
<li className="px-3 py-3 text-xs text-muted-foreground">
|
||||
No matching models
|
||||
</li>
|
||||
) : (
|
||||
filteredAvailableModels.map((model, index) => (
|
||||
<li
|
||||
key={model}
|
||||
className="flex cursor-pointer items-center gap-2.5 border-border/60 border-b px-3 py-2 last:border-b-0 hover:bg-muted/35"
|
||||
onClick={() => toggleModel(model)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`provider-model-remote-${modelsPanelKey}-${index}`}
|
||||
checked={selectedModelIds.includes(model)}
|
||||
onCheckedChange={() => toggleModel(model)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<span
|
||||
className="min-w-0 break-all text-sm leading-tight"
|
||||
{availableModels.length === 0 ? null : (
|
||||
<>
|
||||
<div className="grid grid-cols-[112px_minmax(220px,330px)_auto] items-center gap-3 max-sm:grid-cols-1">
|
||||
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
|
||||
{availableModelsLabel}
|
||||
</span>
|
||||
<Input
|
||||
id={`provider-model-search-${modelsPanelKey}`}
|
||||
type="search"
|
||||
value={modelSearchQuery}
|
||||
onChange={(event) =>
|
||||
setModelSearchQuery(event.target.value)
|
||||
}
|
||||
placeholder="Search"
|
||||
aria-label="Search models"
|
||||
className={modelSearchInputClassName}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
|
||||
onClick={selectAllModels}
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
Select all
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
|
||||
onClick={clearModelSelection}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="max-h-56 overflow-y-auto rounded-[8px] border border-border/70 bg-background/50">
|
||||
{filteredAvailableModels.length === 0 ? (
|
||||
<li className="px-3 py-3 text-xs text-muted-foreground">
|
||||
No matching models
|
||||
</li>
|
||||
) : (
|
||||
filteredAvailableModels.map((model, index) => (
|
||||
<li
|
||||
key={model}
|
||||
className="flex cursor-pointer items-center gap-2.5 border-border/60 border-b px-3 py-2 last:border-b-0 hover:bg-muted/35"
|
||||
onClick={() => toggleModel(model)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`provider-model-remote-${modelsPanelKey}-${index}`}
|
||||
checked={selectedModelIds.includes(model)}
|
||||
onCheckedChange={() => toggleModel(model)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<span
|
||||
className="min-w-0 break-all text-sm leading-tight"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{/* Manual IDs allowed for openrouter only. */}
|
||||
{MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="provider-manual-models"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{availableModels.length === 0
|
||||
? "Or enter model IDs manually (one per line or comma-separated)"
|
||||
: "Additional model IDs (one per line or comma-separated)"}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="provider-manual-models"
|
||||
value={manualModelIds}
|
||||
onChange={(event) =>
|
||||
setManualModelIds(event.target.value)
|
||||
}
|
||||
placeholder={"model-id-1\nmodel-id-2"}
|
||||
rows={4}
|
||||
className="min-h-[80px] resize-y font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue