# 平台化架构重构实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 dialogBox 中的 Painting/Video 硬分支重构为平台描述符模式,新平台只需新建文件夹实现标准接口。 **Architecture:** 每个平台导出 `definePlatform()` 工厂函数,返回 ModelSelector 组件 + controls 列表 + buildTaskBody 等标准接口。dialogBox 退化为纯渲染引擎,通过 `createPlatform(type)` 获取 descriptor 并委托所有平台特定逻辑。 **Tech Stack:** Vue 3 Composition API + Pinia + Vite --- ### Task 1: 创建平台注册表和基础设施 **Files:** - Create: `src/platforms/registry.js` - [ ] **Step 1: 创建 registry.js** ```js // src/platforms/registry.js /** 平台注册表:id → definePlatform 工厂函数 */ const registry = {} /** 注册平台 */ export function registerPlatform(id, factory) { registry[id] = factory } /** 根据平台类型创建平台实例 */ export function createPlatform(type) { const factory = registry[type] if (!factory) throw new Error(`未注册的平台: ${type}`) return factory() } /** 获取所有已注册平台 ID */ export function getRegisteredPlatforms() { return Object.keys(registry) } ``` - [ ] **Step 2: 验证文件无语法错误** Run: `node -e "import('./src/platforms/registry.js').then(m => console.log('OK'))"` 或在 Vite dev 下验证 - [ ] **Step 3: 提交** ```bash git add src/platforms/registry.js git commit -m "feat: 新增平台注册表基础设施" ``` --- ### Task 2: 创建 Painting 平台包 **Files:** - Create: `src/platforms/painting/index.js` - Create: `src/platforms/painting/modelSelector.vue` - Create: `src/platforms/painting/controls/proportion.vue` - Create: `src/platforms/painting/controls/dimension.vue` - Create: `src/platforms/painting/controls/quality.vue` - Create: `src/platforms/painting/controls/quantity.vue` - Create: `src/platforms/painting/imageUploader.vue` - [ ] **Step 1: 迁移 modelSelector(从 `dialogBox/model/painting.vue`)** Copy `src/components/dialogBox/model/painting.vue` → `src/platforms/painting/modelSelector.vue`,无需修改内容。 - [ ] **Step 2: 迁移 proportion(从 `dialogBox/proportion/painting.vue`)** Copy `src/components/dialogBox/proportion/painting.vue` → `src/platforms/painting/controls/proportion.vue`,无需修改内容。 - [ ] **Step 3: 迁移 dimension(从 `dialogBox/dimension/index.vue`)** Copy `src/components/dialogBox/dimension/index.vue` → `src/platforms/painting/controls/dimension.vue`,无需修改内容。 - [ ] **Step 4: 提取 quality 为独立组件(原 dialogBox inline)** 新建 `src/platforms/painting/controls/quality.vue`: ```vue ``` - [ ] **Step 5: 迁移 quantity(从 `dialogBox/quantity/index.vue`)** Copy `src/components/dialogBox/quantity/index.vue` → `src/platforms/painting/controls/quantity.vue`,无需修改内容。 - [ ] **Step 6: 迁移 imageUploader(从 `dialogBox/imageUploader/index.vue`)** Copy `src/components/dialogBox/imageUploader/index.vue` → `src/platforms/painting/imageUploader.vue`,无需修改内容。 - [ ] **Step 7: 创建 Painting descriptor** 新建 `src/platforms/painting/index.js`: ```js import { ref, reactive, computed, markRaw } from 'vue' import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi' import { getModelConfig } from '@/config/models/index.js' import PaintingModelSelector from './modelSelector.vue' import PaintingProportion from './controls/proportion.vue' import DimensionInput from './controls/dimension.vue' import QualitySelect from './controls/quality.vue' import Quantity from './controls/quantity.vue' import ImageUploader from './imageUploader.vue' import { registerPlatform } from '../registry.js' function getDimConfig(config) { if (!config) return null const dimParam = config.params.find(p => p.ui === 'dimension') if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name } const wParam = config.params.find(p => p.ui === 'dimensionWidth') const hParam = config.params.find(p => p.ui === 'dimensionHeight') if (wParam && hParam) return { type: 'split', wParam, hParam } return null } export function definePaintingPlatform() { const model = ref('Flux 2') const modelType = ref('text') const proportion = ref('1:1') const resolution = ref('2k') const customWidth = ref(1024) const customHight = ref(1024) const dimWidth = ref(1024) const dimHeight = ref(1024) const quantity = ref(1) const quality = ref('medium') const modelConfig = ref(null) const promptPlaceholder = ref('描述你想生成的画面和动作。') const paramValues = reactive({}) const state = { model, modelType, proportion, resolution, customWidth, customHight, dimWidth, dimHeight, quantity, quality, paramValues, modelConfig, } function syncDefaults(config) { modelConfig.value = config if (!config) return config.params.forEach(p => { if (!(p.name in paramValues)) { paramValues[p.name] = p.default ?? (p.name === 'outputFormat' ? 'png' : '') } }) const ratioParam = config.params.find(p => p.ui === 'proportion') if (ratioParam) proportion.value = ratioParam.default || '1:1' const resParam = config.params.find(p => p.ui === 'resolution') if (resParam) resolution.value = resParam.default || '2k' const qtyParam = config.params.find(p => p.ui === 'quantity') if (qtyParam) quantity.value = qtyParam.default || 1 const cwParam = config.params.find(p => p.name === 'customWidth') if (cwParam) customWidth.value = cwParam.default || 1024 const chParam = config.params.find(p => p.name === 'customHight') if (chParam) customHight.value = chParam.default || 1024 const qualityParam = config.params.find(p => p.name === 'quality') if (qualityParam) quality.value = qualityParam.default || 'medium' const dc = getDimConfig(config) if (dc?.type === 'split') { dimWidth.value = dc.wParam.default || 1024 dimHeight.value = dc.hParam.default || 1024 } else if (dc?.type === 'combined') { const dimParam = config.params.find(p => p.name === dc.paramName) const raw = dimParam?.default || '' const parsed = dc.config.parse(raw) dimWidth.value = parsed.width dimHeight.value = parsed.height } } // 同步 UI refs → paramValues(由 dialogBox 在合适的时机调用) function syncParamValues() { const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion') if (ratioParam) paramValues[ratioParam.name] = proportion.value const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution') if (resParam) paramValues[resParam.name] = resolution.value const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity') if (qtyParam) paramValues[qtyParam.name] = quantity.value if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) { paramValues.customWidth = customWidth.value } if (modelConfig.value?.params?.find(p => p.name === 'customHight')) { paramValues.customHight = customHight.value } if (modelConfig.value?.params?.find(p => p.name === 'quality')) { paramValues.quality = quality.value } const dc = getDimConfig(modelConfig.value) if (dc?.type === 'split') { paramValues[dc.wParam.name] = dimWidth.value paramValues[dc.hParam.name] = dimHeight.value } else if (dc?.type === 'combined') { paramValues[dc.paramName] = dc.config.format(dimWidth.value, dimHeight.value) } } const controls = [ { name: 'proportion', component: markRaw(PaintingProportion), show: (config) => !!config?.params?.find(p => p.ui === 'proportion'), props: (config) => { const ratioParam = config?.params?.find(p => p.ui === 'proportion') const resParam = config?.params?.find(p => p.ui === 'resolution') return { modelValue: proportion.value, 'onUpdate:modelValue': (v) => { proportion.value = v }, resolution: resolution.value, 'onUpdate:resolution': (v) => { resolution.value = v }, width: customWidth.value, 'onUpdate:width': (v) => { customWidth.value = v }, height: customHight.value, 'onUpdate:height': (v) => { customHight.value = v }, proportionOptions: ratioParam?.options ?.filter(o => o !== 'custom') .map(o => ({ value: o, label: o })) || [], resolutionOptions: resParam?.options ?.map(o => ({ value: o, label: o.toUpperCase() })) || [], allowCustom: ratioParam?.options?.includes('custom') || false, } }, }, { name: 'dimension', component: markRaw(DimensionInput), show: (config) => !!config?.params?.find(p => p.ui === 'dimension' || p.ui === 'dimensionWidth'), props: (config) => { const dc = getDimConfig(config) return { width: dimWidth.value, 'onUpdate:width': (v) => { dimWidth.value = v }, height: dimHeight.value, 'onUpdate:height': (v) => { dimHeight.value = v }, minW: dc?.config?.width?.min || dc?.wParam?.min || 256, maxW: dc?.config?.width?.max || dc?.wParam?.max || 6197, minH: dc?.config?.height?.min || dc?.hParam?.min || 256, maxH: dc?.config?.height?.max || dc?.hParam?.max || 4096, } }, }, { name: 'quality', component: markRaw(QualitySelect), show: (config) => !!config?.params?.find(p => p.name === 'quality'), props: (config) => { const q = config?.params?.find(p => p.name === 'quality') return { modelValue: quality.value, 'onUpdate:modelValue': (v) => { quality.value = v }, options: q?.options?.map(o => ({ value: o, label: o })) || [], } }, }, { name: 'quantity', component: markRaw(Quantity), show: (config) => !!config?.params?.find(p => p.ui === 'quantity'), props: (config) => { const qtyParam = config?.params?.find(p => p.ui === 'quantity') return { modelValue: quantity.value, 'onUpdate:modelValue': (v) => { quantity.value = v }, max: qtyParam?.options?.length ? Math.max(...qtyParam.options) : 4, } }, }, ] const platform = { id: 'painting', label: 'AI绘画2026', ModelSelector: markRaw(PaintingModelSelector), modelSelectorProps: null, controls, ImageUploader: markRaw(ImageUploader), state, model, modelType, modelConfig, promptPlaceholder, async loadModels() { const code = getPlatformCode('Painting') return fetchPlatformModels(code) }, async loadConfig(modelName) { const config = getModelConfig(modelName) syncDefaults(config) return config }, getDefaultModel() { return 'Flux 2' }, showImageUploader() { if (modelType.value !== 'text') return true return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both' }, imageUploadLimit() { if (!modelConfig.value) return 4 const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload') return imageParam?.maxCount || modelConfig.value.maxImages || 4 }, isImageRequired() { return !!(modelConfig.value?.params?.find(p => p.ui === 'imageUpload')) }, buildTaskBody(shared) { syncParamValues() const modelParams = { ...paramValues } if (shared.prompt.value) modelParams.prompt = shared.prompt.value return modelParams }, // 从历史记录恢复参数到 UI state fillFromResult(resultData) { if (resultData.model !== undefined) model.value = resultData.model if (resultData.modelType !== undefined) modelType.value = resultData.modelType if (resultData.proportion !== undefined) proportion.value = resultData.proportion if (resultData.resolution !== undefined) resolution.value = resultData.resolution if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth if (resultData.customHight !== undefined) customHight.value = resultData.customHight if (resultData.quantity !== undefined) quantity.value = resultData.quantity if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams) // 从恢复的 modelParams 同步 dimension/quality UI refs const dc = getDimConfig(modelConfig.value) if (dc?.type === 'split') { if (paramValues[dc.wParam.name] !== undefined) dimWidth.value = paramValues[dc.wParam.name] if (paramValues[dc.hParam.name] !== undefined) dimHeight.value = paramValues[dc.hParam.name] } else if (dc?.type === 'combined') { if (paramValues[dc.paramName]) { const parsed = dc.config.parse(paramValues[dc.paramName]) dimWidth.value = parsed.width dimHeight.value = parsed.height } } if (paramValues.quality !== undefined) quality.value = paramValues.quality }, } return platform } // 自注册 registerPlatform('Painting', definePaintingPlatform) ``` - [ ] **Step 8: 提交** ```bash git add src/platforms/painting/ src/platforms/registry.js git commit -m "feat: 新增 Painting 平台包(descriptor + 控件迁移)" ``` --- ### Task 3: 创建 Video 平台包 **Files:** - Create: `src/platforms/video/index.js` - Create: `src/platforms/video/modelSelector.vue` - Create: `src/platforms/video/controls/pattern.vue` - Create: `src/platforms/video/controls/proportion.vue` - Create: `src/platforms/video/controls/time.vue` - Create: `src/platforms/video/imageUploader.vue` - [ ] **Step 1: 迁移 modelSelector(从 `dialogBox/model/video.vue`)** Copy `src/components/dialogBox/model/video.vue` → `src/platforms/video/modelSelector.vue`,无需修改内容。 - [ ] **Step 2: 迁移 pattern(从 `dialogBox/pattern/index.vue`)** Copy `src/components/dialogBox/pattern/index.vue` → `src/platforms/video/controls/pattern.vue`,无需修改内容。 - [ ] **Step 3: 迁移 proportion(从 `dialogBox/proportion/video.vue`)** Copy `src/components/dialogBox/proportion/video.vue` → `src/platforms/video/controls/proportion.vue`,无需修改内容。 - [ ] **Step 4: 迁移 time(从 `dialogBox/Time/index.vue`)** Copy `src/components/dialogBox/Time/index.vue` → `src/platforms/video/controls/time.vue`,无需修改内容。 - [ ] **Step 5: 迁移 imageUploader(从 `dialogBox/videoImageUploader/index.vue`)** Copy `src/components/dialogBox/videoImageUploader/index.vue` → `src/platforms/video/imageUploader.vue`,无需修改内容。 - [ ] **Step 6: 创建 Video descriptor** 新建 `src/platforms/video/index.js`: ```js import { ref, reactive, markRaw } from 'vue' import { fetchModelConfig } from '@/utils/modelConfig' import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi' import VideoModelSelector from './modelSelector.vue' import Pattern from './controls/pattern.vue' import VideoProportion from './controls/proportion.vue' import Time from './controls/time.vue' import VideoImageUploader from './imageUploader.vue' import { registerPlatform } from '../registry.js' export function defineVideoPlatform() { const model = ref('LTX2.0') const modelType = ref('text') const proportion = ref('16:9') const resolution = ref('1k') const duration = ref(5) const videoPattern = ref('文生视频') const resolutionOptions = ref([ { value: '1k', label: '标清 1K' }, { value: '2k', label: '高清 2K' }, { value: '4k', label: '超清 4K' }, ]) const proportionOptions = ref([ { value: '智能', label: '智能' }, { value: '21:9', label: '21:9' }, { value: '16:9', label: '16:9' }, { value: '4:3', label: '4:3' }, { value: '1:1', label: '1:1' }, { value: '3:4', label: '3:4' }, { value: '9:16', label: '9:16' }, ]) const durationOptions = ref([]) const modelDisplayConfig = ref(null) const promptPlaceholder = ref('描述你想生成的画面和动作。') const state = { model, modelType, proportion, resolution, duration, videoPattern, resolutionOptions, proportionOptions, durationOptions, modelDisplayConfig, } async function loadConfig(modelName, modelTypeVal) { const config = await fetchModelConfig('Video', modelName, modelTypeVal) modelDisplayConfig.value = config if (config?.display) { const d = config.display if (d.resolution) { resolution.value = d.resolution.default || '1k' resolutionOptions.value = d.resolution.options || [] } if (d.proportion) { proportion.value = d.proportion.default || '16:9' proportionOptions.value = d.proportion.options || [] } if (d.duration) { duration.value = d.duration.default || 5 durationOptions.value = d.duration.options || [] } if (d.promptPlaceholder) { promptPlaceholder.value = d.promptPlaceholder.default || '描述你想生成的画面和动作。' } } return config } const controls = [ { name: 'pattern', component: markRaw(Pattern), show: () => true, props: () => ({ modelValue: videoPattern.value, 'onUpdate:modelValue': (v) => { videoPattern.value = v }, }), }, { name: 'proportion', component: markRaw(VideoProportion), show: () => true, props: () => ({ modelValue: proportion.value, 'onUpdate:modelValue': (v) => { proportion.value = v }, resolution: resolution.value, 'onUpdate:resolution': (v) => { resolution.value = v }, proportionOptions: proportionOptions.value, resolutionOptions: resolutionOptions.value, }), }, { name: 'time', component: markRaw(Time), show: () => true, props: () => ({ modelValue: duration.value, 'onUpdate:modelValue': (v) => { duration.value = v }, options: durationOptions.value, }), }, ] const platform = { id: 'video', label: 'AI视频2026', ModelSelector: markRaw(VideoModelSelector), modelSelectorProps: () => ({ videoPattern: videoPattern.value }), controls, ImageUploader: markRaw(VideoImageUploader), state, model, modelType, modelDisplayConfig, promptPlaceholder, async loadModels() { const code = getPlatformCode('Video') return fetchPlatformModels(code) }, async loadConfig(modelName, modelTypeVal) { return loadConfig(modelName, modelTypeVal) }, getDefaultModel() { return 'LTX2.0' }, showImageUploader() { return modelType.value !== 'text' }, imageUploadLimit() { return modelDisplayConfig.value?.display?.images || 1 }, isImageRequired() { return modelType.value !== 'text' }, buildTaskBody(shared) { // 与 Painting 统一:直接返回扁平参数 const modelParams = { prompt: shared.prompt.value, proportion: proportion.value, resolution: resolution.value, duration: duration.value, videoPattern: videoPattern.value, } return modelParams }, fillFromResult(resultData) { if (resultData.model !== undefined) model.value = resultData.model if (resultData.modelType !== undefined) modelType.value = resultData.modelType if (resultData.proportion !== undefined) proportion.value = resultData.proportion if (resultData.resolution !== undefined) resolution.value = resultData.resolution if (resultData.duration !== undefined) duration.value = resultData.duration if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern }, } return platform } registerPlatform('Video', defineVideoPlatform) ``` - [ ] **Step 7: 提交** ```bash git add src/platforms/video/ git commit -m "feat: 新增 Video 平台包(descriptor + 控件迁移)" ``` --- ### Task 4: 重写 dialogBox 为通用编排壳 **Files:** - Modify: `src/components/dialogBox/index.vue`(重写) - Modify: `src/utils/createTask.js` - [ ] **Step 1: 简化 createTask.js 为纯透传** 修改 `src/utils/createTask.js`: ```js // src/utils/createTask.js // 所有平台 descriptor 的 buildTaskBody() 已直接返回扁平 modelParams, // 此文件仅做透传,后续可直接移除 export async function createTask(data) { return data.body } ``` - [ ] **Step 2: 重写 dialogBox/index.vue** 用以下内容完整替换 `src/components/dialogBox/index.vue`: ```vue ``` - [ ] **Step 3: 验证 dialogBox 的 import 路径正确** 检查所有 import 路径是否存在: - `@/platforms/registry.js` → `src/platforms/registry.js` - `@/platforms/painting/index.js` → `src/platforms/painting/index.js` - `@/platforms/video/index.js` → `src/platforms/video/index.js` - [ ] **Step 4: 提交** ```bash git add src/components/dialogBox/index.vue src/utils/createTask.js git commit -m "refactor: dialogBox 重构为通用平台编排壳,委托所有平台特定逻辑" ``` --- ### Task 5: 删除旧代码 **Files:** - Delete: `src/config/models/` 目录 - Delete: `src/config/runninghub/` 目录 - Delete: `src/utils/modelConfig.js` - Delete: `src/components/dialogBox/model/painting.vue` - Delete: `src/components/dialogBox/model/video.vue` - Delete: `src/components/dialogBox/proportion/painting.vue` - Delete: `src/components/dialogBox/proportion/video.vue` - Delete: `src/components/dialogBox/dimension/index.vue` - Delete: `src/components/dialogBox/quantity/index.vue` - Delete: `src/components/dialogBox/pattern/index.vue` - Delete: `src/components/dialogBox/Time/index.vue` - Delete: `src/components/dialogBox/imageUploader/index.vue` - Delete: `src/components/dialogBox/videoImageUploader/index.vue` - Modify: `src/config/index.js` - [ ] **Step 1: 删除 dialogBox 下的旧平台组件** ```bash rm -rf src/components/dialogBox/model/ rm -rf src/components/dialogBox/proportion/ rm -rf src/components/dialogBox/dimension/ rm -rf src/components/dialogBox/quantity/ rm -rf src/components/dialogBox/pattern/ rm -rf src/components/dialogBox/Time/ rm -rf src/components/dialogBox/imageUploader/ rm -rf src/components/dialogBox/videoImageUploader/ ``` - [ ] **Step 2: 删除旧 config 目录** ```bash rm -rf src/config/models/ rm -rf src/config/runninghub/ rm src/config/index.js # 注意:modelConfig.js 暂时保留,Video descriptor 用 fetchModelConfig() 获取 display 配置驱动 UI, # 待 Video 后端化(API 返回参数 schema)后可删除 ``` - [ ] **Step 3: 删除 config/index.js(不再被引用)** ```bash rm src/config/index.js ``` 原来 `config/index.js` 仅导出 runninghub 适配器供 `createTask.js` 使用,现在 createTask 已简化,此文件无用。 - [ ] **Step 4: 搜索并修复残留引用** ```bash grep -r "config/models" src/ --include="*.js" --include="*.vue" grep -r "components/dialogBox/model" src/ --include="*.js" --include="*.vue" grep -r "components/dialogBox/proportion" src/ --include="*.js" --include="*.vue" grep -r "utils/modelConfig" src/ --include="*.js" --include="*.vue" ``` 修复任何残留引用,更新为新的 import 路径。 - [ ] **Step 5: 提交** ```bash git add -A git commit -m "chore: 删除旧架构代码(config/models、runninghub、dialogBox 旧组件)" ``` --- ### Task 6: 验证构建与运行 - [ ] **Step 1: 启动开发服务器** ```bash pnpm dev ``` - [ ] **Step 2: 检查 Painting 流程** 1. 打开首页 → 确认标题显示 "AI绘画2026" 2. 确认模型选择器显示模型列表(从 API 加载) 3. 切换模型 → 确认比例/分辨率/尺寸等控件正确显示/隐藏 4. 输入 prompt → 点击发送 → 确认请求发出 5. 确认生成结果在虚拟滚动列表中显示 - [ ] **Step 3: 检查 Video 流程** 1. 切换到 Video 模式 → 确认标题显示 "AI视频2026" 2. 确认 Pattern 选择器 + 模型选择器正常工作 3. 切换 Pattern → 确认模型列表更新 4. 输入 prompt → 点击发送 → 确认请求发出 - [ ] **Step 4: 检查平台切换** 1. 从 Painting 切换到 Video → 确认所有控件正确替换 2. 从 Video 切换到 Painting → 确认控件恢复 - [ ] **Step 5: 检查历史回填** 1. 点击历史记录中的某项 → 确认参数正确回填到当前平台控件 - [ ] **Step 6: 修复发现的问题并提交** ```bash git add -A git commit -m "fix: 验证后修复平台切换和历史回填问题" ``` --- ### Task 7: 审查后端化方案文档 - [ ] **Step 1: 阅读当前后端化方案** 读取 `docs/模型参数后端化方案.md` - [ ] **Step 2: 对照新架构,指出文档需要变更的部分** 新架构下,各平台的 `loadConfig()` 在 descriptor 内实现,后端化只需改变 descriptor 的内部实现: 1. **统一 API 端点**:所有平台通过同一 API 获取模型列表和参数 schema,不再有 Painting 本地 JS / Video 远程 JSON 的差异 2. **参数元数据格式**:后端返回的模型参数需要包含 `ui` 字段(当前 Painting 在本地 config 中定义),以便 descriptor 的 `show()` 和 `props()` 正确工作 3. **文档需补充**:后端 API 返回的模型参数 schema 格式约定(每个参数需包含 `name`、`type`、`ui`、`default`、`options`、`min/max` 等字段) 4. **不再需要的内容**:文档中 RunningHub 原始 API 的 workflow/seed/width/height 等概念,在新架构中这些由 descriptor 内部消化,不再暴露给 API 调用方 - [ ] **Step 3: 将审查结论写入计划备注或直接修改 doc** --- ### 完成标准 - [ ] `pnpm build` 无错误 - [ ] Painting 流程:模型选择 → 参数调整 → 发送 → 结果展示 - [ ] Video 流程:Pattern → 模型选择 → 参数调整 → 发送 → 结果展示 - [ ] 平台切换:控件正确替换,无残留状态 - [ ] 历史回填:参数正确恢复到控件 - [ ] 新增平台只需 3 步:新建文件夹 → 实现 descriptor → registry 注册一行