feat(tts): split DynamicParamForm into Basic and Advanced sections

Phase 3 of the TTS params/layout/agent-override plan.

Reads the `Group` field shipped in Phase 1 to partition each provider's
params into Basic (always visible) and Advanced (collapsed by default,
chevron toggle, count badge). Edge providers with no advanced params
hide the toggle entirely.

- `partitionSchema(schema)` splits params; unknown group values fall
  into Basic (forward-compat).
- `evaluateDependsOn` runs against the full shared form state, so
  cross-section dependencies (e.g. MiniMax `audio.bitrate` depends on
  basic-section `audio.format`) work without scoping.
- Count badge uses `t("tts.advanced.count", {count})` with i18next
  pluralization (`_one` for English).
- Desktop mirror with inline SVG chevron (no lucide-react dep).

i18n (web + desktop):
- New `tts.advanced.count` + `tts.advanced.count_one` keys in en/vi/zh.
- `tts.advanced.title` already present; verified.

TS contract: `ParamSchema.group?: string` added to web + desktop
`tts-capabilities.ts`. Plain TS interface — no Zod strict-validator
guard needed (verified via grep).

10 new unit tests cover partition order, unknown-group fallback, count
badge respecting DependsOn, and the cross-group MiniMax case.
189/189 web tests pass.
This commit is contained in:
viettranx
2026-04-20 06:28:17 +07:00
parent bd95022efe
commit 85ebbcfbd5
11 changed files with 424 additions and 72 deletions
@@ -34,6 +34,8 @@ export interface ParamSchema {
step?: number;
enum?: EnumOption[];
depends_on?: Dependency[];
/** "advanced" → Advanced section; absent/undefined → Basic section (forward-compat: unknown values → Basic). */
group?: string;
}
export interface VoiceOption {
@@ -1,3 +1,5 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import type { ParamSchema } from "@/api/tts-capabilities"
import {
fieldRenderers,
@@ -34,10 +36,77 @@ export function evaluateDependsOn(
return deps.every((d) => String(formState[d.field]) === String(d.value))
}
/**
* Partitions schema into basic and advanced groups.
* group === "advanced" → advanced bucket; anything else (undefined/absent/unknown) → basic.
* Stable order preserved within each group (source array order).
*/
export function partitionSchema(schema: ParamSchema[]): {
basic: ParamSchema[]
advanced: ParamSchema[]
} {
const basic: ParamSchema[] = []
const advanced: ParamSchema[] = []
for (const param of schema) {
if (param.group === "advanced") {
advanced.push(param)
} else {
basic.push(param)
}
}
return { basic, advanced }
}
/**
* Renders a single param field row (label + description + input widget).
*/
function ParamFieldRow({
param,
value,
onChange,
readonly,
}: {
param: ParamSchema
value: Record<string, ParamValue>
onChange?: (key: string, val: ParamValue) => void
readonly: boolean
}) {
if (!evaluateDependsOn(param.depends_on, value)) return null
const Renderer = fieldRenderers[param.type] ?? DefaultField
const currentVal: ParamValue =
value[param.key] !== undefined
? value[param.key]!
: ((param.default as ParamValue) ?? "")
const handleChange = (val: ParamValue) => {
if (!readonly && onChange) {
onChange(param.key, val)
}
}
return (
<div key={param.key} className="space-y-1">
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
{param.label}
</label>
{param.description && (
<p className="text-xs text-text-secondary">{param.description}</p>
)}
<Renderer
schema={param}
value={currentVal}
onChange={handleChange}
readonly={readonly}
/>
</div>
)
}
/**
* Renders a list of TTS provider params from a ParamSchema array.
* Each field type maps to a dedicated renderer. Visibility is gated by
* evaluateDependsOn. When readonly=true, no onChange callbacks fire.
* Splits params into Basic (always open) and Advanced (collapsed by default).
* evaluateDependsOn always runs against full shared value state.
*
* Desktop callers MUST pass readonly={true} per Phase C constraint.
* NOT mounted in any settings page in Phase A — exported for Phase C wiring.
@@ -48,42 +117,83 @@ export function DynamicParamForm({
onChange,
readonly = false,
}: DynamicParamFormProps) {
const { t } = useTranslation("tts")
const [advancedOpen, setAdvancedOpen] = useState(false)
if (!schema || schema.length === 0) return null
const { basic, advanced } = partitionSchema(schema)
// Count visible advanced params (DependsOn resolves against shared value state)
const visibleAdvancedCount = advanced.filter((p) =>
evaluateDependsOn(p.depends_on, value),
).length
return (
<div className="space-y-4">
{schema.map((param) => {
if (!evaluateDependsOn(param.depends_on, value)) return null
const Renderer = fieldRenderers[param.type] ?? DefaultField
const currentVal: ParamValue =
value[param.key] !== undefined
? value[param.key]!
: ((param.default as ParamValue) ?? "")
const handleChange = (val: ParamValue) => {
if (!readonly && onChange) {
onChange(param.key, val)
}
}
return (
<div key={param.key} className="space-y-1">
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
{param.label}
</label>
{param.description && (
<p className="text-xs text-text-secondary">{param.description}</p>
)}
<Renderer
schema={param}
value={currentVal}
onChange={handleChange}
{/* Basic section — always visible */}
<section data-section="basic">
<div className="space-y-4">
{basic.map((param) => (
<ParamFieldRow
key={param.key}
param={param}
value={value}
onChange={onChange}
readonly={readonly}
/>
</div>
)
})}
))}
</div>
</section>
{/* Advanced section — toggle + collapsible (inline SVG chevron: no lucide-react on desktop) */}
{advanced.length > 0 && (
<section data-section="advanced">
<button
type="button"
onClick={() => setAdvancedOpen((prev) => !prev)}
className="flex w-full items-center gap-1.5 text-sm text-text-secondary hover:text-text transition-colors py-1"
aria-expanded={advancedOpen}
>
{/* ChevronRight inline SVG — rotates to ChevronDown when open */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform ${advancedOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span>{t("advanced.title")}</span>
{visibleAdvancedCount > 0 && (
<span className="ml-1 text-xs text-text-secondary">
({t("advanced.count", { count: visibleAdvancedCount })})
</span>
)}
</button>
{advancedOpen && (
<div className="mt-3 space-y-4">
{advanced.map((param) => (
<ParamFieldRow
key={param.key}
param={param}
value={value}
onChange={onChange}
readonly={readonly}
/>
))}
</div>
)}
</section>
)}
</div>
)
}
@@ -269,6 +269,8 @@
"requiresProvider": "Select a provider above to enable test playback."
},
"advanced": {
"title": "Advanced settings"
"title": "Advanced settings",
"count": "{{count}} advanced settings",
"count_one": "{{count}} advanced setting"
}
}
@@ -269,6 +269,8 @@
"requiresProvider": "Chọn provider ở trên để bật phát thử."
},
"advanced": {
"title": "Cài đặt nâng cao"
"title": "Cài đặt nâng cao",
"count": "{{count}} cài đặt nâng cao",
"count_one": "{{count}} cài đặt nâng cao"
}
}
@@ -269,6 +269,8 @@
"requiresProvider": "请先选择 Provider 以启用试听。"
},
"advanced": {
"title": "高级设置"
"title": "高级设置",
"count": "{{count}} 项高级设置",
"count_one": "{{count}} 项高级设置"
}
}
+2
View File
@@ -34,6 +34,8 @@ export interface ParamSchema {
step?: number;
enum?: EnumOption[];
depends_on?: Dependency[];
/** "advanced" → Advanced section; absent/undefined → Basic section (forward-compat: unknown values → Basic). */
group?: string;
}
export interface VoiceOption {
@@ -1,10 +1,11 @@
/**
* Pure-logic tests for DynamicParamForm utilities.
* NO @testing-library/react — tests isolate state-shape transforms only.
* Covers: evaluateDependsOn, initializeDefaults, applyNestedChange, rendererDispatch, edge cases.
* Covers: evaluateDependsOn, initializeDefaults, applyNestedChange, rendererDispatch,
* partitionSchema, advanced toggle count badge, cross-group DependsOn, edge cases.
*/
import { describe, it, expect, vi } from "vitest";
import { evaluateDependsOn } from "../dynamic-param-form";
import { evaluateDependsOn, partitionSchema } from "../dynamic-param-form";
import {
initializeDefaults,
applyNestedChange,
@@ -180,3 +181,127 @@ describe("empty enum handling", () => {
expect(defaults["format"]).toBe("");
});
});
// ---- partitionSchema ----
describe("partitionSchema", () => {
it("puts group='advanced' params into advanced bucket", () => {
const schema: ParamSchema[] = [
{ key: "speed", type: "range", label: "Speed" },
{ key: "instructions", type: "text", label: "Instructions", group: "advanced" },
];
const { basic, advanced } = partitionSchema(schema);
expect(basic.map((p) => p.key)).toEqual(["speed"]);
expect(advanced.map((p) => p.key)).toEqual(["instructions"]);
});
it("puts params without group into basic bucket", () => {
const schema: ParamSchema[] = [
{ key: "speed", type: "range", label: "Speed" },
{ key: "format", type: "enum", label: "Format" },
];
const { basic, advanced } = partitionSchema(schema);
expect(basic).toHaveLength(2);
expect(advanced).toHaveLength(0);
});
it("preserves source order within each bucket", () => {
const schema: ParamSchema[] = [
{ key: "a", type: "range", label: "A" },
{ key: "b", type: "range", label: "B", group: "advanced" },
{ key: "c", type: "range", label: "C" },
{ key: "d", type: "range", label: "D", group: "advanced" },
];
const { basic, advanced } = partitionSchema(schema);
expect(basic.map((p) => p.key)).toEqual(["a", "c"]);
expect(advanced.map((p) => p.key)).toEqual(["b", "d"]);
});
it("unknown group value (forward-compat) falls into basic bucket", () => {
// Future group values like "expert" should default to basic, not crash.
const schema: ParamSchema[] = [
{ key: "future_param", type: "string", label: "Future", group: "expert" },
];
const { basic, advanced } = partitionSchema(schema);
expect(basic.map((p) => p.key)).toEqual(["future_param"]);
expect(advanced).toHaveLength(0);
});
it("returns empty buckets for empty schema", () => {
const { basic, advanced } = partitionSchema([]);
expect(basic).toHaveLength(0);
expect(advanced).toHaveLength(0);
});
});
// ---- visibleAdvancedCount (count badge logic) ----
describe("visibleAdvancedCount — count badge respects DependsOn", () => {
it("counts all advanced params when no DependsOn constraints", () => {
const advanced: ParamSchema[] = [
{ key: "seed", type: "integer", label: "Seed", group: "advanced" },
{ key: "latency", type: "integer", label: "Latency", group: "advanced" },
];
const value = {};
const count = advanced.filter((p) => evaluateDependsOn(p.depends_on, value)).length;
expect(count).toBe(2);
});
it("excludes advanced param when DependsOn is not satisfied (cross-group)", () => {
// MiniMax-like case: audio.bitrate is advanced, depends on basic audio.format == "mp3"
const advanced: ParamSchema[] = [
{
key: "audio.bitrate",
type: "integer",
label: "Bitrate",
group: "advanced",
depends_on: [{ field: "audio.format", op: "eq", value: "mp3" }],
},
];
const valueWav = { "audio.format": "wav" };
const countWav = advanced.filter((p) => evaluateDependsOn(p.depends_on, valueWav)).length;
expect(countWav).toBe(0);
const valueMp3 = { "audio.format": "mp3" };
const countMp3 = advanced.filter((p) => evaluateDependsOn(p.depends_on, valueMp3)).length;
expect(countMp3).toBe(1);
});
it("returns 0 when advanced bucket is empty (Edge provider — no advanced toggle)", () => {
const advanced: ParamSchema[] = [];
const count = advanced.filter((p) => evaluateDependsOn(p.depends_on, {})).length;
expect(count).toBe(0);
});
});
// ---- cross-group DependsOn (MiniMax bitrate scenario) ----
describe("cross-group DependsOn — evaluateDependsOn uses shared value state", () => {
it("advanced param with depends_on basic field: visible when basic field matches", () => {
const advancedParam: ParamSchema = {
key: "audio.bitrate",
type: "integer",
label: "Bitrate (MP3 only)",
group: "advanced",
depends_on: [{ field: "audio.format", op: "eq", value: "mp3" }],
};
// shared state includes basic field value
expect(evaluateDependsOn(advancedParam.depends_on, { "audio.format": "mp3" })).toBe(true);
expect(evaluateDependsOn(advancedParam.depends_on, { "audio.format": "wav" })).toBe(false);
expect(evaluateDependsOn(advancedParam.depends_on, {})).toBe(false);
});
});
// ---- partitionSchema does not mutate source array ----
describe("partitionSchema immutability", () => {
it("does not mutate source schema array", () => {
const schema: ParamSchema[] = [
{ key: "speed", type: "range", label: "Speed" },
{ key: "seed", type: "integer", label: "Seed", group: "advanced" },
];
const original = [...schema];
partitionSchema(schema);
expect(schema).toEqual(original);
});
});
+133 -32
View File
@@ -1,3 +1,6 @@
import { useState } from "react"
import { ChevronRight } from "lucide-react"
import { useTranslation } from "react-i18next"
import type { ParamSchema } from "@/api/tts-capabilities"
import {
fieldRenderers,
@@ -31,10 +34,80 @@ export function evaluateDependsOn(
return deps.every((d) => String(formState[d.field]) === String(d.value))
}
/**
* Partitions schema into basic and advanced groups.
* group === "advanced" → advanced bucket; anything else (undefined/absent/unknown) → basic.
* Stable order preserved within each group (source array order).
*/
export function partitionSchema(schema: ParamSchema[]): {
basic: ParamSchema[]
advanced: ParamSchema[]
} {
const basic: ParamSchema[] = []
const advanced: ParamSchema[] = []
for (const param of schema) {
if (param.group === "advanced") {
advanced.push(param)
} else {
basic.push(param)
}
}
return { basic, advanced }
}
/**
* Renders a single param field row (label + description + input widget).
* Used by both Basic and Advanced sections to avoid duplication.
*/
function ParamFieldRow({
param,
value,
onChange,
readonly,
}: {
param: ParamSchema
value: Record<string, ParamValue>
onChange?: (key: string, val: ParamValue) => void
readonly: boolean
}) {
if (!evaluateDependsOn(param.depends_on, value)) return null
const Renderer = fieldRenderers[param.type] ?? DefaultField
const currentVal: ParamValue =
value[param.key] !== undefined
? value[param.key]!
: ((param.default as ParamValue) ?? "")
const handleChange = (val: ParamValue) => {
if (!readonly && onChange) {
onChange(param.key, val)
}
}
return (
<div key={param.key} className="space-y-1">
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
{param.label}
</label>
{param.description && (
<p className="text-xs text-muted-foreground">{param.description}</p>
)}
<Renderer
schema={param}
value={currentVal}
onChange={handleChange}
readonly={readonly}
/>
</div>
)
}
/**
* Renders a list of TTS provider params from a ParamSchema array.
* Each field type maps to a dedicated renderer. Visibility is gated by
* evaluateDependsOn. When readonly=true, no onChange callbacks fire.
* Splits params into Basic (always open) and Advanced (collapsed by default).
* Basic: params without group or group !== "advanced".
* Advanced: params with group === "advanced", gated by toggle.
* evaluateDependsOn always runs against full shared value state.
*
* NOT mounted in tts-page.tsx in Phase A — exported for Phase C wiring.
*/
@@ -44,42 +117,70 @@ export function DynamicParamForm({
onChange,
readonly = false,
}: DynamicParamFormProps) {
const { t } = useTranslation("tts")
const [advancedOpen, setAdvancedOpen] = useState(false)
if (!schema || schema.length === 0) return null
const { basic, advanced } = partitionSchema(schema)
// Count visible advanced params (DependsOn resolves against shared value state)
const visibleAdvancedCount = advanced.filter((p) =>
evaluateDependsOn(p.depends_on, value),
).length
return (
<div className="space-y-4">
{schema.map((param) => {
if (!evaluateDependsOn(param.depends_on, value)) return null
const Renderer = fieldRenderers[param.type] ?? DefaultField
const currentVal: ParamValue =
value[param.key] !== undefined
? value[param.key]!
: ((param.default as ParamValue) ?? "")
const handleChange = (val: ParamValue) => {
if (!readonly && onChange) {
onChange(param.key, val)
}
}
return (
<div key={param.key} className="space-y-1">
<label className="text-sm font-medium" htmlFor={`param-${param.key}`}>
{param.label}
</label>
{param.description && (
<p className="text-xs text-muted-foreground">{param.description}</p>
)}
<Renderer
schema={param}
value={currentVal}
onChange={handleChange}
{/* Basic section — always visible */}
<section data-section="basic">
<div className="space-y-4">
{basic.map((param) => (
<ParamFieldRow
key={param.key}
param={param}
value={value}
onChange={onChange}
readonly={readonly}
/>
</div>
)
})}
))}
</div>
</section>
{/* Advanced section — toggle + collapsible */}
{advanced.length > 0 && (
<section data-section="advanced">
<button
type="button"
onClick={() => setAdvancedOpen((prev) => !prev)}
className="flex w-full items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
aria-expanded={advancedOpen}
>
<ChevronRight
className={`h-4 w-4 shrink-0 transition-transform ${advancedOpen ? "rotate-90" : ""}`}
/>
<span>{t("advanced.title")}</span>
{visibleAdvancedCount > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({t("advanced.count", { count: visibleAdvancedCount })})
</span>
)}
</button>
{advancedOpen && (
<div className="mt-3 space-y-4">
{advanced.map((param) => (
<ParamFieldRow
key={param.key}
param={param}
value={value}
onChange={onChange}
readonly={readonly}
/>
))}
</div>
)}
</section>
)}
</div>
)
}
+3 -1
View File
@@ -255,6 +255,8 @@
"requiresProvider": "Select a provider above to enable test playback."
},
"advanced": {
"title": "Advanced settings"
"title": "Advanced settings",
"count": "{{count}} advanced settings",
"count_one": "{{count}} advanced setting"
}
}
+3 -1
View File
@@ -255,6 +255,8 @@
"requiresProvider": "Chọn provider ở trên để bật phát thử."
},
"advanced": {
"title": "Cài đặt nâng cao"
"title": "Cài đặt nâng cao",
"count": "{{count}} cài đặt nâng cao",
"count_one": "{{count}} cài đặt nâng cao"
}
}
+3 -1
View File
@@ -255,6 +255,8 @@
"requiresProvider": "请先选择 Provider 以启用试听。"
},
"advanced": {
"title": "高级设置"
"title": "高级设置",
"count": "{{count}} 项高级设置",
"count_one": "{{count}} 项高级设置"
}
}