package app import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-sql-driver/mysql" ) type aiModelRequest struct { Name string `json:"name"` Protocol string `json:"protocol"` BaseURL string `json:"baseUrl"` ModelName string `json:"modelName"` APIKey string `json:"apiKey"` ClearAPIKey bool `json:"clearApiKey"` Temperature *float64 `json:"temperature"` MaxTokens *int `json:"maxTokens"` TimeoutMS *int `json:"timeoutMs"` Status *int `json:"status"` IsDefault *bool `json:"isDefault"` // Raw provider fields merged into every call, e.g. {"thinking":{"type":"disabled"}} // to stop a model that reasons by default from spending the budget thinking. Extra *string `json:"extra"` } // The column is JSON, so an invalid value has to be refused before it is stored // and starts failing every call at read time instead. func aiModelExtra(value *string, fallback any) (any, error) { if value == nil { return fallback, nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil, nil } var parsed map[string]any if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { return nil, fmt.Errorf("扩展参数必须是 JSON 对象") } return trimmed, nil } const aiModelColumns = `id,name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,extra_json,created_at,updated_at` func (a *App) scanAIModel(scan func(dest ...any) error) (aiModel, error) { var model aiModel var cipher string var extra sql.NullString var created, updated time.Time if err := scan(&model.ID, &model.Name, &model.Protocol, &model.BaseURL, &model.ModelName, &cipher, &model.Temperature, &model.MaxTokens, &model.TimeoutMS, &model.Status, &model.IsDefault, &extra, &created, &updated); err != nil { return aiModel{}, err } if strings.TrimSpace(extra.String) != "" { if err := json.Unmarshal([]byte(extra.String), &model.Extra); err != nil { return model, fmt.Errorf("模型 %s 的扩展参数不是合法 JSON", model.Name) } } model.HasAPIKey = cipher != "" if cipher != "" { plain, err := a.decryptSecret(cipher) if err != nil { // A key that cannot be decrypted (rotated encryption key) must not // silently become an empty key that reaches the provider. return model, fmt.Errorf("模型 %s 的密钥无法解密,请重新填写", model.Name) } model.APIKey = plain } model.CreatedAt = created.Format(time.RFC3339) model.UpdatedAt = updated.Format(time.RFC3339) return model, nil } func (a *App) loadAIModel(ctx context.Context, id int64) (aiModel, error) { row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE id=?`, id) return a.scanAIModel(row.Scan) } // The default model is what the reply worker uses when an agent has no explicit // binding: the configured id first, then the flagged row. func (a *App) defaultAIModel(ctx context.Context) (aiModel, error) { if configured := strings.TrimSpace(a.configPlain(ctx, "ai.default_model_id", "")); configured != "" { // The field is labelled "ID", but typing the model's name into it is an // easy mistake that MySQL would quietly turn into id 0. Accept either, // rather than silently ignoring what the operator chose. row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE status=1 AND (id=? OR name=?) ORDER BY id=? DESC LIMIT 1`, configured, configured, configured) if model, err := a.scanAIModel(row.Scan); err == nil { return model, nil } } row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE status=1 ORDER BY is_default DESC,id ASC LIMIT 1`) model, err := a.scanAIModel(row.Scan) if errors.Is(err, sql.ErrNoRows) { return aiModel{}, fmt.Errorf("尚未配置可用的 AI 模型") } return model, err } func (a *App) adminAIModels(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT `+aiModelColumns+` FROM ai_models ORDER BY is_default DESC,id ASC`) if err != nil { fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败") return } defer rows.Close() items := []aiModel{} for rows.Next() { model, scanErr := a.scanAIModel(rows.Scan) if scanErr != nil && model.ID == 0 { fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败") return } model.APIKey = "" items = append(items, model) } if rows.Err() != nil { fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败") return } protocols := make([]map[string]string, 0, len(aiProtocols)) for _, protocol := range aiProtocols { protocols = append(protocols, map[string]string{"value": protocol, "label": aiProtocolName(protocol)}) } reply(w, map[string]any{ "items": items, "protocols": protocols, "enabled": a.configBool(r.Context(), "ai.enabled", false), "secretMask": maskedSecret, }) } func (a *App) normalizeAIModelRequest(req *aiModelRequest, existing *aiModel) error { req.Name = strings.TrimSpace(req.Name) req.Protocol = strings.ToLower(strings.TrimSpace(req.Protocol)) req.BaseURL = strings.TrimSpace(req.BaseURL) req.ModelName = strings.TrimSpace(req.ModelName) if existing != nil { if req.Name == "" { req.Name = existing.Name } if req.Protocol == "" { req.Protocol = existing.Protocol } } if req.Name == "" || len([]rune(req.Name)) > 60 { return fmt.Errorf("模型名称须为 1–60 字") } if !containsString(aiProtocols, req.Protocol) { return fmt.Errorf("不支持的模型协议") } if a.config.Environment == "production" && req.Protocol == "debug" { return fmt.Errorf("生产环境禁止使用本地调试模型") } if len(req.BaseURL) > 300 || len([]rune(req.ModelName)) > 120 { return fmt.Errorf("接口地址最多 300 字,模型名称最多 120 字") } if _, err := parseAIBaseURL(req.Protocol, req.BaseURL, a.config.Environment == "production"); err != nil { return err } if req.Protocol != "debug" && req.Protocol != "webhook" && req.ModelName == "" { return fmt.Errorf("请填写模型名称,例如 gpt-4o-mini 或 deepseek-chat") } if req.Temperature != nil && (*req.Temperature < 0 || *req.Temperature > 2) { return fmt.Errorf("温度必须在 0 到 2 之间") } if req.MaxTokens != nil && (*req.MaxTokens < 16 || *req.MaxTokens > 4096) { return fmt.Errorf("最大回复长度必须在 16 到 4096 之间") } if req.TimeoutMS != nil && (*req.TimeoutMS < 1000 || *req.TimeoutMS > 60000) { return fmt.Errorf("超时必须在 1000 到 60000 毫秒之间") } if req.Status != nil && *req.Status != 0 && *req.Status != 1 { return fmt.Errorf("状态无效") } return nil } func (a *App) adminCreateAIModel(w http.ResponseWriter, r *http.Request) { var req aiModelRequest if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "模型配置格式不正确") return } if err := a.normalizeAIModelRequest(&req, nil); err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } cipher, err := a.encryptSecret(strings.TrimSpace(req.APIKey)) if err != nil { fail(w, http.StatusInternalServerError, 50001, "加密模型密钥失败") return } temperature, maxTokens, timeout, status := 0.8, 256, 15000, 1 if req.Temperature != nil { temperature = *req.Temperature } if req.MaxTokens != nil { maxTokens = *req.MaxTokens } if req.TimeoutMS != nil { timeout = *req.TimeoutMS } if req.Status != nil { status = *req.Status } isDefault := req.IsDefault != nil && *req.IsDefault extra, err := aiModelExtra(req.Extra, nil) if err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } defer func() { _ = tx.Rollback() }() if isDefault { if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET is_default=0 WHERE is_default=1`); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } } result, err := tx.ExecContext(r.Context(), `INSERT INTO ai_models (name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,extra_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, req.Name, req.Protocol, req.BaseURL, req.ModelName, cipher, temperature, maxTokens, timeout, status, btoi(isDefault), extra) if err != nil { if isDuplicateAIModelName(err) { fail(w, http.StatusBadRequest, 20001, "模型名称已存在") return } fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } id, _ := result.LastInsertId() if err = tx.Commit(); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } a.audit(r, "create", "ai_model", id, map[string]any{"name": req.Name, "protocol": req.Protocol}) reply(w, map[string]any{"id": id}) } func (a *App) adminUpdateAIModel(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "模型不存在") return } existing, err := a.loadAIModel(r.Context(), id) if errors.Is(err, sql.ErrNoRows) { fail(w, http.StatusNotFound, 30001, "模型不存在") return } var req aiModelRequest if decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "模型配置格式不正确") return } if err = a.normalizeAIModelRequest(&req, &existing); err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } // An empty key means "keep the stored one"; clearing is explicit. cipherClause, cipherArgs := "", []any{} if req.ClearAPIKey { cipherClause = ",api_key_cipher=''" } else if strings.TrimSpace(req.APIKey) != "" { cipher, encryptErr := a.encryptSecret(strings.TrimSpace(req.APIKey)) if encryptErr != nil { fail(w, http.StatusInternalServerError, 50001, "加密模型密钥失败") return } cipherClause = ",api_key_cipher=?" cipherArgs = append(cipherArgs, cipher) } temperature, maxTokens, timeout, status := existing.Temperature, existing.MaxTokens, existing.TimeoutMS, existing.Status if req.Temperature != nil { temperature = *req.Temperature } if req.MaxTokens != nil { maxTokens = *req.MaxTokens } if req.TimeoutMS != nil { timeout = *req.TimeoutMS } if req.Status != nil { status = *req.Status } isDefault := existing.IsDefault if req.IsDefault != nil { isDefault = *req.IsDefault } var existingExtra any if len(existing.Extra) > 0 { encoded, _ := json.Marshal(existing.Extra) existingExtra = string(encoded) } extra, err := aiModelExtra(req.Extra, existingExtra) if err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } defer func() { _ = tx.Rollback() }() if isDefault { if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET is_default=0 WHERE is_default=1 AND id<>?`, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } } args := append([]any{req.Name, req.Protocol, req.BaseURL, req.ModelName, temperature, maxTokens, timeout, status, btoi(isDefault), extra}, cipherArgs...) args = append(args, id) if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET name=?,protocol=?,base_url=?,model_name=?,temperature=?,max_tokens=?,timeout_ms=?,status=?,is_default=?,extra_json=?`+cipherClause+` WHERE id=?`, args...); err != nil { if isDuplicateAIModelName(err) { fail(w, http.StatusBadRequest, 20001, "模型名称已存在") return } fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } if err = tx.Commit(); err != nil { fail(w, http.StatusInternalServerError, 50001, "保存模型失败") return } a.audit(r, "update", "ai_model", id, map[string]any{"name": req.Name, "protocol": req.Protocol, "status": status}) reply(w, map[string]any{"success": true}) } func (a *App) adminDeleteAIModel(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "模型不存在") return } if _, err = a.db.ExecContext(r.Context(), `DELETE FROM ai_models WHERE id=?`, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "删除模型失败") return } a.audit(r, "delete", "ai_model", id, nil) reply(w, map[string]any{"success": true}) } // A probe call from the console: the operator sees latency, tokens and the // actual reply before any conversation depends on the configuration. func (a *App) adminTestAIModel(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "模型不存在") return } model, err := a.loadAIModel(r.Context(), id) if errors.Is(err, sql.ErrNoRows) { fail(w, http.StatusNotFound, 30001, "模型不存在") return } if err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } provider, err := newAIProvider(model, a.config.Environment == "production") if err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } request := aiChatRequest{ System: "你在参与一次接口连通性测试,请直接给出简短的中文回复。", Messages: []aiChatMessage{{Role: "user", Text: aiProbePrompt}}, } started := time.Now() result, err := provider.Chat(r.Context(), request) latency := int(time.Since(started).Milliseconds()) a.logAICall(r.Context(), model.ID, "probe", 0, 0, latency, result, err) if err != nil { fail(w, http.StatusBadGateway, 50002, err.Error()) return } a.audit(r, "test", "ai_model", id, map[string]any{"latencyMs": latency}) reply(w, map[string]any{ "reply": result.Text, "latencyMs": latency, "promptTokens": result.PromptTokens, "completionTokens": result.CompletionTokens, "finishReason": result.FinishReason, }) } func (a *App) logAICall(ctx context.Context, modelID int64, scene string, agentUserID, conversationID int64, latencyMS int, result aiChatResult, callErr error) { status, message := 1, "" if callErr != nil { status = 0 message = callErr.Error() if len([]rune(message)) > 500 { message = string([]rune(message)[:500]) } } text := result.Text if len([]rune(text)) > 1000 { text = string([]rune(text)[:1000]) } digest := "" if scene != "" { sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", scene, agentUserID, conversationID))) digest = hex.EncodeToString(sum[:])[:32] } _, _ = a.db.ExecContext(ctx, `INSERT INTO ai_call_logs (model_id,scene,agent_user_id,conversation_id,latency_ms,prompt_tokens,completion_tokens,status,error,prompt_digest,reply_text) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, modelID, scene, agentUserID, conversationID, latencyMS, result.PromptTokens, result.CompletionTokens, status, message, digest, text) } func isDuplicateAIModelName(err error) bool { var mysqlErr *mysql.MySQLError return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "uk_ai_models_name") } // Global switches are validated together because several of them only make // sense as a pair, and the console tests them before the worker ever runs. func (a *App) validateAIConfig(ctx context.Context) error { if !a.configBool(ctx, "ai.enabled", false) { return fmt.Errorf("AI 托管当前未启用") } minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000) maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000) if minDelay < 0 || maxDelay < minDelay || maxDelay > 120000 { return fmt.Errorf("回复延迟区间无效,最大延迟须不小于最小延迟且不超过 120000 毫秒") } if concurrency := a.configInt(ctx, "ai.max_concurrency", 4); concurrency < 1 || concurrency > 64 { return fmt.Errorf("并发调用上限必须在 1 到 64 之间") } if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit < 0 { return fmt.Errorf("每日调用上限不能为负数") } if limit := a.configInt(ctx, "ai.agent_daily_reply_limit", 0); limit < 0 { return fmt.Errorf("单账号每日回复上限不能为负数") } if days := a.configInt(ctx, "ai.log_retention_days", 30); days < 1 || days > 365 { return fmt.Errorf("调用日志保留天数必须在 1 到 365 之间") } for _, batch := range strings.Split(a.configPlain(ctx, "ai.allow_batches", ""), ",") { if len(strings.TrimSpace(batch)) > 64 { return fmt.Errorf("测试批次名称长度不能超过 64 位") } } return nil } func (a *App) configInt(ctx context.Context, key string, fallback int) int { value, err := strconv.Atoi(strings.TrimSpace(a.configPlain(ctx, key, ""))) if err != nil { return fallback } return value }