docs(pricing): 模型规则重设计(视频/推理/音频/图片自适应 schema + 预览)
- §4.2 model 规则改为模型类型自适应:unit 7 种(per_1K/per_1M/per_1/per_second/per_minute/per_hour/per_char) + tiered 阶梯(分档不累加)+ match 维度(mediaType/thinking/outputAudio/outputResolution/inputLength)+ price 收敛 input/output/cacheHit 与 unitPrice - §5 计算器:token 计算器 + 单位计算器;ChargeUsage 扩展模型维度 - §7 枚举透传 modelType(100推理/200图片/300音频/600视频) - preview HTML 重写模型编辑器为四类自适应 + 示例填充 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
诉求:
|
||||
1. 计价对象**固定枚举**:`工作流` + `模型列表`(model-gateway 系统模型)+ `业务模块`(如 AI客服)。后端提供枚举接口,管理端为这些项配置价格标准。
|
||||
2. 工作流可**同时配置三套价格**(per_item / per_second / per_token),创建/使用工作流时按本次执行**选择一种**扣费方式。
|
||||
3. 模型计价采用 model-gateway 已有的 OpenAI 风格 `PriceConfig`(per_1M,含 match 条件、audio 变体、缓存、折扣),不同模型类型配置内容不同。
|
||||
3. 模型计价按模型类型自适应:视频(输出有声/无声 × 分辨率 × 输入含不含视频 × 按token/按秒)、推理(阶梯 × 思考/非思考 × 输入token区间 × 输入输出价 × 输入含不含音频)、音频(按字数/时长/按token)、图片(按分辨率 × 按张数)。
|
||||
4. 业务模块按**周期**订阅计价(按年),通用 `per_period` 建模,后续可扩月/季。
|
||||
|
||||
目标:模型价格**单一来源**(只配一份,工作流与直接调模型共用);计价对象枚举 = 表结构;计算器注册表统一派发。
|
||||
@@ -27,7 +27,7 @@
|
||||
| subject_type | subject_id | 说明 |
|
||||
|---|---|---|
|
||||
| `workflow` | `workflow` | 固定单主体,规则为三模式容器 |
|
||||
| `model` | model-gateway 模型 **ID** | 每个系统模型一个主体,规则为其 PriceConfig |
|
||||
| `model` | model-gateway 模型 **ID** | 每个系统模型一个主体,规则为其模型类型自适应计费规则(见 §4.2) |
|
||||
| `business` | 业务模块标识(如 `ai_customer_service`) | 固定写死的一组业务模块,规则为周期订阅价格 |
|
||||
|
||||
枚举接口 `GET /pricing/subjects` 返回全部可配置主体:
|
||||
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS pricing_config (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(subject_type, subject_id);
|
||||
```
|
||||
|
||||
- **去掉 `charge_mode` 列**:rules 自描述(workflow 三模式都在 rules 里;model 的 unit 在 PriceConfig 内)。
|
||||
- **去掉 `charge_mode` 列**:rules 自描述(workflow 三模式都在 rules 里;model 的 unit/tiered 在 modelCfg 内)。
|
||||
- 存量迁移:旧 `biz_key` 行 → `subject_type='workflow', subject_id=biz_key`;`charge_mode` 列 DROP。线上无真实数据可直接重建表(本期结算未接)。
|
||||
|
||||
### 3.2 charge_order(改造:biz_key → subject_type/subject_id)
|
||||
@@ -72,8 +72,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_pricing_config_subject ON pricing_config(su
|
||||
-- 唯一幂等键:uk_charge_order_subject (subject_type, subject_id, biz_order_no)
|
||||
subject_type VARCHAR(16) NOT NULL,
|
||||
subject_id VARCHAR(64) NOT NULL,
|
||||
charge_mode VARCHAR(32) NOT NULL, -- 建单时选中/派发键:per_item/per_second/per_token/per_1K/per_1M/per_1/per_period
|
||||
rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段;model: 整个 PriceConfig;business: 周期价格规则
|
||||
charge_mode VARCHAR(32) NOT NULL, -- 建单时选中/派发键:per_item/per_second/per_token/per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period
|
||||
rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段;model: 整个 modelCfg(§4.2);business: 周期价格规则
|
||||
-- 其余字段(user_id/status/actual_amount/usage/settle_time 等)不变
|
||||
```
|
||||
|
||||
@@ -95,26 +95,90 @@ rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段
|
||||
- `per_token` 为 `{}`(仅启用标记),**不含价格映射**——价格取自各模型自己的计价配置(见 §5.3)。
|
||||
- Save 校验:逐个校验已配模式的 JSON 结构(档位/单价/模型名合法、mode 名合法)。
|
||||
|
||||
### 4.2 model —— model-gateway `PriceConfig` 原样
|
||||
### 4.2 model —— 模型类型自适应计费规则
|
||||
|
||||
统一结构(rules 按序**首条命中**,先写优先级高):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": "per_1M",
|
||||
"tiered": false,
|
||||
"rules": [
|
||||
{ "name": "输入[0,32000]", "input": 0.6, "output": 3.6, "cacheHit": 0.12,
|
||||
"inputAudio": 9, "cacheHitAudio": 1.8, "cacheStorageHour": 0.017,
|
||||
"match": { "inputLengthMax": 32000 } },
|
||||
{ "name": "输入(32000,128000]", "input": 0.9, "output": 5.4, "cacheHit": 0.18,
|
||||
"inputAudio": 13.5, "cacheHitAudio": 2.7, "cacheStorageHour": 0.017,
|
||||
"match": { "inputLengthMin": 32001, "inputLengthMax": 128000 } }
|
||||
{ "name": "规则名", "match": {...}, "price": {...} }
|
||||
],
|
||||
"currency": "CNY",
|
||||
"discount": null
|
||||
}
|
||||
```
|
||||
|
||||
- 字段与 model-gateway `entity.PriceConfig / PriceRule / PriceMatch / PriceDiscount` 完全一致。
|
||||
- Save 校验:`unit ∈ {per_1K, per_1M, per_1}`、rules 数组结构、match 字段类型(audio/video/reasoning 不同模型类型内容不同,但结构统一)。
|
||||
**unit 计费单位**(7 种,charge_mode 集合的子集):
|
||||
|
||||
| unit | 语义 | 适用 |
|
||||
|---|---|---|
|
||||
| `per_1M` | 每百万 token | 推理 / 视频按token / 音频按token |
|
||||
| `per_1K` | 每千 token | 同上(小模型) |
|
||||
| `per_1` | 每个(每张图) | 图片按张数 |
|
||||
| `per_second` | 每秒 | 视频按时长 |
|
||||
| `per_minute` | 每分钟 | 音频时长 |
|
||||
| `per_hour` | 每小时 | 音频时长 |
|
||||
| `per_char` | 每字 | 音频按字数 |
|
||||
|
||||
**match 命中条件**(全可选,空=任意调用;命中=全部条件满足):
|
||||
|
||||
| 字段 | 值 | 说明 | 用于 |
|
||||
|---|---|---|---|
|
||||
| `mediaType` | text/audio/video/image | 输入媒体类型 | 视频「输入含/不含视频」、推理「输入含/不含音频」 |
|
||||
| `thinking` | true/false | 思考/非思考模式 | 推理 |
|
||||
| `outputAudio` | true/false | 输出有声/无声 | 视频 |
|
||||
| `outputResolution` | 自由字符串 | 输出分辨率(视频 480p/720p/1080p/2k/4k;图片 512x512/1024x1024) | 视频、图片 |
|
||||
| `inputLengthMin/Max` | int | 输入 token 范围(>=/<=,阶梯分档用) | 推理 |
|
||||
|
||||
**price 计价项**:
|
||||
- token 单位(per_1K/per_1M):`input`(输入单价)、`output`(输出单价)、`cacheHit`(缓存命中输入单价,缺省 0=无优惠)
|
||||
- 非 token 单位(per_1/per_second/per_minute/per_hour/per_char):`unitPrice`(每单位单价)
|
||||
|
||||
**tiered 阶梯(分档不累加)**:仅 token 单位;`tiered:true` 表示按输入 token 分档——档位区间相邻不重叠(下档 max+1=上档 min),结算按 promptTokens 命中哪档就整单按哪档 input/output/cacheHit 价;`tiered:false` 规则间靠其他维度区分。
|
||||
|
||||
> 与 model-gateway `PriceConfig` 的关系:不复用其字段结构(`inputAudio/cacheHitAudio/cacheStorageHour` 删除,音频输入差价改由 `mediaType:"audio"` 命中独立规则表达)。规则级/模型级 `discount` 字段保留(本期模型结算未接,仅建模)。
|
||||
|
||||
**示例**:
|
||||
|
||||
视频 · 按秒(分辨率 × 有声/无声 × 输入含不含视频):
|
||||
```json
|
||||
{
|
||||
"unit": "per_second", "tiered": false,
|
||||
"rules": [
|
||||
{ "name": "无声-720p-输入不含视频", "match": {"outputAudio": false, "outputResolution": "720p", "mediaType": "text"}, "price": {"unitPrice": 0.8} },
|
||||
{ "name": "有声-1080p-输入含视频", "match": {"outputAudio": true, "outputResolution": "1080p", "mediaType": "video"}, "price": {"unitPrice": 1.5} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
推理 · 按token 阶梯(思考/非思考 × 输入token档 × 输入含不含音频):
|
||||
```json
|
||||
{
|
||||
"unit": "per_1M", "tiered": true,
|
||||
"rules": [
|
||||
{ "name": "思考-输入≤32000-文本", "match": {"thinking": true, "inputLengthMax": 32000, "mediaType": "text"}, "price": {"input": 0.6, "output": 3.6, "cacheHit": 0.12} },
|
||||
{ "name": "思考-输入32001~128000-文本", "match": {"thinking": true, "inputLengthMin": 32001, "inputLengthMax": 128000, "mediaType": "text"}, "price": {"input": 0.9, "output": 5.4, "cacheHit": 0.18} },
|
||||
{ "name": "思考-输入含音频", "match": {"thinking": true, "mediaType": "audio"}, "price": {"input": 9, "output": 3.6, "cacheHit": 1.8} },
|
||||
{ "name": "非思考-统一价", "match": {"thinking": false}, "price": {"input": 0.3, "output": 1.2, "cacheHit": 0.06} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
音频 · 按字数;图片 · 按张数×分辨率:
|
||||
```json
|
||||
{ "unit": "per_char", "tiered": false, "rules": [ { "name": "语音-统一价(万字符5元)", "price": {"unitPrice": 0.0005} } ] }
|
||||
|
||||
{ "unit": "per_1", "tiered": false,
|
||||
"rules": [
|
||||
{ "name": "512x512", "match": {"outputResolution": "512x512"}, "price": {"unitPrice": 0.2} },
|
||||
{ "name": "1024x1024", "match": {"outputResolution": "1024x1024"}, "price": {"unitPrice": 0.5} }
|
||||
] }
|
||||
```
|
||||
|
||||
- Save 校验:`unit ∈ {per_1K, per_1M, per_1, per_second, per_minute, per_hour, per_char}`;`tiered:true` 仅 token 单位且 inputLength 档相邻不重叠;match 字段取值合法;price 非负。
|
||||
|
||||
### 4.3 business —— 周期订阅(per_period)
|
||||
|
||||
@@ -133,17 +197,17 @@ rule_snapshot TEXT NOT NULL, -- workflow: 选中模式的规则片段
|
||||
### 5.1 统一 charge_mode 枚举
|
||||
|
||||
```
|
||||
per_item | per_second | per_token | per_1K | per_1M | per_1 | per_period
|
||||
per_item | per_second | per_token | per_1K | per_1M | per_1 | per_minute | per_hour | per_char | per_period
|
||||
```
|
||||
|
||||
### 5.2 新增 per_1K/per_1M/per_1 计算器(一个实现注册三个键)
|
||||
### 5.2 新增 token 计算器(per_1K/per_1M)与单位计算器(per_1/per_second/per_minute/per_hour/per_char)
|
||||
|
||||
完整移植 model-gateway `service/price.go` 的 `CalcCost` 逻辑:
|
||||
- 按 `match` 条件**首条命中**定价规则(mediaType 精确值 + token 区间含输入/输出/总/缓存)
|
||||
- 各价格项按 unit 基准换算求和:`cost = tokens/base * 单价`
|
||||
- **audio 变体**:`inputAudio/cacheHitAudio` 存在时优先(mediaType=audio)
|
||||
- **折扣**:规则级 `discount` 覆盖模型级,命中有效期才生效(effective 为空数组=长期有效)
|
||||
- 结果收敛 6 位小数
|
||||
- **token 计算器**(注册 per_1K、per_1M 两键,base=1000/100 万):
|
||||
`cost = (prompt-cached)/base×input + cached/base×cacheHit + completion/base×output`;match 用 promptTokens 判 inputLength 档、用 usage 的 MediaType/Thinking/OutputAudio/OutputResolution 判维度,**首条命中**。
|
||||
- **单位计算器**(注册 per_1/per_second/per_minute/per_hour/per_char 五键,base=1/1/60/3600/1,用量源=张数/秒/秒/秒/字数):`cost = 用量/base×unitPrice`。
|
||||
- **阶梯=分档不累加**:tiered 只影响 match 语义(按输入 token 档)与校验(档相邻不重叠),无分段累加逻辑。
|
||||
- **无规则命中 → 报错「无匹配计费规则」**(配置缺失建单即暴露,不静默收 0)。
|
||||
- 折扣:规则级/模型级 `discount` 字段保留(本期模型结算未接,仅建模)。
|
||||
|
||||
### 5.3 ChargeUsage 扩展(模型计价入参,本期备用)
|
||||
|
||||
@@ -152,16 +216,22 @@ type ChargeUsage struct {
|
||||
DurationSec float64 `json:"durationSec"`
|
||||
ItemCount int64 `json:"itemCount"`
|
||||
TokensByModel map[string]int64 `json:"tokensByModel"`
|
||||
PromptTokens int64 `json:"promptTokens"` // per_1M 用
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
MediaType string `json:"mediaType"` // audio/no_video/has_video
|
||||
// —— 模型计价扩展 ——
|
||||
PromptTokens int64 `json:"promptTokens"` // token 计费用:输入
|
||||
CompletionTokens int64 `json:"completionTokens"` // token 计费用:输出
|
||||
CachedTokens int64 `json:"cachedTokens"` // token 计费用:缓存命中
|
||||
CharCount int64 `json:"charCount"` // per_char 用
|
||||
ImageCount int64 `json:"imageCount"` // per_1(图片)用
|
||||
MediaType string `json:"mediaType"` // text/audio/video/image(输入媒体)
|
||||
Thinking *bool `json:"thinking"` // 推理 思考/非思考
|
||||
OutputAudio *bool `json:"outputAudio"` // 视频 输出有声/无声
|
||||
OutputResolution string `json:"outputResolution"` // 视频/图片 输出分辨率
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 per_token 语义(工作流结算)
|
||||
|
||||
结算时:`usage.TokensByModel` 逐模型 → 查该模型 subject 的 `PriceConfig` → per_unit 计算器算费 → 累加;**未配价模型按 0 计**(不收费、不报错)。
|
||||
结算时:`usage.TokensByModel` 逐模型 → 查该模型 subject 的 modelCfg(§4.2)→ token 计算器算费 → 累加;**未配价模型按 0 计**(不收费、不报错)。
|
||||
|
||||
> ⚠️ **设计决策**:per_token **费率不冻结在 rule_snapshot**(建单时未知会用哪些模型),结算时按模型 subject **实时价格**算。这是「模型价格单一来源」的代价,与 v0.2「改价不影响在途单」的 rule_snapshot 保证有冲突,仅 per_token 模式例外,其余模式仍走快照。
|
||||
|
||||
@@ -192,7 +262,7 @@ type ChargeUsage struct {
|
||||
|
||||
**OpenOrder 校验**:
|
||||
- workflow:`chargeMode ∈ rules 的 key`;`rule_snapshot = rules[chargeMode]`
|
||||
- model:`charge_mode = config.unit`;`rule_snapshot = 整个 PriceConfig`
|
||||
- model:`charge_mode = config.unit`;`rule_snapshot = 整个 modelCfg(unit/tiered/rules)`
|
||||
- business:`charge_mode = per_period`;`rule_snapshot = 周期规则 JSON`
|
||||
- 门禁 `min_balance` 照旧;建单前校验规则 JSON;幂等返回既有单。
|
||||
|
||||
@@ -201,6 +271,7 @@ type ChargeUsage struct {
|
||||
## 7. 枚举实现(subject_service.go,新)
|
||||
|
||||
- `GET /pricing/subjects`:consul 解析 model-gateway 服务地址 → 调 `GET /listModelManage`(`system_model=true`)→ 合并固定 workflow 项 + 固定业务模块组(`consts/pricing`)。
|
||||
- 模型项返回 `modelId/modelName/modelType`(modelType 透传 model-gateway 枚举编码:100推理/200图片/300音频/600视频),管理端按 modelType 分组渲染对应编辑器。
|
||||
- 每项查 `pricing_config` 是否已存在(hasConfig / enabled)。
|
||||
- model-gateway 服务地址与鉴权放 `config.yml`(复用 `common/http` + consul 解析,同 market_service 调订单服务的模式)。
|
||||
- model-gateway 不可用时:枚举接口**直接报错**(管理端低频接口,失败暴露更清晰,可重试);workflow 项仍为固定返回。
|
||||
@@ -220,12 +291,16 @@ type ChargeUsage struct {
|
||||
| min_balance 门禁照旧 | 非冻结门禁语义不变 |
|
||||
| 业务模块固定写死(AI客服等) | 与「固定枚举」一致;新增业务模块改常量,暂不做管理端增删 |
|
||||
| 周期计费用 per_period 通用建模 | 扩月/季只扩 period 允许集合,不改代码结构 |
|
||||
| 阶梯=分档不累加 | 用户确认;匹配统一 rules 首条命中,tiered 仅约束 inputLength 分档校验 |
|
||||
| match 维度全可选 + 首条命中 | 与 model-gateway 移植逻辑一致,规则即优先级 |
|
||||
| 价格项收敛 input/output/cacheHit + unitPrice | 四类模型一套结构;音频输入差价用 mediaType:"audio" 规则表达(删 inputAudio/cacheHitAudio/cacheStorageHour) |
|
||||
| 输出分辨率自由字符串 | 视频/图片枚举值管理端 UI 下拉提供,DB 不预设 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 遗留(另期)
|
||||
|
||||
- 模型 subject 结算接入:ai-agent 上报模型用量(PromptTokens/CompletionTokens/CachedTokens/MediaType)→ open_order/settle。
|
||||
- 模型 subject 结算接入:ai-agent 上报模型用量(PromptTokens/CompletionTokens/CachedTokens/CharCount/ImageCount/MediaType/Thinking/OutputAudio/OutputResolution)→ open_order/settle。
|
||||
- 业务模块订阅购买流程接入(按周期下单/扣费/续费)。
|
||||
- model-gateway 停扣(切换时机沿用 v0.2 §8.2)。
|
||||
- 工作流侧「选择本次扣费方式」的 UI/配置项(ai-agent 侧,本期不动)。
|
||||
@@ -234,14 +309,14 @@ type ChargeUsage struct {
|
||||
|
||||
## 10. 改动清单
|
||||
|
||||
1. `consts/pricing/charge_mode.go`:加 `per_1K/per_1M/per_1/per_period`(含 period 允许集合 `{year}`)
|
||||
1. `consts/pricing/charge_mode.go`:加 `per_1K/per_1M/per_1/per_minute/per_hour/per_char/per_period`(含 period 允许集合 `{year}`)
|
||||
2. `consts/pricing/subject.go`(新):subject_type 常量 workflow/model/business + 业务模块固定列表(AI客服 → `ai_customer_service`)
|
||||
3. `model/entity/pricing/pricing_config.go`:biz_key/charge_mode → subject_type/subject_id
|
||||
4. `model/dto/pricing/config_dto.go`:Save/Get/List/Info 换 subject 字段
|
||||
5. `model/dto/pricing/charge_dto.go`:OpenOrderReq 加 subjectType/subjectId/chargeMode;ChargeOrderInfo 的 bizKey → subjectType/subjectId;DAO 入参同步
|
||||
6. `dao/pricing/pricing_config_dao.go`:Get/Save/List 按 (subject_type, subject_id)
|
||||
7. `dao/pricing/charge_order_dao.go`:幂等键改 (subject_type, subject_id, biz_order_no)
|
||||
8. `service/pricing/charge_calc.go`:per_unit 计算器 + per_period 计算器 + ChargeUsage 扩展 + per_token 语义改模型价
|
||||
8. `service/pricing/charge_calc.go`:token 计算器(per_1K/per_1M)+ 单位计算器(per_1/per_second/per_minute/per_hour/per_char)+ per_period 计算器 + ChargeUsage 扩展(模型维度)+ per_token 语义改模型价
|
||||
9. `service/pricing/pricing_service.go`:OpenOrder 校验 chargeMode/unit/period;Save 按类型校验 rules;settleOrder 派发
|
||||
10. `service/pricing/subject_service.go`(新):枚举接口(workflow + 实时模型 + 固定业务模块)
|
||||
11. `controller/pricing/pricing_controller.go`:加 Subjects
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
.subj .type-badge{font-size:11px;border-radius:3px;padding:1px 6px;color:#fff;flex-shrink:0}
|
||||
.subj .conf-badge{font-size:11px;border-radius:3px;padding:1px 6px;flex-shrink:0}
|
||||
.conf-yes{background:#f0f9eb;color:var(--success)} .conf-no{background:#fef0f0;color:var(--danger)}
|
||||
.t-reason{background:#e6a23c}.t-chat{background:#409eff}.t-audio{background:#67c23a}.t-video{background:#909399}.t-workflow{background:#8e44ad}.t-business{background:#e74c3c}
|
||||
.t-reason{background:#e6a23c}.t-image{background:#00bcd4}.t-audio{background:#67c23a}.t-video{background:#909399}.t-workflow{background:#8e44ad}.t-business{background:#e74c3c}
|
||||
.mode-chip{font-size:11px;background:#f4f4f5;color:var(--text2);border-radius:3px;padding:1px 5px}
|
||||
/* 右侧编辑区 */
|
||||
.editor{flex:1;min-width:0;overflow:hidden}
|
||||
@@ -67,6 +67,7 @@
|
||||
.slider:before{position:absolute;content:"";height:16px;width:16px;left:2px;bottom:2px;background:#fff;transition:.2s;border-radius:50%}
|
||||
input:checked+.slider{background:var(--primary)}
|
||||
input:checked+.slider:before{transform:translateX(20px)}
|
||||
input:disabled+.slider{background:#e4e7ed;cursor:not-allowed}
|
||||
.json{width:100%;min-height:320px;font-family:Consolas,Menlo,monospace;font-size:12.5px;border:1px solid var(--border);border-radius:6px;padding:12px;background:#1e1e1e;color:#d4d4d4;white-space:pre;overflow:auto;line-height:1.55}
|
||||
.json .p-key{color:#9cdcfe}.json .p-str{color:#ce9178}.json .p-num{color:#b5cea8}.json .p-bool{color:#569cd6}.json .p-null{color:#6a9955}
|
||||
.note{border:1px solid #e6a23c;background:#fdf6ec;color:#b88230;border-radius:6px;padding:10px 14px;font-size:12.5px;line-height:1.7;margin-bottom:16px}
|
||||
@@ -79,7 +80,11 @@
|
||||
.rule-card .rc-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.rule-card .rc-head input{flex:1}
|
||||
.rc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
||||
.rc-sec{font-size:12px;color:var(--text3);margin:10px 0 6px;display:flex;align-items:center;gap:6px}
|
||||
.rc-sec::before{content:"";width:3px;height:12px;background:#e6a23c;border-radius:2px}
|
||||
.rc-sec.price::before{background:var(--primary)}
|
||||
.money-note{font-size:12px;color:var(--text3);margin-bottom:8px}
|
||||
.field-pill{font-size:11px;color:var(--text2);background:#f0f2f5;border-radius:3px;padding:1px 5px;margin-right:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -180,22 +185,30 @@
|
||||
<div class="e-body" id="ed-model" style="display:none">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="c-head">PriceConfig 配置
|
||||
<span class="st"><select id="m-example" onchange="loadExample()">
|
||||
<option value="">选择示例填充</option>
|
||||
<option value="audio">音频模型示例</option>
|
||||
<option value="video">视频模型示例</option>
|
||||
<option value="reason">推理模型示例</option>
|
||||
</select></span>
|
||||
<div class="c-head">模型计费规则(按模型类型自适应)
|
||||
<span class="st"><select id="m-example" onchange="loadExample()"><option value="">选择示例填充</option></select></span>
|
||||
</div>
|
||||
<div class="c-body">
|
||||
<div class="f-row">
|
||||
<div class="f"><label>计价基准 unit</label><select id="m-unit" onchange="renderModel()"><option value="per_1M" selected>per_1M(百万token)</option><option value="per_1K">per_1K(千token)</option><option value="per_1">per_1(单条)</option></select></div>
|
||||
<div class="f"><label>计费单位 unit</label>
|
||||
<select id="m-unit" onchange="onUnitChange()">
|
||||
<option value="per_1M">per_1M(每百万token)</option>
|
||||
<option value="per_1K">per_1K(每千token)</option>
|
||||
<option value="per_1">per_1(每个/每张图)</option>
|
||||
<option value="per_second">per_second(每秒)</option>
|
||||
<option value="per_minute">per_minute(每分钟)</option>
|
||||
<option value="per_hour">per_hour(每小时)</option>
|
||||
<option value="per_char">per_char(每字)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="f"><label>阶梯分档 tiered</label>
|
||||
<label class="switch"><input type="checkbox" id="m-tiered" disabled onchange="renderModel()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="f"><label>币种</label><select id="m-currency" onchange="renderModel()"><option value="CNY" selected>CNY</option><option value="USD">USD</option></select></div>
|
||||
<div class="f"><label>启用</label><label class="switch"><input type="checkbox" id="m-enabled" checked onchange="renderModel()"><span class="slider"></span></label></div>
|
||||
</div>
|
||||
<div class="f-row"><div class="f"><label>模型级折扣 rate(如 0.4=4折,空=无)</label><input class="num" id="m-discount" type="number" step="0.01" oninput="renderModel()"></div></div>
|
||||
<div class="money-note">价格单位:元 / 基准单位(per_1M 即每百万 token)。不同模型类型配置内容不同——音频/视频/推理见示例。</div>
|
||||
<div class="hint" id="m-tiered-hint">阶梯=按输入 token 分档,<b>命中哪档整单按哪档价(不累加)</b>。档位区间相邻不重叠(下档 max+1 = 上档 min)。仅 token 单位(per_1K/per_1M)可用。</div>
|
||||
<div class="money-note" id="m-price-note" style="margin-top:8px"></div>
|
||||
<div id="m-rules"></div>
|
||||
<button class="btn mini" onclick="addModelRule()">+ 加规则</button>
|
||||
</div>
|
||||
@@ -210,7 +223,7 @@
|
||||
</div></div>
|
||||
<div class="section-title">rules JSON(实时预览)</div>
|
||||
<textarea class="json" id="m-json" readonly spellcheck="false"></textarea>
|
||||
<div class="note" style="margin-top:12px">结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, mediaType}</code>。mediaType = audio / no_video / has_video 由请求体参考媒体字段推导。</div>
|
||||
<div class="note" style="margin-top:12px">结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>。匹配 = 全部条件满足的<b>首条规则</b>命中,无命中报错。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,7 +267,7 @@
|
||||
<div class="col" style="flex:1;min-width:0">
|
||||
<div class="section-title">GET /pricing/subjects · 枚举响应</div>
|
||||
<textarea class="json" id="api-json" readonly spellcheck="false"></textarea>
|
||||
<div class="hint" style="margin-top:8px">模型部分实时调 model-gateway <code>/listModelManage</code>(system_model=true);model-gateway 不可用时接口报错。</div>
|
||||
<div class="hint" style="margin-top:8px">模型部分实时调 model-gateway <code>/listModelManage</code>(system_model=true);modelType 透传 model-gateway 枚举编码(100推理/200图片/300音频/600视频);model-gateway 不可用时接口报错。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,42 +279,62 @@
|
||||
/* ================= 主体数据(模拟枚举) ================= */
|
||||
const SUBJECTS = [
|
||||
{type:'workflow', id:'workflow', name:'工作流', modes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
|
||||
{type:'model', id:'101', name:'deepseek-v3', modelType:'reason', typeLabel:'推理', conf:true},
|
||||
{type:'model', id:'102', name:'gpt-4o', modelType:'chat', typeLabel:'对话', conf:true},
|
||||
{type:'model', id:'103', name:'语音合成·统一', modelType:'audio', typeLabel:'音频', conf:false},
|
||||
{type:'model', id:'104', name:'视频理解·统一', modelType:'video', typeLabel:'视频', conf:false},
|
||||
{type:'model', id:'101', name:'deepseek-v3(推理)', modelType:'reason', typeLabel:'推理', conf:true},
|
||||
{type:'model', id:'102', name:'文生视频·配音', modelType:'video', typeLabel:'视频', conf:false},
|
||||
{type:'model', id:'103', name:'语音合成', modelType:'audio', typeLabel:'音频', conf:false},
|
||||
{type:'model', id:'104', name:'文生图', modelType:'image', typeLabel:'图片', conf:false},
|
||||
{type:'business', id:'ai_customer_service', name:'AI客服', typeLabel:'业务', conf:false},
|
||||
];
|
||||
|
||||
/* ================= 模型示例(对应 design §4.2) ================= */
|
||||
/* ================= 四类模型示例(对应新设计 §4.2) ================= */
|
||||
const EXAMPLES = {
|
||||
audio:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
|
||||
rules:[{name:'音频-统一价格(万字符5元)', input:500, output:500, cacheHit:0, inputAudio:500, cacheHitAudio:0, cacheStorageHour:0, match:''}]},
|
||||
video:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
|
||||
rules:[
|
||||
{name:'输入不含视频', input:23, output:23, cacheHit:0, inputAudio:0, cacheHitAudio:0, cacheStorageHour:0, match:'{"mediaType":"no_video"}'},
|
||||
{name:'输入包含视频', input:14, output:14, cacheHit:0, inputAudio:0, cacheHitAudio:0, cacheStorageHour:0, match:'{"mediaType":"has_video"}'},
|
||||
video:[
|
||||
{label:'视频·按秒(分辨率×有声/无声×输入媒体)', unit:'per_second', tiered:false, rules:[
|
||||
{name:'无声-720p-输入不含视频', mediaType:'text', outputAudio:false, outputResolution:'720p', unitPrice:0.8},
|
||||
{name:'有声-1080p-输入含视频', mediaType:'video', outputAudio:true, outputResolution:'1080p', unitPrice:1.5},
|
||||
]},
|
||||
reason:{unit:'per_1M',currency:'CNY',enabled:1,minBalance:0,
|
||||
rules:[
|
||||
{name:'输入[0,32000]', input:0.6, output:3.6, cacheHit:0.12, inputAudio:9, cacheHitAudio:1.8, cacheStorageHour:0.017, match:'{"inputLengthMax":32000}'},
|
||||
{name:'输入(32000,128000]', input:0.9, output:5.4, cacheHit:0.18, inputAudio:13.5, cacheHitAudio:2.7, cacheStorageHour:0.017, match:'{"inputLengthMin":32001,"inputLengthMax":128000}'},
|
||||
{name:'输入(128000,256000]', input:1.8, output:10.8, cacheHit:0.36, inputAudio:27, cacheHitAudio:5.4, cacheStorageHour:0.017, match:'{"inputLengthMin":128001,"inputLengthMax":256000}'},
|
||||
{label:'视频·按token(百万)', unit:'per_1M', tiered:false, rules:[
|
||||
{name:'文本输入-无声-1080p', mediaType:'text', outputAudio:false, outputResolution:'1080p', input:0.6, output:3.6, cacheHit:0.12},
|
||||
{name:'视频输入-有声-1080p', mediaType:'video', outputAudio:true, outputResolution:'1080p', input:9, output:27, cacheHit:1.8},
|
||||
]},
|
||||
],
|
||||
reason:[
|
||||
{label:'推理·阶梯(思考×输入token档×输入媒体)', unit:'per_1M', tiered:true, rules:[
|
||||
{name:'思考-输入≤32000-文本', mediaType:'text', thinking:true, inLenMax:32000, input:0.6, output:3.6, cacheHit:0.12},
|
||||
{name:'思考-输入32001~128000-文本', mediaType:'text', thinking:true, inLenMin:32001, inLenMax:128000, input:0.9, output:5.4, cacheHit:0.18},
|
||||
{name:'思考-输入含音频', mediaType:'audio', thinking:true, input:9, output:3.6, cacheHit:1.8},
|
||||
{name:'非思考-统一价', thinking:false, input:0.3, output:1.2, cacheHit:0.06},
|
||||
]},
|
||||
],
|
||||
audio:[
|
||||
{label:'音频·按字数', unit:'per_char', tiered:false, rules:[
|
||||
{name:'语音-统一价(万字符5元)', unitPrice:0.0005},
|
||||
]},
|
||||
{label:'音频·按分钟', unit:'per_minute', tiered:false, rules:[
|
||||
{name:'语音-统一价', unitPrice:2},
|
||||
]},
|
||||
{label:'音频·按token(百万)', unit:'per_1M', tiered:false, rules:[
|
||||
{name:'统一价', input:500, output:500, cacheHit:0},
|
||||
]},
|
||||
],
|
||||
image:[
|
||||
{label:'图片·按张数×分辨率', unit:'per_1', tiered:false, rules:[
|
||||
{name:'512x512', outputResolution:'512x512', unitPrice:0.2},
|
||||
{name:'1024x1024', outputResolution:'1024x1024', unitPrice:0.5},
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
/* ================= 状态 ================= */
|
||||
let current = 'workflow';
|
||||
let currentModelType = '';
|
||||
let modelRules = [];
|
||||
|
||||
/* ================= 渲染左侧枚举 ================= */
|
||||
function renderSubjects(){
|
||||
const wf = SUBJECTS.find(s=>s.type==='workflow');
|
||||
document.getElementById('subj-workflow').innerHTML = subjectHTML(wf);
|
||||
const models = SUBJECTS.filter(s=>s.type==='model');
|
||||
document.getElementById('subj-models').innerHTML = models.map(subjectHTML).join('');
|
||||
const biz = SUBJECTS.filter(s=>s.type==='business');
|
||||
document.getElementById('subj-business').innerHTML = biz.map(subjectHTML).join('');
|
||||
document.getElementById('subj-workflow').innerHTML = subjectHTML(SUBJECTS.find(s=>s.type==='workflow'));
|
||||
document.getElementById('subj-models').innerHTML = SUBJECTS.filter(s=>s.type==='model').map(subjectHTML).join('');
|
||||
document.getElementById('subj-business').innerHTML = SUBJECTS.filter(s=>s.type==='business').map(subjectHTML).join('');
|
||||
}
|
||||
function subjectHTML(s){
|
||||
const conf = s.type==='workflow' ? s.hasConfig : s.conf;
|
||||
@@ -322,21 +355,18 @@ function subjectHTML(s){
|
||||
function selectSubject(type, id){
|
||||
current = type+':'+id;
|
||||
renderSubjects();
|
||||
const panels = ['ed-workflow','ed-model','ed-business','ed-api'];
|
||||
panels.forEach(p=>document.getElementById(p).style.display='none');
|
||||
['ed-workflow','ed-model','ed-business','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
|
||||
if(type==='workflow'){
|
||||
document.getElementById('ed-workflow').style.display='flex';
|
||||
document.getElementById('ed-title').textContent='工作流';
|
||||
document.getElementById('ed-sub').textContent='subjectType=workflow · subjectId=workflow';
|
||||
loadWorkflowState();
|
||||
renderWorkflowTiers(); renderWorkflow();
|
||||
} else if(type==='model'){
|
||||
document.getElementById('ed-model').style.display='flex';
|
||||
const s = SUBJECTS.find(x=>x.type==='model'&&x.id===id);
|
||||
document.getElementById('ed-title').textContent=s.name;
|
||||
document.getElementById('ed-sub').textContent=`subjectType=model · subjectId=${s.id}(${s.typeLabel})`;
|
||||
const ex = EXAMPLES[s.modelType];
|
||||
if(ex && !document.getElementById('m-example').value){ loadState(ex); }
|
||||
else if(!modelRules.length){ modelRules=[emptyRule()]; renderModel(); }
|
||||
document.getElementById('ed-sub').textContent=`subjectType=model · subjectId=${s.id}(${s.typeLabel},modelType=${s.modelType})`;
|
||||
selectModel(s);
|
||||
} else if(type==='business'){
|
||||
document.getElementById('ed-business').style.display='flex';
|
||||
const s = SUBJECTS.find(x=>x.type==='business'&&x.id===id);
|
||||
@@ -345,6 +375,120 @@ function selectSubject(type, id){
|
||||
renderBusiness();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 模型编辑器 ================= */
|
||||
function selectModel(s){
|
||||
currentModelType = s.modelType;
|
||||
const sel = document.getElementById('m-example');
|
||||
sel.innerHTML = `<option value="">选择示例填充</option>` +
|
||||
EXAMPLES[s.modelType].map((e,i)=>`<option value="${i}">${e.label}</option>`).join('');
|
||||
// 切模型自动加载该类型第一个示例
|
||||
sel.value = '0';
|
||||
loadExample();
|
||||
}
|
||||
function emptyRule(){
|
||||
return {name:'', mediaType:'', thinking:'', outputAudio:'', outputResolution:'', inLenMin:'', inLenMax:'', input:0, output:0, cacheHit:0, unitPrice:0};
|
||||
}
|
||||
function addModelRule(){ modelRules.push(emptyRule()); renderModel(); }
|
||||
function loadExample(){
|
||||
const i = document.getElementById('m-example').value;
|
||||
if(i===''){ modelRules=[emptyRule()]; renderModel(); return; }
|
||||
const ex = EXAMPLES[currentModelType][+i];
|
||||
document.getElementById('m-unit').value = ex.unit;
|
||||
document.getElementById('m-tiered').checked = !!ex.tiered;
|
||||
document.getElementById('m-currency').value = ex.currency||'CNY';
|
||||
document.getElementById('m-minbalance').value = ex.minBalance||0;
|
||||
modelRules = ex.rules.map(r=>({name:r.name, mediaType:r.mediaType||'', thinking:r.thinking==null?'':String(r.thinking),
|
||||
outputAudio:r.outputAudio==null?'':String(r.outputAudio), outputResolution:r.outputResolution||'',
|
||||
inLenMin:r.inLenMin==null?'':String(r.inLenMin), inLenMax:r.inLenMax==null?'':String(r.inLenMax),
|
||||
input:r.input==null?0:r.input, output:r.output==null?0:r.output, cacheHit:r.cacheHit==null?0:r.cacheHit,
|
||||
unitPrice:r.unitPrice==null?0:r.unitPrice}));
|
||||
onUnitChange(); renderModel();
|
||||
}
|
||||
function onUnitChange(){
|
||||
const unit = document.getElementById('m-unit').value;
|
||||
const token = unit==='per_1K'||unit==='per_1M';
|
||||
document.getElementById('m-tiered').disabled = !token;
|
||||
if(!token){ document.getElementById('m-tiered').checked = false; }
|
||||
document.getElementById('m-price-note').innerHTML = token
|
||||
? 'token 单位价格按 <b>元/基准</b>(per_1M=每百万token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
|
||||
: '非 token 单位价格按 <b>元/单位</b> 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。';
|
||||
renderModel();
|
||||
}
|
||||
function fsel(key, i, opts, labels){
|
||||
const o = opts.map((v,j)=>`<option value="${v}" ${modelRules[i][key]===v?'selected':''}>${labels[j]}</option>`).join('');
|
||||
return `<div class="f"><label>${key}</label><select onchange="modelRules[${i}].${key}=this.value;renderModel()">${o}</select></div>`;
|
||||
}
|
||||
function ftext(key, i, ph){
|
||||
return `<div class="f"><label>${key}</label><input value="${esc(modelRules[i][key])}" placeholder="${ph}" oninput="modelRules[${i}].${key}=this.value;renderModel()"></div>`;
|
||||
}
|
||||
function fnum(key, i, label, step){
|
||||
return `<div class="f"><label>${label}</label><input class="num" type="number" step="${step||'0.01'}" value="${modelRules[i][key]}" oninput="modelRules[${i}].${key}=+this.value;renderModel()"></div>`;
|
||||
}
|
||||
function renderModel(){
|
||||
const box = document.getElementById('m-rules');
|
||||
const unit = document.getElementById('m-unit').value;
|
||||
const token = unit==='per_1K'||unit==='per_1M';
|
||||
box.innerHTML = modelRules.map((r,i)=>{
|
||||
const mt = currentModelType;
|
||||
let match = '';
|
||||
match += `<div class="rc-sec">命中条件 match</div><div class="rc-grid">`;
|
||||
match += fsel('mediaType', i, ['','text','audio','video','image'], ['不限制','文本 text','音频 audio','视频 video','图片 image']);
|
||||
if(mt==='reason'){
|
||||
match += fsel('thinking', i, ['','true','false'], ['不限制','思考模式','非思考模式']);
|
||||
match += fnum('inLenMin', i, '输入token下界', 1);
|
||||
match += fnum('inLenMax', i, '输入token上界', 1);
|
||||
}
|
||||
if(mt==='video'){
|
||||
match += fsel('outputAudio', i, ['','true','false'], ['不限制','有声','无声']);
|
||||
match += ftext('outputResolution', i, '480p/720p/1080p/2k/4k');
|
||||
}
|
||||
if(mt==='image'){
|
||||
match += ftext('outputResolution', i, '512x512/1024x1024/2048x2048');
|
||||
}
|
||||
match += `</div>`;
|
||||
const price = token
|
||||
? fnum('input', i, '输入单价 input') + fnum('output', i, '输出单价 output') + fnum('cacheHit', i, '缓存命中 cacheHit')
|
||||
: fnum('unitPrice', i, '每单位单价 unitPrice');
|
||||
return `<div class="rule-card">
|
||||
<div class="rc-head">
|
||||
<span style="color:var(--text3);font-size:12px">规则 #${i+1}</span>
|
||||
<input value="${esc(r.name)}" placeholder="规则名(如:有声-1080p-输入含视频)" oninput="modelRules[${i}].name=this.value;renderModel()">
|
||||
<button class="btn mini danger" onclick="modelRules.splice(${i},1);renderModel()">删</button>
|
||||
</div>
|
||||
${match}
|
||||
<div class="rc-sec price">计价项 price</div>
|
||||
<div class="rc-grid">${price}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const cfg = buildModelCfg();
|
||||
document.getElementById('m-json').value = JSON.stringify(cfg,null,2);
|
||||
}
|
||||
function buildModelCfg(){
|
||||
const unit = document.getElementById('m-unit').value;
|
||||
const token = unit==='per_1K'||unit==='per_1M';
|
||||
const tiered = document.getElementById('m-tiered').checked;
|
||||
const rules = modelRules.filter(r=>r.name||Object.values(r).slice(1).some(v=>v!==''&&v!==0))
|
||||
.map(r=>{
|
||||
const match = {};
|
||||
if(r.mediaType) match.mediaType = r.mediaType;
|
||||
if(r.thinking!=='') match.thinking = r.thinking==='true';
|
||||
if(r.outputAudio!=='') match.outputAudio = r.outputAudio==='true';
|
||||
if(r.outputResolution) match.outputResolution = r.outputResolution;
|
||||
if(r.inLenMin!=='') match.inputLengthMin = +r.inLenMin;
|
||||
if(r.inLenMax!=='') match.inputLengthMax = +r.inLenMax;
|
||||
const price = token ? {input:+r.input, output:+r.output, cacheHit:+r.cacheHit} : {unitPrice:+r.unitPrice};
|
||||
return {name:r.name, ...(Object.keys(match).length?{match}:{}), price};
|
||||
});
|
||||
const modelCfg = { unit, tiered, rules,
|
||||
currency:document.getElementById('m-currency').value,
|
||||
discount:null };
|
||||
return { subjectType:'model', subjectId:current.split(':')[1], rules:modelCfg,
|
||||
minBalance:+document.getElementById('m-minbalance').value,
|
||||
enabled:document.getElementById('m-enabled').checked?1:0 };
|
||||
}
|
||||
|
||||
/* ================= 业务模块 ================= */
|
||||
function renderBusiness(){
|
||||
const cfg = { subjectType:'business', subjectId:current.split(':')[1],
|
||||
rules:{ period:document.getElementById('b-period').value, price:+document.getElementById('b-price').value },
|
||||
@@ -353,17 +497,18 @@ function renderBusiness(){
|
||||
enabled:document.getElementById('b-enabled').checked?1:0 };
|
||||
document.getElementById('b-json').value = JSON.stringify(cfg,null,2);
|
||||
}
|
||||
function selectSubjectRaw(){}
|
||||
|
||||
/* ================= 枚举响应示例 ================= */
|
||||
function loadApiExample(){
|
||||
['ed-workflow','ed-model','ed-api'].forEach(p=>document.getElementById(p).style.display='none');
|
||||
document.getElementById('ed-api').style.display='flex';
|
||||
document.getElementById('ed-title').textContent='枚举响应示例';
|
||||
document.getElementById('ed-sub').textContent='GET /pricing/subjects';
|
||||
const wf = SUBJECTS.find(s=>s.type==='workflow');
|
||||
const mt = {'reason':100,'image':200,'audio':300,'video':600};
|
||||
const api = {
|
||||
subjects:[
|
||||
{subjectType:'workflow', subjectId:'workflow', name:'工作流', chargeModes:['per_item','per_second','per_token'], hasConfig:true, enabled:1},
|
||||
...SUBJECTS.filter(s=>s.type==='model').map(s=>({subjectType:'model', subjectId:s.id, name:s.name, modelType:s.modelType, hasConfig:s.conf, enabled:1})),
|
||||
...SUBJECTS.filter(s=>s.type==='model').map(s=>({subjectType:'model', subjectId:s.id, name:s.name, modelType:s.modelType, modelTypeCode:mt[s.modelType], hasConfig:s.conf, enabled:1})),
|
||||
...SUBJECTS.filter(s=>s.type==='business').map(s=>({subjectType:'business', subjectId:s.id, name:s.name, chargeModes:['per_period'], hasConfig:s.conf, enabled:1}))
|
||||
]
|
||||
};
|
||||
@@ -380,7 +525,6 @@ function renderWorkflowTiers(){
|
||||
<td><button class="btn mini danger" onclick="tiers.splice(${i},1);renderWorkflowTiers();renderWorkflow()">删</button></td></tr>`).join('');
|
||||
}
|
||||
function addTier(){ tiers.push({maxSec:60,price:89}); renderWorkflowTiers(); renderWorkflow(); }
|
||||
function loadWorkflowState(){ renderWorkflowTiers(); renderWorkflow(); }
|
||||
function renderWorkflow(){
|
||||
const rules = {};
|
||||
if(document.getElementById('w-item-on').checked){
|
||||
@@ -399,57 +543,6 @@ function renderWorkflow(){
|
||||
document.getElementById('wf-json').value = JSON.stringify(cfg,null,2);
|
||||
}
|
||||
|
||||
/* ================= 模型编辑 ================= */
|
||||
function emptyRule(){ return {name:'',input:0,output:0,cacheHit:0,inputAudio:0,cacheHitAudio:0,cacheStorageHour:0,match:''}; }
|
||||
function addModelRule(){ modelRules.push(emptyRule()); renderModel(); }
|
||||
function loadExample(){
|
||||
const v=document.getElementById('m-example').value;
|
||||
if(!v) return;
|
||||
loadState(JSON.parse(JSON.stringify(EXAMPLES[v])));
|
||||
}
|
||||
function loadState(ex){
|
||||
document.getElementById('m-unit').value=ex.unit||'per_1M';
|
||||
document.getElementById('m-currency').value=ex.currency||'CNY';
|
||||
document.getElementById('m-enabled').checked = ex.enabled!==0;
|
||||
document.getElementById('m-minbalance').value = ex.minBalance||0;
|
||||
document.getElementById('m-discount').value = ex.discount ? ex.discount.rate : '';
|
||||
modelRules = ex.rules && ex.rules.length ? JSON.parse(JSON.stringify(ex.rules)) : [emptyRule()];
|
||||
renderModel();
|
||||
}
|
||||
function renderModel(){
|
||||
const box=document.getElementById('m-rules');
|
||||
box.innerHTML = modelRules.map((r,i)=>`<div class="rule-card">
|
||||
<div class="rc-head">
|
||||
<span style="color:var(--text3);font-size:12px">规则 #${i+1}</span>
|
||||
<input value="${esc(r.name)}" placeholder="规则名(如:输入[0,32000])" oninput="modelRules[${i}].name=this.value;renderModel()">
|
||||
<button class="btn mini danger" onclick="modelRules.splice(${i},1);renderModel()">删</button>
|
||||
</div>
|
||||
<div class="rc-grid">
|
||||
${num('input','输入单价 input',i,'元/基准')}
|
||||
${num('output','输出单价 output',i,'元/基准')}
|
||||
${num('cacheHit','缓存命中 cacheHit',i,'元/基准')}
|
||||
${num('inputAudio','音频输入 inputAudio',i,'元/基准')}
|
||||
${num('cacheHitAudio','音频缓存 cacheHitAudio',i,'元/基准')}
|
||||
${num('cacheStorageHour','缓存存储 cacheStorageHour',i,'元/小时')}
|
||||
</div>
|
||||
<div class="f" style="margin-top:8px"><label>命中条件 match(JSON,空=无条件命中)</label>
|
||||
<input value="${esc(r.match)}" placeholder='{"inputLengthMax":32000} 或 {"mediaType":"has_video"}' oninput="modelRules[${i}].match=this.value;renderModel()"></div>
|
||||
</div>`).join('');
|
||||
const rules = modelRules.filter(r=>r.name||r.input||r.output||r.cacheHit||r.inputAudio||r.cacheHitAudio||r.cacheStorageHour||r.match)
|
||||
.map(r=>({name:r.name, input:+r.input, output:+r.output, cacheHit:+r.cacheHit,
|
||||
inputAudio:+r.inputAudio, cacheHitAudio:+r.cacheHitAudio, cacheStorageHour:+r.cacheStorageHour,
|
||||
...(r.match?{match:JSON.parse(r.match||'{}')}:{})}));
|
||||
const unit = document.getElementById('m-unit').value;
|
||||
const dis = document.getElementById('m-discount').value;
|
||||
const cfg = { unit, rules, currency:document.getElementById('m-currency').value,
|
||||
...(dis!==''?{discount:{rate:+dis}}:{discount:null}) };
|
||||
document.getElementById('m-json').value = JSON.stringify({subjectType:'model', subjectId:current.split(':')[1], rules:cfg, minBalance:+document.getElementById('m-minbalance').value, enabled:document.getElementById('m-enabled').checked?1:0},null,2);
|
||||
}
|
||||
function num(key,label,i,unit){
|
||||
return `<div class="f"><label>${label}</label><input type="number" step="0.01" value="${modelRules[i][key]}" oninput="modelRules[${i}].${key}=+this.value;renderModel()"></div>`;
|
||||
}
|
||||
function esc(s){ return (s||'').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
|
||||
/* ================= 保存(mock) ================= */
|
||||
function saveMock(){
|
||||
const t=document.getElementById('toast');
|
||||
@@ -457,13 +550,12 @@ function saveMock(){
|
||||
t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800);
|
||||
}
|
||||
|
||||
/* ================= 工具 ================= */
|
||||
function esc(s){ return (s||'').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
|
||||
/* ================= 初始化 ================= */
|
||||
renderSubjects();
|
||||
selectSubject('workflow','workflow');
|
||||
document.getElementById('w-item-on').checked=true;
|
||||
document.getElementById('w-sec-on').checked=true;
|
||||
document.getElementById('w-tok-on').checked=true;
|
||||
renderWorkflow();
|
||||
selectSubject('model','102'); // 默认打开视频模型,展示多维匹配
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user