mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
* feat(dashboard): add search to Playground model picker dropdown (#4086) The shared ModelSelectModal (combo builder + CLI-code cards) already had search, but the Playground's raw model <select> in StudioConfigPane stayed a flat unsearchable list - unusable once a provider like OpenRouter contributed 50+ models. Adds a search input above the dropdown that filters options via filterModelsByQuery() (Turkish-safe accent/case-insensitive match, reusing matchesSearch()). The currently selected model always stays pinned in the list even when it doesn't match the query, so typing never silently swaps the active selection. Reuses the existing common.search i18n key already translated in all 42 locales - no new key needed. * chore(6811): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)
This commit is contained in:
committed by
GitHub
parent
6904484f51
commit
e8fce67e70
@@ -0,0 +1 @@
|
||||
- **feat(dashboard):** search box on the Playground's raw model `<select>` (#4086) — the shared `ModelSelectModal` (combo builder + CLI-code cards) already had search, but Playground's `StudioConfigPane` model dropdown stayed a flat unsearchable list, unusable once a provider like OpenRouter contributed 50+ models. Typing now filters the dropdown (Turkish-safe accent/case-insensitive match via `matchesSearch`), while the currently selected model always stays pinned in the list even if it no longer matches the query, so typing never silently swaps the active selection. Reuses the existing `common.search` i18n key (already translated in all 42 locales) — no new translation key needed. Regression guard: `tests/unit/playground-model-selection-3731.test.ts` (`filterModelsByQuery`), `tests/unit/ui/playground-model-search-4086.test.tsx`.
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ParamSliders, { type PlaygroundParams } from "./ParamSliders";
|
||||
import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
|
||||
import { endpointToPath } from "@/lib/playground/codeExport";
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
import { pickDefaultModel, resolveModelFilterKey } from "./modelSelection";
|
||||
import { filterModelsByQuery, pickDefaultModel, resolveModelFilterKey } from "./modelSelection";
|
||||
import ReasoningControls from "./ReasoningControls";
|
||||
import {
|
||||
resolveReasoningControls,
|
||||
@@ -63,7 +64,11 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
|
||||
* - SLOT_IMPROVE: ImprovePromptButton will be injected here
|
||||
*/
|
||||
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
|
||||
const t = useTranslations("common");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// #4086: search/filter query for the Model dropdown — flat provider catalogs (e.g.
|
||||
// 50+ OpenRouter models) made the plain <select> unusable without scrolling.
|
||||
const [modelQuery, setModelQuery] = useState("");
|
||||
const {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -91,6 +96,17 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
|
||||
const { availableModels, modelCapabilities, loading: loadingModels } =
|
||||
useAvailableModels(modelFilterKey);
|
||||
|
||||
// #4086: filter the dropdown by the search query, but always keep the currently selected
|
||||
// model in the list even when it doesn't match — otherwise typing a query would silently
|
||||
// change the active selection out from under the user.
|
||||
const filteredModels = useMemo(() => {
|
||||
const filtered = filterModelsByQuery(availableModels, modelQuery);
|
||||
if (configState.model && !filtered.includes(configState.model)) {
|
||||
return [configState.model, ...filtered];
|
||||
}
|
||||
return filtered;
|
||||
}, [availableModels, modelQuery, configState.model]);
|
||||
|
||||
// #6241: resolve the reasoning controls for the currently selected model from the capability
|
||||
// flags the /models catalog exposes (supportsThinking / effort_tiers).
|
||||
const reasoningSpec = resolveReasoningControls(modelCapabilities[configState.model]);
|
||||
@@ -207,18 +223,30 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
|
||||
Model
|
||||
</label>
|
||||
{availableModels.length > 0 ? (
|
||||
<select
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
disabled={loadingModels}
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
>
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
{availableModels.length > 1 && (
|
||||
<input
|
||||
type="text"
|
||||
value={modelQuery}
|
||||
onChange={(e) => setModelQuery(e.target.value)}
|
||||
placeholder={t("search")}
|
||||
aria-label={t("search")}
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
value={configState.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
disabled={loadingModels}
|
||||
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
|
||||
>
|
||||
{filteredModels.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
//
|
||||
// These pure helpers encode the fix and are unit-tested directly.
|
||||
|
||||
import { matchesSearch } from "@/shared/utils/turkishText";
|
||||
|
||||
/**
|
||||
* Resolve the catalog-namespace key used to filter the model list for a provider.
|
||||
* - Built-in providers ("openai", "anthropic", …) filter by their id.
|
||||
@@ -44,3 +46,15 @@ export function pickDefaultModel(
|
||||
if (currentModel && availableModels.includes(currentModel)) return null;
|
||||
return availableModels[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the model dropdown list by a free-text search query (#4086 — the raw Playground
|
||||
* model `<select>` had no way to narrow a long list, e.g. 50+ OpenRouter models).
|
||||
* Accent/case-insensitive, Turkish-safe substring match against the model id (see
|
||||
* `matchesSearch` — raw `toLowerCase().includes()` mangles İ/ı). An empty/whitespace query
|
||||
* returns the full list unchanged.
|
||||
*/
|
||||
export function filterModelsByQuery(models: string[], query: string): string[] {
|
||||
if (!query.trim()) return models;
|
||||
return models.filter((m) => matchesSearch(m, query));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
pickDefaultModel,
|
||||
resolveModelFilterKey,
|
||||
filterModelsByQuery,
|
||||
} from "../../src/app/(dashboard)/dashboard/playground/components/modelSelection.ts";
|
||||
|
||||
// Regression guards for #3731 (dup #3009): the Playground model selector was unusable
|
||||
@@ -48,3 +49,26 @@ test("pickDefaultModel: a current model not in the list is replaced by the first
|
||||
test("pickDefaultModel: a valid current model is kept (no redundant update)", () => {
|
||||
assert.equal(pickDefaultModel("b", ["a", "b"]), null);
|
||||
});
|
||||
|
||||
// Regression guards for #4086: search/filter on the raw Playground model <select>.
|
||||
|
||||
test("filterModelsByQuery: empty query returns the full list unchanged", () => {
|
||||
const models = ["openai/gpt-4o", "anthropic/claude-3"];
|
||||
assert.deepEqual(filterModelsByQuery(models, ""), models);
|
||||
assert.deepEqual(filterModelsByQuery(models, " "), models);
|
||||
});
|
||||
|
||||
test("filterModelsByQuery: matches case-insensitively on substring", () => {
|
||||
const models = ["openai/gpt-4o", "anthropic/claude-3", "openrouter/mistral-large"];
|
||||
assert.deepEqual(filterModelsByQuery(models, "GPT"), ["openai/gpt-4o"]);
|
||||
assert.deepEqual(filterModelsByQuery(models, "claude"), ["anthropic/claude-3"]);
|
||||
});
|
||||
|
||||
test("filterModelsByQuery: matches provider/namespace prefix", () => {
|
||||
const models = ["openai/gpt-4o", "anthropic/claude-3", "openrouter/mistral-large"];
|
||||
assert.deepEqual(filterModelsByQuery(models, "openrouter"), ["openrouter/mistral-large"]);
|
||||
});
|
||||
|
||||
test("filterModelsByQuery: no matches returns an empty list", () => {
|
||||
assert.deepEqual(filterModelsByQuery(["a", "b"], "zzz"), []);
|
||||
});
|
||||
|
||||
162
tests/unit/ui/playground-model-search-4086.test.tsx
Normal file
162
tests/unit/ui/playground-model-search-4086.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// #4086: the raw Playground model <select> had no search/filter, forcing users to scroll
|
||||
// a flat list (e.g. 50+ OpenRouter models). Regression guard for the search box added to
|
||||
// StudioConfigPane's model picker.
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/playground/codeExport", () => ({
|
||||
endpointToPath: (ep: string) => `/v1/${ep}`,
|
||||
}));
|
||||
|
||||
const AVAILABLE_MODELS = ["openai/gpt-4o", "anthropic/claude-3", "openrouter/mistral-large"];
|
||||
|
||||
vi.mock("@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels", () => ({
|
||||
useAvailableModels: () => ({
|
||||
availableModels: AVAILABLE_MODELS,
|
||||
modelCapabilities: {},
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions", () => ({
|
||||
useProviderOptions: () => ({
|
||||
provider: "",
|
||||
setProvider: vi.fn(),
|
||||
providerOptions: [],
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: StudioConfigPane } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/playground/components/StudioConfigPane"
|
||||
);
|
||||
const { DEFAULT_PARAMS } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders"
|
||||
);
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function makeConfig() {
|
||||
return {
|
||||
endpoint: "chat.completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "openai/gpt-4o",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
params: { ...DEFAULT_PARAMS },
|
||||
};
|
||||
}
|
||||
|
||||
function renderPane(
|
||||
configState: ReturnType<typeof makeConfig>,
|
||||
setConfigState: (s: ReturnType<typeof makeConfig>) => void
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<StudioConfigPane
|
||||
configState={configState}
|
||||
setConfigState={setConfigState as (s: typeof configState) => void}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("StudioConfigPane model search (#4086)", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a search input above the model select", () => {
|
||||
const config = makeConfig();
|
||||
const el = renderPane(config, vi.fn());
|
||||
const searchInput = el.querySelector(
|
||||
"input[type='text'][placeholder='search']"
|
||||
) as HTMLInputElement | null;
|
||||
expect(searchInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows all models in the select when the search box is empty", () => {
|
||||
const config = makeConfig();
|
||||
const el = renderPane(config, vi.fn());
|
||||
const modelSelect = Array.from(el.querySelectorAll<HTMLSelectElement>("select")).find((s) =>
|
||||
Array.from(s.options).some((o) => o.value === "openai/gpt-4o")
|
||||
);
|
||||
expect(modelSelect).toBeTruthy();
|
||||
expect(modelSelect?.options.length).toBe(AVAILABLE_MODELS.length);
|
||||
});
|
||||
|
||||
it("filters the model select options as the user types", () => {
|
||||
const config = makeConfig();
|
||||
const el = renderPane(config, vi.fn());
|
||||
|
||||
const searchInput = el.querySelector(
|
||||
"input[type='text'][placeholder='search']"
|
||||
) as HTMLInputElement;
|
||||
expect(searchInput).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
setInputValue(searchInput, "claude");
|
||||
});
|
||||
|
||||
const modelSelect = Array.from(el.querySelectorAll<HTMLSelectElement>("select")).find((s) =>
|
||||
Array.from(s.options).some((o) => o.value === "anthropic/claude-3")
|
||||
);
|
||||
expect(modelSelect).toBeTruthy();
|
||||
// The non-matching "openrouter/mistral-large" model is filtered out. The currently
|
||||
// selected "openai/gpt-4o" stays pinned (see the dedicated test below) even though it
|
||||
// doesn't match "claude" — so the matched model plus the pinned selection remain.
|
||||
const values = Array.from(modelSelect?.options ?? []).map((o) => o.value);
|
||||
expect(values).toContain("anthropic/claude-3");
|
||||
expect(values).not.toContain("openrouter/mistral-large");
|
||||
});
|
||||
|
||||
it("keeps a currently selected model visible even when it doesn't match the query", () => {
|
||||
const config = makeConfig(); // model = "openai/gpt-4o"
|
||||
const el = renderPane(config, vi.fn());
|
||||
|
||||
const searchInput = el.querySelector(
|
||||
"input[type='text'][placeholder='search']"
|
||||
) as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
setInputValue(searchInput, "claude");
|
||||
});
|
||||
|
||||
const modelSelect = Array.from(el.querySelectorAll<HTMLSelectElement>("select")).find((s) =>
|
||||
Array.from(s.options).some((o) => o.value === "anthropic/claude-3")
|
||||
);
|
||||
const values = Array.from(modelSelect?.options ?? []).map((o) => o.value);
|
||||
expect(values).toContain("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user