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:
Yaowei Zheng
2026-07-23 01:07:45 +08:00
committed by GitHub
parent 6d56ce9ccc
commit d01d0faf7a
10 changed files with 363 additions and 183 deletions
+148 -114
View File
@@ -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>