feat(web): settings form polish — dialog layout, homepage button, no default rows, unified dropdown text (#39)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,16 +35,69 @@ import { thinkingLevelOptionsFor } from "../chat/thinking-level";
|
||||
|
||||
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
|
||||
|
||||
/** "" represents not overridden (falls back to the current config), corresponding to the defaultValue placeholder row. */
|
||||
function withDefaultOption(
|
||||
/**
|
||||
* Dropdown rows from a dictionary's [value, description] pairs (exported for unit tests).
|
||||
* The "" (not-overridden / inherit) row is filtered out per review — the menus offer only
|
||||
* concrete values in dictionary order, the user picks explicitly. An unset stored value
|
||||
* simply matches no row, so the OptionMenu trigger falls back to its placeholder
|
||||
* ((缺省)/(default), the same convention as the tools-table permission menu); nothing is
|
||||
* ever written silently, and the reset link next to each menu rewinds a local pick back to "".
|
||||
*/
|
||||
export function optionRows(
|
||||
entries: ReadonlyArray<readonly [string, string]>,
|
||||
): ReadonlyArray<OptionMenuChoice<string>> {
|
||||
return entries.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value || S.agent.defaultValue,
|
||||
label: value || S.agent.defaultValue,
|
||||
description,
|
||||
}));
|
||||
return entries
|
||||
.filter(([value]) => value !== "")
|
||||
.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value,
|
||||
label: value,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* OptionMenu whose pick can be explicitly rewound: the runtime menus no longer offer an
|
||||
* inherit row, so without this an accidental pick could not be backed out before saving.
|
||||
* The reset link (top-right of the label line, visible only while a value is picked) sets
|
||||
* the LOCAL edit state back to "" — the save guard skips empty values, nothing is written.
|
||||
* The unset trigger shows the (default) placeholder, matching the tools-table permission menu.
|
||||
*/
|
||||
function ResettableOptionMenu({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ReadonlyArray<OptionMenuChoice<string>>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<OptionMenu
|
||||
label={label}
|
||||
fullWidth
|
||||
size="sm"
|
||||
placeholder={S.agent.defaultValue}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
{value !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
title={S.agent.resetToDefault}
|
||||
aria-label={S.agent.resetToDefault}
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-0 top-0 text-xs text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
{S.agent.resetToDefault}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
|
||||
@@ -501,17 +554,18 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
};
|
||||
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
// "none" is no longer offered (many models cannot disable thinking) but stays a valid stored
|
||||
// value: assembly lives in thinkingLevelOptionsFor, gated on the **persisted** config — a
|
||||
// misclick onto another tier keeps the legacy row until the change is actually saved, so the
|
||||
// stored value stays reachable (see thinking-level.ts).
|
||||
// Thinking level composes both review rounds: the "" inherit row is filtered (the user picks
|
||||
// explicitly; unset shows the (default) placeholder and the reset link rewinds to it), "none"
|
||||
// is no longer offered (many models cannot disable thinking) but stays a valid stored value —
|
||||
// when the **persisted** config carries it, a display-only legacy row is appended, so a
|
||||
// misclick onto another tier keeps it reachable until the change is actually saved
|
||||
// (see thinking-level.ts).
|
||||
const thinkingLevelOptions = thinkingLevelOptionsFor(
|
||||
S.agent.thinkingLevelOptions,
|
||||
S.agent.defaultValue,
|
||||
S.agent.thinkingLevelNoneKept,
|
||||
cfg.model?.thinkingLevel,
|
||||
);
|
||||
const compactionModeOptions = withDefaultOption(S.agent.compactionModeOptions);
|
||||
const compactionModeOptions = optionRows(S.agent.compactionModeOptions);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -534,10 +588,8 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<OptionMenu
|
||||
<ResettableOptionMenu
|
||||
label={S.agent.thinkingLevel}
|
||||
fullWidth
|
||||
size="sm"
|
||||
value={thinkingLevel}
|
||||
onChange={setThinkingLevel}
|
||||
options={thinkingLevelOptions}
|
||||
@@ -577,10 +629,8 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<OptionMenu
|
||||
<ResettableOptionMenu
|
||||
label={S.agent.compactionMode}
|
||||
fullWidth
|
||||
size="sm"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={compactionModeOptions}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function thinkingLevelLabel(
|
||||
/** One dropdown row of the agent-settings thinking-level menu (shape of OptionMenuChoice<string>). */
|
||||
export interface ThinkingLevelOptionRow {
|
||||
value: string;
|
||||
/** Compact text on the trigger button ("" renders the localized default tag). */
|
||||
/** Compact text on the trigger button. */
|
||||
triggerLabel: string;
|
||||
/** Panel row title. */
|
||||
label: string;
|
||||
@@ -51,25 +51,30 @@ export interface ThinkingLevelOptionRow {
|
||||
|
||||
/**
|
||||
* Assembles the agent-settings thinking-level dropdown rows from the dictionary's
|
||||
* [value, description] pairs (which no longer include `none`). Backward compatibility:
|
||||
* when the **persisted** config already stores `none` (a legacy value), a display-only
|
||||
* row for it is appended — the trigger shows the real stored state instead of "—" and
|
||||
* nothing is silently rewritten. Gating on the persisted value (not the local edit
|
||||
* state) means a misclick onto another tier keeps the row until the change is actually
|
||||
* saved, so the user can always click back to the value still stored on disk.
|
||||
* [value, description] pairs, composing both review decisions:
|
||||
* - the "" (inherit) row is **filtered** — the menu offers only concrete tiers, the user
|
||||
* picks explicitly; an unset value shows the OptionMenu's (default) placeholder and the
|
||||
* reset link next to the menu rewinds a local pick back to it;
|
||||
* - `none` is no longer offered (many models cannot disable thinking) but stays a valid
|
||||
* stored value: when the **persisted** config already carries it, a display-only row is
|
||||
* appended — the trigger shows the real stored state and nothing is silently rewritten.
|
||||
* Gating on the persisted value (not the local edit state) means a misclick onto another
|
||||
* tier keeps the row until the change is actually saved, so the user can always click
|
||||
* back to the value still stored on disk.
|
||||
*/
|
||||
export function thinkingLevelOptionsFor(
|
||||
options: ReadonlyArray<readonly [string, string]>,
|
||||
defaultTag: string,
|
||||
noneKeptDescription: string,
|
||||
storedLevel: string | undefined,
|
||||
): ThinkingLevelOptionRow[] {
|
||||
const rows = options.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value || defaultTag,
|
||||
label: value || defaultTag,
|
||||
description,
|
||||
}));
|
||||
const rows = options
|
||||
.filter(([value]) => value !== "")
|
||||
.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value,
|
||||
label: value,
|
||||
description,
|
||||
}));
|
||||
if (storedLevel === "none") {
|
||||
rows.push({
|
||||
value: "none",
|
||||
|
||||
@@ -51,6 +51,7 @@ import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { toastError, toastSuccess } from "../../components/ui/toast";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
@@ -1242,17 +1243,7 @@ function ModelDialog({
|
||||
{S.models.modelId}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-baseline gap-2.5">
|
||||
{/* Model homepage (the model's own page; gateway groups have per-model URLs) — only for existing rows, whose identity is settled. */}
|
||||
{row && modelHomepageUrl(row.provider, row.modelId) && (
|
||||
<a
|
||||
href={modelHomepageUrl(row.provider, row.modelId)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
|
||||
>
|
||||
{S.models.homepage} ↗
|
||||
</a>
|
||||
)}
|
||||
{/* The model-homepage entry lives in the dialog header (top-right button); only the "get model ids" provider link stays here. */}
|
||||
{dialogProvider?.modelsUrl && (
|
||||
<a
|
||||
href={dialogProvider.modelsUrl}
|
||||
@@ -1368,14 +1359,16 @@ function ModelDialog({
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Header: logo + display name + badges + upstream id (existing model). */}
|
||||
{/* Header: logo + display name + badges + upstream id (existing model); the model
|
||||
homepage entry lives here as a small secondary button on the right (moved out of
|
||||
the form body — it's a property of the model, not an input). */}
|
||||
{!isNew && (
|
||||
<div className="flex items-center gap-2.5 rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800/60">
|
||||
<ProviderLogo
|
||||
provider={form.provider}
|
||||
className="h-6 w-6 shrink-0 text-gray-700 dark:text-gray-300"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
||||
{form.displayName ?? form.modelId}
|
||||
{isDefault && <Badge tone="brand">{S.models.default}</Badge>}
|
||||
@@ -1389,6 +1382,31 @@ function ModelDialog({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row && modelHomepageUrl(row.provider, row.modelId) && (
|
||||
<a
|
||||
href={modelHomepageUrl(row.provider, row.modelId)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-800 transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.models.homepage}
|
||||
{/* External-link glyph (opens in a new tab) */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1508,100 +1526,110 @@ function ModelDialog({
|
||||
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
|
||||
/>
|
||||
|
||||
{/* 3) Context window: unit on the right inside the input */}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.contextWindow}
|
||||
</span>
|
||||
<span className="relative block">
|
||||
<Input
|
||||
size="sm"
|
||||
value={form.contextWindow}
|
||||
inputMode="numeric"
|
||||
disabled={!canEdit}
|
||||
invalid={Boolean(fieldErrors.contextWindow)}
|
||||
onChange={(e) => set({ contextWindow: digitsOnly(e.target.value) })}
|
||||
className="pr-14 font-mono"
|
||||
placeholder={
|
||||
preset
|
||||
? S.models.contextWindowHint
|
||||
: S.models.contextWindowDefaultHint(CUSTOM_CONTEXT_DEFAULT)
|
||||
}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||
{S.models.contextWindowUnit}
|
||||
{/* 3) Context window + max output tokens side by side (one row): the "Token" unit
|
||||
sits inside each box as a muted right suffix. Placeholders cannot scroll, so at
|
||||
this half width they carry only a short line; the full explanation lives in the
|
||||
input's title (hover) — the owner explicitly prefers saving the vertical space
|
||||
over a visible hint line. Only field errors appear under a cell. Max output
|
||||
tokens: per-model cap on the request's output — when set it wins over the
|
||||
Agent's system_config value; empty inherits it (lets a small-context local
|
||||
model stay under its window). */}
|
||||
<div className="grid grid-cols-2 items-start gap-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.contextWindow}
|
||||
</span>
|
||||
</span>
|
||||
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
|
||||
</label>
|
||||
|
||||
{/* 4) Max output tokens: per-model cap on the request's output — when set it wins
|
||||
over the Agent's system_config value; empty inherits it. Lets a small-context
|
||||
local model stay under its window (the per-Agent default may not fit). */}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.maxTokens}
|
||||
</span>
|
||||
<Input
|
||||
size="sm"
|
||||
value={form.maxTokens}
|
||||
inputMode="numeric"
|
||||
disabled={!canEdit}
|
||||
invalid={Boolean(fieldErrors.maxTokens)}
|
||||
onChange={(e) => set({ maxTokens: digitsOnly(e.target.value) })}
|
||||
className="font-mono"
|
||||
placeholder={S.models.maxTokensHint}
|
||||
/>
|
||||
{fieldErrors.maxTokens ? (
|
||||
<FieldError text={fieldErrors.maxTokens} />
|
||||
) : (
|
||||
<span className="mt-1 block text-xs text-gray-400 dark:text-gray-500">
|
||||
{S.models.maxTokensCapHint}
|
||||
<span className="relative block">
|
||||
<Input
|
||||
size="sm"
|
||||
value={form.contextWindow}
|
||||
inputMode="numeric"
|
||||
disabled={!canEdit}
|
||||
invalid={Boolean(fieldErrors.contextWindow)}
|
||||
onChange={(e) => set({ contextWindow: digitsOnly(e.target.value) })}
|
||||
className="pr-12 font-mono"
|
||||
// The title mirrors the placeholder: at half width the (EN) copy can clip, hover reveals it in full.
|
||||
title={
|
||||
preset
|
||||
? S.models.contextWindowHint
|
||||
: S.models.contextWindowDefaultHint(CUSTOM_CONTEXT_DEFAULT)
|
||||
}
|
||||
placeholder={
|
||||
preset
|
||||
? S.models.contextWindowHint
|
||||
: S.models.contextWindowDefaultHint(CUSTOM_CONTEXT_DEFAULT)
|
||||
}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||
{S.models.tokenUnit}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* 5) Pricing: three fields side by side; currency and unit (/M tok) both
|
||||
shown inside the input, no need to repeat in the title. */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.pricing}
|
||||
</p>
|
||||
{/* Errors land right under the offending field (which is also outlined red): with three fields side by side, only sticking close to the field makes clear which one it is. */}
|
||||
<div className="grid grid-cols-3 items-start gap-2">
|
||||
{(
|
||||
[
|
||||
["cacheRead", S.models.priceCacheRead, form.cacheRead],
|
||||
["cacheWrite", S.models.priceCacheWrite, form.cacheWrite],
|
||||
["output", S.models.priceOutput, form.output],
|
||||
] as Array<[keyof FieldErrors & keyof RowState, string, string]>
|
||||
).map(([key, label, value]) => (
|
||||
<label key={key} className="block">
|
||||
<span className="mb-1 block text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||
<span className="relative block">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400">
|
||||
{CURRENCY_SYMBOL[currency]}
|
||||
</span>
|
||||
<Input
|
||||
size="sm"
|
||||
value={value}
|
||||
inputMode="decimal"
|
||||
disabled={!canEdit}
|
||||
invalid={Boolean(fieldErrors[key])}
|
||||
onChange={(e) => set({ [key]: decimalOnly(e.target.value) })}
|
||||
className="pl-4 pr-11 text-right font-mono"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||
{S.models.priceUnitShort}
|
||||
</span>
|
||||
</span>
|
||||
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.maxTokens}
|
||||
</span>
|
||||
<span className="relative block">
|
||||
<Input
|
||||
size="sm"
|
||||
value={form.maxTokens}
|
||||
inputMode="numeric"
|
||||
disabled={!canEdit}
|
||||
invalid={Boolean(fieldErrors.maxTokens)}
|
||||
onChange={(e) => set({ maxTokens: digitsOnly(e.target.value) })}
|
||||
className="pr-12 font-mono"
|
||||
// Short placeholder (fits the half-width box); the full explanation incl. the small-context advice is the hover title.
|
||||
title={S.models.maxTokensTitle}
|
||||
placeholder={S.models.maxTokensHint}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||
{S.models.tokenUnit}
|
||||
</span>
|
||||
</span>
|
||||
{fieldErrors.maxTokens && <FieldError text={fieldErrors.maxTokens} />}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 6) Identity: model id (renamable) + display name and group (side by side) */}
|
||||
{/* 4) Pricing: three fields side by side with self-contained labels (… price) — no
|
||||
standalone section heading; currency and unit (/M tok) are shown inside the input.
|
||||
Errors land right under the offending field (which is also outlined red): with
|
||||
three fields side by side, only sticking close to the field makes clear which one it is. */}
|
||||
<div className="grid grid-cols-3 items-start gap-2">
|
||||
{(
|
||||
[
|
||||
["cacheRead", S.models.priceCacheRead, form.cacheRead],
|
||||
["cacheWrite", S.models.priceCacheWrite, form.cacheWrite],
|
||||
["output", S.models.priceOutput, form.output],
|
||||
] as Array<[keyof FieldErrors & keyof RowState, string, string]>
|
||||
).map(([key, label, value]) => (
|
||||
<label key={key} className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
<span className="relative block">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400">
|
||||
{CURRENCY_SYMBOL[currency]}
|
||||
</span>
|
||||
<Input
|
||||
size="sm"
|
||||
value={value}
|
||||
inputMode="decimal"
|
||||
disabled={!canEdit}
|
||||
invalid={Boolean(fieldErrors[key])}
|
||||
onChange={(e) => set({ [key]: decimalOnly(e.target.value) })}
|
||||
className="pl-4 pr-11 text-right font-mono"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-gray-400">
|
||||
{S.models.priceUnitShort}
|
||||
</span>
|
||||
</span>
|
||||
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 5) Identity: model id (renamable) + display name and group (side by side) */}
|
||||
{!isNew && identityFields}
|
||||
{/* Legacy entries carrying a non-openai client_type (historical config): read-only display. */}
|
||||
{!isNew && !preset && form.clientType && form.clientType !== "openai" && (
|
||||
@@ -1610,25 +1638,31 @@ function ModelDialog({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Vision capability: for preset models it's flagged by the built-in catalog (read-only); custom models can be checked. */}
|
||||
{/* Vision capability: for preset models it's flagged by the built-in catalog (read-only);
|
||||
custom models toggle it here — an iOS-style switch sitting inline right next to the
|
||||
label (per owner: no full-row stretch, no standing explanation text). Only the OFF
|
||||
state shows one small muted line: images are then read via the configured vision
|
||||
proxy model (describe_image). */}
|
||||
{!preset && (
|
||||
<div>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${canEdit ? "cursor-pointer" : "cursor-not-allowed"}`}
|
||||
className={`inline-flex items-center gap-2 ${canEdit ? "cursor-pointer" : "cursor-not-allowed"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.vision}
|
||||
</span>
|
||||
<Switch
|
||||
checked={form.vision}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => set({ vision: e.target.checked })}
|
||||
className={canEdit ? "cursor-pointer" : "cursor-not-allowed"}
|
||||
onChange={(vision) => set({ vision })}
|
||||
aria-label={S.models.vision}
|
||||
/>
|
||||
{S.models.vision}
|
||||
</label>
|
||||
{/* Note toggles with the checkbox: supports images → read_image feeds them back directly; doesn't → describe_image reads them on its behalf. */}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{form.vision ? S.models.visionOnHint : S.models.visionOffHint}
|
||||
</p>
|
||||
{!form.vision && (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{S.models.visionOffProxyHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user