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

37 KiB
Raw Blame History

平台化架构重构实现计划

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: 迁移 modelSelectordialogBox/model/painting.vue

Copy src/components/dialogBox/model/painting.vuesrc/platforms/painting/modelSelector.vue,无需修改内容。

  • Step 2: 迁移 proportiondialogBox/proportion/painting.vue

Copy src/components/dialogBox/proportion/painting.vuesrc/platforms/painting/controls/proportion.vue,无需修改内容。

  • Step 3: 迁移 dimensiondialogBox/dimension/index.vue

Copy src/components/dialogBox/dimension/index.vuesrc/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: 迁移 quantitydialogBox/quantity/index.vue

Copy src/components/dialogBox/quantity/index.vuesrc/platforms/painting/controls/quantity.vue,无需修改内容。

  • Step 6: 迁移 imageUploaderdialogBox/imageUploader/index.vue

Copy src/components/dialogBox/imageUploader/index.vuesrc/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: 迁移 modelSelectordialogBox/model/video.vue

Copy src/components/dialogBox/model/video.vuesrc/platforms/video/modelSelector.vue,无需修改内容。

  • Step 2: 迁移 patterndialogBox/pattern/index.vue

Copy src/components/dialogBox/pattern/index.vuesrc/platforms/video/controls/pattern.vue,无需修改内容。

  • Step 3: 迁移 proportiondialogBox/proportion/video.vue

Copy src/components/dialogBox/proportion/video.vuesrc/platforms/video/controls/proportion.vue,无需修改内容。

  • Step 4: 迁移 timedialogBox/Time/index.vue

Copy src/components/dialogBox/Time/index.vuesrc/platforms/video/controls/time.vue,无需修改内容。

  • Step 5: 迁移 imageUploaderdialogBox/videoImageUploader/index.vue

Copy src/components/dialogBox/videoImageUploader/index.vuesrc/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.jssrc/platforms/registry.js

  • @/platforms/painting/index.jssrc/platforms/painting/index.js

  • @/platforms/video/index.jssrc/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 流程
  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: 修复发现的问题并提交
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 格式约定(每个参数需包含 nametypeuidefaultoptionsmin/max 等字段)
  4. 不再需要的内容:文档中 RunningHub 原始 API 的 workflow/seed/width/height 等概念,在新架构中这些由 descriptor 内部消化,不再暴露给 API 调用方
  • Step 3: 将审查结论写入计划备注或直接修改 doc

完成标准

  • pnpm build 无错误
  • Painting 流程:模型选择 → 参数调整 → 发送 → 结果展示
  • Video 流程Pattern → 模型选择 → 参数调整 → 发送 → 结果展示
  • 平台切换:控件正确替换,无残留状态
  • 历史回填:参数正确恢复到控件
  • 新增平台只需 3 步:新建文件夹 → 实现 descriptor → registry 注册一行