Files
gpt-load/internal/control/settings.go
T
tbphp 18079996f0 refactor(settings): 重构设置页分区并统一分组设置的覆盖交互 (#576)
* refactor(settings): 按业务语义重排配置分区,收敛默认态展示密度

- 路由策略并入亲和分区(路由与调度),validation_interval 移出超时分组并入重试/凭据健康,
  models_dev_auto_sync_enabled 从请求转发移至数据与维护,header_rules 与 inject_usage_options
  合并为上游请求改写;6 个分区拆为 7 个语义更清晰的分区。
- 新增 SettingRow 承载默认态单行展示、覆盖态才展开控件与说明文字,默认态徽标与操作按钮收敛,
  避免逐字段重复常驻的说明文案。

* refactor(settings): 抽取 SettingRow 为共享组件并同步分组设置页展示密度

- SettingRow 从设置页移至 components/config,替换分组设置页运行时区块里的
  RuntimeOverrideRow,行为对齐:默认态收敛徽标/按钮、覆盖态才展开控件与说明。
- RuntimeOverrideRow 无剩余引用,一并删除。
- 顺带修正分组页 inject_usage_options 折叠态展示 help 文案而非当前值的问题,
  改为展示已启用/已禁用,说明文字移入 help 提示。
- 请求亲和沿用原有 SegmentedControl 三态控件,未纳入本次迁移。

* fix(settings): 按反馈调整 SettingRow 交互细节

- 提示统一用已有 AppTooltip 组件,替换原生 title;且不论是否覆盖,说明都只在
  标签旁以 tooltip 展示,不再于覆盖态额外常驻一份。
- 覆盖/恢复默认按钮从行尾移到值后面,改回可见的按钮样式,不再靠悬停淡入。
- 覆盖态不再叠加“显式覆盖”徽标,仅保留左侧强调色与底色区分;待恢复/环境变量
  锁定两种状态的徽标保留。
- 路由策略改用 SegmentedControl 二选一切换,替换下拉选择,和代理等控件观感一致。
- 各分区行列表之间补上小间距,避免行与行贴在一起。

* fix(settings): 覆盖/恢复默认按钮改为图标 + tooltip

文字按钮换成小方形图标按钮:覆盖用 PencilLine,恢复默认用 RotateCcw,配色沿用
原有 action/warning 两种 tone;按钮不再显示文字,改为 hover/聚焦时通过
AppTooltip 展示对应文案,无障碍上保留 aria-label。

* fix(settings): 覆盖/恢复默认图标去掉按钮底色,仅悬停反馈

去掉常驻边框和背景色,图标默认裸露显示,与旁边的说明提示图标风格保持一致;
悬停/聚焦时才出现对应 tone 的浅色背景。

* fix(settings): 覆盖图标恢复悬停显示

未覆盖态(含待恢复默认)的“覆盖”图标恢复为鼠标悬停/键盘聚焦到该行才显示,
平时隐藏;已覆盖态的“恢复默认”图标保持常驻可见,因为该行本身已有强调色和
底色区分,不需要靠图标常驻来提示。窄屏和纯触屏环境下始终显示,避免无法触发。

* refactor(settings): 移除 inject_usage_options 并统一设置页控件尺寸

移除 inject_usage_options 配置,改为始终注入:
- 该开关只在「OpenAI Completions + 原生透传 + 流式」这一条路径生效(转换路径的
  usage 来自 SDK,Anthropic/Gemini 原生流自带 usage),默认即为开启,暴露为用户
  配置的收益远低于理解成本。
- 后端删除设置键、分组覆盖与渠道约束校验,IncludeUsage 固定为 ObserveUsage;
  新增迁移 0008 清理 system_settings 行与分组 overrides 中的历史键,否则解析器
  会以 "unknown runtime setting" 拒绝整份配置。
- 前端删除两处设置行、资源投影字段与三语文案。

设置页与分组设置页的控件:
- 新增 --setting-control-height,并在 .setting-row/.setting-block 上覆盖
  --control-compact/xs/sm,让输入框、切换控件、下拉统一小一档;变量继承进子组件,
  不需要和组件自身 scoped 样式比优先级。
- 固定行内容高度,折叠态与覆盖态不再有高度差,点开编辑时行不会跳动。
- 块级说明(CORS 策略、下游响应头规则)从常驻小字改为标签旁 tooltip,与设置行一致。

分组设置页删除只读的渠道展示:顶部已有渠道信息,此处重复且不可编辑。

* fix(storage): 修复迁移 0008 在 PostgreSQL 上的两处方言问题

- 反引号是 MySQL/SQLite 语法,PostgreSQL 解析 `key` = ? 直接报 syntax error,
  导致启动中断。改用 GORM 结构体条件,由驱动按方言自行加引号。
- 往 json 列写 []byte 时 PostgreSQL 驱动按 bytea 处理,报 invalid input syntax
  for type json。改为写字符串。

已在 SQLite / PostgreSQL 16 / MySQL 8 三种方言上实跑升级路径:注入历史的
system_settings 行与分组 overrides 后启动,确认键被清除、分组只剩其余覆盖、
迁移正常记账。

* refactor(settings): 合并上游 Header 到跨域分区,统一控件尺寸与请求头/响应头命名

- 分组页控件尺寸未跟着变小,是因为上一轮的尺寸变量覆盖只挂在 .setting-row/
  .setting-block 上;分组页的姓名/验活模型输入框、权重与请求亲和的 SegmentedControl
  都不在这两个类名内。改为挂在 .settings__content/.group-settings__content 内容区
  (不含侧边导航与保存栏),覆盖 --control-compact/xs/sm/md 四个尺寸变量,让设置页
  和分组页的所有交互控件统一收敛到 26px。

- "上游请求改写"分区被删除,header_rules 合并进浏览器访问分区,与 CORS、
  下游响应头规则放在一起(顺序:CORS → 上游请求头规则 → 下游响应头规则);
  该分区原本只剩 header_rules 一项(inject_usage_options 上一轮已删除),
  单独占一个侧边菜单已经没有意义。

- 请求头/响应头命名统一:全局设置页"全局 Header Rules"改名"上游请求头规则",
  与已有的"下游响应头规则"对称;分组页"HeaderRules"同步改名"上游请求头规则"。
  新分区标题定为"Header 与跨域"。

- 清理合并后失效的 settings.headers.* 键(title/currentPublishedRuleCount/
  defaultSource/overrideSource/pendingRestoreSource/override/restoreDefault/
  inherited/resetPending/replacementWarning/securityNotice),改用已有的
  settings.runtime.* 通用状态词汇,与 CORS、下游响应头规则保持一致。

已用真实浏览器验证:分区合并后的顺序与文案、校验横幅跳转到正确分区并定位到
非法输入框、分组页控件高度与设置页一致。

* refactor(group-settings): 统一请求亲和与上游请求头规则的覆盖交互

请求亲和由三态 SegmentedControl 改为 SettingRow 覆盖/恢复默认模式;
上游请求头规则由 details 折叠改为 SettingBlock,并补上独立的覆盖开关,
与设置页的交互模式完全一致;同步移除废弃的展开态路由状态与文案。

* fix(group-settings): 恢复默认时不再回显未保存的旧覆盖值

分组已保存覆盖值后,草稿中点击恢复默认会读到仍是旧覆盖值的 effective
快照,导致视觉上"看起来没变"。为出站代理、超时/次数类、请求亲和、
上游请求头规则补充待恢复态:展示占位文案与徽标,保存后才回填真实的
全局生效值,与设置页面的重置反馈保持一致。

* feat(group-settings): 验活模型改为可搜索候选输入

复用参数覆盖规则里模型输入的 datalist 模式,把分组当前模型作为候选,
输入即过滤;候选取上游模型 ID(验活按该值请求上游),别名作为副标题
展示。后端不限制该值,仍保留自由输入。

* fix(settings): 按 review 处理迁移账本断言与三处设置项细节

- 外部数据库契约里的迁移账本断言硬编码到 0007,只在 PostgreSQL/MySQL 下
  执行,本地 SQLite 会跳过,导致 0008 落库后仅 CI 报错
- 下游响应头规则的待恢复态未取草稿值,取消覆盖后仍回显旧规则,与同区的
  上游请求头规则表现不一致
- 连接与可靠性分区的数字输入清空后写入 0,其中重试次数与拉黑阈值的 0 合法,
  会被静默保存;改为与路由分区一致按 NaN 处理
- 修正 pendingRestoreSource 的英文文案
2026-09-06 22:54:21 +08:00

374 lines
12 KiB
Go

package control
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gpt-load/internal/outboundproxy"
"gpt-load/internal/platform/encryption"
"gpt-load/internal/platform/epochms"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/state"
"gpt-load/internal/storage/models"
)
type HeaderRulesResponse struct {
Set map[string]string `json:"set"`
Remove []string `json:"remove"`
}
type CORSConfigResponse struct {
Enabled bool `json:"enabled"`
AllowedOrigins []string `json:"allowed_origins"`
AllowedMethods []string `json:"allowed_methods"`
AllowedHeaders []string `json:"allowed_headers"`
ExposedHeaders []string `json:"exposed_headers"`
AllowCredentials bool `json:"allow_credentials"`
MaxAge int `json:"max_age"`
}
type SettingsValuesResponse struct {
FirstByteTimeout int64 `json:"first_byte_timeout"`
RequestTimeout int64 `json:"request_timeout"`
StreamIdleTimeout int64 `json:"stream_idle_timeout"`
HeaderRules HeaderRulesResponse `json:"header_rules"`
CORS CORSConfigResponse `json:"cors"`
ResponseHeaderRules HeaderRulesResponse `json:"response_header_rules"`
RetryCount int `json:"retry_count"`
RouteStrategy state.RouteStrategy `json:"route_strategy"`
BlacklistThreshold int `json:"blacklist_threshold"`
AffinityEnabled bool `json:"affinity_enabled"`
AffinityTTL int64 `json:"affinity_ttl"`
AffinityCapacity int `json:"affinity_capacity"`
ValidationInterval int64 `json:"validation_interval"`
RequestLogRetentionDays int `json:"request_log_retention_days"`
ModelsDevAutoSyncEnabled bool `json:"models_dev_auto_sync_enabled"`
ProxyConfig outboundproxy.View `json:"proxy_config"`
}
type SettingsResponse struct {
Revision uint64 `json:"-"`
Values SettingsValuesResponse `json:"values"`
Overrides []string `json:"overrides"`
ReadOnly []string `json:"read_only,omitempty"`
}
type SettingsUpdateRequest struct {
Settings map[string]json.RawMessage `json:"settings"`
}
type persistedSettingUpdate struct {
key string
value *string
}
func (request *SettingsUpdateRequest) UnmarshalJSON(data []byte) error {
if err := rejectDuplicateJSONFields(data); err != nil {
return err
}
var object map[string]json.RawMessage
if err := json.Unmarshal(data, &object); err != nil {
return err
}
if object == nil || len(object) != 1 {
return fmt.Errorf("request body must contain only settings")
}
rawSettings, exists := object["settings"]
if !exists {
return fmt.Errorf("request body must contain settings")
}
trimmed := bytes.TrimSpace(rawSettings)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) || trimmed[0] != '{' {
return fmt.Errorf("settings must be a non-null JSON object")
}
var settings map[string]json.RawMessage
if err := json.Unmarshal(rawSettings, &settings); err != nil {
return err
}
if settings == nil {
return fmt.Errorf("settings must be a non-null JSON object")
}
request.Settings = settings
return nil
}
func (s *Service) GetSettings(ctx context.Context) (SettingsResponse, error) {
s.writeMu.RLock()
defer s.writeMu.RUnlock()
snapshot := s.manager.Current()
if snapshot == nil {
return SettingsResponse{}, app_errors.ErrInternalServer
}
return s.getSettingsWithSnapshot(ctx, s.db, snapshot)
}
func (s *Service) getSettingsWithSnapshot(
ctx context.Context,
db *gorm.DB,
snapshot *state.ConfigSnapshot,
) (SettingsResponse, error) {
var rows []models.SystemSetting
if err := db.WithContext(ctx).
Order(clause.OrderBy{Columns: []clause.OrderByColumn{{Column: clause.Column{Name: "key"}}}}).
Find(&rows).Error; err != nil {
return SettingsResponse{}, app_errors.ParseDBError(err)
}
return mapSettingsResponse(
snapshot,
rows,
s.modelsDevAutoSyncOverride,
s.encryption,
s.environmentProxy,
)
}
func (s *Service) UpdateSettings(
ctx context.Context,
request SettingsUpdateRequest,
) (SettingsResponse, error) {
updates, err := normalizeSettingUpdates(request, s.encryption)
if err != nil {
return SettingsResponse{}, err
}
if s.modelsDevAutoSyncOverride != nil && settingRequestContains(request, state.SettingModelsDevAutoSyncEnabled) {
return SettingsResponse{}, app_errors.ErrValidation
}
previousAutoSyncEnabled := false
snapshot, err := s.writeConfig(ctx, func(tx *gorm.DB) error {
previousAutoSyncEnabled = s.modelsDevAutoSyncEnabled()
return s.applySettingUpdates(tx, updates)
}, nil)
if err != nil {
return SettingsResponse{}, err
}
s.requestCatalogSyncOnEnable(
settingRequestContains(request, state.SettingModelsDevAutoSyncEnabled),
previousAutoSyncEnabled,
snapshot.Settings.ModelsDevAutoSyncEnabled,
)
return s.GetSettings(ctx)
}
func (s *Service) requestCatalogSyncOnEnable(requested, before, after bool) {
if requested && !before && after && s.catalogSync != nil {
s.catalogSync.RequestImmediateSync()
}
}
func (s *Service) applySettingUpdates(
tx *gorm.DB,
updates []persistedSettingUpdate,
) error {
for _, update := range updates {
if update.value == nil {
if err := tx.Where(&models.SystemSetting{Key: update.key}).
Delete(&models.SystemSetting{}).Error; err != nil {
return app_errors.ParseDBError(err)
}
continue
}
updatedAtMS, err := epochms.FromTime(s.now())
if err != nil {
return app_errors.ErrInternalServer
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at_ms"}),
}).Create(&models.SystemSetting{
Key: update.key,
Value: *update.value,
UpdatedAtMS: updatedAtMS,
}).Error; err != nil {
return app_errors.ParseDBError(err)
}
}
return nil
}
func normalizeSettingUpdates(
request SettingsUpdateRequest,
encryptionService encryption.Service,
) ([]persistedSettingUpdate, error) {
if len(request.Settings) == 0 {
return nil, app_errors.ErrBadRequest
}
keys := make([]string, 0, len(request.Settings))
for key := range request.Settings {
keys = append(keys, key)
}
sort.Strings(keys)
updates := make([]persistedSettingUpdate, 0, len(keys))
for _, key := range keys {
if key != outboundproxy.SystemSettingKey && !state.IsRuntimeSettingKey(key) {
return nil, app_errors.ErrValidation
}
raw := bytes.TrimSpace(request.Settings[key])
if bytes.Equal(raw, []byte("null")) {
updates = append(updates, persistedSettingUpdate{key: key})
continue
}
if key == outboundproxy.SystemSettingKey {
config, err := outboundproxy.Decode(string(raw))
if err != nil || config.Mode == outboundproxy.ModeInherit || encryptionService == nil {
return nil, app_errors.ErrValidation
}
encoded, err := outboundproxy.Encode(config)
if err != nil {
return nil, app_errors.ErrValidation
}
ciphertext, err := encryptionService.Encrypt(encoded)
if err != nil {
return nil, app_errors.ErrInternalServer
}
updates = append(updates, persistedSettingUpdate{key: key, value: &ciphertext})
continue
}
value, err := decodeSettingValue(raw)
if err != nil {
return nil, app_errors.ErrValidation
}
if err := state.ValidateRuntimeSetting(key, value); err != nil {
return nil, app_errors.ErrValidation
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, app_errors.ErrValidation
}
text := string(encoded)
updates = append(updates, persistedSettingUpdate{key: key, value: &text})
}
return updates, nil
}
func settingRequestContains(request SettingsUpdateRequest, key string) bool {
_, exists := request.Settings[key]
return exists
}
func decodeSettingValue(raw []byte) (any, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
var extra json.RawMessage
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return nil, fmt.Errorf("multiple JSON values")
}
return nil, err
}
return value, nil
}
func mapSettingsResponse(
snapshot *state.ConfigSnapshot,
rows []models.SystemSetting,
modelsDevAutoSyncOverride *bool,
encryptionService encryption.Service,
environmentProxy *outboundproxy.Config,
) (SettingsResponse, error) {
settings := snapshot.Settings
set := make(map[string]string, len(settings.HeaderRules.Set))
for name, value := range settings.HeaderRules.Set {
set[name] = value
}
remove := append([]string{}, settings.HeaderRules.Remove...)
responseSet := make(map[string]string, len(settings.ResponseHeaderRules.Set))
for name, value := range settings.ResponseHeaderRules.Set {
responseSet[name] = value
}
responseRemove := append([]string{}, settings.ResponseHeaderRules.Remove...)
overrides := make([]string, 0, len(rows))
var configuredProxy *outboundproxy.Config
for _, row := range rows {
if state.IsRuntimeSettingKey(row.Key) {
overrides = append(overrides, row.Key)
}
if row.Key == outboundproxy.SystemSettingKey {
if encryptionService == nil {
return SettingsResponse{}, app_errors.ErrInternalServer
}
plaintext, err := encryptionService.Decrypt(row.Value)
if err != nil {
return SettingsResponse{}, app_errors.ErrInternalServer
}
config, err := outboundproxy.Decode(plaintext)
if err != nil || config.Mode == outboundproxy.ModeInherit {
return SettingsResponse{}, app_errors.ErrInternalServer
}
plaintext = ""
configuredProxy = &config
}
}
effectiveProxy, err := outboundproxy.Resolve(nil, nil, configuredProxy, environmentProxy)
if err != nil {
return SettingsResponse{}, app_errors.ErrInternalServer
}
proxyView, err := outboundproxy.NewView(configuredProxy, effectiveProxy)
if err != nil {
return SettingsResponse{}, app_errors.ErrInternalServer
}
readOnly := make([]string, 0)
modelsDevAutoSyncEnabled := settings.ModelsDevAutoSyncEnabled
if modelsDevAutoSyncOverride != nil {
modelsDevAutoSyncEnabled = *modelsDevAutoSyncOverride
readOnly = append(readOnly, state.SettingModelsDevAutoSyncEnabled)
}
return SettingsResponse{
Revision: snapshot.Revision,
Values: SettingsValuesResponse{
FirstByteTimeout: durationSeconds(settings.FirstByteTimeout),
RequestTimeout: durationSeconds(settings.RequestTimeout),
StreamIdleTimeout: durationSeconds(settings.StreamIdleTimeout),
HeaderRules: HeaderRulesResponse{
Set: set,
Remove: remove,
},
CORS: CORSConfigResponse{
Enabled: settings.CORS.Enabled,
AllowedOrigins: append([]string{}, settings.CORS.AllowedOrigins...),
AllowedMethods: append([]string{}, settings.CORS.AllowedMethods...),
AllowedHeaders: append([]string{}, settings.CORS.AllowedHeaders...),
ExposedHeaders: append([]string{}, settings.CORS.ExposedHeaders...),
AllowCredentials: settings.CORS.AllowCredentials,
MaxAge: settings.CORS.MaxAgeSeconds,
},
ResponseHeaderRules: HeaderRulesResponse{
Set: responseSet,
Remove: responseRemove,
},
RetryCount: settings.RetryCount,
RouteStrategy: settings.RouteStrategy,
BlacklistThreshold: settings.BlacklistThreshold,
AffinityEnabled: settings.AffinityEnabled,
AffinityTTL: durationSeconds(settings.AffinityTTL),
AffinityCapacity: settings.AffinityCapacity,
ValidationInterval: durationSeconds(settings.ValidationInterval),
RequestLogRetentionDays: settings.RequestLogRetentionDays,
ModelsDevAutoSyncEnabled: modelsDevAutoSyncEnabled,
ProxyConfig: proxyView,
},
Overrides: overrides,
ReadOnly: readOnly,
}, nil
}
func durationSeconds(value time.Duration) int64 {
return int64(value / time.Second)
}