You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

16 KiB

扩展可视化倍率设置设计文档

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

需求概述

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

当前限制

现有的可视化倍率设置只能设置:

  • 模型倍率
  • 补全倍率

目标

新增以下5个高级倍率字段:

  • 缓存读取倍率
  • 缓存创建倍率
  • 图片倍率
  • 音频输入倍率
  • 音频输出倍率

设计方案

界面设计

主表格(保持不变)

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

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

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

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

{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个字段:

{
  name: 'gpt-4',
  price: '',
  ratio: '',
  completionRatio: '',
  cacheRatio: '',           // 新增
  cacheCreationRatio: '',   // 新增
  imageRatio: '',           // 新增
  audioRatio: '',           // 新增
  audioCompletionRatio: '', // 新增
  hasConflict: false
}

数据初始化

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 字段:

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. 编辑模型

编辑现有模型时,需要在弹窗中正确回显高级倍率字段的值:

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 函数中处理高级倍率字段:

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 函数中增加对高级倍率字段的验证:

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 组件
  • 保持相同的 labelplaceholder 样式
  • 表单区域使用 Form.Section 包裹,标题为"高级比例(留空使用默认值)”

5. 国际化支持

需要为新增的文本添加翻译:

中文 (zh-CN.json)

{
  "高级比例(留空使用默认值)": "高级比例(留空使用默认值)",
  "缓存读取倍率": "缓存读取倍率",
  "缓存创建倍率(5分钟)": "缓存创建倍率(5分钟)",
  "图片倍率": "图片倍率",
  "音频输入倍率": "音频输入倍率",
  "音频输出倍率": "音频输出倍率",
  "默认值:1.0": "默认值:1.0",
  "默认值:1.25": "默认值:1.25"
}

英文 (en.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 配合 minstep 属性

实施检查清单