refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-24 18:43:18 +08:00
committed by GitHub
parent c7465625a4
commit 3f868ac385
50 changed files with 2391 additions and 1190 deletions
@@ -39,8 +39,8 @@ import type {
ModelUpdateEntry,
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project";
import { useAuth } from "../../state/auth";
@@ -48,11 +48,13 @@ import { USD_TO_CNY, useTheme } from "../../state/theme";
import type { Currency } from "../../state/theme";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { FieldError, FieldLabel } from "../../components/ui/field";
import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { Select } from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
import { Badge } from "../../components/ui/badge";
import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon";
@@ -233,11 +235,6 @@ export function nextPointers(args: {
};
}
/** Field-level error text: placed directly below the offending input (the input itself is highlighted red via Input's invalid prop). */
function FieldError({ text }: { text: string }) {
return <span className="mt-1 block text-xs text-red-600 dark:text-red-400">{text}</span>;
}
/** Fields in the config dialog that can be highlighted red on error (keys match RowState field names, so they can be cleared per edit action). */
type FieldErrors = Partial<
Record<
@@ -383,7 +380,7 @@ export function ModelsPage() {
setDefaultModel(res.defaultModel);
setVisionModel(res.visionModel);
} catch (e) {
setLoadError(e instanceof ApiError ? e.message : S.common.unknownError);
setLoadError(apiErrorText(e));
}
}, [projectId]);
@@ -431,7 +428,7 @@ export function ModelsPage() {
setRows(nextRows);
if (nextDefault !== undefined) setDefaultModel(nextDefault);
if (effectiveVision !== undefined) setVisionModel(effectiveVision);
toastError(e instanceof ApiError ? e.message : S.common.unknownError);
toastError(apiErrorText(e));
return false;
} finally {
setBusy(false);
@@ -449,7 +446,7 @@ export function ModelsPage() {
if (!rows) return;
const merged = syncRowsWithCatalog(rows);
if (merged.added === 0 && merged.updated === 0) {
toastSuccess(S.models.syncUpToDate);
toastInfo(S.models.syncUpToDate);
return;
}
await persist(
@@ -484,7 +481,7 @@ export function ModelsPage() {
setSpeedResults((prev) =>
new Map(prev).set(key, {
ok: false,
message: e instanceof ApiError ? e.message : S.common.unknownError,
message: apiErrorText(e),
}),
);
}
@@ -813,6 +810,7 @@ export function ModelsPage() {
<Input
size="sm"
label={S.models.groupNameLabel}
required
value={groupName}
invalid={Boolean(groupNameError)}
onChange={(e) => {
@@ -826,7 +824,7 @@ export function ModelsPage() {
className="font-mono"
autoFocus
/>
{groupNameError && <FieldError text={groupNameError} />}
{groupNameError && <FieldError>{groupNameError}</FieldError>}
</label>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">{S.models.addGroupDesc}</p>
</Modal>
@@ -1121,7 +1119,7 @@ function ModelDialog({
if (res.ok) toastSuccess(S.models.testOk(res.latencyMs ?? 0));
else toastError(S.models.testFailed(res.message ?? ""));
} catch (e) {
toastError(S.models.testFailed(e instanceof ApiError ? e.message : S.common.unknownError));
toastError(S.models.testFailed(apiErrorText(e)));
} finally {
setTesting(false);
}
@@ -1133,6 +1131,17 @@ function ModelDialog({
* top-level banner: it's too far from the error site, and with three price fields it's
* hard to tell which one is wrong).
*/
// base URL required-field policy: an OpenAI-protocol endpoint can't be
// inferred — required for custom / user-defined groups and entries with an explicit
// openai protocol (gateway groups already have it pre-filled); optional for entries
// auto-routed within a first-party vendor group (the client has its own official
// default endpoint). Shared by validation and the label's required "*" mark.
const openAiLike =
form.clientType.trim().toLowerCase().includes("openai") ||
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
const baseUrlRequired = !preset && openAiLike;
const validated = (): RowState | null => {
const modelId = form.modelId.trim();
const ref: ModelRefDto = { provider: form.provider, modelId };
@@ -1143,16 +1152,7 @@ function ModelDialog({
else if (!sameModelRef(ref, form.original) && existingRefs.some((r) => sameModelRef(r, ref))) {
errs.modelId = S.models.modelIdExists;
}
// base URL required-field policy: an OpenAI-protocol endpoint can't be
// inferred — required for custom / user-defined groups and entries with an explicit
// openai protocol (gateway groups already have it pre-filled); optional for entries
// auto-routed within a first-party vendor group (the client has its own official
// default endpoint).
const openAiLike =
form.clientType.trim().toLowerCase().includes("openai") ||
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
if (!preset && openAiLike && !baseUrl) errs.baseUrl = S.models.baseUrlRequired;
if (baseUrlRequired && !baseUrl) errs.baseUrl = S.models.baseUrlRequired;
// Under PUT full-table replace semantics, omitting pricing means deleting it: all three
// prices must be either all empty or all filled, to avoid a partial entry silently
@@ -1241,6 +1241,10 @@ function ModelDialog({
<span className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.modelId}
{/* Required mark, hand-placed: this label row is custom (link on the right), so FieldLabel's asterisk doesn't apply. */}
<span className="ml-0.5 text-red-500 dark:text-red-400" aria-hidden>
*
</span>
</span>
<span className="flex shrink-0 items-baseline gap-2.5">
{/* The model-homepage entry lives in the dialog header (top-right button); only the "get model ids" provider link stays here. */}
@@ -1258,6 +1262,7 @@ function ModelDialog({
</span>
<Input
size="sm"
required
value={form.modelId}
disabled={!canEdit}
invalid={Boolean(fieldErrors.modelId)}
@@ -1266,7 +1271,7 @@ function ModelDialog({
autoFocus={isNew}
placeholder={S.models.modelIdHint}
/>
{fieldErrors.modelId && <FieldError text={fieldErrors.modelId} />}
{fieldErrors.modelId && <FieldError>{fieldErrors.modelId}</FieldError>}
{/* Hint when auto-routing misses (first-party provider group, adding): warns without blocking. */}
{autoRouteMiss && (
<span className="mt-1 block text-xs text-amber-600 dark:text-amber-400">
@@ -1346,10 +1351,26 @@ function ModelDialog({
<Button
variant="primary"
onClick={() => {
// Validate first: if validation fails, show it via the top-level hint right away without popping the confirm dialog.
// Validate first: if validation fails, the inline field errors show right away without popping the confirm dialog.
if (!validated()) return;
if (isNew) submit("save");
else setConfirming("save");
if (isNew || row === null) {
submit("save");
return;
}
// Nothing changed: report it instead of confirming a no-op write (the
// baseline is rebuilt exactly like the form's initial state, so a plain
// JSON compare is field-exact).
const initial: RowState = {
...row,
cacheRead: usdToInput(row.cacheRead, currency),
cacheWrite: usdToInput(row.cacheWrite, currency),
output: usdToInput(row.output, currency),
};
if (JSON.stringify(form) === JSON.stringify(initial)) {
toastInfo(S.common.noChangesToSave);
return;
}
setConfirming("save");
}}
>
{S.common.confirm}
@@ -1498,7 +1519,7 @@ function ModelDialog({
<span className="font-mono">{form.credential.apiKeyMasked}</span>
{form.credential.createdAt && (
<span className="text-gray-400">
{S.models.createdAt} {formatDateTime(form.credential.createdAt)}
{S.common.created} {formatDateTime(form.credential.createdAt)}
</span>
)}
{canEdit && (
@@ -1514,16 +1535,20 @@ function ModelDialog({
</div>
)}
{/* 2) base URL */}
{/* 2) base URL (required for custom / user-defined groups and explicit openai protocol — see
baseUrlRequired). Official-protocol entries (everything except the OpenAI-protocol path)
carry a caution: a custom endpoint must still speak the vendor's official protocol. */}
<Input
size="sm"
label={S.models.baseUrl}
required={baseUrlRequired}
value={form.baseUrl}
disabled={!canEdit}
onChange={(e) => set({ baseUrl: e.target.value })}
className="font-mono"
placeholder={preset ? S.models.baseUrlHint : "https://…"}
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
{...(!openAiLike ? { hint: S.models.baseUrlOfficialNote } : {})}
/>
{/* 3) Context window + max output tokens side by side (one row): the "Token" unit
@@ -1536,9 +1561,7 @@ function ModelDialog({
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>
<FieldLabel>{S.models.contextWindow}</FieldLabel>
<span className="relative block">
<Input
size="sm"
@@ -1564,12 +1587,10 @@ function ModelDialog({
{S.models.tokenUnit}
</span>
</span>
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
{fieldErrors.contextWindow && <FieldError>{fieldErrors.contextWindow}</FieldError>}
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.maxTokens}
</span>
<FieldLabel>{S.models.maxTokens}</FieldLabel>
<span className="relative block">
<Input
size="sm"
@@ -1587,7 +1608,7 @@ function ModelDialog({
{S.models.tokenUnit}
</span>
</span>
{fieldErrors.maxTokens && <FieldError text={fieldErrors.maxTokens} />}
{fieldErrors.maxTokens && <FieldError>{fieldErrors.maxTokens}</FieldError>}
</label>
</div>
@@ -1604,9 +1625,7 @@ function ModelDialog({
] 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>
<FieldLabel>{label}</FieldLabel>
<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]}
@@ -1624,7 +1643,7 @@ function ModelDialog({
{S.models.priceUnitShort}
</span>
</span>
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />}
{fieldErrors[key] && <FieldError>{fieldErrors[key]}</FieldError>}
</label>
))}
</div>
@@ -1669,30 +1688,21 @@ function ModelDialog({
{/* Confirmation before writing config (save / set default / set as vision proxy model / remove): stacked on top of the config dialog. */}
{confirming && (
<Modal
<ConfirmModal
open
title={CONFIRM_TITLE[confirming]()}
tone={confirming === "remove" ? "danger" : "primary"}
onClose={() => setConfirming(null)}
footer={
<>
<Button onClick={() => setConfirming(null)}>{S.common.cancel}</Button>
<Button
variant={confirming === "remove" ? "danger" : "primary"}
onClick={() => {
const action = confirming;
setConfirming(null);
submit(action);
}}
>
{S.common.confirm}
</Button>
</>
}
onConfirm={() => {
const action = confirming;
setConfirming(null);
submit(action);
}}
>
<p className="text-sm text-gray-700 dark:text-gray-300">
{CONFIRM_BODY[confirming](form.displayName ?? form.modelId)}
</p>
</Modal>
</ConfirmModal>
)}
</Modal>
);
@@ -1733,6 +1743,7 @@ function GroupKeyDialog({
<Input
size="sm"
label={S.models.apiKey}
required
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}