37 KiB
平台化架构重构实现计划
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
// 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: 提交
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:
<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:
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: 提交
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:
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: 提交
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:
// 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:
<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: 提交
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 下的旧平台组件
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 目录
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(不再被引用)
rm src/config/index.js
原来 config/index.js 仅导出 runninghub 适配器供 createTask.js 使用,现在 createTask 已简化,此文件无用。
- Step 4: 搜索并修复残留引用
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: 提交
git add -A
git commit -m "chore: 删除旧架构代码(config/models、runninghub、dialogBox 旧组件)"
Task 6: 验证构建与运行
- Step 1: 启动开发服务器
pnpm dev
- Step 2: 检查 Painting 流程
- 打开首页 → 确认标题显示 "AI绘画2026"
- 确认模型选择器显示模型列表(从 API 加载)
- 切换模型 → 确认比例/分辨率/尺寸等控件正确显示/隐藏
- 输入 prompt → 点击发送 → 确认请求发出
- 确认生成结果在虚拟滚动列表中显示
- Step 3: 检查 Video 流程
- 切换到 Video 模式 → 确认标题显示 "AI视频2026"
- 确认 Pattern 选择器 + 模型选择器正常工作
- 切换 Pattern → 确认模型列表更新
- 输入 prompt → 点击发送 → 确认请求发出
- Step 4: 检查平台切换
- 从 Painting 切换到 Video → 确认所有控件正确替换
- 从 Video 切换到 Painting → 确认控件恢复
- Step 5: 检查历史回填
- 点击历史记录中的某项 → 确认参数正确回填到当前平台控件
- Step 6: 修复发现的问题并提交
git add -A
git commit -m "fix: 验证后修复平台切换和历史回填问题"
Task 7: 审查后端化方案文档
- Step 1: 阅读当前后端化方案
读取 docs/模型参数后端化方案.md
- Step 2: 对照新架构,指出文档需要变更的部分
新架构下,各平台的 loadConfig() 在 descriptor 内实现,后端化只需改变 descriptor 的内部实现:
- 统一 API 端点:所有平台通过同一 API 获取模型列表和参数 schema,不再有 Painting 本地 JS / Video 远程 JSON 的差异
- 参数元数据格式:后端返回的模型参数需要包含
ui字段(当前 Painting 在本地 config 中定义),以便 descriptor 的show()和props()正确工作 - 文档需补充:后端 API 返回的模型参数 schema 格式约定(每个参数需包含
name、type、ui、default、options、min/max等字段) - 不再需要的内容:文档中 RunningHub 原始 API 的 workflow/seed/width/height 等概念,在新架构中这些由 descriptor 内部消化,不再暴露给 API 调用方
- Step 3: 将审查结论写入计划备注或直接修改 doc
完成标准
pnpm build无错误- Painting 流程:模型选择 → 参数调整 → 发送 → 结果展示
- Video 流程:Pattern → 模型选择 → 参数调整 → 发送 → 结果展示
- 平台切换:控件正确替换,无残留状态
- 历史回填:参数正确恢复到控件
- 新增平台只需 3 步:新建文件夹 → 实现 descriptor → registry 注册一行