diff --git a/ui/desktop/frontend/src/api/tts-capabilities.ts b/ui/desktop/frontend/src/api/tts-capabilities.ts index 9ea08192..6431310e 100644 --- a/ui/desktop/frontend/src/api/tts-capabilities.ts +++ b/ui/desktop/frontend/src/api/tts-capabilities.ts @@ -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 { diff --git a/ui/desktop/frontend/src/components/dynamic-param-form.tsx b/ui/desktop/frontend/src/components/dynamic-param-form.tsx index 9373d402..8dd93338 100644 --- a/ui/desktop/frontend/src/components/dynamic-param-form.tsx +++ b/ui/desktop/frontend/src/components/dynamic-param-form.tsx @@ -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 + 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 ( +
+ + {param.description && ( +

{param.description}

+ )} + +
+ ) +} + /** * 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 (
- {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 ( -
- - {param.description && ( -

{param.description}

- )} - +
+ {basic.map((param) => ( + -
- ) - })} + ))} +
+ + + {/* Advanced section — toggle + collapsible (inline SVG chevron: no lucide-react on desktop) */} + {advanced.length > 0 && ( +
+ + + {advancedOpen && ( +
+ {advanced.map((param) => ( + + ))} +
+ )} +
+ )}
) } diff --git a/ui/desktop/frontend/src/i18n/locales/en/tts.json b/ui/desktop/frontend/src/i18n/locales/en/tts.json index 6614bf49..4353628f 100644 --- a/ui/desktop/frontend/src/i18n/locales/en/tts.json +++ b/ui/desktop/frontend/src/i18n/locales/en/tts.json @@ -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" } } diff --git a/ui/desktop/frontend/src/i18n/locales/vi/tts.json b/ui/desktop/frontend/src/i18n/locales/vi/tts.json index 5d21b56c..4eb7e8b2 100644 --- a/ui/desktop/frontend/src/i18n/locales/vi/tts.json +++ b/ui/desktop/frontend/src/i18n/locales/vi/tts.json @@ -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" } } diff --git a/ui/desktop/frontend/src/i18n/locales/zh/tts.json b/ui/desktop/frontend/src/i18n/locales/zh/tts.json index 2721ab2a..b5c958b3 100644 --- a/ui/desktop/frontend/src/i18n/locales/zh/tts.json +++ b/ui/desktop/frontend/src/i18n/locales/zh/tts.json @@ -269,6 +269,8 @@ "requiresProvider": "请先选择 Provider 以启用试听。" }, "advanced": { - "title": "高级设置" + "title": "高级设置", + "count": "{{count}} 项高级设置", + "count_one": "{{count}} 项高级设置" } } diff --git a/ui/web/src/api/tts-capabilities.ts b/ui/web/src/api/tts-capabilities.ts index d964d9b4..97a6d855 100644 --- a/ui/web/src/api/tts-capabilities.ts +++ b/ui/web/src/api/tts-capabilities.ts @@ -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 { diff --git a/ui/web/src/components/__tests__/dynamic-param-form.test.ts b/ui/web/src/components/__tests__/dynamic-param-form.test.ts index 4862206a..5b46d604 100644 --- a/ui/web/src/components/__tests__/dynamic-param-form.test.ts +++ b/ui/web/src/components/__tests__/dynamic-param-form.test.ts @@ -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); + }); +}); diff --git a/ui/web/src/components/dynamic-param-form.tsx b/ui/web/src/components/dynamic-param-form.tsx index d55a3da7..369855bb 100644 --- a/ui/web/src/components/dynamic-param-form.tsx +++ b/ui/web/src/components/dynamic-param-form.tsx @@ -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 + 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 ( +
+ + {param.description && ( +

{param.description}

+ )} + +
+ ) +} + /** * 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 (
- {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 ( -
- - {param.description && ( -

{param.description}

- )} - +
+ {basic.map((param) => ( + -
- ) - })} + ))} +
+ + + {/* Advanced section — toggle + collapsible */} + {advanced.length > 0 && ( +
+ + + {advancedOpen && ( +
+ {advanced.map((param) => ( + + ))} +
+ )} +
+ )}
) } diff --git a/ui/web/src/i18n/locales/en/tts.json b/ui/web/src/i18n/locales/en/tts.json index bc08ed13..ba8c10d9 100644 --- a/ui/web/src/i18n/locales/en/tts.json +++ b/ui/web/src/i18n/locales/en/tts.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/vi/tts.json b/ui/web/src/i18n/locales/vi/tts.json index 36e8139b..f6eafb22 100644 --- a/ui/web/src/i18n/locales/vi/tts.json +++ b/ui/web/src/i18n/locales/vi/tts.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/zh/tts.json b/ui/web/src/i18n/locales/zh/tts.json index 293d898c..872a043c 100644 --- a/ui/web/src/i18n/locales/zh/tts.json +++ b/ui/web/src/i18n/locales/zh/tts.json @@ -255,6 +255,8 @@ "requiresProvider": "请先选择 Provider 以启用试听。" }, "advanced": { - "title": "高级设置" + "title": "高级设置", + "count": "{{count}} 项高级设置", + "count_one": "{{count}} 项高级设置" } }