diff --git a/go/controllers/platform_agent_api.go b/go/controllers/platform_agent_api.go new file mode 100644 index 0000000..48f5017 --- /dev/null +++ b/go/controllers/platform_agent_api.go @@ -0,0 +1,888 @@ +package controllers + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformAgentApiController 智能体API管理(yz_platform_agent_api,平台端) +type PlatformAgentApiController struct { + beego.Controller +} + +// agentApiHTTPClient 用于探测上游接口连通性 +var agentApiHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, +} + +// agentApiProtocol 上游协议族,决定请求路径、鉴权头与响应解析方式 +type agentApiProtocol string + +const ( + protocolAnthropic agentApiProtocol = "anthropic" + protocolGemini agentApiProtocol = "gemini" + protocolOpenAI agentApiProtocol = "openai" +) + +// detectAgentApiProtocol 由上游名称与地址关键词推断协议族 +// 上游名称由用户自由填写,因此这里用包含匹配而非枚举比对; +// 未命中任何关键词时按 OpenAI 兼容协议处理(绝大多数中转与国产网关都兼容该协议) +func detectAgentApiProtocol(provider, url string) agentApiProtocol { + hay := strings.ToLower(provider + " " + url) + + for _, k := range []string{"anthropic", "claude"} { + if strings.Contains(hay, k) { + return protocolAnthropic + } + } + for _, k := range []string{"gemini", "generativelanguage", "googleapis"} { + if strings.Contains(hay, k) { + return protocolGemini + } + } + return protocolOpenAI +} + +func (c *PlatformAgentApiController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformAgentApiController) ok(msg string, data interface{}) { + resp := map[string]interface{}{"code": 200, "msg": msg} + if data != nil { + resp["data"] = data + } + c.Data["json"] = resp + _ = c.ServeJSON() +} + +func (c *PlatformAgentApiController) platformClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +// agentApiPayload 新增/更新请求体 +// ApiKeys 用指针:编辑时不传该字段表示不修改已存密钥列表 +type agentApiPayload struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + UseCustomURL int8 `json:"use_custom_url"` + CustomURL string `json:"custom_url"` + ApiKeys *[]models.AgentApiKey `json:"api_keys"` + Models []string `json:"models"` + Status *int8 `json:"status"` + Remark string `json:"remark"` +} + +// apiKeysToJSON 把密钥列表序列化为 JSON 字符串入库 +// 去掉空密钥、按 key 去重,备注允许为空 +func apiKeysToJSON(list []models.AgentApiKey) string { + cleaned := make([]models.AgentApiKey, 0, len(list)) + seen := make(map[string]bool, len(list)) + for _, item := range list { + k := strings.TrimSpace(item.Key) + if k == "" || seen[k] { + continue + } + seen[k] = true + cleaned = append(cleaned, models.AgentApiKey{ + Key: k, + Remark: strings.TrimSpace(item.Remark), + }) + } + b, err := json.Marshal(cleaned) + if err != nil { + return "[]" + } + return string(b) +} + +// apiKeysFromJSON 把库里的 JSON 字符串反序列化为密钥列表 +// 兼容三种历史/异常格式:对象数组、纯字符串数组、单个裸密钥字符串 +func apiKeysFromJSON(raw string) []models.AgentApiKey { + raw = strings.TrimSpace(raw) + if raw == "" { + return []models.AgentApiKey{} + } + + if strings.HasPrefix(raw, "[") { + // 标准格式:[{"key":"sk-x","remark":"mimo198"}] + var objs []models.AgentApiKey + if err := json.Unmarshal([]byte(raw), &objs); err == nil { + out := make([]models.AgentApiKey, 0, len(objs)) + for _, o := range objs { + if strings.TrimSpace(o.Key) != "" { + out = append(out, o) + } + } + return out + } + // 退化格式:["sk-x","sk-y"] + var strs []string + if err := json.Unmarshal([]byte(raw), &strs); err == nil { + out := make([]models.AgentApiKey, 0, len(strs)) + for _, s := range strs { + if s = strings.TrimSpace(s); s != "" { + out = append(out, models.AgentApiKey{Key: s}) + } + } + return out + } + return []models.AgentApiKey{} + } + + // 迁移遗漏时的兜底:整个字段就是一个裸密钥 + return []models.AgentApiKey{{Key: raw}} +} + +// modelsToJSON 把模型数组序列化为 JSON 字符串入库 +func modelsToJSON(list []string) string { + cleaned := make([]string, 0, len(list)) + seen := make(map[string]bool, len(list)) + for _, m := range list { + m = strings.TrimSpace(m) + if m == "" || seen[m] { + continue + } + seen[m] = true + cleaned = append(cleaned, m) + } + b, err := json.Marshal(cleaned) + if err != nil { + return "[]" + } + return string(b) +} + +// modelsFromJSON 把库里的 JSON 字符串反序列化为模型数组 +// 兼容历史数据可能存的逗号分隔格式 +func modelsFromJSON(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{} + } + if strings.HasPrefix(raw, "[") { + var out []string + if err := json.Unmarshal([]byte(raw), &out); err == nil { + return out + } + return []string{} + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// agentApiToMap 统一输出结构 +// 密钥以明文返回,前端卡片默认脱敏、可切换显示;接口本身已受平台端鉴权保护 +func agentApiToMap(row *models.PlatformAgentApi) map[string]interface{} { + keys := apiKeysFromJSON(row.ApiKeys) + out := map[string]interface{}{ + "id": row.ID, + "provider": row.Provider, + "api_keys": keys, + "key_count": len(keys), + "base_url": row.BaseURL, + "use_custom_url": row.UseCustomURL, + "custom_url": row.CustomURL, + "models": modelsFromJSON(row.Models), + "status": row.Status, + "remark": row.Remark, + "user_id": row.UserID, + "user_name": row.UserName, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": "", + } + if row.UpdateTime != nil { + out["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05") + } + return out +} + +// validateAgentApiPayload 校验必填与地址格式 +func validateAgentApiPayload(p *agentApiPayload, isCreate bool) error { + // 上游接口为用户自由填写的文本,不做枚举校验 + p.Provider = strings.TrimSpace(p.Provider) + if p.Provider == "" { + return fmt.Errorf("请输入上游接口名称") + } + if len([]rune(p.Provider)) > 100 { + return fmt.Errorf("上游接口名称过长") + } + + p.BaseURL = strings.TrimSpace(p.BaseURL) + p.CustomURL = strings.TrimSpace(p.CustomURL) + + if p.UseCustomURL == 1 { + if p.CustomURL == "" { + return fmt.Errorf("请输入自定义地址") + } + if !isHTTPURL(p.CustomURL) { + return fmt.Errorf("自定义地址需以 http:// 或 https:// 开头") + } + } else { + p.CustomURL = "" + if p.BaseURL == "" { + return fmt.Errorf("请输入接口地址") + } + if !isHTTPURL(p.BaseURL) { + return fmt.Errorf("接口地址需以 http:// 或 https:// 开头") + } + } + + if len(p.Models) == 0 { + return fmt.Errorf("请至少添加一个模型") + } + + // 新增时必须带密钥;编辑时不传表示沿用已存密钥列表 + if p.ApiKeys != nil { + valid := 0 + for _, k := range *p.ApiKeys { + if strings.TrimSpace(k.Key) != "" { + valid++ + } + if len([]rune(k.Remark)) > 100 { + return fmt.Errorf("密钥备注过长,单条限 100 字") + } + } + if valid == 0 { + return fmt.Errorf("请至少添加一个 API Key") + } + } else if isCreate { + return fmt.Errorf("请至少添加一个 API Key") + } + + return nil +} + +func isHTTPURL(s string) bool { + low := strings.ToLower(s) + return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") +} + +// List GET /platform/agentApi/list?page=1&pageSize=24&keyword=&provider=&status= +func (c *PlatformAgentApiController) List() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 24) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 24 + } + if pageSize > 200 { + pageSize = 200 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + provider := strings.TrimSpace(c.GetString("provider")) + statusStr := strings.TrimSpace(c.GetString("status")) + + qs := models.Orm.QueryTable(new(models.PlatformAgentApi)).Filter("is_deleted", 0) + + cond := orm.NewCondition() + needCond := false + + // 上游名称为自由文本,用模糊匹配而非精确相等 + if provider != "" { + cond = cond.And("provider__icontains", provider) + needCond = true + } + if statusStr != "" { + if st, err := strconv.Atoi(statusStr); err == nil { + cond = cond.And("status", st) + needCond = true + } + } + if keyword != "" { + kw := orm.NewCondition(). + Or("provider__icontains", keyword). + Or("base_url__icontains", keyword). + Or("custom_url__icontains", keyword). + Or("models__icontains", keyword). + Or("remark__icontains", keyword) + cond = cond.AndCond(kw) + needCond = true + } + if needCond { + qs = qs.SetCond(cond) + } + + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + var rows []models.PlatformAgentApi + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, agentApiToMap(&rows[i])) + } + + c.ok("success", map[string]interface{}{ + "list": list, + "total": total, + }) +} + +// Detail GET /platform/agentApi/:id +func (c *PlatformAgentApiController) Detail() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + row, err := c.findByID() + if err != nil { + return + } + + c.ok("success", agentApiToMap(row)) +} + +// findByID 读取路径参数并查询记录,出错时已写入响应 +func (c *PlatformAgentApiController) findByID() (*models.PlatformAgentApi, error) { + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return nil, fmt.Errorf("无效ID") + } + + var row models.PlatformAgentApi + err = models.Orm.QueryTable(new(models.PlatformAgentApi)). + Filter("id", id). + Filter("is_deleted", 0). + One(&row) + if err != nil { + if err == orm.ErrNoRows { + c.jsonErr(404, 404, "配置不存在") + } else { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + } + return nil, err + } + return &row, nil +} + +// Create POST /platform/agentApi +func (c *PlatformAgentApiController) Create() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var p agentApiPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if err := validateAgentApiPayload(&p, true); err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + + status := int8(1) + if p.Status != nil { + status = *p.Status + } + + userID := uint64(claims.UserID) + row := &models.PlatformAgentApi{ + Provider: p.Provider, + BaseURL: p.BaseURL, + UseCustomURL: p.UseCustomURL, + CustomURL: p.CustomURL, + ApiKeys: apiKeysToJSON(*p.ApiKeys), + Models: modelsToJSON(p.Models), + Status: status, + Remark: strings.TrimSpace(p.Remark), + UserID: &userID, + UserName: &claims.Username, + IsDeleted: 0, + } + + id, err := models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "创建失败: "+err.Error()) + return + } + row.ID = uint64(id) + + c.ok("创建成功", agentApiToMap(row)) +} + +// Update PUT /platform/agentApi/:id +func (c *PlatformAgentApiController) Update() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + row, err := c.findByID() + if err != nil { + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var p agentApiPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if err := validateAgentApiPayload(&p, false); err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + + now := time.Now() + updates := map[string]interface{}{ + "provider": p.Provider, + "base_url": p.BaseURL, + "use_custom_url": p.UseCustomURL, + "custom_url": p.CustomURL, + "models": modelsToJSON(p.Models), + "remark": strings.TrimSpace(p.Remark), + "update_time": now, + } + if p.Status != nil { + updates["status"] = *p.Status + } + // 未上送 api_keys 表示不修改已存密钥列表 + if p.ApiKeys != nil { + updates["api_keys"] = apiKeysToJSON(*p.ApiKeys) + } + + _, err = models.Orm.QueryTable(new(models.PlatformAgentApi)). + Filter("id", row.ID). + Update(updates) + if err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + + c.ok("更新成功", nil) +} + +// Delete DELETE /platform/agentApi/:id +func (c *PlatformAgentApiController) Delete() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + row, err := c.findByID() + if err != nil { + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.PlatformAgentApi)). + Filter("id", row.ID). + Update(map[string]interface{}{ + "is_deleted": 1, + "delete_time": now, + }) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + + c.ok("删除成功", nil) +} + +// BatchDelete POST /platform/agentApi/batchDelete +func (c *PlatformAgentApiController) BatchDelete() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var p struct { + IDs []uint64 `json:"ids"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(p.IDs) == 0 { + c.jsonErr(400, 400, "请选择要删除的配置") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.PlatformAgentApi)). + Filter("id__in", p.IDs). + Filter("is_deleted", 0). + Update(map[string]interface{}{ + "is_deleted": 1, + "delete_time": now, + }) + if err != nil { + c.jsonErr(500, 500, "批量删除失败: "+err.Error()) + return + } + + c.ok("批量删除成功", nil) +} + +// ToggleStatus POST /platform/agentApi/:id/status +func (c *PlatformAgentApiController) ToggleStatus() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + row, err := c.findByID() + if err != nil { + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var p struct { + Status *int8 `json:"status"` + } + if err := json.Unmarshal(raw, &p); err != nil || p.Status == nil { + c.jsonErr(400, 400, "参数错误") + return + } + if *p.Status != 0 && *p.Status != 1 { + c.jsonErr(400, 400, "状态值不正确") + return + } + + now := time.Now() + _, err = models.Orm.QueryTable(new(models.PlatformAgentApi)). + Filter("id", row.ID). + Update(map[string]interface{}{ + "status": *p.Status, + "update_time": now, + }) + if err != nil { + c.jsonErr(500, 500, "状态切换失败: "+err.Error()) + return + } + + c.ok("操作成功", nil) +} + +// agentApiTestResult 测试结果统一结构,与前端 test.vue 字段对应 +type agentApiTestResult struct { + Success bool `json:"success"` + Message string `json:"message"` + StatusCode int `json:"status_code,omitempty"` + LatencyMs int64 `json:"latency_ms"` + Model string `json:"model,omitempty"` + KeyRemark string `json:"key_remark,omitempty"` + Response string `json:"response,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// Test POST /platform/agentApi/test +// 请求体:{ "id": 1, "model": "gpt-4o", "key_index": 0, "prompt": "你好" } +// key_index 指定用密钥列表中的第几个密钥,缺省用第一个 +func (c *PlatformAgentApiController) Test() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var p struct { + ID uint64 `json:"id"` + Model string `json:"model"` + KeyIndex *int `json:"key_index"` + Prompt string `json:"prompt"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if p.ID == 0 { + c.jsonErr(400, 400, "缺少配置ID") + return + } + + var row models.PlatformAgentApi + err = models.Orm.QueryTable(new(models.PlatformAgentApi)). + Filter("id", p.ID). + Filter("is_deleted", 0). + One(&row) + if err != nil { + c.jsonErr(404, 404, "配置不存在") + return + } + + model := strings.TrimSpace(p.Model) + if model == "" { + list := modelsFromJSON(row.Models) + if len(list) == 0 { + c.jsonErr(400, 400, "该配置未添加模型") + return + } + model = list[0] + } + + // 挑选待测密钥 + keys := apiKeysFromJSON(row.ApiKeys) + if len(keys) == 0 { + c.jsonErr(400, 400, "该配置未添加 API Key") + return + } + idx := 0 + if p.KeyIndex != nil { + idx = *p.KeyIndex + } + if idx < 0 || idx >= len(keys) { + c.jsonErr(400, 400, "指定的密钥不存在") + return + } + chosen := keys[idx] + + prompt := strings.TrimSpace(p.Prompt) + if prompt == "" { + prompt = "你好" + } + + result := probeAgentApi(&row, chosen, model, prompt) + c.ok("success", result) +} + +// probeAgentApi 用指定密钥按上游协议族发起一次最小化对话请求 +func probeAgentApi( + row *models.PlatformAgentApi, + apiKey models.AgentApiKey, + model, prompt string, +) agentApiTestResult { + base := strings.TrimRight(row.EffectiveURL(), "/") + if base == "" { + return agentApiTestResult{ + Success: false, Message: "接口地址为空", + Model: model, KeyRemark: apiKey.Remark, + } + } + + protocol := detectAgentApiProtocol(row.Provider, base) + + var ( + endpoint string + body []byte + headers = map[string]string{"Content-Type": "application/json"} + err error + ) + + switch protocol { + case protocolAnthropic: + endpoint = base + "/messages" + headers["x-api-key"] = apiKey.Key + headers["anthropic-version"] = "2023-06-01" + body, err = json.Marshal(map[string]interface{}{ + "model": model, + "max_tokens": 64, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + }) + + case protocolGemini: + endpoint = fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, model, apiKey.Key) + body, err = json.Marshal(map[string]interface{}{ + "contents": []map[string]interface{}{ + {"parts": []map[string]string{{"text": prompt}}}, + }, + }) + + default: + // OpenAI 兼容协议,覆盖大多数官方接口、国产网关与自建中转 + endpoint = base + "/chat/completions" + headers["Authorization"] = "Bearer " + apiKey.Key + body, err = json.Marshal(map[string]interface{}{ + "model": model, + "max_tokens": 64, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + }) + } + + if err != nil { + return agentApiTestResult{ + Success: false, Message: "构造请求失败", Detail: err.Error(), + Model: model, KeyRemark: apiKey.Remark, + } + } + + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return agentApiTestResult{ + Success: false, Message: "构造请求失败", Detail: err.Error(), + Model: model, KeyRemark: apiKey.Remark, + } + } + for k, v := range headers { + req.Header.Set(k, v) + } + + start := time.Now() + resp, err := agentApiHTTPClient.Do(req) + latency := time.Since(start).Milliseconds() + + if err != nil { + return agentApiTestResult{ + Success: false, + Message: "请求上游失败(网络不通或地址错误)", + Detail: err.Error(), + LatencyMs: latency, + Model: model, + KeyRemark: apiKey.Remark, + } + } + defer resp.Body.Close() + + // 限制读取长度,避免超长响应占满内存 + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024)) + preview := string(respBody) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return agentApiTestResult{ + Success: false, + Message: fmt.Sprintf("上游返回 HTTP %d", resp.StatusCode), + StatusCode: resp.StatusCode, + LatencyMs: latency, + Model: model, + KeyRemark: apiKey.Remark, + Detail: preview, + } + } + + return agentApiTestResult{ + Success: true, + Message: "连接正常,密钥与模型可用", + StatusCode: resp.StatusCode, + LatencyMs: latency, + Model: model, + KeyRemark: apiKey.Remark, + Response: extractAgentReply(protocol, respBody, preview), + } +} + +// extractAgentReply 从上游响应里提取模型回复文本,解析失败则回退为原始预览 +func extractAgentReply(protocol agentApiProtocol, respBody []byte, fallback string) string { + switch protocol { + case protocolAnthropic: + var r struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(respBody, &r) == nil && len(r.Content) > 0 { + return r.Content[0].Text + } + + case protocolGemini: + var r struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if json.Unmarshal(respBody, &r) == nil && + len(r.Candidates) > 0 && len(r.Candidates[0].Content.Parts) > 0 { + return r.Candidates[0].Content.Parts[0].Text + } + + default: + var r struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if json.Unmarshal(respBody, &r) == nil && len(r.Choices) > 0 { + return r.Choices[0].Message.Content + } + } + return fallback +} diff --git a/go/models/init.go b/go/models/init.go index 6b10b9d..d36509d 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -67,6 +67,7 @@ func Init(_ string) { new(PlatformAccountPoolCursor), new(PlatformAccountPoolCodex), new(PlatformNotebook), + new(PlatformAgentApi), new(CmsArticleCategory), new(CmsArticle), diff --git a/go/models/platform_agent_api.go b/go/models/platform_agent_api.go new file mode 100644 index 0000000..d53bc83 --- /dev/null +++ b/go/models/platform_agent_api.go @@ -0,0 +1,44 @@ +package models + +import "time" + +// PlatformAgentApi 智能体API管理表: yz_platform_agent_api +// 按上游接口存储调用地址、密钥列表、模型列表 +// Provider 为用户自由填写的上游名称,不做枚举约束 +// ApiKeys 存 JSON 数组,支持同一上游下挂多个账号的密钥,每条可带备注 +type PlatformAgentApi struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Provider string `orm:"column(provider);size(100)" json:"provider"` + BaseURL string `orm:"column(base_url);size(500)" json:"base_url"` + UseCustomURL int8 `orm:"column(use_custom_url);default(0)" json:"use_custom_url"` + CustomURL string `orm:"column(custom_url);size(500)" json:"custom_url"` + ApiKeys string `orm:"column(api_keys);type(text);null" json:"api_keys"` + Models string `orm:"column(models);type(text);null" json:"models"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Remark string `orm:"column(remark);size(500)" json:"remark"` + UserID *uint64 `orm:"column(user_id);null" json:"user_id"` + UserName *string `orm:"column(user_name);size(100);null" json:"user_name"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformAgentApi) TableName() string { + return "yz_platform_agent_api" +} + +// EffectiveURL 返回实际生效的接口地址 +// 勾选了自定义地址时用 CustomURL,否则用 BaseURL +func (m *PlatformAgentApi) EffectiveURL() string { + if m.UseCustomURL == 1 && m.CustomURL != "" { + return m.CustomURL + } + return m.BaseURL +} + +// AgentApiKey 单条密钥:密钥本身 + 可选备注(如账号名 mimo198) +type AgentApiKey struct { + Key string `json:"key"` + Remark string `json:"remark"` +} diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 18029f9..1bd16bc 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -268,6 +268,15 @@ func Register() { beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update") beego.Router("/platform/notebook/delete/:id", &controllers.PlatformNotebookController{}, "delete:Delete") + // 智能体API管理(yz_platform_agent_api) + // 注意:固定路径需先于 /:id 通配路径注册,避免 list/test/batchDelete 被当作 ID 解析 + beego.Router("/platform/agentApi/list", &controllers.PlatformAgentApiController{}, "get:List") + beego.Router("/platform/agentApi/test", &controllers.PlatformAgentApiController{}, "post:Test") + beego.Router("/platform/agentApi/batchDelete", &controllers.PlatformAgentApiController{}, "post:BatchDelete") + beego.Router("/platform/agentApi", &controllers.PlatformAgentApiController{}, "post:Create") + beego.Router("/platform/agentApi/:id/status", &controllers.PlatformAgentApiController{}, "post:ToggleStatus") + beego.Router("/platform/agentApi/:id", &controllers.PlatformAgentApiController{}, "get:Detail;put:Update;delete:Delete") + // 日程提醒管理 beego.Router("/platform/reminder/list", &controllers.PlatformReminderController{}, "get:GetReminderList") beego.Router("/platform/reminder/test", &controllers.PlatformReminderController{}, "post:TestReminder") diff --git a/platform/src/api/agentapimanagement.js b/platform/src/api/agentapimanagement.js new file mode 100644 index 0000000..d292c54 --- /dev/null +++ b/platform/src/api/agentapimanagement.js @@ -0,0 +1,76 @@ +import request from "@/utils/request"; + +/** + * 智能体 API 管理模块 + * 按上游接口(OpenAI / Anthropic / Gemini 等)存储调用地址、Key、模型列表 + */ + +/** 上游接口配置列表 */ +export function getAgentApiList(params) { + return request({ + url: "/platform/agentApi/list", + method: "get", + params, + }); +} + +/** 上游接口配置详情 */ +export function getAgentApiDetail(id) { + return request({ + url: `/platform/agentApi/${id}`, + method: "get", + }); +} + +/** 新增上游接口配置 */ +export function createAgentApi(data) { + return request({ + url: "/platform/agentApi", + method: "post", + data, + }); +} + +/** 更新上游接口配置 */ +export function updateAgentApi(id, data) { + return request({ + url: `/platform/agentApi/${id}`, + method: "put", + data, + }); +} + +/** 删除上游接口配置 */ +export function deleteAgentApi(id) { + return request({ + url: `/platform/agentApi/${id}`, + method: "delete", + }); +} + +/** 批量删除 */ +export function batchDeleteAgentApi(ids) { + return request({ + url: "/platform/agentApi/batchDelete", + method: "post", + data: { ids }, + }); +} + +/** 切换启用状态 */ +export function toggleAgentApiStatus(id, status) { + return request({ + url: `/platform/agentApi/${id}/status`, + method: "post", + data: { status }, + }); +} + +/** 测试指定配置下某个模型的连通性 */ +export function testAgentApiConnection(data) { + return request({ + url: "/platform/agentApi/test", + method: "post", + data, + }); +} diff --git a/platform/src/views/tools/agentapimanagement/components/detail.vue b/platform/src/views/tools/agentapimanagement/components/detail.vue new file mode 100644 index 0000000..67edd84 --- /dev/null +++ b/platform/src/views/tools/agentapimanagement/components/detail.vue @@ -0,0 +1,301 @@ + + + + + + + {{ providerInitial(detail.provider) }} + + + {{ providerLabel(detail.provider) }} + + {{ apiKeys.length }} 个密钥 · {{ models.length }} 个模型 + + + + {{ Number(detail.status) === 1 ? '已启用' : '已禁用' }} + + + + + + {{ detail.id }} + + + + {{ detail.base_url || '—' }} + + 已启用自定义地址,实际调用走下方地址 + + + + + {{ detail.custom_url || '—' }} + + + + + + + + {{ k.remark }} + + 密钥 {{ idx + 1 }} + + 复制 + + + + + + 未配置密钥 + + + + + + {{ m }} + + + 未配置模型 + + 共 {{ models.length }} 个模型,点击可复制 + + + + + {{ detail.remark || '—' }} + + + + {{ detail.create_time || '—' }} + + + + {{ detail.update_time || '—' }} + + + + + + + + + + + + + + 关闭 + + + + + + + diff --git a/platform/src/views/tools/agentapimanagement/components/edit.vue b/platform/src/views/tools/agentapimanagement/components/edit.vue new file mode 100644 index 0000000..fb80652 --- /dev/null +++ b/platform/src/views/tools/agentapimanagement/components/edit.vue @@ -0,0 +1,557 @@ + + + + + + + + 用于区分不同上游来源,内容完全由你决定 + + + + + + 使用自定义地址(中转 / 代理 / 私有部署) + + + + + + 勾选自定义后,实际调用将使用此地址,上方地址仅作留档 + + + + + 暂未添加密钥 + + + {{ idx + 1 }} + + + + + + + + + + + + 添加密钥 + + + 同一上游下有多个账号时可添加多条密钥,备注用于区分账号,可留空 + + + + + + + + + {{ m }} + + + 暂未添加模型 + + + + + + 添加模型 + + + + + + + + 启用 + 禁用 + + + + + + + + + + + + + + + + + diff --git a/platform/src/views/tools/agentapimanagement/components/test.vue b/platform/src/views/tools/agentapimanagement/components/test.vue new file mode 100644 index 0000000..367278b --- /dev/null +++ b/platform/src/views/tools/agentapimanagement/components/test.vue @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + 同一上游下有多个账号时,选择要验证的那个密钥 + + + + + + 可从已配置模型中选择,也可直接输入其他模型名 + + + + + + + + + + 测试选中模型 + + + + 测试全部模型({{ models.length }}) + + + + 测试结果 + + + + + + + + + 正在测试{{ batchTesting ? `(${results.length}/${models.length})` : '' }},请稍候... + + + + + + + + {{ r.success ? '成功' : '失败' }} + + {{ r.model }} + + {{ r.key_remark }} + + {{ r.latency_ms }} ms + + + {{ r.message || (r.success ? '连接正常' : '连接异常') }} + + {{ r.response }} + {{ r.detail }} + + + + + + 关闭 + + + + + + + diff --git a/platform/src/views/tools/agentapimanagement/constants.js b/platform/src/views/tools/agentapimanagement/constants.js new file mode 100644 index 0000000..b98e380 --- /dev/null +++ b/platform/src/views/tools/agentapimanagement/constants.js @@ -0,0 +1,133 @@ +/** + * 智能体 API 管理 - 共享工具 + * + * 上游接口(provider)为用户自由填写的文本,不做枚举限制。 + * 这里只提供展示辅助(配色、首字母徽标)与数据规整能力。 + */ + +/** 徽标配色池,按上游名称哈希稳定取色,保证同一上游每次渲染颜色一致 */ +const BADGE_COLORS = [ + '#10a37f', + '#d97757', + '#4285f4', + '#4d6bfe', + '#ff6a00', + '#615ced', + '#3859ff', + '#16b98c', + '#7c3aed', + '#e6465e', + '#0ea5e9', + '#f59e0b', +]; + +/** 上游名称展示文本 */ +export function providerLabel(value) { + const s = String(value || '').trim(); + return s || '未填写上游'; +} + +/** + * 由上游名称稳定推导徽标颜色 + * 用简单字符串哈希取模,纯展示用途 + */ +export function providerColor(value) { + const s = String(value || '').trim(); + if (!s) return '#909399'; + let hash = 0; + for (let i = 0; i < s.length; i += 1) { + hash = (hash * 31 + s.charCodeAt(i)) % 100000; + } + return BADGE_COLORS[hash % BADGE_COLORS.length]; +} + +/** + * 徽标显示字符:取上游名称首个可见字符 + * 英文转大写,中文原样显示 + */ +export function providerInitial(value) { + const s = String(value || '').trim(); + if (!s) return '?'; + return s.charAt(0).toUpperCase(); +} + +/** + * 计算实际生效的接口地址 + * 勾选了自定义地址时用 custom_url,否则用 base_url + */ +export function effectiveUrl(row) { + if (!row) return ''; + return Number(row.use_custom_url) === 1 ? row.custom_url || '' : row.base_url || ''; +} + +/** + * 把后端返回的 models 统一规整成字符串数组 + * 后端可能返回 JSON 字符串、数组或逗号分隔字符串 + */ +export function normalizeModels(models) { + if (!models) return []; + if (Array.isArray(models)) return models.filter(Boolean).map((m) => String(m).trim()); + if (typeof models === 'string') { + const raw = models.trim(); + if (!raw) return []; + if (raw.startsWith('[')) { + try { + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr.filter(Boolean).map((m) => String(m).trim()) : []; + } catch { + return []; + } + } + return raw + .split(',') + .map((m) => m.trim()) + .filter(Boolean); + } + return []; +} + +/** + * 把后端返回的 api_keys 统一规整成 [{ key, remark }] 数组 + * 兼容对象数组、纯字符串数组、单个裸密钥字符串 + */ +export function normalizeApiKeys(apiKeys) { + if (!apiKeys) return []; + + const toItem = (v) => { + if (v && typeof v === 'object') { + const key = String(v.key || '').trim(); + if (!key) return null; + return { key, remark: String(v.remark || '').trim() }; + } + const key = String(v || '').trim(); + return key ? { key, remark: '' } : null; + }; + + if (Array.isArray(apiKeys)) { + return apiKeys.map(toItem).filter(Boolean); + } + + if (typeof apiKeys === 'string') { + const raw = apiKeys.trim(); + if (!raw) return []; + if (raw.startsWith('[')) { + try { + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr.map(toItem).filter(Boolean) : []; + } catch { + return []; + } + } + return [{ key: raw, remark: '' }]; + } + + return []; +} + +/** API Key 脱敏:保留首 6 位与末 4 位 */ +export function maskKey(key) { + if (!key) return '—'; + const s = String(key); + if (s.length <= 12) return '*'.repeat(s.length); + return `${s.slice(0, 6)}${'*'.repeat(8)}${s.slice(-4)}`; +} diff --git a/platform/src/views/tools/agentapimanagement/index.vue b/platform/src/views/tools/agentapimanagement/index.vue new file mode 100644 index 0000000..6194658 --- /dev/null +++ b/platform/src/views/tools/agentapimanagement/index.vue @@ -0,0 +1,738 @@ + + + + 智能体 API 管理 + + + + 新增上游接口 + + + + 刷新 + + + + + + + + + + + + + + + + + + + + + + + + 查询 + + 重置 + + + + + + + + + + + + {{ providerInitial(row.provider) }} + + + + {{ providerLabel(row.provider) }} + + + {{ keysOf(row).length }} 个密钥 · {{ modelsOf(row).length }} 个模型 + + + handleCommand(cmd, row)"> + + 操作 + + + + + + 详情 + + + 测试连通性 + + + 编辑 + + + 复制新建 + + + 删除 + + + + + + + + + + 接口地址 + + 自定义 + + + + 复制 + + + + {{ effectiveUrl(row) || '—' }} + + + + + + API Key + {{ keysOf(row).length }} 个 + + + + + + {{ k.remark }} + + 密钥 {{ idx + 1 }} + + + {{ revealedKeys.has(`${row.id}-${idx}`) ? '隐藏' : '显示' }} + + + 复制 + + + + + {{ revealedKeys.has(`${row.id}-${idx}`) ? k.key : maskKey(k.key) }} + + + + 未配置密钥 + + + + + + 模型列表 + 点击可复制 + {{ modelsOf(row).length }} 个 + + + + {{ m }} + + + 未配置模型 + + + + + 备注 + {{ row.remark }} + + + + + {{ row.create_time || '' }} + + + + + + + + + + + 加载中... + + + + + + + 正在加载更多... + + + 已加载全部 {{ total }} 条配置 + + + + + + + + + + + + + + + + + +
{{ r.response }}
{{ r.detail }}