"use client"; import * as React from "react"; import { Bookmark, Braces, Calculator, ChevronDown, Clock, Dices, Globe, RotateCcw, Save, Settings2, SlidersHorizontal, Sparkles, Terminal, Wrench, X } from "lucide-react"; import type { PolyModel, GenerationSettingsInput, ModelPreset } from "@/lib/client/types"; import { Button } from "@/components/ui/button"; import { Input, Textarea, Label } from "@/components/ui/input"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Segmented } from "@/components/ui/segmented"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ResponsiveDialog } from "@/components/ui/sheet"; import { Tooltip } from "@/components/ui/tooltip"; import { Badge } from "@/components/ui/badge"; import { EmptyState, Skeleton } from "@/components/ui/misc"; import { PromptDialog } from "@/components/common/prompt-dialog"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { useApp } from "@/components/app/store"; import { api, useApi } from "@/lib/client/api"; import { errorMessage } from "@/lib/client/humanize"; import { toast } from "@/components/ui/toast"; import { groupsFor, isSetValue, presetCompatibility, presetToSettings, PARAM_DEFS, type SettingsGroup } from "@/lib/models/params"; import { cn, formatTokens } from "@/lib/utils"; export type ChatSettings = GenerationSettingsInput & { tools?: string[] }; const EFFORT_LABEL: Record = { none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum" }; const GROUP_ICON: Record = { generation: , reasoning: , output: , tools: , advanced: }; const DEFAULT_OPEN: SettingsGroup[] = ["generation", "reasoning"]; const UNSET = "__default"; export function countActiveSettings(s: ChatSettings | undefined): number { if (!s) return 0; return Object.entries(s).filter(([k, v]) => isSetValue(k, v)).length; } /** * Capability-driven model configuration, grouped Generation / Reasoning / Output / Tools / Advanced. * Only controls the model supports are rendered; unsupported ones are listed at the bottom and never sent. * Phone: full bottom sheet. Desktop: right-side panel so the conversation stays visible. */ export function ModelConfig({ model, settings, onChange, systemPrompt, onSystemPromptChange, trigger, allowSavePreset = true }: { model: PolyModel | undefined; settings: ChatSettings; onChange: (s: ChatSettings) => void; systemPrompt: string; onSystemPromptChange: (v: string) => void; trigger?: React.ReactNode; allowSavePreset?: boolean }) { const [open, setOpen] = React.useState(false); const [presetsOpen, setPresetsOpen] = React.useState(false); const [saveOpen, setSaveOpen] = React.useState(false); const [openGroups, setOpenGroups] = React.useState>(() => new Set(DEFAULT_OPEN)); const p = model?.parameters ?? {}; const c = model?.capabilities; const set = (k: K, v: ChatSettings[K]) => onChange({ ...settings, [k]: v }); const unset = (k: keyof ChatSettings) => { const next = { ...settings }; delete next[k]; onChange(next); }; const active = countActiveSettings(settings); const groups = React.useMemo(() => (model ? groupsFor(model) : []), [model]); const unsupported = React.useMemo(() => (model ? PARAM_DEFS.filter((d) => !d.supports(model)).map((d) => d.label) : []), [model]); const toggleGroup = (g: SettingsGroup) => setOpenGroups((prev) => { const n = new Set(prev); if (n.has(g)) n.delete(g); else n.add(g); return n; }); const savePreset = async (name: string) => { if (!model) return; const { tools, ...parameters } = settings; try { await api("/api/presets?kind=model", { method: "POST", json: { name, modelKey: model.key, systemPrompt: systemPrompt || null, parameters, tools: { builtin: tools ?? [] } } }); toast.success("Preset saved", "Find it under Presets, or apply it from here."); } catch (e) { toast.error("Could not save preset", errorMessage(e)); throw e; } }; const applyPreset = (preset: ModelPreset) => { const next = presetToSettings(preset) as ChatSettings; onChange(next); if (preset.systemPrompt) onSystemPromptChange(preset.systemPrompt); setPresetsOpen(false); toast.success(`Applied “${preset.name}”`, preset.systemPrompt ? "Parameters and system prompt updated." : "Parameters updated."); }; const samplingNote = model?.metadata?.samplingMode === "conditional" && settings.reasoningEffort !== "none" ? "Temperature, top-p and penalties are accepted only when reasoning effort is “None”. They are dropped otherwise." : null; const activeIn = (g: SettingsGroup) => PARAM_DEFS.filter((d) => d.group === g && isSetValue(d.key, (settings as Record)[d.key])).length; const renderControl = (key: string) => { if (!model) return null; switch (key) { case "temperature": return (v === undefined ? unset("temperature") : set("temperature", v))} />; case "topP": return (v === undefined ? unset("topP") : set("topP", v))} />; case "topK": return (v === undefined ? unset("topK") : set("topK", v))} />; case "maxTokens": return ( (v === undefined ? unset("maxTokens") : set("maxTokens", v))} /> ); case "reasoningEffort": { const levels = p.reasoningEffortLevels ?? []; return ({ value: l, label: EFFORT_LABEL[l] ?? l }))} onChange={(v) => (v === undefined ? unset("reasoningEffort") : set("reasoningEffort", v as ChatSettings["reasoningEffort"]))} />; } case "thinkingBudget": { const r = p.thinkingBudgetRange ?? { min: 0, max: 32_000 }; return (v === undefined ? unset("thinkingBudget") : set("thinkingBudget", Math.round(v)))} />; } case "includeReasoning": return (v ? unset("includeReasoning") : set("includeReasoning", false))} />; case "verbosity": return ({ value: l, label: EFFORT_LABEL[l] }))} onChange={(v) => (v === undefined ? unset("verbosity") : set("verbosity", v as ChatSettings["verbosity"]))} />; case "responseFormat": return (
(!v || v === "text" ? unset("responseFormat") : set("responseFormat", { type: v as "json" | "json_schema", schema: settings.responseFormat?.schema, strict: true }))} /> {settings.responseFormat?.type === "json_schema" ? set("responseFormat", { type: "json_schema", schema, strict: true })} /> : null}
); case "stop": return ( set("stop", e.target.value.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 4))} placeholder="e.g. END, ###" /> ); case "webSearch": return } label="Web search" hint="Provider-native search with citations (billed by the provider)" checked={Boolean(settings.webSearch)} onChange={(v) => (v ? set("webSearch", true) : unset("webSearch"))} />; case "codeExecution": return } label="Code execution" hint="Provider-hosted sandbox (not combinable with web search on Anthropic)" checked={Boolean(settings.codeExecution)} onChange={(v) => (v ? set("codeExecution", true) : unset("codeExecution"))} />; case "tools": return (

Built-in PolyLLM tools (server-side, side-effect free)

{[ { id: "calculator", label: "Calculator", icon: }, { id: "clock", label: "Clock", icon: }, { id: "random", label: "Random", icon: }, ].map((t) => { const on = settings.tools?.includes(t.id); return ( ); })}
); case "toolChoice": return settings.tools?.length ? set("toolChoice", (v ?? "auto") as "auto" | "none" | "required")} /> : null; case "seed": return (v === undefined ? unset("seed") : set("seed", v))} />; case "frequencyPenalty": return (v === undefined ? unset("frequencyPenalty") : set("frequencyPenalty", v))} />; case "presencePenalty": return (v === undefined ? unset("presencePenalty") : set("presencePenalty", v))} />; default: return null; } }; return ( <> setOpen(true)} className="contents"> {trigger ?? ( )} {model ? : null} Configure {model?.displayName ?? "model"} {model?.limits?.contextTokens ? {formatTokens(model.limits.contextTokens)} ctx : null} } description="Only settings this model supports are shown. Nothing else is ever sent to the provider." footer={
{allowSavePreset ? ( ) : ( )}
} >