mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
The model dropdown already exposes a "Default (provider)" option meaning "follow the CLI's current selection". Tagging the runtime's preferred model with a small "default" chip created two competing notions of "default" in the same UI and confused users. Remove the chip from both the create-agent ModelDropdown and the inspector ModelPicker; keep the underlying RuntimeModel.default flag intact since thinking-prop-row still uses it as a fallback heuristic. Co-authored-by: multica-agent <github@multica.ai>
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Loader2, Plus } from "lucide-react";
|
|
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import {
|
|
PickerItem,
|
|
PropertyPicker,
|
|
} from "../../../issues/components/pickers";
|
|
import { CHIP_CLASS } from "./chip";
|
|
import { useT } from "../../../i18n";
|
|
|
|
/**
|
|
* Inline model picker for the agent inspector. Lighter cousin of
|
|
* `ModelDropdown` (which is used in the create-agent dialog) — same data
|
|
* source via `runtimeModelsOptions`, but renders inside a PropertyPicker so
|
|
* it fits a single PropRow. Drops the "select a runtime first" state because
|
|
* the inspector only renders this picker after a runtime is bound.
|
|
*
|
|
* Unsupported providers (e.g. hermes, which reads its own config) render an
|
|
* inert italic "Managed by runtime" label instead of a clickable picker —
|
|
* the back-end ignores agent.model for those runtimes anyway.
|
|
*/
|
|
export function ModelPicker({
|
|
runtimeId,
|
|
runtimeOnline,
|
|
value,
|
|
canEdit = true,
|
|
onChange,
|
|
}: {
|
|
runtimeId: string | null;
|
|
runtimeOnline: boolean;
|
|
value: string;
|
|
/** When false, render a static read-only display and skip the popover. */
|
|
canEdit?: boolean;
|
|
onChange: (next: string) => Promise<void> | void;
|
|
}) {
|
|
const { t } = useT("agents");
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const modelsQuery = useQuery(
|
|
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
|
|
);
|
|
const supported = modelsQuery.data?.supported ?? true;
|
|
// Memoise the model list so every downstream useMemo gets a stable
|
|
// reference; `?? []` would mint a fresh array on every render and
|
|
// invalidate filters needlessly.
|
|
const models = useMemo(
|
|
() => modelsQuery.data?.models ?? [],
|
|
[modelsQuery.data],
|
|
);
|
|
|
|
const filtered = useMemo(() => {
|
|
const s = search.trim().toLowerCase();
|
|
if (!s) return models;
|
|
return models.filter(
|
|
(m) =>
|
|
m.id.toLowerCase().includes(s) || m.label.toLowerCase().includes(s),
|
|
);
|
|
}, [models, search]);
|
|
|
|
const trimmedSearch = search.trim();
|
|
const exactMatch = models.some(
|
|
(m) => m.id === trimmedSearch || m.label === trimmedSearch,
|
|
);
|
|
const canCreate = trimmedSearch.length > 0 && !exactMatch;
|
|
|
|
const triggerLabel = value || t(($) => $.pickers.model_default);
|
|
const triggerTitle = t(($) => $.pickers.model_tooltip, { value: triggerLabel });
|
|
|
|
const select = async (id: string) => {
|
|
setOpen(false);
|
|
setSearch("");
|
|
if (id !== value) await onChange(id);
|
|
};
|
|
|
|
if (!supported && !modelsQuery.isLoading) {
|
|
return (
|
|
<span className="truncate italic text-muted-foreground">
|
|
{t(($) => $.pickers.model_managed_by_runtime)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (!canEdit) {
|
|
return (
|
|
<span
|
|
className="min-w-0 truncate px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
|
title={triggerTitle}
|
|
>
|
|
{triggerLabel}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PropertyPicker
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
width="w-auto min-w-[16rem] max-w-md"
|
|
align="start"
|
|
tooltip={triggerTitle}
|
|
triggerRender={
|
|
<button
|
|
type="button"
|
|
className={CHIP_CLASS}
|
|
aria-label={triggerTitle}
|
|
/>
|
|
}
|
|
trigger={
|
|
<span className="min-w-0 truncate font-mono text-[11px]">
|
|
{triggerLabel}
|
|
</span>
|
|
}
|
|
header={
|
|
<div className="p-1.5">
|
|
<Input
|
|
autoFocus
|
|
placeholder={t(($) => $.pickers.model_search_placeholder)}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
}
|
|
>
|
|
{modelsQuery.isLoading && (
|
|
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
{t(($) => $.pickers.model_discovering)}
|
|
</div>
|
|
)}
|
|
|
|
{!modelsQuery.isLoading &&
|
|
filtered.map((m) => (
|
|
<PickerItem
|
|
key={m.id}
|
|
selected={m.id === value}
|
|
onClick={() => void select(m.id)}
|
|
// Tooltip carries the canonical model id even when the chip
|
|
// shows the friendlier label, so users can always see what
|
|
// string actually ships to the agent.
|
|
tooltip={m.label !== m.id ? `${m.label} · ${m.id}` : m.id}
|
|
>
|
|
{/* PickerItem wraps children in a flex `<span>`. Putting a
|
|
`<div>` inside that <span> is block-in-inline (invalid
|
|
HTML5) and triggers the browser-default centering quirk
|
|
that pushes descendants off-axis (model IDs floated to the
|
|
center instead of left-aligning under their labels). Use
|
|
`<span block text-left>` to keep layout deterministic —
|
|
matches the fix already applied in thinking-picker.tsx. */}
|
|
<span className="block min-w-0 flex-1 text-left">
|
|
<span className="block truncate text-[13px] font-medium">{m.label}</span>
|
|
{m.label !== m.id && (
|
|
<span className="mt-0.5 block truncate font-mono text-[10px] leading-snug text-muted-foreground">
|
|
{m.id}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</PickerItem>
|
|
))}
|
|
|
|
{!modelsQuery.isLoading && filtered.length === 0 && !canCreate && (
|
|
<p className="px-3 py-3 text-center text-xs text-muted-foreground">
|
|
{t(($) => $.pickers.model_empty)}
|
|
</p>
|
|
)}
|
|
|
|
{canCreate && (
|
|
<PickerItem
|
|
selected={false}
|
|
onClick={() => void select(trimmedSearch)}
|
|
tooltip={t(($) => $.pickers.model_custom_tooltip, { value: trimmedSearch })}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 shrink-0 text-primary" />
|
|
<span className="truncate text-primary">
|
|
{t(($) => $.pickers.model_custom_use, { value: trimmedSearch })}
|
|
</span>
|
|
</PickerItem>
|
|
)}
|
|
|
|
{value && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void select("")}
|
|
className="mt-1 flex w-full items-center border-t px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
|
|
title={t(($) => $.pickers.model_clear_title)}
|
|
>
|
|
{t(($) => $.pickers.model_clear)}
|
|
</button>
|
|
)}
|
|
</PropertyPicker>
|
|
);
|
|
}
|