feat(web): chat model picker lists key-configured models first, with a show-all expander (#22)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+211
-10
No files matched your search
@@ -7,7 +7,8 @@
|
|||||||
* indicator) + Model + send (up arrow);
|
* indicator) + Model + send (up arrow);
|
||||||
* In draft state (Session not yet created), when models/onChangeModel are supplied, the model
|
* In draft state (Session not yet created), when models/onChangeModel are supplied, the model
|
||||||
* selector sits to the left of the send button (provider logo + name, popup opens **downward**,
|
* selector sits to the left of the send button (provider logo + name, popup opens **downward**,
|
||||||
* with a top quick-search box, and an internal scroll cap to avoid overflowing the screen) — once
|
* with a top quick-search box, an internal scroll cap to avoid overflowing the screen, and a
|
||||||
|
* configured-key-first list with a bottom "show all" row — see ModelSelect) — once
|
||||||
* the Session is created the model is locked, and the same spot switches to a read-only
|
* the Session is created the model is locked, and the same spot switches to a read-only
|
||||||
* logo + name display;
|
* logo + name display;
|
||||||
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
|
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
|
||||||
@@ -56,7 +57,7 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
|
|||||||
import { SkillIcon } from "../skills/skill-icon-view";
|
import { SkillIcon } from "../skills/skill-icon-view";
|
||||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||||
import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
|
import { hasConfiguredKey, sameModelRef, visibleChatModels } from "../models/model-grouping";
|
||||||
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
|
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
|
||||||
import { matchSlash, removeSlashToken } from "./slash-token";
|
import { matchSlash, removeSlashToken } from "./slash-token";
|
||||||
import {
|
import {
|
||||||
@@ -206,6 +207,13 @@ function modelLabel(m: ModelInfo): string {
|
|||||||
return m.displayName ?? m.modelId;
|
return m.displayName ?? m.modelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No key" marker for the model dropdown's key-less rows: a key struck through by a prohibition
|
||||||
|
* slash (24x24 line art, grayscale via currentColor, matching the approval-mode icon style).
|
||||||
|
*/
|
||||||
|
const NO_KEY_ICON =
|
||||||
|
"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model selector (draft state only; docked to the left of the send button): both the button and
|
* Model selector (draft state only; docked to the left of the send button): both the button and
|
||||||
* candidate items show the provider logo. The menu opens **downward** — the draft card is
|
* candidate items show the provider logo. The menu opens **downward** — the draft card is
|
||||||
@@ -214,6 +222,12 @@ function modelLabel(m: ModelInfo): string {
|
|||||||
* scroll (max-h-56) so it never overflows the browser's viewport height no matter how many models
|
* scroll (max-h-56) so it never overflows the browser's viewport height no matter how many models
|
||||||
* there are. On narrow screens only the logo remains (name hidden); list items mark the project
|
* there are. On narrow screens only the logo remains (name hidden); list items mark the project
|
||||||
* default.
|
* default.
|
||||||
|
* By default only models with a configured API key are listed (stored masked key — the same
|
||||||
|
* standard as the model page's key status; `envKey` is merely the NAME of a fallback env var and
|
||||||
|
* doesn't count), with the selected and the default model always visible even without a key; a
|
||||||
|
* muted bottom row reveals the remaining key-less models (marked by a struck-through key icon,
|
||||||
|
* with the "no key" text in its title) without closing the menu or changing the selection. When
|
||||||
|
* no model has a key at all, everything is listed directly.
|
||||||
*/
|
*/
|
||||||
function ModelSelect({
|
function ModelSelect({
|
||||||
models,
|
models,
|
||||||
@@ -231,12 +245,21 @@ function ModelSelect({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
// Expanded "show all" state: collapses back to key-configured models on each open.
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
const current = models.find((m) => sameModelRef(m, value));
|
const current = models.find((m) => sameModelRef(m, value));
|
||||||
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
|
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
|
||||||
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
|
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
|
||||||
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
|
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
|
||||||
// (user-defined groups after, custom last), in-group order preserved.
|
// (user-defined groups after, custom last), in-group order preserved. By default the list
|
||||||
const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
|
// keeps only key-configured models (selected/default always included; lists everything when
|
||||||
|
// no model has a key); the query filters what's visible.
|
||||||
|
const visible = visibleChatModels(models, { showAll, query, selected: value, defaultModel });
|
||||||
|
// How many models the key filter hides under the current query (0 when expanded): drives the bottom "show all" row.
|
||||||
|
const hiddenCount = showAll
|
||||||
|
? 0
|
||||||
|
: visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length -
|
||||||
|
visible.length;
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
open={open}
|
open={open}
|
||||||
@@ -251,7 +274,11 @@ function ModelSelect({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = !open;
|
const next = !open;
|
||||||
setOpen(next);
|
setOpen(next);
|
||||||
if (next) setQuery(""); // Always start from the full list each time it opens
|
if (next) {
|
||||||
|
// Each open starts from the unsearched, collapsed (configured-only) list.
|
||||||
|
setQuery("");
|
||||||
|
setShowAll(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||||
>
|
>
|
||||||
@@ -292,10 +319,10 @@ function ModelSelect({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-56 overflow-y-auto">
|
<div className="max-h-56 overflow-y-auto">
|
||||||
{filtered.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<p className="px-3 py-1.5 text-xs text-gray-400">{S.models.noSearchResults}</p>
|
<p className="px-3 py-1.5 text-xs text-gray-400">{S.models.noSearchResults}</p>
|
||||||
)}
|
)}
|
||||||
{filtered.map((m) => (
|
{visible.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={`${m.provider}:${m.modelId}`}
|
key={`${m.provider}:${m.modelId}`}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -311,6 +338,18 @@ function ModelSelect({
|
|||||||
>
|
>
|
||||||
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
|
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
|
||||||
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
|
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
|
||||||
|
{/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a
|
||||||
|
struck-through key icon (the "no key" text lives in the title/aria-label). */}
|
||||||
|
{!hasConfiguredKey(m) && (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
title={S.models.noKey}
|
||||||
|
aria-label={S.models.noKey}
|
||||||
|
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<GlyphIcon d={NO_KEY_ICON} size={13} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{sameModelRef(m, defaultModel) && (
|
{sameModelRef(m, defaultModel) && (
|
||||||
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{S.models.default}
|
{S.models.default}
|
||||||
@@ -322,6 +361,20 @@ function ModelSelect({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Bottom expander row (pinned below the scroll area, mirroring the search box on top):
|
||||||
|
reveals the models hidden by the configured-key filter in place — the menu stays open
|
||||||
|
and the selection is untouched. */}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll(true)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{S.models.showModelsWithoutKey(hiddenCount)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,3 +98,48 @@ export function groupModelRows<T extends ModelRowLike>(
|
|||||||
export function orderModelsLikeLibrary<T extends ModelRowLike>(rows: T[]): T[] {
|
export function orderModelsLikeLibrary<T extends ModelRowLike>(rows: T[]): T[] {
|
||||||
return groupModelRows(rows, "").flatMap((g) => g.rows);
|
return groupModelRows(rows, "").flatMap((g) => g.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Row shape for the configured-key filter: adds the read-only credential display (the DTO's ModelInfo is a superset). */
|
||||||
|
export interface ModelCredentialRowLike extends ModelRowLike {
|
||||||
|
credential?: { apiKeyMasked?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the model has an API key configured: judged solely by the stored (masked) key — the
|
||||||
|
* same standard as the chat credential guide and the model page's key status. `envKey` is only
|
||||||
|
* the NAME of a fallback environment variable; its presence says nothing about whether that
|
||||||
|
* variable is actually set, so it never counts as configured.
|
||||||
|
*/
|
||||||
|
export function hasConfiguredKey(m: ModelCredentialRowLike): boolean {
|
||||||
|
return !!m.credential?.apiKeyMasked;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisibleChatModelsOptions {
|
||||||
|
/** true = list every model (the dropdown's expanded "show all" state). */
|
||||||
|
showAll: boolean;
|
||||||
|
query: string;
|
||||||
|
/** Currently selected model: always visible even without a configured key (the active choice must never be invisible). */
|
||||||
|
selected?: ModelRefValue | null;
|
||||||
|
/** Project default model: always visible even without a configured key. */
|
||||||
|
defaultModel?: ModelRefValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate list for the chat model dropdown: library order → keep only key-configured models
|
||||||
|
* (plus the selected and the default model, unless showAll) → the query then filters whatever
|
||||||
|
* is visible. When NO model has a configured key, the key filter degrades to showAll
|
||||||
|
* (everything listed), so the dropdown is never uselessly empty.
|
||||||
|
*/
|
||||||
|
export function visibleChatModels<T extends ModelCredentialRowLike>(
|
||||||
|
models: T[],
|
||||||
|
{ showAll, query, selected, defaultModel }: VisibleChatModelsOptions,
|
||||||
|
): T[] {
|
||||||
|
const ordered = orderModelsLikeLibrary(models);
|
||||||
|
const keep =
|
||||||
|
showAll || !ordered.some(hasConfiguredKey)
|
||||||
|
? ordered
|
||||||
|
: ordered.filter(
|
||||||
|
(m) => hasConfiguredKey(m) || sameModelRef(m, selected) || sameModelRef(m, defaultModel),
|
||||||
|
);
|
||||||
|
return keep.filter((m) => matchesQuery(m, query));
|
||||||
|
}
|
||||||
@@ -382,6 +382,8 @@ export const en: Strings = {
|
|||||||
readOnlyHint: "Members have read-only access; only owners can change models and credentials",
|
readOnlyHint: "Members have read-only access; only owners can change models and credentials",
|
||||||
empty: "No models configured yet",
|
empty: "No models configured yet",
|
||||||
noKey: "No key",
|
noKey: "No key",
|
||||||
|
showModelsWithoutKey: (n: number): string =>
|
||||||
|
`Show model${n === 1 ? "" : "s"} without a key (${n})`,
|
||||||
pendingSave: "(pending save)",
|
pendingSave: "(pending save)",
|
||||||
modelIdExists: "This model id already exists",
|
modelIdExists: "This model id already exists",
|
||||||
pricingAllOrNone: "Fill all three prices",
|
pricingAllOrNone: "Fill all three prices",
|
||||||
|
|||||||
@@ -359,6 +359,8 @@ export const zh = {
|
|||||||
readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行",
|
readOnlyHint: "member 只读;模型与 credential 修改仅 owner 可执行",
|
||||||
empty: "尚未配置任何模型",
|
empty: "尚未配置任何模型",
|
||||||
noKey: "未配置 key",
|
noKey: "未配置 key",
|
||||||
|
/** Chat model dropdown's bottom expander row: reveals the models hidden by the configured-key filter. */
|
||||||
|
showModelsWithoutKey: (n: number): string => `显示未配置 key 的模型(${n} 个)`,
|
||||||
pendingSave: "(待保存)",
|
pendingSave: "(待保存)",
|
||||||
modelIdExists: "该模型 id 已存在",
|
modelIdExists: "该模型 id 已存在",
|
||||||
pricingAllOrNone: "三项价格需一并填写",
|
pricingAllOrNone: "三项价格需一并填写",
|
||||||
|
|||||||
@@ -6,16 +6,20 @@
|
|||||||
* provider not in the catalog becomes a custom-built group — each forms its own
|
* provider not in the catalog becomes a custom-built group — each forms its own
|
||||||
* group, sorted by name and appended after custom; empty groups are hidden, except the
|
* group, sorted by name and appended after custom; empty groups are hidden, except the
|
||||||
* custom group, which is always shown when there's no search query, hosting the generic
|
* custom group, which is always shown when there's no search query, hosting the generic
|
||||||
* "add model" entry point.
|
* "add model" entry point. Also covers the chat dropdown's visibility rule (visibleChatModels):
|
||||||
|
* key-configured models only by default (a stored masked key, judged by hasConfiguredKey),
|
||||||
|
* selected/default always visible, everything listed when nothing is configured or on showAll.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
|
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
|
||||||
import {
|
import {
|
||||||
groupModelRows,
|
groupModelRows,
|
||||||
orderModelsLikeLibrary,
|
hasConfiguredKey,
|
||||||
matchesQuery,
|
matchesQuery,
|
||||||
|
orderModelsLikeLibrary,
|
||||||
|
visibleChatModels,
|
||||||
} from "../src/features/models/model-grouping";
|
} from "../src/features/models/model-grouping";
|
||||||
import type { ModelRowLike } from "../src/features/models/model-grouping";
|
import type { ModelCredentialRowLike, ModelRowLike } from "../src/features/models/model-grouping";
|
||||||
|
|
||||||
const rows: ModelRowLike[] = [
|
const rows: ModelRowLike[] = [
|
||||||
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
|
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
|
||||||
@@ -131,6 +135,101 @@ describe("groupModelRows", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasConfiguredKey", () => {
|
||||||
|
it("only a stored (masked) key counts as configured", () => {
|
||||||
|
expect(
|
||||||
|
hasConfiguredKey({
|
||||||
|
provider: "anthropic",
|
||||||
|
modelId: "m",
|
||||||
|
credential: { apiKeyMasked: "sk-a***xyz" },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(hasConfiguredKey({ provider: "anthropic", modelId: "m" })).toBe(false);
|
||||||
|
expect(hasConfiguredKey({ provider: "anthropic", modelId: "m", credential: {} })).toBe(false);
|
||||||
|
// envKey is merely the NAME of a fallback env var (nothing says the var is actually set): never counts.
|
||||||
|
const envOnly = { provider: "anthropic", modelId: "m", envKey: "ANTHROPIC_API_KEY" };
|
||||||
|
expect(hasConfiguredKey(envOnly)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("visibleChatModels", () => {
|
||||||
|
const configured = (provider: string, modelId: string): ModelCredentialRowLike => ({
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
credential: { apiKeyMasked: "sk-***" },
|
||||||
|
});
|
||||||
|
const keyless = (provider: string, modelId: string): ModelCredentialRowLike => ({
|
||||||
|
provider,
|
||||||
|
modelId,
|
||||||
|
});
|
||||||
|
const pool: ModelCredentialRowLike[] = [
|
||||||
|
keyless("deepseek", "deepseek-v4"),
|
||||||
|
configured("anthropic", "claude-sonnet-4-6"),
|
||||||
|
keyless("anthropic", "claude-opus-4-8"),
|
||||||
|
configured("moonshot", "kimi-k2.6"),
|
||||||
|
keyless("custom", "my-proxy"),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("by default lists only key-configured models, in library order", () => {
|
||||||
|
expect(visibleChatModels(pool, { showAll: false, query: "" }).map((m) => m.modelId)).toEqual([
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"kimi-k2.6",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showAll lists everything, still in library order", () => {
|
||||||
|
expect(visibleChatModels(pool, { showAll: true, query: "" }).map((m) => m.modelId)).toEqual([
|
||||||
|
"deepseek-v4",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"claude-opus-4-8",
|
||||||
|
"kimi-k2.6",
|
||||||
|
"my-proxy",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the selected and the default model stay visible even without a key", () => {
|
||||||
|
const visible = visibleChatModels(pool, {
|
||||||
|
showAll: false,
|
||||||
|
query: "",
|
||||||
|
selected: { provider: "anthropic", modelId: "claude-opus-4-8" },
|
||||||
|
defaultModel: { provider: "deepseek", modelId: "deepseek-v4" },
|
||||||
|
});
|
||||||
|
expect(visible.map((m) => m.modelId)).toEqual([
|
||||||
|
"deepseek-v4", // default, key-less — kept
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"claude-opus-4-8", // selected, key-less — kept
|
||||||
|
"kimi-k2.6",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when no model has a configured key, everything is listed (never an empty dropdown)", () => {
|
||||||
|
const none = [keyless("anthropic", "a"), keyless("moonshot", "b")];
|
||||||
|
expect(visibleChatModels(none, { showAll: false, query: "" }).map((m) => m.modelId)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the query filters what's visible: hidden key-less models only match once showAll", () => {
|
||||||
|
expect(visibleChatModels(pool, { showAll: false, query: "opus" })).toEqual([]);
|
||||||
|
expect(visibleChatModels(pool, { showAll: true, query: "opus" }).map((m) => m.modelId)).toEqual(
|
||||||
|
["claude-opus-4-8"],
|
||||||
|
);
|
||||||
|
// The query also narrows the configured-only view.
|
||||||
|
expect(
|
||||||
|
visibleChatModels(pool, { showAll: false, query: "kimi" }).map((m) => m.modelId),
|
||||||
|
).toEqual(["kimi-k2.6"]);
|
||||||
|
// ...and a key-less selected model kept by the exception is still searchable.
|
||||||
|
expect(
|
||||||
|
visibleChatModels(pool, {
|
||||||
|
showAll: false,
|
||||||
|
query: "opus",
|
||||||
|
selected: { provider: "anthropic", modelId: "claude-opus-4-8" },
|
||||||
|
}).map((m) => m.modelId),
|
||||||
|
).toEqual(["claude-opus-4-8"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("orderModelsLikeLibrary", () => {
|
describe("orderModelsLikeLibrary", () => {
|
||||||
it("flattens to the library page's order: built-in provider order, user groups after, custom last", () => {
|
it("flattens to the library page's order: built-in provider order, user groups after, custom last", () => {
|
||||||
const rows: ModelRowLike[] = [
|
const rows: ModelRowLike[] = [
|
||||||
|
|||||||
Reference in new issue
Block a user