diff --git a/CLAUDE.md b/CLAUDE.md index 09f25ff..310e5cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度 **Painting 和 Video 走两套不同的任务构造路径:** - **Painting(新架构)**:本地模型参数 schema → 专用控件 + 动态表单 → `X-Session-Id` header + 扁平 API body -- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body +- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body。Video 模型列表从远程静态 JSON(`VITE_API_MODEL_RESOURCE/.../video.json`)获取,workflow 配置按日缓存于 localStorage ### 关键目录 @@ -42,6 +42,7 @@ src/ │ │ ├── index.vue # 编排中心:组装所有控件,处理 handleStart()、模型配置加载、参数回填 │ │ ├── model/ # 模型选择器(按 API 返回的 tags 分组,value 编码为 tag::display_name) │ │ ├── proportion/ # 比例/分辨率选择器(painting.vue 用于 Painting,video.vue 用于 Video) +│ │ ├── dimension/ # 尺寸输入 Popover(W/H 数字输入 + 比例锁,支持 split/combined 两种模式) │ │ ├── imageUploader/ # 图片上传(Painting) │ │ ├── videoImageUploader/ # 视频图片上传(Video) │ │ ├── quantity/ # 生成数量选择器(支持 1-6,上限由模型配置派生) @@ -67,19 +68,28 @@ src/ │ ├── plugins.js # Vite 插件配置(unplugin-auto-import + unplugin-vue-components) │ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用) │ ├── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析(Video 专用) -│ ├── models/ # Painting 模型参数 schema:每模型一个 JS 文件,定义 params 和各字段的 ui 类型 -│ └── modelConfig/ # 静态模型列表:painting.json(按 tag 分组)、video.json(按 pattern 分组) +│ └── models/ # Painting 模型参数 schema:每模型一个 JS 文件,定义 params 和各字段的 ui 类型 ``` ### 模型参数配置(Painting 新架构) -`src/config/models/` 下每个模型一个 JS 文件,参数通过不同 UI 组件承载: +`src/config/models/` 下每个模型一个 JS 文件,参数通过 `ui` 字段映射到不同 UI 组件: -- **`ui: 'textarea'`** → Sender 组件主输入框(prompt) -- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover,options 可含 `custom` 开启自定义 W/H) -- **`ui: 'quantity'`** → `Quantity` 组件(动态上限由 model config 的 `options` 数组最大值派生) -- **`ui: 'imageUpload'`** → `ImageUploader` 组件 -- **`ui: 'hidden'`** → 无 UI,仅写入默认值(如 outputFormat: 'png') +| `ui` 值 | 组件 | 说明 | +|---------|------|------| +| `textarea` | Sender 内置 textarea | prompt 输入框 | +| `proportion` | `paintingProportion` | 比例选择 Popover(options 含 `custom` 时可自定义 W/H) | +| `resolution` | `paintingProportion` 内部 | 分辨率子选项,与 proportion 共用一个 Popover | +| `dimension` | `DimensionInput` | **组合模式**:单个 `"W*H"` 字符串参数,通过 `dimension.parse/format` 序列化 | +| `dimensionWidth` + `dimensionHeight` | `DimensionInput` | **拆分模式**:两个独立 number 参数,共享同一个 Popover 和比例锁 | +| `select` | `Select` | 通用下拉选择(如 quality) | +| `quantity` | `Quantity` | 生成张数(上限由 `options` 最大值派生) | +| `imageUpload` | `ImageUploader` | 参考图上传 | +| `hidden` | 无 | 静默写入默认值 | + +**dimension 模式区分**:dialogBox 通过 `dimConfig` computed 自动检测 —— 找 `ui: 'dimension'`(组合)或 `ui: 'dimensionWidth'`(拆分),两种模式共用同一个 `DimensionInput` 组件。 + +**条件显示**:`showWhen: { aspectRatio: 'custom' }` 使参数仅在 proportion 选 `custom` 时显示(如 `customWidth`/`customHight`)。 模型选择器从 API(`fetchPlatformModels`)获取模型列表,按 API 返回的 `tags` 数组字段分组(`text`→生成模型,`edit`→编辑模型,`vision`→视觉理解模型)。 @@ -87,13 +97,15 @@ src/ `src/config/models/index.js` 中 `displayNameMap` 负责将 API 返回的 `display_name` 映射到 config key。因为同一模型在不同 tag 下可能共用一个 `display_name`(如 `GPT-Image-2` 和 `GPT-image-2` 分别对应编辑/生成),config key 采用内部中文名区分。 +**已知 bug**:`displayNameMap` 存在重复 key `'GPT-Image-2'`,第二条会覆盖第一条,导致文字生图版 GPT-Image-2 查找走 `displayNameMap` 时映射到 I2I 版的 config。当前因 `model.value` 已经是中文 config key,直达 `configs[]` 而非走 map,暂时不触发。若后续改为按 `display_name` 查找,需修复此重复 key。 + ### dialogBox 编排中心 `src/components/dialogBox/index.vue` 是核心编排组件,负责: - **模型选择切换**:监听 `model` + `modelType` 变化,调用 `loadModelConfig()` 加载模型参数 schema -- **派生 UI 配置**:从 model config 计算 `paintingProportionOpts`(滤除 `custom` 选项)、`paintingResolutionOpts`、`hasCustomSize`(是否显示自定义尺寸)、`quantityMax` -- **状态管理**:`customWidth`/`customHight` 通过 `v-model:width`/`v-model:height` 与 `paintingProportion` 双向绑定 +- **派生 UI 配置**:从 model config 计算 `showProportion`、`showDimension`、`showQuality`、`showQuantity`(各自检查对应 `ui` 字段是否存在);`hasCustomSize`(proportion options 含 `custom`);`dimConfig`(自动识别 combined/split 模式) +- **状态管理**:`dimWidth`/`dimHeight` 通过 `v-model:width`/`v-model:height` 与 `DimensionInput` 双向绑定;`qualityValue` 通过 `v-model` 与 Select 组件双向绑定 - **参数回填**:`fillParamsFromResult()` 供历史记录重编辑使用 - **任务发起**:`handleStart()` 收集所有参数 → 构造 `data` → 调用 `taskPolling.js:generate()` diff --git a/Vidu Q3-T2V.json b/Vidu Q3-T2V.json deleted file mode 100644 index 625281a..0000000 --- a/Vidu Q3-T2V.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "nodeInfoList": { - "prompt":{ "nodeId":"2", "fieldName":"prompt", "fieldValue":"" }, - "resolution":{ "nodeId":"2", "fieldName":"resolution", "fieldValue":"" }, - "proportion":{ "nodeId":"2", "fieldName":"aspect_ratio", "fieldValue":"" }, - "duration":{ "nodeId":"2", "fieldName":"duration", "fieldValue": 5}, - "audio":{ "nodeId":"2", "fieldName":"audio", "fieldValue": false} - }, - "workflowId": "2036349280088231938", - "display": { - "promptPlaceholder": {"default": "描述你想生成的画面和动作。"}, - "prompt": {"default": ""}, - "resolution": {"default": "1k","options":[ - { "value": "360", "label": "流畅 360P" }, - { "value": "540", "label": "标清 540P" }, - { "value": "720", "label": "高清 720P" }, - { "value": "1k", "label": "超清 1K" } - ]}, - "proportion": {"default": "16:9","options":[ - { "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" } - ]}, - "duration": {"default": 5,"options":[ - { "value": 5, "label": "5秒" }, - { "value": 10, "label": "10秒" }, - { "value": 15, "label": "15秒" } - ]}, - "audio": {"default": false} - } -} \ No newline at end of file diff --git a/components.d.ts b/components.d.ts index f0d77a2..fc2d743 100644 --- a/components.d.ts +++ b/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { Canvas: typeof import('./src/components/canvas/index.vue')['default'] copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default'] DialogBox: typeof import('./src/components/dialogBox/index.vue')['default'] + Dimension: typeof import('./src/components/dialogBox/dimension/index.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElInput: typeof import('element-plus/es')['ElInput'] diff --git a/docs/模型参数后端化方案.md b/docs/模型参数后端化方案.md new file mode 100644 index 0000000..9919c44 --- /dev/null +++ b/docs/模型参数后端化方案.md @@ -0,0 +1,648 @@ +# 模型参数配置后端化方案 + +## 1. 背景与目标 + +当前模型参数配置硬编码在前端 `src/config/models/*.js` 中,新增模型或修改参数需要前端发版,业务层无法感知参数定义。 + +目标:将参数配置迁移到业务层,管理员后台配置模型参数,前端通过 API 按模型 ID 动态获取,实现**零发版上线新模型配置**。 + +``` +页面加载: + GET /suanli/v1/platforms/{code}/models → 模型列表(现有接口,不变) + GET /suanli/v1/platforms/{code}/models/params → 批量拉全部模型参数(新接口) + 前端存入 Map + +切换模型: + config = paramsMap.get(modelId) → 内存读取,0 网络请求 + +兜底(缓存 miss 时): + GET /suanli/v1/models/{model_id}/params → 单个模型参数 +``` + +--- + +# 第一部分:后端方案 + +## 2. API 设计 + +### 2.1 批量获取平台下所有模型参数(主接口) + +``` +GET /suanli/v1/platforms/{platform_code}/models/params +``` + +**Response:** + +```json +{ + "code": 0, + "data": { + "models": [ + { + "id": "uuid-of-flux-2", + "input_type": "text", + "max_images": 4, + "params": [ + { + "name": "prompt", + "label": "提示词", + "type": "string", + "required": true, + "ui": "textarea" + }, + { + "name": "aspectRatio", + "label": "比例", + "type": "select", + "default": "1:1", + "ui": "proportion", + "options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"], + "show_when": null + }, + { + "name": "customWidth", + "label": "自定义宽度", + "type": "number", + "default": 1024, + "min": 512, + "max": 2048, + "ui": "hidden", + "show_when": { "aspectRatio": "custom" } + }, + { + "name": "resolution", + "label": "分辨率", + "type": "select", + "default": "1k", + "ui": "resolution", + "options": ["1k", "2k", "4k"] + } + ] + }, + { + "id": "uuid-of-qwen-2.0", + "input_type": "text", + "params": [ + { + "name": "prompt", + "label": "提示词", + "type": "string", + "required": true, + "ui": "textarea" + }, + { + "name": "size", + "label": "尺寸", + "type": "string", + "default": "1024*1024", + "ui": "dimension", + "dimension": { + "delimiter": "*", + "width": { "min": 512, "max": 2048 }, + "height": { "min": 512, "max": 2048 } + } + }, + { + "name": "imageNum", + "label": "生成张数", + "type": "select", + "default": 1, + "ui": "quantity", + "options": [1, 2, 3, 4, 5, 6] + } + ] + } + ] + } +} +``` + +### 2.2 单个模型参数(兜底接口) + +``` +GET /suanli/v1/models/{model_id}/params +``` + +**Response:** `data` 为单个模型对象(不含 `models` 数组包装),其余结构同 2.1。 + +### 2.3 参数字段规范 + +每个 param 对象的字段定义: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | 参数 key,提交任务时作为 body 字段名 | +| `label` | string | 是 | 中文显示名 | +| `type` | string | 是 | `string` / `number` / `boolean` / `select` / `image` | +| `default` | any | 是 | 默认值,类型必须与 `type` 一致 | +| `required` | bool | 否 | 是否必填,默认 false | +| `ui` | string | 是 | UI 组件标识(见 2.4 映射表) | +| `options` | array | 否 | `type=select` 时的可选项列表 | +| `min` | number | 否 | `type=number` 时的最小值 | +| `max` | number | 否 | `type=number` 时的最大值 | +| `max_count` | number | 否 | `ui=imageUpload` 时的最大上传张数 | +| `dimension` | object | 否 | `ui=dimension` 时的尺寸配置 | +| `show_when` | object | 否 | 条件显示,如 `{"aspectRatio": "custom"}` | + +**`dimension` 对象(仅 `ui=dimension` 时出现):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `delimiter` | string | W/H 分隔符,固定 `*` | +| `width.min` | number | 宽度最小值 | +| `width.max` | number | 宽度最大值 | +| `height.min` | number | 高度最小值 | +| `height.max` | number | 高度最大值 | + +### 2.4 `ui` 字段枚举 + +| `ui` 值 | 前端渲染组件 | 说明 | +|---------|-------------|------| +| `textarea` | Sender 内置 textarea | 提示词输入框 | +| `proportion` | `paintingProportion` | 比例选择 Popover(含 resolution + custom 尺寸) | +| `resolution` | `paintingProportion` 内部 | 分辨率子选项 | +| `dimension` | `DimensionInput` | 组合模式 W×H(如 `1024*1024`) | +| `dimensionWidth` | `DimensionInput` | 拆分模式:独立宽度(须与 `dimensionHeight` 配对) | +| `dimensionHeight` | `DimensionInput` | 拆分模式:独立高度(须与 `dimensionWidth` 配对) | +| `select` | `Select` | 通用下拉选择(如 quality) | +| `quantity` | `Quantity` | 生成张数选择器 | +| `imageUpload` | `ImageUploader` | 参考图上传 | +| `hidden` | 无 | 不渲染,静默写入默认值 | + +> `dimensionWidth` + `dimensionHeight` 必须成对出现,前端自动关联到同一个 DimensionInput 组件。 + +--- + +## 3. 数据库设计 + +### 3.1 表结构 + +`model_params` 是独立的配置表,与 `models` 表分离,通过 `model_id` 关联: + +``` +┌─────────────────────┐ ┌──────────────────────────┐ +│ models(平台模型表) │ │ model_params(参数配置表) │ +│ │ 1:N │ │ +│ id (UUID) │◄────────│ model_id (FK) │ +│ display_name │ │ name, label, type │ +│ platform_code │ │ ui, default_val │ +│ tags │ │ options, min, max... │ +│ input_type │ │ sort_order │ +│ max_images │ │ created_at / updated_at │ +└─────────────────────┘ └──────────────────────────┘ +``` + +```sql +CREATE TABLE model_params ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + model_id VARCHAR(64) NOT NULL, -- 关联 models.id(UUID) + name VARCHAR(64) NOT NULL, -- 参数 key + label VARCHAR(64) NOT NULL, -- 中文显示名 + type VARCHAR(16) NOT NULL, -- string | number | boolean | select | image + default_val JSON NOT NULL, -- 默认值 + required TINYINT DEFAULT 0, + ui VARCHAR(32) NOT NULL, -- UI 组件标识 + options JSON DEFAULT NULL, + min_val INT DEFAULT NULL, + max_val INT DEFAULT NULL, + max_count INT DEFAULT NULL, + show_when JSON DEFAULT NULL, + sort_order INT DEFAULT 0, + dimension JSON DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_model_id (model_id) +); +``` + +`dimension` JSON 格式: +```json +{ + "delimiter": "*", + "width": { "min": 512, "max": 2048 }, + "height": { "min": 512, "max": 2048 } +} +``` + +### 3.2 关键约束 + +- `model_params` 与 `models` 完全解耦,各自独立维护 +- 一个 `model_id` 可有多条参数行,`sort_order` 控制前端渲染顺序 +- 模型可以没有参数配置(`model_params` 中无记录),前端兼容处理(不渲染额外 UI) +- `model_id` 由管理员手动关联,不是自动生成 + +### 3.3 初始数据迁移 + +将当前 `src/config/models/*.js` 中 8 个模型的参数转为 INSERT 语句。`model_id` 须对应 `models` 表中该模型的 UUID。完整示例见第 7 节。 + +--- + +## 4. 管理员配置后台 + +``` +操作流程: +┌──────────────────────────────────────────────────────────┐ +│ 1. 进入「模型参数配置」页面 │ +│ 2. 选择平台 + 模型(从 models 表读取,按 platform_code 过滤) │ +│ 3. 为该模型添加/编辑参数行,每行配置: │ +│ - 参数名 (name) - 中文标签 (label) │ +│ - 数据类型 (type) - UI 组件 (ui) │ +│ - 默认值 (default) - 可选项 (options) │ +│ - 数值范围 (min/max) - 尺寸配置 (dimension) │ +│ - 条件显示 (show_when) │ +│ 4. 拖拽调整参数排序 (sort_order) │ +│ 5. 保存后前端缓存 TTL 过期自动生效,也可通知用户手动刷新 │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. 接口实现伪代码 + +```python +# GET /suanli/v1/platforms/{platform_code}/models/params +def get_platform_model_params(platform_code): + models = db.query( + "SELECT id, input_type, max_images FROM models WHERE platform_code = ?", + platform_code + ) + result = [] + for m in models: + params = db.query( + "SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order", + m.id + ) + result.append({ + "id": m.id, + "input_type": m.input_type, + "max_images": m.max_images, + "params": [format_param(p) for p in params], + }) + return {"code": 0, "data": {"models": result}} + + +# GET /suanli/v1/models/{model_id}/params +def get_model_params(model_id): + m = db.query("SELECT id, input_type, max_images FROM models WHERE id = ?", model_id) + if not m: + return {"code": 404, "msg": "模型不存在"} + params = db.query( + "SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order", + model_id + ) + return { + "code": 0, + "data": { + "id": m.id, + "input_type": m.input_type, + "max_images": m.max_images, + "params": [format_param(p) for p in params], + } + } + + +def format_param(p): + return { + "name": p.name, + "label": p.label, + "type": p.type, + "default": json.loads(p.default_val), + "required": bool(p.required), + "ui": p.ui, + "options": json.loads(p.options) if p.options else None, + "min": p.min_val, + "max": p.max_val, + "max_count": p.max_count, + "show_when": json.loads(p.show_when) if p.show_when else None, + "dimension": json.loads(p.dimension) if p.dimension else None, + } +``` + +--- + +## 6. 响应示例汇总 + +### 6.1 即梦 4.6(拆分维度 `dimensionWidth` + `dimensionHeight`) + +```json +{ + "id": "jimeng-uuid", + "input_type": "text", + "params": [ + { "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" }, + { "name": "width", "label": "宽度", "type": "number", "default": 1024, "min": 900, "max": 6197, "ui": "dimensionWidth" }, + { "name": "height", "label": "高度", "type": "number", "default": 1024, "min": 768, "max": 4096, "ui": "dimensionHeight" }, + { "name": "forceSingle", "label": "强制单张", "type": "boolean", "default": false, "ui": "hidden" } + ] +} +``` + +### 6.2 通义万相 2.0(组合维度 `dimension` + `quantity`) + +```json +{ + "id": "qwen-uuid", + "input_type": "text", + "params": [ + { "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" }, + { + "name": "size", "label": "尺寸", "type": "string", "default": "1024*1024", "ui": "dimension", + "dimension": { "delimiter": "*", "width": { "min": 512, "max": 2048 }, "height": { "min": 512, "max": 2048 } } + }, + { "name": "imageNum", "label": "生成张数", "type": "select", "default": 1, "ui": "quantity", "options": [1, 2, 3, 4, 5, 6] } + ] +} +``` + +### 6.3 GPT-Image-2(`proportion` + `resolution` + `select`) + +```json +{ + "id": "gpt-image-uuid", + "input_type": "text", + "params": [ + { "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" }, + { + "name": "aspectRatio", "label": "比例", "type": "select", "default": "1:1", "ui": "proportion", + "options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"] + }, + { "name": "customWidth", "label": "自定义宽度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } }, + { "name": "customHight", "label": "自定义高度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } }, + { "name": "resolution", "label": "分辨率", "type": "select", "default": "1k", "ui": "resolution", "options": ["1k", "2k", "4k"] }, + { "name": "quality", "label": "质量", "type": "select", "default": "medium", "ui": "select", "options": ["low", "medium", "high"] } + ] +} +``` + +--- + +# 第二部分:前端改动 + +## 7. 缓存策略 + +三级防护,全部位于客户端浏览器: + +``` +请求流程: + 用户操作 + → L1 内存缓存(Map,会话级,10min TTL) + → L2 localStorage 缓存(持久化,10min TTL) + → 请求冷却检查(localStorage,30s 冷却期) + → API 请求 +``` + +| 层级 | 存储位置 | 生命周期 | 说明 | +|------|---------|---------|------| +| L1 | JS 内存 Map | 标签页关闭即销毁 | 同会话内切换模型 0 延迟 | +| L2 | localStorage | 持久化,跨会话/刷新 | 刷新页面后直接命中,无需请求 | +| 冷却期 | localStorage | 独立于缓存 | 限制 API 调用频率最低 30s/次 | + +**正常场景**:首次访问 → API 请求 → 写入 L1 + L2。刷新页面 → L2 命中,0 请求。 + +**极端场景**:清掉 localStorage 连刷 10 次 → 请求冷却生效,实际只有 1 次请求到达后端。 + +--- + +## 8. 新增文件 + +### 8.1 API 层 + +```js +// src/apis/display/index.js(追加以下两个函数) + +// 批量获取平台所有模型参数 +export const fetchPlatformModelParams = (platformCode) => + service.get(`/suanli/v1/platforms/${platformCode}/models/params`) + +// 获取单个模型参数(兜底) +export const fetchModelParams = (modelId) => + service.get(`/suanli/v1/models/${modelId}/params`) +``` + +### 8.2 缓存层 + +```js +// src/utils/modelParams.js(新文件) + +import { fetchPlatformModelParams } from '@/apis/display' + +const CACHE_TTL = 10 * 60 * 1000 // 缓存有效期:10 分钟 +const COOLDOWN = 30 * 1000 // 请求冷却期:30 秒 +const STORAGE_PREFIX = 'model_params_' + +const memoryCache = new Map() // L1: { platformCode → { data, timestamp } } +const pendingRequests = new Map() // 并发去重 + +function getStorageCache(platformCode) { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + platformCode) + if (!raw) return null + const { data, timestamp } = JSON.parse(raw) + if (Date.now() - timestamp < CACHE_TTL) { + return new Map(data) + } + } catch { /* ignore */ } + return null +} + +function setStorageCache(platformCode, map) { + try { + localStorage.setItem(STORAGE_PREFIX + platformCode, JSON.stringify({ + data: [...map], + timestamp: Date.now(), + })) + } catch { /* ignore */ } +} + +function getCooldownRemaining(platformCode) { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + platformCode + '_lastFetch') + if (!raw) return 0 + const elapsed = Date.now() - parseInt(raw) + return elapsed < COOLDOWN ? COOLDOWN - elapsed : 0 + } catch { return 0 } +} + +function setFetchTimestamp(platformCode) { + try { + localStorage.setItem(STORAGE_PREFIX + platformCode + '_lastFetch', Date.now().toString()) + } catch { /* ignore */ } +} + +export function clearModelParamsCache(platformCode) { + memoryCache.delete(platformCode) + localStorage.removeItem(STORAGE_PREFIX + platformCode) +} + +export async function getModelParamsMap(platformCode) { + // 1. L1 内存缓存 + const mem = memoryCache.get(platformCode) + if (mem && Date.now() - mem.timestamp < CACHE_TTL) { + return mem.data + } + + // 2. L2 localStorage 缓存 + const storage = getStorageCache(platformCode) + if (storage) { + memoryCache.set(platformCode, { data: storage, timestamp: Date.now() }) + return storage + } + + // 3. 并发去重 + if (pendingRequests.has(platformCode)) { + return pendingRequests.get(platformCode) + } + + // 4. 请求冷却 + const cooldown = getCooldownRemaining(platformCode) + if (cooldown > 0) { + const promise = new Promise(resolve => { + setTimeout(() => { + pendingRequests.delete(platformCode) + resolve(getModelParamsMap(platformCode)) + }, cooldown) + }) + pendingRequests.set(platformCode, promise) + return promise + } + + // 5. 发起请求 + setFetchTimestamp(platformCode) + const promise = fetchPlatformModelParams(platformCode) + .then(res => { + const map = new Map() + for (const m of res.data.models) { + map.set(m.id, m) + } + memoryCache.set(platformCode, { data: map, timestamp: Date.now() }) + setStorageCache(platformCode, map) + pendingRequests.delete(platformCode) + return map + }) + .catch(err => { + pendingRequests.delete(platformCode) + throw err + }) + + pendingRequests.set(platformCode, promise) + return promise +} +``` + +--- + +## 9. 改造文件 + +### 9.1 dialogBox/index.vue + +**数据流变化:** + +``` +当前: + model.value (display_name) + → getModelConfig(modelName) // 静态 JS 文件查找 + → modelConfig (computed) + → UI 渲染 + +新方案: + modelId (选中模型的 UUID) + → modelConfig = paramsMap.get(modelId) // 从预取 Map 查找 + → UI 渲染 +``` + +**核心代码改造:** + +```js +// 当前 +const modelConfig = computed(() => { + return props.type === 'Painting' ? getModelConfig(model.value) : null +}) + +// 新方案 +const paramsMap = ref(new Map()) +const modelConfig = computed(() => { + if (props.type !== 'Painting') return null + return paramsMap.value.get(currentModelId.value) || null +}) + +onMounted(async () => { + const map = await getModelParamsMap('ai_painting_talk') + paramsMap.value = map +}) +``` + +**其他改动点:** + +- `modelConfig` watcher 中维度初始化改用统一 `parseDimension()` 替代 `dimension.parse()` +- `fillParamsFromResult` 中维度恢复同理 +- `displayNameMap` 逻辑移除 + +### 9.2 model/painting.vue(模型选择器) + +- 选中值从 `display_name` 改为 `model_id` +- emit 从 `update:modelValue(displayName)` 改为 `update:modelValue(modelId)` +- 同时 emit `update:typeValue(inputType)` 保持不变 + +### 9.3 dimension 处理统一化 + +移除每个模型配置中自定义的 `parse`/`format` 函数,前端统一处理: + +```js +// src/components/dialogBox/index.vue 中集中定义 +function parseDimension(raw, delimiter = '*') { + const parts = (raw || '1024*1024').split(delimiter) + return { + width: parseInt(parts[0]) || 1024, + height: parseInt(parts[1]) || 1024, + } +} + +function formatDimension(w, h, delimiter = '*') { + return `${w}${delimiter}${h}` +} +``` + +--- + +## 10. 迁移步骤 + +| 阶段 | 负责方 | 内容 | +|------|--------|------| +| **1. 后端准备** | 后端 | 创建 `model_params` 表 → 录入 8 个模型配置 → 实现批量+单个接口 → 联调验证 | +| **2. 前端适配** | 前端 | 新增 API 函数 + 缓存层 → 改造 dialogBox + model 选择器 → 保留静态配置为 fallback | +| **3. 清理** | 前端 | 确认全量走新接口 → 删除 `src/config/models/*.js` → 删除 `displayNameMap`、`getModelConfig` | + +--- + +## 11. 边界情况 + +### 11.1 接口失败降级 + +迁移期间接口失败时回退静态配置: + +```js +const modelConfig = computed(() => { + if (paramsMap.value.size > 0) { + return paramsMap.value.get(currentModelId.value) || null + } + return getModelConfig(model.value) // fallback +}) +``` + +### 11.2 缓存主动刷新 + +管理员修改配置后,前端提供「刷新配置」按钮调用 `clearModelParamsCache()` 立即生效。 + +### 11.3 模型无参数配置 + +若 `model_id` 在 `model_params` 表中无记录,前端仅渲染 prompt 输入框(textarea),不显示其他 UI 组件。 + +### 11.4 `show_when` 条件显示 + +当前仅支持 `{ "aspectRatio": "custom" }` 条件。后续如需扩展,前后端同步约定新的条件字段和取值。 + +### 11.5 向后兼容 + +- 新增接口路径 `models/params`,不影响现有模型列表接口 +- Video 路径不受影响(继续使用 `runninghub.Playload()` 适配器) +- Painting 的 `modelParams` 扁平提交格式不变 diff --git a/src/components/dialogBox/dimension/index.vue b/src/components/dialogBox/dimension/index.vue new file mode 100644 index 0000000..1a70538 --- /dev/null +++ b/src/components/dialogBox/dimension/index.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/src/components/dialogBox/index.vue b/src/components/dialogBox/index.vue index 9cc0cd3..801a8bd 100644 --- a/src/components/dialogBox/index.vue +++ b/src/components/dialogBox/index.vue @@ -41,6 +41,26 @@ :resolution-options="paintingResolutionOpts" :allow-custom="hasCustomSize" /> + + @@ -86,6 +106,8 @@ import VideoImageUploader from './videoImageUploader/index.vue' import Time from './Time/index.vue' import paintingProportion from './proportion/painting.vue' import Quantity from './quantity/index.vue' +import DimensionInput from './dimension/index.vue' +import Select from '@/components/Select/index.vue' import { Sender } from 'vue-element-plus-x' import { useDisplayStore } from '@/stores' import { generate } from '@/utils/taskPolling' @@ -132,9 +154,7 @@ const showImageUploader = computed(() => { // 模型是否有数量参数(imageNum),或生成类模型显示张数选择 const showQuantity = computed(() => { if (props.type !== 'Painting') return false - const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity') - if (qtyParam) return true - return modelType.value === 'text' && !modelConfig.value?.params?.find(p => p.name === 'forceSingle') + return !!modelConfig.value?.params?.find(p => p.ui === 'quantity') }) // 模型是否使用 proportion 组件(aspectRatio 参数) @@ -148,6 +168,40 @@ const hasCustomSize = computed(() => { return ratioParam?.options?.includes('custom') || false }) +// 尺寸输入(DimensionInput)相关 +const dimWidth = ref(1024) +const dimHeight = ref(1024) +const qualityValue = ref('medium') + +const showDimension = computed(() => { + return !!modelConfig.value?.params?.find(p => p.ui === 'dimension' || p.ui === 'dimensionWidth') +}) + +const dimConfig = computed(() => { + if (!modelConfig.value) return null + const dimParam = modelConfig.value.params.find(p => p.ui === 'dimension') + if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name } + const wParam = modelConfig.value.params.find(p => p.ui === 'dimensionWidth') + const hParam = modelConfig.value.params.find(p => p.ui === 'dimensionHeight') + if (wParam && hParam) return { type: 'split', wParam, hParam } + return null +}) + +const dimMinW = computed(() => dimConfig.value?.config?.width?.min || dimConfig.value?.wParam?.min || 256) +const dimMaxW = computed(() => dimConfig.value?.config?.width?.max || dimConfig.value?.wParam?.max || 6197) +const dimMinH = computed(() => dimConfig.value?.config?.height?.min || dimConfig.value?.hParam?.min || 256) +const dimMaxH = computed(() => dimConfig.value?.config?.height?.max || dimConfig.value?.hParam?.max || 4096) + +const showQuality = computed(() => { + return !!modelConfig.value?.params?.find(p => p.name === 'quality') +}) + +const qualityOpts = computed(() => { + const q = modelConfig.value?.params?.find(p => p.name === 'quality') + if (q?.options) return q.options.map(o => ({ value: o, label: o })) + return [] +}) + // 从模型配置派生比例选项,回退到默认值 const paintingProportionOpts = computed(() => { const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion') @@ -215,6 +269,19 @@ watch(modelConfig, (config) => { if (cwParam) customWidth.value = cwParam.default || 1024 const chParam = config.params.find(p => p.name === 'customHight') if (chParam) customHight.value = chParam.default || 1024 + // 同步 dimension 和 quality 默认值 + const qualityParam = config.params.find(p => p.name === 'quality') + if (qualityParam) qualityValue.value = qualityParam.default || 'medium' + const dc = dimConfig.value + if (dc?.type === 'split') { + dimWidth.value = dc.wParam.default || 1024 + dimHeight.value = dc.hParam.default || 1024 + } else if (dc?.type === 'combined') { + const raw = paramValues[dc.paramName] || config.params.find(p => p.name === dc.paramName)?.default || '' + const parsed = dc.config.parse(raw) + dimWidth.value = parsed.width + dimHeight.value = parsed.height + } }, { immediate: true }) // 反向同步:UI refs → paramValues @@ -240,6 +307,21 @@ watch(customHight, (val) => { paramValues.customHight = val } }) +watch([dimWidth, dimHeight], ([w, h]) => { + const dc = dimConfig.value + if (!dc) return + if (dc.type === 'split') { + paramValues[dc.wParam.name] = w + paramValues[dc.hParam.name] = h + } else if (dc.type === 'combined') { + paramValues[dc.paramName] = dc.config.format(w, h) + } +}) +watch(qualityValue, (val) => { + if (modelConfig.value?.params?.find(p => p.name === 'quality')) { + paramValues.quality = val + } +}) // 同步参考图片到 paramValues watch(referenceImages, (imgs) => { @@ -403,6 +485,21 @@ const fillParamsFromResult = (resultData) => { if (resultData.duration !== undefined) duration.value = resultData.duration if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams) + // 从恢复的 modelParams 同步 dimension/quality UI refs + nextTick(() => { + const dc = dimConfig.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) qualityValue.value = paramValues.quality + }) } defineExpose({ @@ -667,4 +764,28 @@ watch(() => props.type, (newType) => { // .gerenate:hover{ // background: rgba(0, 15, 51, 0.20); // } +// 画质选择器 +.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; +} diff --git a/src/config/modelConfig/painting.json b/src/config/modelConfig/painting.json deleted file mode 100644 index 7e34bb7..0000000 --- a/src/config/modelConfig/painting.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "generate": [ - { "value": "flux", "label": "flux" }, - { "value": "zImage", "label": "Z-image" }, - { "value": "jimeng", "label": "jimeng" }, - { "value": "QwenImage", "label": "QwenImage" } - ], - "edit": [ - { "value": "BananaPro", "label": "Banana-Pro" }, - { "value": "Qwen-image", "label": "Qwen-image" }, - { "value": "Kontext", "label": "Kontext" }, - { "value": "Jimeng_4.0", "label": "Jimeng.4.0" } - ], - "vision": [ - { "value": "Qwen3.5plus", "label": "Qwen3.5plus", "disabled": true } - ] -} diff --git a/src/config/modelConfig/video.json b/src/config/modelConfig/video.json deleted file mode 100644 index 6171db5..0000000 --- a/src/config/modelConfig/video.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "文生视频": [ - { "value": "LTX2.0", "label": "LTX2.0 T2V" }, - { "value": "viduQ3-T2V", "label": "viduQ3 T2V" } - ], - "首尾帧": [ - { "value": "Hailuo-02-fast", "label": "海螺 fast" }, - { "value": "LTX2.0-I2V", "label": "LTX2.0 I2V" }, - { "value": "LTX2.3-T2V", "label": "LTX2.3 T2V", "disabled": true }, - { "value": "ViduQ3-turbo", "label": "ViduQ3-turbo" } - ], - "数字人": [ - { "value": "FlashHead", "label": "FlashHead" } - ], - "全能参考": [ - { "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true } - ], - "智能多帧": [ - { "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true } - ], - "主体参考": [ - { "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true } - ] -} diff --git a/src/config/models/jimeng.js b/src/config/models/jimeng.js index bd9bc3c..042c0b3 100644 --- a/src/config/models/jimeng.js +++ b/src/config/models/jimeng.js @@ -18,7 +18,7 @@ export default { default: 1024, min: 900, max: 6197, - ui: 'number', + ui: 'dimensionWidth', }, { name: 'height', @@ -27,7 +27,14 @@ export default { default: 1024, min: 768, max: 4096, - ui: 'number', + ui: 'dimensionHeight', + }, + { + name: 'forceSingle', + label: '强制单张', + type: 'boolean', + default: false, + ui: 'hidden', }, ], } diff --git a/src/config/models/qwen-edit.js b/src/config/models/qwen-edit.js index 7d13bff..0d87bde 100644 --- a/src/config/models/qwen-edit.js +++ b/src/config/models/qwen-edit.js @@ -22,17 +22,19 @@ export default { }, { name: 'size', - label: '分辨率', - type: 'select', + label: '尺寸', + type: 'string', default: '1024*1024', - options: [ - '1024*1024', '1536*1536', - '768*1152', '1024*1536', '1152*768', '1536*1024', - '960*1280', '1080*1440', '1280*960', '1440*1080', - '720*1280', '1080*1920', '1280*720', '1920*1080', - '1344*576', '2048*872', - ], - ui: 'select', + ui: 'dimension', + dimension: { + parse: (val) => { + const parts = (val || '1024*1024').split('*') + return { width: parseInt(parts[0]) || 1024, height: parseInt(parts[1]) || 1024 } + }, + format: (w, h) => `${w}*${h}`, + width: { min: 512, max: 2048 }, + height: { min: 512, max: 2048 }, + }, }, { name: 'imageNum', diff --git a/src/config/models/qwen.js b/src/config/models/qwen.js index e076dd9..da2c342 100644 --- a/src/config/models/qwen.js +++ b/src/config/models/qwen.js @@ -14,17 +14,19 @@ export default { }, { name: 'size', - label: '分辨率', - type: 'select', + label: '尺寸', + type: 'string', default: '1024*1024', - options: [ - '1024*1024', '1536*1536', - '768*1152', '1024*1536', '1152*768', '1536*1024', - '960*1280', '1080*1440', '1280*960', '1440*1080', - '720*1280', '1080*1920', '1280*720', '1920*1080', - '1344*576', '2048*872', - ], - ui: 'select', + ui: 'dimension', + dimension: { + parse: (val) => { + const parts = (val || '1024*1024').split('*') + return { width: parseInt(parts[0]) || 1024, height: parseInt(parts[1]) || 1024 } + }, + format: (w, h) => `${w}*${h}`, + width: { min: 512, max: 2048 }, + height: { min: 512, max: 2048 }, + }, }, { name: 'imageNum', diff --git a/src/views/home/display/index.vue b/src/views/home/display/index.vue index 070cb5f..69e5e91 100644 --- a/src/views/home/display/index.vue +++ b/src/views/home/display/index.vue @@ -105,7 +105,6 @@ const isInitializing = ref(true) const { canvasVisible, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay) const chargeType = computed(() => getChargeType(props.type)) -// console.log(chargeType.value) const timeOptions = [ { label: '全部', value: 'all' }, @@ -138,41 +137,61 @@ const toggleDisplay = (newValue, oldValue) => { const conversion = (newlist) => { const temp = newlist.map((item) => { - const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : [] - const generateData = JSON.parse(item.result || '{}') + // 从 outputs 扁平数组提取 URL + const files = item.outputs?.map(o => o.url) || [] + const request = item.request || {} + const generateData = { + model: item.model_name || '', + modelType: request.modelType || '', + prompt: request.prompt || '', + proportion: request.aspectRatio || '', + referenceImages: request.referenceImages || [], + quantity: request.imageNum || files.length || 1, + resolution: request.resolution || '', + customWidth: request.customWidth, + customHight: request.customHight, + duration: request.duration || '', + videoPattern: request.videoPattern || '', + modelParams: { ...request }, + } + // 将 API status 映射为 UI 展示状态 + let uiStatus = 'success' + if (item.status === 'failed' || item.status === 'cancelled') uiStatus = 'error' + else if (item.status === 'queued' || item.status === 'processing') uiStatus = 'generate' + return { id: item.id, - taskId: item.taskId, + taskId: item.id, type: props.type, collection: item.collection, - status: 'success', - generateData: generateData, - time: item.createTime, - files: files, - collectStatus: item.collectStatus || {} + status: uiStatus, + generateData, + time: item.created_at || '', + files, + collectStatus: item.collectStatus || {}, } }) - console.log(temp) return temp } const fetchHistory = async (isLoadMore = false) => { if (isLoading.value || (!isLoadMore && !hasMoreData.value)) return - + isLoading.value = true - + try { const pageToFetch = isLoadMore ? currentPage.value + 1 : 1 - + const result = await requestTaskHistory({ user_id: userStore.userInfo.id, platform_code: getPlatformCode(props.type), + status: 'completed', page: pageToFetch, pageSize: 10 }) const dataList = result.data?.list || result.data || [] - + if (dataList.length === 0) { hasMoreData.value = false if (!isLoadMore) { @@ -192,9 +211,9 @@ const fetchHistory = async (isLoadMore = false) => { } else { useDisplay.initHistoryList(adaptedList) currentPage.value = 1 - + await nextTick() - + for (let i = 0; i < 5; i++) { setTimeout(() => { if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') { @@ -202,7 +221,7 @@ const fetchHistory = async (isLoadMore = false) => { } }, 100 * i) } - + setTimeout(() => { if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') { scrollerRef.value.scrollToBottom() @@ -214,9 +233,9 @@ const fetchHistory = async (isLoadMore = false) => { }, 300) }, 600) } - + hasMoreData.value = dataList.length === 10 - + } catch (error) { console.error('获取历史失败:', error) ElMessage({ @@ -230,7 +249,7 @@ const fetchHistory = async (isLoadMore = false) => { const handleScroll = (scrollInfo) => { if (isInitializing.value) return - + if (!scrollInfo) return const { isAtPageTop, isAtPageBottom, distanceToPageTop, distanceToPageBottom } = scrollInfo @@ -241,7 +260,7 @@ const handleScroll = (scrollInfo) => { isLoadingMoreLocked.value = false }, 3000) } - + if (isAtPageBottom) { useDisplay.Sender_variant = 'updown' } else if (distanceToPageTop >= 350) { @@ -282,7 +301,7 @@ const handleDeleteSuccess = (id) => { onMounted(() => { if (!props.loading) return refreshing.value = true - + nextTick(() => { useDisplay.scrollerRef = scrollerRef.value useDisplay.resetPagination()