mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
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:
1 parent
bd95022efe
commit
85ebbcfbd5
11 files changed
+424
-72
No files matched your search
@@ -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}} 项高级设置"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user