Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions web/packages/ui/src/components/floating-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ export interface FloatingFieldProps {
/** 固定后缀(`.svc.cluster.local`)。同 `prefix`,画在框内右侧。 */
suffix?: React.ReactNode
action?: React.ReactNode
/** 跟在 `?` 后面的一段弱化文字,如字段的示例值。 */
aside?: React.ReactNode
/**
* 警示条 —— 「什么情况下应该开启/填写这一项」。
*
Expand Down Expand Up @@ -433,6 +435,7 @@ export function FloatingField({
prefix,
suffix,
action,
aside,
error,
notice,
expandedContent,
Expand Down Expand Up @@ -491,6 +494,7 @@ export function FloatingField({
<FieldHint>{hint}</FieldHint>
</span>
) : null}
{aside ? <span className="text-muted-foreground">{aside}</span> : null}
</>
)

Expand Down
29 changes: 29 additions & 0 deletions web/src/modules/inferences/SiteProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
SheetHeader,
SheetTitle,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@modelsphere/ui";
import { SlidersHorizontal, TriangleAlert } from "lucide-react";
import { useModulePath } from "@/shell";
Expand All @@ -30,6 +36,7 @@ import { useT } from "@/modules/inferences/i18n";
import { ProfileEditorBody, SaveButton, useProfileEditor } from "@/modules/inferences/components/ProfileEditor";
import { catalogTokenKey, listSecrets } from "@/modules/inferences/catalog-write-api";
import { markedWritable } from "@/modules/inferences/catalog-write-lib";
import { answerText } from "@swiss/lib/inputs";

export function SiteProfilePage() {
const t = useT();
Expand Down Expand Up @@ -158,6 +165,28 @@ function ProfileView({ p }: { p: SiteProfile }) {
{ label: f("pathTemplate"), value: mono(p.model?.pathTemplate) },
{ label: f("overrides"), value: kv(p.model?.overrides) },
])}
<SectionCard title={t("profile.groups.inputs")}>
{p.inputs && Object.keys(p.inputs).length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{f("key")}</TableHead>
<TableHead>{f("value")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(p.inputs).map(([k, x]) => (
<TableRow key={k}>
<TableCell className="font-mono text-xs break-all">{k}</TableCell>
<TableCell className="font-mono text-xs break-all">{answerText(x)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
v(undefined)
)}
</SectionCard>
{card("routing", [
{ label: f("routeEnabled"), value: onOff(!!p.route?.enabled) },
{ label: f("gateway"), value: mono(p.route?.gateway) },
Expand Down
64 changes: 64 additions & 0 deletions web/src/modules/inferences/components/DeployForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { DeployForm } from "./DeployForm";
import { EMPTY, planRequest, type Form } from "@swiss/components/DeploySettings";
import type { CacheCard } from "@swiss/lib/cache";
import type { InputRow } from "@swiss/lib/inputs";
import type { RouteOptions } from "@/modules/inferences/routeOptions";
import en from "@/modules/inferences/locales/en-US.json";
import zh from "@/modules/inferences/locales/zh-CN.json";
Expand Down Expand Up @@ -219,3 +220,66 @@ describe("GPU capacity", () => {
expect(html).toContain("24");
});
});

describe("variant inputs", () => {
const master = { key: "storageKVOffload.mooncake.master", title: "Mooncake master", description: "host:port of the master", pattern: "[^\\s:/]+:[0-9]+", example: "mooncake-master.example:50051" };
const render = (form: Form, inputs: InputRow[]) =>
renderToStaticMarkup(
<DeployForm
form={form} onChange={() => {}} submitted={false} target={null} defaults={{}} gpuOptions={[]} inputs={inputs}
expanded={{ basic: false, resources: true, routing: false, advanced: false }}
onExpand={() => {}} idPrefix="test-"
/>,
);
it("renders each input as its variant declares it", () => {
const html = render({ ...EMPTY, serviceId: "model" }, [{ input: master }]);
expect(html).toContain("Mooncake master");
expect([en, zh].some((l) => html.includes(l.deploy.fields.inputExample.replace("{example}", master.example)))).toBe(true);
expect(html).not.toContain(`placeholder="${master.example}"`);
expect(html).not.toMatch(/placeholder="[^"]*mooncake-master\.example/);
expect([en, zh].some((l) => html.includes(l.deploy.fields.inputRequired))).toBe(true);
expect(html).toContain('aria-invalid="true"');
});
it("shows the answer that applies when the field is left empty", () => {
const html = render({ ...EMPTY, serviceId: "model" }, [{ input: master, answer: "site.svc:50051", from: "site" }]);
expect(html).toContain('placeholder="site.svc:50051"');
expect(html).not.toContain('aria-invalid="true"');
});
it("says when a typed answer does not match the pattern", () => {
const html = render({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "no-port" } }, [{ input: master }]);
expect(html).toContain('value="no-port"');
expect([en, zh].some((l) => html.includes(l.deploy.fields.inputProblem.string.replace(" {pattern}", "")))).toBe(true);
});
it("offers a pick between the answers when more than one is found, the applying one shown", () => {
const choices = [{ answer: "site.svc:50051", from: "site" as const }, { answer: "tuned.svc:50051", from: "variant" as const }];
const html = render({ ...EMPTY, serviceId: "model" }, [{ input: master, answer: "site.svc:50051", from: "site", choices }]);
const single = render({ ...EMPTY, serviceId: "model" }, [{ input: master, answer: "site.svc:50051", from: "site" }]);
// The list opens on a click; closed, the field is the plain one.
expect(html).not.toContain('role="listbox"');
expect(html).toBe(single);
expect(html).toContain('placeholder="site.svc:50051"');
});
it("offers to save a typed answer to the site profile, naming the one it replaces", () => {
const row: InputRow = { input: master, answer: "mc-m.svc:50051", from: "site", site: { answer: "mc-m.svc:50051", written: "mc-{{model}}.svc:50051" } };
const remember = (form: Form, on?: Record<string, boolean>) =>
renderToStaticMarkup(
<DeployForm
form={form} onChange={() => {}} submitted={false} target={null} defaults={{}} gpuOptions={[]} inputs={[row]} remember={on} onRemember={() => {}}
expanded={{ basic: false, resources: true, routing: false, advanced: false }}
onExpand={() => {}} idPrefix="test-"
/>,
);
const offers = (h: string) => [en, zh].some((l) => h.includes(l.deploy.fields.inputRemember));
const typed = remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }, {});
expect(offers(typed)).toBe(true);
expect(typed).toContain("mc-{{model}}.svc:50051");
expect(offers(remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "mc-m.svc:50051" } }, {}))).toBe(false);
expect(offers(remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "no-port" } }, {}))).toBe(false);
expect(offers(remember({ ...EMPTY, serviceId: "model" }, {}))).toBe(false);
expect(offers(remember({ ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } }))).toBe(false);
});
it("sends a typed answer at the input's key", () => {
const form = { ...EMPTY, serviceId: "model", inputs: { [master.key]: "mine.svc:50051" } };
expect(planRequest(form, { model: "model", inputs: [master] }).overrides).toMatchObject({ storageKVOffload: { mooncake: { master: "mine.svc:50051" } } });
});
});
73 changes: 70 additions & 3 deletions web/src/modules/inferences/components/DeployForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useId, useState, type ReactNode } from "react";
import {
Badge,
Button,
Checkbox,
DataSelect,
DropdownMenu,
DropdownMenuContent,
Expand All @@ -25,6 +26,8 @@ import { Plus, RefreshCw, X } from "lucide-react";
import type { ClusterInfo } from "@swiss/lib/api";
import { cacheFieldError, type CacheCard, type CacheDefaults } from "@swiss/lib/cache";
import { unlistedProducts, type GPUCapacity } from "@swiss/lib/gpu";
import { CHECK_MS, inputError, rememberable, type InputRow } from "@swiss/lib/inputs";
import { useDebounced } from "@swiss/lib/useDebounced";
import { effective, imageChoices, type Form, type Toleration } from "@swiss/components/DeploySettings";
import { MAX_VALUES, rejectKey } from "@swiss/lib/routeDirectives";
import { FALLBACK_ROUTE_OPTIONS, routeDirectives, runtimeValue, type RouteDirective, type RouteOptions } from "@/modules/inferences/routeOptions";
Expand Down Expand Up @@ -59,10 +62,16 @@ export interface DeployFormProps {
// The chart's values and swissd's layers for the Override YAML editor.
helmValues?: HelmValuesSource;
recovery?: ReactNode;
// The variant's inputs, each with the answer that applies when its field is left empty.
inputs?: InputRow[];
// Which typed answers to save to the site profile once the deploy applies; absent where it cannot be saved.
remember?: Record<string, boolean>;
onRemember?: (key: string, on: boolean) => void;
}

export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery }: DeployFormProps) {
export function DeployForm({ form, onChange, upgrade, submitted, target, cluster, defaults, gpuOptions, gpuListed, gpuResources, capacity, expanded, onExpand, idPrefix, routeOptions, catalogRoute, helmValues, recovery, inputs, remember, onRemember }: DeployFormProps) {
const t = useT();
const checked = useDebounced(form.inputs, CHECK_MS);
const on = effective(form);
const f = (k: string) => t(`deploy.fields.${k}`);
const forced = unlistedProducts(gpuListed, form.gpuProducts);
Expand Down Expand Up @@ -149,6 +158,34 @@ export function DeployForm({ form, onChange, upgrade, submitted, target, cluster
<FloatingField value={shown(form.localPath, defaults.localPath || defaults.localPathTemplate)} label={f("localPath")} hint={f("localPathHint")}>
<Suggest value={form.localPath} onChange={(localPath) => onChange({ localPath })} suggestion={defaults.localPath} placeholder={defaults.localPath || defaults.localPathTemplate} />
</FloatingField>
{inputs?.map((row) => {
const { input, answer, choices, site } = row;
const value = form.inputs[input.key] ?? "";
const error = inputError({ input, answer }, checked[input.key]);
const set = (v: string) => onChange({ inputs: { ...form.inputs, [input.key]: v } });
return (
<div key={input.key} className="flex flex-col gap-1.5">
<FloatingField
value={shown(value, answer)}
label={input.title}
required
hint={input.description ? `${input.description} (${input.key})` : input.key}
aside={t("deploy.fields.inputExample", { example: String(input.example) })}
error={error && (error === "required" ? t("deploy.fields.inputRequired") : t(`deploy.fields.inputProblem.${input.type ?? "string"}`, { pattern: input.pattern ?? "" }))}
>
<Suggest value={value} onChange={set} suggestion={answer} placeholder={answer} choices={choices?.map((c) => ({ value: c.answer, label: f(`inputFrom.${c.from}`) }))} />
</FloatingField>
{remember && onRemember && rememberable(row, checked[input.key]) && (
<label className="flex flex-wrap items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Checkbox checked={!!remember[input.key]} onCheckedChange={(on) => onRemember(input.key, !!on)} />
{f("inputRemember")}
<FieldHint>{f("inputRememberHint")}</FieldHint>
{site && <span className="break-all">· {t("deploy.fields.inputReplaces", { answer: site.written })}</span>}
</label>
)}
</div>
);
})}
{defaults.image && (
<FloatingField
layout="inline"
Expand Down Expand Up @@ -473,20 +510,28 @@ export function RouteExtras({ form, onChange, options, showRuntime = false, mode
}

// Tab fills an empty field with the default it shows, as swiss's own form does.
function Suggest({ value, onChange, suggestion, bare, placeholder, className, ...props }: Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> & { value: string; onChange: (v: string) => void; suggestion?: string; bare?: boolean }) {
// choices list under the field when the empty field is clicked; typing closes the list.
function Suggest({ value, onChange, suggestion, bare, placeholder, className, choices, ...props }: Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> & { value: string; onChange: (v: string) => void; suggestion?: string; bare?: boolean; choices?: { value: string; label: string }[] }) {
const [focused, setFocused] = useState(false);
const [listed, setListed] = useState(false);
const offer = !value && !props.disabled ? suggestion : undefined;
const hint = !!offer && focused;
const Control = bare ? Input : FieldInput;
return (
<div className="relative h-full w-full min-w-0">
<Control
autoComplete="off"
{...props}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onBlur={() => {
setFocused(false);
setListed(false);
}}
onClick={() => setListed(!!choices?.length && !value)}
onKeyDown={(e) => {
if (e.key === "Escape") setListed(false);
if (e.key !== "Tab" || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || !offer) return;
e.preventDefault();
onChange(offer);
Expand All @@ -497,6 +542,28 @@ function Suggest({ value, onChange, suggestion, bare, placeholder, className, ..
{hint && (
<kbd className={cn("pointer-events-none absolute right-3 rounded border px-1 py-px text-[10px] leading-none text-muted-foreground", bare ? "top-1/2 -translate-y-1/2" : "top-[27px]")}>tab</kbd>
)}
{listed && !value && choices && (
<div role="listbox" className="absolute top-full right-0 left-0 z-50 mt-1 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10">
{choices.map((c) => (
// mousedown, not click: the field keeps focus, and its blur does not close the list first.
<button
key={c.label}
type="button"
role="option"
aria-selected={false}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
onChange(c.value);
setListed(false);
}}
>
<span className="min-w-0 flex-1 font-mono text-xs break-all">{c.value}</span>
<span className="shrink-0 text-xs text-muted-foreground">{c.label}</span>
</button>
))}
</div>
)}
</div>
);
}
Expand Down
Loading
Loading