Przeglądaj źródła

docs: add extended visual ratio settings design spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
fengsilin 1 tydzień temu
rodzic
commit
b699dda2ad
1 zmienionych plików z 511 dodań i 0 usunięć
  1. +511
    -0
      docs/superpowers/specs/2026-06-04-extended-visual-ratio-settings-design.md

+ 511
- 0
docs/superpowers/specs/2026-06-04-extended-visual-ratio-settings-design.md Wyświetl plik

@@ -0,0 +1,511 @@
# 扩展可视化倍率设置设计文档

> **创建日期**:2026-06-04
> **状态**:待实施
> **相关组件**:ModelSettingsVisualEditor.jsx

## 需求概述

在现有的 `ModelSettingsVisualEditor` 组件中添加高级倍率字段支持,允许用户为每个模型单独设置缓存、图片、音频相关的倍率。

### 当前限制

现有的可视化倍率设置只能设置:
- 模型倍率
- 补全倍率

### 目标

新增以下5个高级倍率字段:
- 缓存读取倍率
- 缓存创建倍率
- 图片倍率
- 音频输入倍率
- 音频输出倍率

## 设计方案

### 界面设计

#### 主表格(保持不变)

表格列结构保持现有的简洁布局:

| 列名 | 说明 |
|------|------|
| 模型名称 | 显示模型名称,如有矛盾显示红色标签 |
| 模型固定价格 | 按次计费的价格输入框 |
| 模型倍率 | 按量计费的模型倍率 |
| 补全倍率 | 按量计费的补全倍率 |
| 操作 | 编辑、删除按钮 |

#### 编辑弹窗(新增"高级比例"区域)

在现有的表单结构中,在"定价模式"和"价格设置方式"区域之后,增加一个新的表单区域:

```jsx
{pricingMode === 'per-token' && (
<>
{/* 现有的价格设置方式区域 */}
<Form.Section text={t('价格设置方式')}>
...
</Form.Section>

{/* 新增:高级比例区域 */}
<Form.Section text={t('高级比例(留空使用默认值)')}>
<Form.InputNumber
field="cacheRatio"
label={t('缓存读取倍率')}
min={0}
step={0.01}
placeholder={t('默认值:1.0')}
/>
<Form.InputNumber
field="cacheCreationRatio"
label={t('缓存创建倍率(5分钟)')}
min={0}
step={0.01}
placeholder={t('默认值:1.25')}
/>
<Form.InputNumber
field="imageRatio"
label={t('图片倍率')}
min={0}
step={0.01}
placeholder={t('默认值:1.0')}
/>
<Form.InputNumber
field="audioRatio"
label={t('音频输入倍率')}
min={0}
step={0.01}
placeholder={t('默认值:1.0')}
/>
<Form.InputNumber
field="audioCompletionRatio"
label={t('音频输出倍率')}
min={0}
step={0.01}
placeholder={t('默认值:1.0')}
/>
</Form.Section>
</>
)}
```

**设计决策**:
- 只在"按量计费"模式下显示高级比例字段
- 按次计费模式不需要这些倍率
- 使用 `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个字段
- [ ] 更新编辑模型逻辑,正确回显高级倍率字段
- [ ] 更新添加/更新模型逻辑,处理高级倍率字段
- [ ] 添加输入验证,确保输入有效数字
- [ ] 添加国际化文本(中英文)
- [ ] 测试数据初始化、编辑、保存功能
- [ ] 测试边界情况和默认值处理

Ładowanie…
Anuluj
Zapisz