AI_Painting_V2.0/docs/superpowers/plans/2026-06-09-platform-architecture-plan.md

1212 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 平台化架构重构实现计划
> **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
<template>
<Select
v-model="model"
:options="options"
width="auto"
class="quality-select"
>
<template #prefix>
<span class="quality-label">画质</span>
</template>
</Select>
</template>
<script setup>
import Select from '@/components/Select/index.vue'
const props = defineProps({
modelValue: { type: String, default: 'medium' },
options: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const model = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
</script>
<style lang="less" scoped>
.quality-select {
:deep(.select-header) {
height: 40px;
padding: 0 15px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
&:hover { background: #e9eaeb; }
}
:deep(.select-text) { font-size: 14px; }
}
.quality-label {
font-family: "Microsoft YaHei";
font-size: 12px;
color: #999;
}
</style>
```
- [ ] **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
<template>
<Transition name="slide-up">
<div class="input-container" :class="{ generate : !props.isGenerate }" @click="handleContainerClick">
<div v-if="!props.isGenerate" class="title">{{ platform.label }}</div>
<div class="sender-top">
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">
回到底部<img src="@/assets/dialog/ArrowDown.svg">
</div>
<div v-show="showUploader" class="upload-img-container">
<div class="reference-diagram">
<component
v-if="platform.ImageUploader"
:is="platform.ImageUploader"
ref="referenceDiagramRef"
v-model="referenceImages"
v-bind="uploaderBindings"
/>
</div>
</div>
</div>
<Sender
v-model="prompt"
:variant="useDisplay.Sender_variant"
:placeholder="platform.promptPlaceholder.value"
:submit-btn-disabled="isgerenate"
:auto-size="autoSizeConfig"
>
<template v-if="useDisplay.Sender_variant !== 'default'" #prefix>
<div class="prefix-self-wrap">
<component
:is="platform.ModelSelector"
:modelValue="platform.model.value"
@update:modelValue="platform.model.value = $event"
:typeValue="platform.modelType.value"
@update:typeValue="platform.modelType.value = $event"
v-bind="(platform.modelSelectorProps && platform.modelSelectorProps()) || {}"
/>
<template v-for="ctrl in visibleControls" :key="ctrl.name">
<component
:is="ctrl.component"
v-bind="ctrl.props(getCurrentConfig())"
/>
</template>
</div>
</template>
<template #action-list>
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
<el-button v-if="isgerenate" round color="#626aef">
<i-ep-loading style="animation: spin 1s linear infinite;" />
</el-button>
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="">
<img v-else src="@/assets/dialog/writerArrow.svg" alt="">
<div v-show="useDisplay.Sender_variant !== 'default'">发送</div>
</div>
</div>
</template>
</Sender>
</div>
</Transition>
</template>
<script setup>
import { Sender } from 'vue-element-plus-x'
import { useDisplayStore } from '@/stores'
import { generate } from '@/utils/taskPolling'
import { useRouter } from 'vue-router'
import { createPlatform } from '@/platforms/registry.js'
import { getModelId } from '@/utils/modelApi'
// 确保平台包被加载(触发自注册)
import '@/platforms/painting/index.js'
import '@/platforms/video/index.js'
const props = defineProps({
isGenerate: { type: Boolean, default: false },
generate: { type: Boolean, default: false },
type: { type: String, default: 'Painting' },
})
const router = useRouter()
const useDisplay = useDisplayStore()
const isgerenate = ref(false)
const prompt = ref('')
const referenceImages = ref([])
const platform = computed(() => createPlatform(props.type))
const getCurrentConfig = () => {
// Painting: modelConfig, Video: modelDisplayConfig
return platform.value.modelConfig?.value ?? platform.value.modelDisplayConfig?.value ?? null
}
const visibleControls = computed(() => {
const config = getCurrentConfig()
return platform.value.controls.filter(c => c.show(config))
})
const showUploader = computed(() => {
return platform.value.showImageUploader()
})
const uploaderBindings = computed(() => {
const p = platform.value
if (p.id === 'painting') {
return { limit: p.imageUploadLimit() }
}
if (p.id === 'video') {
return { modelType: p.modelType.value, imagesCount: p.imageUploadLimit() }
}
return {}
})
const autoSizeConfig = computed(() => {
if (useDisplay.Sender_variant !== 'default') {
return { minRows: 5, maxRows: 9 }
}
return { minRows: 1, maxRows: 1 }
})
const handleStart = async () => {
const p = platform.value
if (props.type === 'Video' && p.model.value === 'Seedance 2.0') {
ElMessage.primary('敬请期待 Seedance 2.0')
return
}
if (!props.isGenerate) {
router.push({ name: 'home', query: { loading: false, Generate: true, type: props.type } })
}
if (!prompt.value) {
ElMessage.error('请输入提示词')
return
}
if (showUploader.value && p.isImageRequired() && !referenceImages.value.length) {
ElMessage.warning('请上传图片')
return
}
isgerenate.value = true
const modelId = await getModelId(props.type, p.model.value)
// 所有平台统一返回扁平 modelParams
const body = await p.buildTaskBody({ prompt, referenceImages })
const generateData = {
model: p.model.value,
modelType: p.modelType.value,
prompt: prompt.value,
referenceImages: referenceImages.value,
modelParams: body,
}
const data = {
type: props.type,
modelType: p.modelType.value,
modelName: p.model.value,
modelId: modelId || '',
body,
request: JSON.stringify(generateData),
}
await generate(data, generateData)
}
const fillParamsFromResult = (resultData) => {
if (!resultData) return
platform.value.fillFromResult(resultData)
if (resultData.prompt !== undefined) prompt.value = resultData.prompt
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
}
defineExpose({ fillParamsFromResult, handleStart })
const handleContainerClick = () => {
if (useDisplay.Sender_variant === 'default') {
useDisplay.Sender_variant = 'updown'
}
}
const handleScrollToBottom = () => {
useDisplay.scrollToBottom()
}
watch(() => useDisplay.isSubGerenate, (v) => { isgerenate.value = v }, { immediate: true })
// 模型变更 → 加载配置
watch(
[() => platform.value.model.value, () => platform.value.modelType.value],
async ([newModel, newModelType]) => {
if (!newModel) return
if (platform.value.id === 'video') {
await platform.value.loadConfig(newModel, newModelType)
} else {
platform.value.loadConfig(newModel)
}
},
)
// 平台切换 → 设置默认模型 + 预加载模型列表
watch(() => props.type, (newType) => {
const p = createPlatform(newType)
p.model.value = p.getDefaultModel()
p.loadModels()
}, { immediate: true })
</script>
<style lang="less" scoped>
/* 保留原有所有样式不变 */
.input-container {
width: 50%;
max-width: 880px;
position: absolute;
bottom: 30px;
z-index: 100;
left: 50%;
transform: translateX(-50%);
border-radius: 10px;
transition: all 0.3s ease;
}
.sender-top {
width: 100%;
display: flex;
justify-content: flex-end;
cursor: pointer;
transition: all 0.3s ease;
z-index: 101;
margin-bottom: 10px;
position: relative;
.scroll-to-bottom-text {
position: absolute;
bottom: 0;
right: 0;
z-index: 2;
padding: 10px;
border-radius: 10px;
background-color: #F8F9FA;
color: #666;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
&:active { transform: scale(0.95); }
&:hover { background-color: #F0F1F2; }
}
.upload-img-container {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
display: flex;
justify-content: start;
align-items: center;
z-index: 1;
gap: 16px;
padding-left: 20px;
.reference-diagram {
display: flex;
align-items: center;
}
}
}
.generate {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
border: none;
box-shadow: none;
:deep(.el-sender) {
border: none;
box-shadow: none;
}
}
.prefix-self-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
img { height: 50px; border-radius: 4px; }
}
.title {
background-color: #FFF;
color: #333;
text-align: center;
font-family: "Alibaba PuHuiTi";
font-size: 24px;
font-style: normal;
font-weight: 500;
line-height: normal;
margin-bottom: 106px;
}
:deep(.el-sender) {
background-color: #F5F6F7;
border: none;
border-radius: 20px;
}
:deep(.el-sender:focus-within) {
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
}
:deep(.el-popover.el-popper) {
border-radius: 20px;
}
.select {
background: #ffffff;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.10);
}
.upload-btn {
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.10);
background: #ffffff;
cursor: pointer;
position: relative;
}
.upload-btn:hover { background: #E5E7EB; }
.circle-btn {
position: absolute;
right: 0px;
top: 0px;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 90;
transition: all 0.3s ease;
color: rgb(0, 0, 0);
font-size: 20px;
&:hover { transform: scale(1.1); }
&:active { transform: scale(0.95); }
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-up-enter-from {
opacity: 0;
transform: translate(-50%, 100%);
}
.slide-up-leave-to {
opacity: 0;
transform: translate(-50%, 100%);
}
.gerenate {
display: inline-flex;
height: 40px;
padding: 0 20px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
background: rgba(0, 15, 51, 0.10);
cursor: pointer;
color: #000F33;
text-align: center;
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: normal;
}
.isprompt {
color: #ffffff;
background-color: #000F33;
}
</style>
```
- [ ] **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 注册一行