feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
(实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
**不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
This commit is contained in:
1 parent
8890c30bb5
commit
1ac02bd77e
12 files changed
+998
-85
No files matched your search
@@ -0,0 +1,67 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// LLM provider 分组的线上投影:读(GET /admin/llm)与写(POST /admin/llm 的
|
||||
// 响应)**必须同一套映射**。面板是「读回来 → 改一处 → 整组提交」,两个方向
|
||||
// 一旦不一致(例如读侧漏了 reasoning),面板保存一次就会把那把键抹掉。
|
||||
|
||||
// adminLlmProviderDTO 把存储分组映射成线上的 AdminLlmProvider。API key 绝不
|
||||
// 进入线上结构。reasoning 无条件下发(kind 可能为空串 = 未声明形状),
|
||||
// persona 路由表按原值深拷贝,使调用方拿到的结构不会被后续改动影响。
|
||||
func adminLlmProviderDTO(g runtimeconfig.Group, name string, enabled bool) AdminLlmProvider {
|
||||
return AdminLlmProvider{
|
||||
ID: g.ID,
|
||||
Name: name,
|
||||
Provider: g.Provider,
|
||||
BaseURL: g.BaseURL,
|
||||
Model: g.Model,
|
||||
Multimodal: g.Multimodal,
|
||||
Enabled: enabled,
|
||||
Fallback: g.Fallback,
|
||||
MaxTokens: g.MaxTokens,
|
||||
Reasoning: &AdminLlmReasoning{Kind: strings.TrimSpace(g.Reasoning.Kind)},
|
||||
PersonaModels: cloneStringMap(g.PersonaModels),
|
||||
PersonaProviders: cloneStringMap(g.PersonaProviders),
|
||||
}
|
||||
}
|
||||
|
||||
// cloneStringMap 浅拷贝一张 persona 路由表。nil 保持 nil,因此 omitempty 仍会
|
||||
// 省略该键——「该组没有 per-persona 绑定」与「绑定表为空对象」在线上是同一种
|
||||
// 状态(都表示没有路由),不需要两套表示。
|
||||
func cloneStringMap(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizePersonaMap 规整面板提交的 persona 路由表:键与值去除首尾空白。
|
||||
// 只做规整,绝不丢弃或改写成别的值——空键/空值/未知协议名一律留给
|
||||
// runtimeconfig.Group.Validate 报错(写端点据此回 400),因此非法输入
|
||||
// 永远不会被静默吸收。规整的理由是路由查找用的是 trim 后的 persona id,
|
||||
// 带空白的键会变成永远匹配不上的静默死配置;两个键在 trim 后撞到一起
|
||||
// (" cyrene " 与 "cyrene")则直接报错,而不是悄悄让其中一个消失。
|
||||
func normalizePersonaMap(field string, m map[string]string) (map[string]string, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
key := strings.TrimSpace(k)
|
||||
if _, dup := out[key]; dup {
|
||||
return nil, fmt.Errorf("%s: persona id %q 去除首尾空白后与另一条目重复", field, key)
|
||||
}
|
||||
out[key] = strings.TrimSpace(v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user