diff --git a/docs/superpowers/specs/2026-06-04-extended-visual-ratio-settings-design.md b/docs/superpowers/specs/2026-06-04-extended-visual-ratio-settings-design.md new file mode 100644 index 0000000..63eccf1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-extended-visual-ratio-settings-design.md @@ -0,0 +1,511 @@ +# 扩展可视化倍率设置设计文档 + +> **创建日期**:2026-06-04 +> **状态**:待实施 +> **相关组件**:ModelSettingsVisualEditor.jsx + +## 需求概述 + +在现有的 `ModelSettingsVisualEditor` 组件中添加高级倍率字段支持,允许用户为每个模型单独设置缓存、图片、音频相关的倍率。 + +### 当前限制 + +现有的可视化倍率设置只能设置: +- 模型倍率 +- 补全倍率 + +### 目标 + +新增以下5个高级倍率字段: +- 缓存读取倍率 +- 缓存创建倍率 +- 图片倍率 +- 音频输入倍率 +- 音频输出倍率 + +## 设计方案 + +### 界面设计 + +#### 主表格(保持不变) + +表格列结构保持现有的简洁布局: + +| 列名 | 说明 | +|------|------| +| 模型名称 | 显示模型名称,如有矛盾显示红色标签 | +| 模型固定价格 | 按次计费的价格输入框 | +| 模型倍率 | 按量计费的模型倍率 | +| 补全倍率 | 按量计费的补全倍率 | +| 操作 | 编辑、删除按钮 | + +#### 编辑弹窗(新增"高级比例"区域) + +在现有的表单结构中,在"定价模式"和"价格设置方式"区域之后,增加一个新的表单区域: + +```jsx +{pricingMode === 'per-token' && ( + <> + {/* 现有的价格设置方式区域 */} + + ... + + + {/* 新增:高级比例区域 */} + + + + + + + + +)} +``` + +**设计决策**: +- 只在"按量计费"模式下显示高级比例字段 +- 按次计费模式不需要这些倍率 +- 使用 `Form.InputNumber` 确保输入的是有效数字 +- `placeholder` 显示默认值提示 +- `step={0.01}` 允许小数点后两位精度 + +### 数据结构 + +#### 内部状态扩展 + +在 `models` 状态数组中,每个模型对象增加5个字段: + +```javascript +{ + name: 'gpt-4', + price: '', + ratio: '', + completionRatio: '', + cacheRatio: '', // 新增 + cacheCreationRatio: '', // 新增 + imageRatio: '', // 新增 + audioRatio: '', // 新增 + audioCompletionRatio: '', // 新增 + hasConflict: false +} +``` + +#### 数据初始化 + +```javascript +useEffect(() => { + try { + const modelPrice = JSON.parse(props.options.ModelPrice || '{}'); + const modelRatio = JSON.parse(props.options.ModelRatio || '{}'); + const completionRatio = JSON.parse(props.options.CompletionRatio || '{}'); + // 新增:解析高级倍率字段 + const cacheRatio = JSON.parse(props.options.CacheRatio || '{}'); + const createCacheRatio = JSON.parse(props.options.CreateCacheRatio || '{}'); + const imageRatio = JSON.parse(props.options.ImageRatio || '{}'); + const audioRatio = JSON.parse(props.options.AudioRatio || '{}'); + const audioCompletionRatio = JSON.parse(props.options.AudioCompletionRatio || '{}'); + + // 合并所有模型名称 + const modelNames = new Set([ + ...Object.keys(modelPrice), + ...Object.keys(modelRatio), + ...Object.keys(completionRatio), + ...Object.keys(cacheRatio), + ...Object.keys(createCacheRatio), + ...Object.keys(imageRatio), + ...Object.keys(audioRatio), + ...Object.keys(audioCompletionRatio), + ]); + + const modelData = Array.from(modelNames).map((name) => { + return { + name, + price: modelPrice[name] === undefined ? '' : modelPrice[name], + ratio: modelRatio[name] === undefined ? '' : modelRatio[name], + completionRatio: completionRatio[name] === undefined ? '' : completionRatio[name], + cacheRatio: cacheRatio[name] === undefined ? '' : cacheRatio[name], + cacheCreationRatio: createCacheRatio[name] === undefined ? '' : createCacheRatio[name], + imageRatio: imageRatio[name] === undefined ? '' : imageRatio[name], + audioRatio: audioRatio[name] === undefined ? '' : audioRatio[name], + audioCompletionRatio: audioCompletionRatio[name] === undefined ? '' : audioCompletionRatio[name], + hasConflict: modelPrice[name] !== '' && (modelRatio[name] !== '' || completionRatio[name] !== ''), + }; + }); + + setModels(modelData); + } catch (error) { + console.error('JSON解析错误:', error); + } +}, [props.options]); +``` + +#### API 请求结构 + +保存时将5个新增字段序列化到对应的 JSON 字段: + +```javascript +const SubmitData = async () => { + setLoading(true); + const output = { + ModelPrice: {}, + ModelRatio: {}, + CompletionRatio: {}, + CacheRatio: {}, // 新增 + CreateCacheRatio: {}, // 新增 + ImageRatio: {}, // 新增 + AudioRatio: {}, // 新增 + AudioCompletionRatio: {} // 新增 + }; + + try { + // 数据转换 + models.forEach((model) => { + if (model.price !== '') { + output.ModelPrice[model.name] = parseFloat(model.price); + } else { + if (model.ratio !== '') + output.ModelRatio[model.name] = parseFloat(model.ratio); + if (model.completionRatio !== '') + output.CompletionRatio[model.name] = parseFloat(model.completionRatio); + } + + // 新增:处理高级倍率字段 + if (model.cacheRatio !== '') + output.CacheRatio[model.name] = parseFloat(model.cacheRatio); + if (model.cacheCreationRatio !== '') + output.CreateCacheRatio[model.name] = parseFloat(model.cacheCreationRatio); + if (model.imageRatio !== '') + output.ImageRatio[model.name] = parseFloat(model.imageRatio); + if (model.audioRatio !== '') + output.AudioRatio[model.name] = parseFloat(model.audioRatio); + if (model.audioCompletionRatio !== '') + output.AudioCompletionRatio[model.name] = parseFloat(model.audioCompletionRatio); + }); + + // 准备API请求数组 + const finalOutput = { + ModelPrice: JSON.stringify(output.ModelPrice, null, 2), + ModelRatio: JSON.stringify(output.ModelRatio, null, 2), + CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2), + CacheRatio: JSON.stringify(output.CacheRatio, null, 2), + CreateCacheRatio: JSON.stringify(output.CreateCacheRatio, null, 2), + ImageRatio: JSON.stringify(output.ImageRatio, null, 2), + AudioRatio: JSON.stringify(output.AudioRatio, null, 2), + AudioCompletionRatio: JSON.stringify(output.AudioCompletionRatio, null, 2), + }; + + const requestQueue = Object.entries(finalOutput).map(([key, value]) => { + return API.put('/api/option/', { key, value }); + }); + + // 批量处理请求 + const results = await Promise.all(requestQueue); + + // 验证结果(保持现有逻辑) + // ... + } catch (error) { + // 错误处理 + } finally { + setLoading(false); + } +}; +``` + +### 交互逻辑 + +#### 1. 编辑模型 + +编辑现有模型时,需要在弹窗中正确回显高级倍率字段的值: + +```javascript +const editModel = (record) => { + setIsEditMode(true); + + // 现有的定价模式设置逻辑 + // ... + + // 创建模型副本 + const modelCopy = { ...record }; + + // 如果有倍率数据,转换为 token price(现有逻辑) + if (record.ratio) { + modelCopy.tokenPrice = calculateTokenPriceFromRatio(parseFloat(record.ratio)).toString(); + if (record.completionRatio) { + modelCopy.completionTokenPrice = ( + parseFloat(modelCopy.tokenPrice) * parseFloat(record.completionRatio) + ).toString(); + } + } + + // 设置当前模型 + setCurrentModel(modelCopy); + setVisible(true); + + // 设置表单值 + setTimeout(() => { + if (formRef.current) { + formRef.current.setValues({ + name: modelCopy.name, + // 现有字段 + ratioInput: modelCopy.ratio, + completionRatioInput: modelCopy.completionRatio, + modelTokenPrice: modelCopy.tokenPrice, + completionTokenPrice: modelCopy.completionTokenPrice, + // 新增:高级倍率字段 + cacheRatio: modelCopy.cacheRatio, + cacheCreationRatio: modelCopy.cacheCreationRatio, + imageRatio: modelCopy.imageRatio, + audioRatio: modelCopy.audioRatio, + audioCompletionRatio: modelCopy.audioCompletionRatio, + }); + } + }, 0); +}; +``` + +#### 2. 添加/更新模型 + +在 `addOrUpdateModel` 函数中处理高级倍率字段: + +```javascript +const addOrUpdateModel = (values) => { + const existingModelIndex = models.findIndex( + (model) => model.name === values.name, + ); + + const updatedModel = { + name: values.name, + price: values.price || '', + ratio: values.ratio || '', + completionRatio: values.completionRatio || '', + // 新增:高级倍率字段 + cacheRatio: values.cacheRatio || '', + cacheCreationRatio: values.cacheCreationRatio || '', + imageRatio: values.imageRatio || '', + audioRatio: values.audioRatio || '', + audioCompletionRatio: values.audioCompletionRatio || '', + }; + + updatedModel.hasConflict = + updatedModel.price !== '' && + (updatedModel.ratio !== '' || updatedModel.completionRatio !== ''); + + if (existingModelIndex >= 0) { + // 更新现有模型 + setModels((prev) => + prev.map((model, index) => + index !== existingModelIndex ? model : updatedModel + ) + ); + setVisible(false); + showSuccess(t('更新成功')); + } else { + // 添加新模型 + if (models.some((model) => model.name === values.name)) { + showError(t('模型名称已存在')); + return; + } + setModels((prev) => [updatedModel, ...prev]); + setVisible(false); + showSuccess(t('添加成功')); + } +}; +``` + +#### 3. 输入验证 + +在 `updateModel` 函数中增加对高级倍率字段的验证: + +```javascript +const updateModel = (name, field, value) => { + if (isNaN(value) && value !== '') { // 允许空字符串 + showError(t('请输入数字')); + return; + } + setModels((prev) => + prev.map((model) => { + if (model.name !== name) return model; + const updated = { ...model, [field]: value }; + updated.hasConflict = + updated.price !== '' && + (updated.ratio !== '' || updated.completionRatio !== ''); + return updated; + }), + ); +}; +``` + +### 默认值处理策略 + +**前端处理默认值**:用户输入框留空时,保存为空字符串,后端使用全局默认值。 + +**默认值映射**: +- 缓存读取倍率:1.0 +- 缓存创建倍率:1.25 +- 图片倍率:1.0 +- 音频输入倍率:1.0 +- 音频输出倍率:1.0 + +**实现方式**: +- 输入框使用 `placeholder` 提示默认值 +- 提交时空字符串保持为空,不转换 +- 后端处理空值时返回对应的默认值 + +### 模型能力处理 + +**策略**:全部显示,用户自行判断 + +- 所有5个高级倍率字段始终显示在编辑弹窗中 +- 用户根据自己使用的模型判断是否填写相应字段 +- 不在前端进行模型能力验证或字段隐藏 + +**设计理由**: +- 简化实现,避免维护复杂的模型能力映射 +- 给用户更多控制权,允许为任意模型设置任意倍率 +- 与渠道价格设置的实现方式保持一致 + +## 实现要点 + +### 1. 字段映射 + +确保前端字段名与后端 `options` 表中的 key 一致: + +| 前端字段名 | 后端 Option Key | 默认值 | +|-----------|----------------|--------| +| cacheRatio | CacheRatio | 1.0 | +| cacheCreationRatio | CreateCacheRatio | 1.25 | +| imageRatio | ImageRatio | 1.0 | +| audioRatio | AudioRatio | 1.0 | +| audioCompletionRatio | AudioCompletionRatio | 1.0 | + +### 2. 数值验证 + +- 输入时验证为有效数字:`if (isNaN(value) && value !== '')` +- 范围验证:`min={0}` 确保非负数 +- 小数精度:`step={0.01}` 允许两位小数 + +### 3. 空值处理 + +- 空字符串视为未设置,保存时保持为空字符串 +- 后端处理时如果值为空,返回对应的全局默认值 +- 前端解析 JSON 时,如果字段不存在,默认为空字符串:`cacheRatio[name] === undefined ? '' : cacheRatio[name]` + +### 4. 样式统一 + +- 使用与现有字段一致的 `Form.InputNumber` 组件 +- 保持相同的 `label`、`placeholder` 样式 +- 表单区域使用 `Form.Section` 包裹,标题为"高级比例(留空使用默认值)" + +### 5. 国际化支持 + +需要为新增的文本添加翻译: + +**中文 (zh-CN.json)**: +```json +{ + "高级比例(留空使用默认值)": "高级比例(留空使用默认值)", + "缓存读取倍率": "缓存读取倍率", + "缓存创建倍率(5分钟)": "缓存创建倍率(5分钟)", + "图片倍率": "图片倍率", + "音频输入倍率": "音频输入倍率", + "音频输出倍率": "音频输出倍率", + "默认值:1.0": "默认值:1.0", + "默认值:1.25": "默认值:1.25" +} +``` + +**英文 (en.json)**: +```json +{ + "高级比例(留空使用默认值)": "Advanced Ratios (Leave empty for defaults)", + "缓存读取倍率": "Cache Read Ratio", + "缓存创建倍率(5分钟)": "Cache Creation Ratio (5min)", + "图片倍率": "Image Ratio", + "音频输入倍率": "Audio Input Ratio", + "音频输出倍率": "Audio Output Ratio", + "默认值:1.0": "Default: 1.0", + "默认值:1.25": "Default: 1.25" +} +``` + +## 测试要点 + +### 1. 数据初始化测试 + +- 测试从空 options 初始化,确保所有字段为空字符串 +- 测试从已有数据初始化,确保正确解析所有倍率值 + +### 2. 编辑功能测试 + +- 测试编辑现有模型,确保高级倍率字段正确回显 +- 测试添加新模型,确保所有字段可以正常输入 +- 测试删除模型,确保相关数据正确清理 + +### 3. 保存功能测试 + +- 测试只填写部分高级倍率字段,确保正确保存 +- 测试所有高级倍率字段都留空,确保保存为空字符串 +- 测试填写数值超出范围,确保验证生效 + +### 4. 默认值测试 + +- 测试留空字段,确保后端使用默认值 +- 测试输入 0,确保保存为数值 0 而不是默认值 + +### 5. 边界情况测试 + +- 测试输入负数,确保被拒绝(min={0}) +- 测试输入非数字字符,确保显示错误提示 +- 测试输入非常多小数位,确保 step={0.01} 生效 + +## 参考实现 + +本设计参考了 `feat/channel-pricing-extended` 分支中的 `ChannelPricingView.jsx` 实现,特别是: + +1. **表单结构**:使用 `Form.Section` 包裹高级比例字段 +2. **字段命名**:使用与后端 Option Key 一致的字段名 +3. **默认值处理**:使用 placeholder 提示默认值,空字符串表示使用默认值 +4. **输入验证**:使用 `Form.InputNumber` 配合 `min` 和 `step` 属性 + +## 实施检查清单 + +- [ ] 修改 `ModelSettingsVisualEditor.jsx`,增加高级比例表单区域 +- [ ] 更新数据初始化逻辑,解析新增的5个 JSON 字段 +- [ ] 更新保存逻辑,序列化并提交新增的5个字段 +- [ ] 更新编辑模型逻辑,正确回显高级倍率字段 +- [ ] 更新添加/更新模型逻辑,处理高级倍率字段 +- [ ] 添加输入验证,确保输入有效数字 +- [ ] 添加国际化文本(中英文) +- [ ] 测试数据初始化、编辑、保存功能 +- [ ] 测试边界情况和默认值处理