修复智能体bug
This commit is contained in:
@@ -31,32 +31,31 @@ var agentApiHTTPClient = &http.Client{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// agentApiProtocol 上游协议族,决定请求路径、鉴权头与响应解析方式
|
// 支持的请求协议白名单,与前端 constants.js 的 PROTOCOLS 保持一致
|
||||||
type agentApiProtocol string
|
var agentApiProtocols = map[string]bool{
|
||||||
|
models.AgentProtocolChatCompletions: true,
|
||||||
|
models.AgentProtocolResponses: true,
|
||||||
|
models.AgentProtocolAnthropic: true,
|
||||||
|
models.AgentProtocolGemini: true,
|
||||||
|
models.AgentProtocolCustom: true,
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
// agentApiEndpointPath 各协议在根地址之后要追加的路径
|
||||||
protocolAnthropic agentApiProtocol = "anthropic"
|
// custom 与 gemini 不在此表中:custom 不追加任何路径,gemini 需要嵌入模型名
|
||||||
protocolGemini agentApiProtocol = "gemini"
|
var agentApiEndpointPath = map[string]string{
|
||||||
protocolOpenAI agentApiProtocol = "openai"
|
models.AgentProtocolChatCompletions: "/chat/completions",
|
||||||
)
|
models.AgentProtocolResponses: "/responses",
|
||||||
|
models.AgentProtocolAnthropic: "/messages",
|
||||||
|
}
|
||||||
|
|
||||||
// detectAgentApiProtocol 由上游名称与地址关键词推断协议族
|
// normalizeProtocol 缺省或非法值统一回落到 Chat Completions
|
||||||
// 上游名称由用户自由填写,因此这里用包含匹配而非枚举比对;
|
// 该协议被绝大多数官方接口、国产网关与自建中转支持,作为兜底最安全
|
||||||
// 未命中任何关键词时按 OpenAI 兼容协议处理(绝大多数中转与国产网关都兼容该协议)
|
func normalizeProtocol(p string) string {
|
||||||
func detectAgentApiProtocol(provider, url string) agentApiProtocol {
|
p = strings.TrimSpace(strings.ToLower(p))
|
||||||
hay := strings.ToLower(provider + " " + url)
|
if p == "" || !agentApiProtocols[p] {
|
||||||
|
return models.AgentProtocolChatCompletions
|
||||||
for _, k := range []string{"anthropic", "claude"} {
|
|
||||||
if strings.Contains(hay, k) {
|
|
||||||
return protocolAnthropic
|
|
||||||
}
|
}
|
||||||
}
|
return p
|
||||||
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) {
|
func (c *PlatformAgentApiController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
@@ -97,6 +96,7 @@ func (c *PlatformAgentApiController) platformClaims() (*jwtutil.Claims, error) {
|
|||||||
// ApiKeys 用指针:编辑时不传该字段表示不修改已存密钥列表
|
// ApiKeys 用指针:编辑时不传该字段表示不修改已存密钥列表
|
||||||
type agentApiPayload struct {
|
type agentApiPayload struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
UseCustomURL int8 `json:"use_custom_url"`
|
UseCustomURL int8 `json:"use_custom_url"`
|
||||||
CustomURL string `json:"custom_url"`
|
CustomURL string `json:"custom_url"`
|
||||||
@@ -214,9 +214,11 @@ func modelsFromJSON(raw string) []string {
|
|||||||
// 密钥以明文返回,前端卡片默认脱敏、可切换显示;接口本身已受平台端鉴权保护
|
// 密钥以明文返回,前端卡片默认脱敏、可切换显示;接口本身已受平台端鉴权保护
|
||||||
func agentApiToMap(row *models.PlatformAgentApi) map[string]interface{} {
|
func agentApiToMap(row *models.PlatformAgentApi) map[string]interface{} {
|
||||||
keys := apiKeysFromJSON(row.ApiKeys)
|
keys := apiKeysFromJSON(row.ApiKeys)
|
||||||
|
protocol := normalizeProtocol(row.Protocol)
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"id": row.ID,
|
"id": row.ID,
|
||||||
"provider": row.Provider,
|
"provider": row.Provider,
|
||||||
|
"protocol": protocol,
|
||||||
"api_keys": keys,
|
"api_keys": keys,
|
||||||
"key_count": len(keys),
|
"key_count": len(keys),
|
||||||
"base_url": row.BaseURL,
|
"base_url": row.BaseURL,
|
||||||
@@ -247,6 +249,15 @@ func validateAgentApiPayload(p *agentApiPayload, isCreate bool) error {
|
|||||||
return fmt.Errorf("上游接口名称过长")
|
return fmt.Errorf("上游接口名称过长")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求协议必须是白名单内的值,缺省回落到 Chat Completions
|
||||||
|
p.Protocol = strings.TrimSpace(strings.ToLower(p.Protocol))
|
||||||
|
if p.Protocol == "" {
|
||||||
|
p.Protocol = models.AgentProtocolChatCompletions
|
||||||
|
}
|
||||||
|
if !agentApiProtocols[p.Protocol] {
|
||||||
|
return fmt.Errorf("请求协议不正确")
|
||||||
|
}
|
||||||
|
|
||||||
p.BaseURL = strings.TrimSpace(p.BaseURL)
|
p.BaseURL = strings.TrimSpace(p.BaseURL)
|
||||||
p.CustomURL = strings.TrimSpace(p.CustomURL)
|
p.CustomURL = strings.TrimSpace(p.CustomURL)
|
||||||
|
|
||||||
@@ -297,6 +308,33 @@ func isHTTPURL(s string) bool {
|
|||||||
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
|
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildAgentEndpoint 依据协议算出实际请求地址
|
||||||
|
// custom 协议直接把填写的地址当完整端点,不做任何追加;
|
||||||
|
// 其余协议在根地址后追加各自路径,若地址已以该路径结尾则不重复追加
|
||||||
|
func buildAgentEndpoint(protocol, base, model, apiKey string) string {
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
|
||||||
|
switch protocol {
|
||||||
|
case models.AgentProtocolCustom:
|
||||||
|
return base
|
||||||
|
|
||||||
|
case models.AgentProtocolGemini:
|
||||||
|
// 模型名嵌在路径中间,无固定后缀可判重
|
||||||
|
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, model, apiKey)
|
||||||
|
|
||||||
|
default:
|
||||||
|
path := agentApiEndpointPath[protocol]
|
||||||
|
if path == "" {
|
||||||
|
path = "/chat/completions"
|
||||||
|
}
|
||||||
|
// 用户可能填根地址,也可能直接填完整端点,后者不再追加
|
||||||
|
if strings.HasSuffix(strings.ToLower(base), strings.ToLower(path)) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// List GET /platform/agentApi/list?page=1&pageSize=24&keyword=&provider=&status=
|
// List GET /platform/agentApi/list?page=1&pageSize=24&keyword=&provider=&status=
|
||||||
func (c *PlatformAgentApiController) List() {
|
func (c *PlatformAgentApiController) List() {
|
||||||
if _, err := c.platformClaims(); err != nil {
|
if _, err := c.platformClaims(); err != nil {
|
||||||
@@ -446,6 +484,7 @@ func (c *PlatformAgentApiController) Create() {
|
|||||||
userID := uint64(claims.UserID)
|
userID := uint64(claims.UserID)
|
||||||
row := &models.PlatformAgentApi{
|
row := &models.PlatformAgentApi{
|
||||||
Provider: p.Provider,
|
Provider: p.Provider,
|
||||||
|
Protocol: p.Protocol,
|
||||||
BaseURL: p.BaseURL,
|
BaseURL: p.BaseURL,
|
||||||
UseCustomURL: p.UseCustomURL,
|
UseCustomURL: p.UseCustomURL,
|
||||||
CustomURL: p.CustomURL,
|
CustomURL: p.CustomURL,
|
||||||
@@ -500,6 +539,7 @@ func (c *PlatformAgentApiController) Update() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"provider": p.Provider,
|
"provider": p.Provider,
|
||||||
|
"protocol": p.Protocol,
|
||||||
"base_url": p.BaseURL,
|
"base_url": p.BaseURL,
|
||||||
"use_custom_url": p.UseCustomURL,
|
"use_custom_url": p.UseCustomURL,
|
||||||
"custom_url": p.CustomURL,
|
"custom_url": p.CustomURL,
|
||||||
@@ -640,6 +680,7 @@ func (c *PlatformAgentApiController) ToggleStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// agentApiTestResult 测试结果统一结构,与前端 test.vue 字段对应
|
// agentApiTestResult 测试结果统一结构,与前端 test.vue 字段对应
|
||||||
|
// Endpoint 回显实际请求的完整地址,便于排查路径拼接类问题(如上游根地址少了版本前缀)
|
||||||
type agentApiTestResult struct {
|
type agentApiTestResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -647,10 +688,20 @@ type agentApiTestResult struct {
|
|||||||
LatencyMs int64 `json:"latency_ms"`
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
KeyRemark string `json:"key_remark,omitempty"`
|
KeyRemark string `json:"key_remark,omitempty"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
Response string `json:"response,omitempty"`
|
Response string `json:"response,omitempty"`
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redactKeyInURL 隐去 URL 查询串里的密钥
|
||||||
|
// Gemini 协议把 key 放在 query 中,回显 endpoint 前必须脱敏
|
||||||
|
func redactKeyInURL(rawURL, key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rawURL, key, "***")
|
||||||
|
}
|
||||||
|
|
||||||
// Test POST /platform/agentApi/test
|
// Test POST /platform/agentApi/test
|
||||||
// 请求体:{ "id": 1, "model": "gpt-4o", "key_index": 0, "prompt": "你好" }
|
// 请求体:{ "id": 1, "model": "gpt-4o", "key_index": 0, "prompt": "你好" }
|
||||||
// key_index 指定用密钥列表中的第几个密钥,缺省用第一个
|
// key_index 指定用密钥列表中的第几个密钥,缺省用第一个
|
||||||
@@ -726,7 +777,7 @@ func (c *PlatformAgentApiController) Test() {
|
|||||||
c.ok("success", result)
|
c.ok("success", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeAgentApi 用指定密钥按上游协议族发起一次最小化对话请求
|
// probeAgentApi 用指定密钥按配置的请求协议发起一次最小化对话请求
|
||||||
func probeAgentApi(
|
func probeAgentApi(
|
||||||
row *models.PlatformAgentApi,
|
row *models.PlatformAgentApi,
|
||||||
apiKey models.AgentApiKey,
|
apiKey models.AgentApiKey,
|
||||||
@@ -740,18 +791,18 @@ func probeAgentApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol := detectAgentApiProtocol(row.Provider, base)
|
// 协议由配置显式指定,不再根据上游名称猜测
|
||||||
|
protocol := normalizeProtocol(row.Protocol)
|
||||||
|
endpoint := buildAgentEndpoint(protocol, base, model, apiKey.Key)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
endpoint string
|
|
||||||
body []byte
|
body []byte
|
||||||
headers = map[string]string{"Content-Type": "application/json"}
|
headers = map[string]string{"Content-Type": "application/json"}
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case protocolAnthropic:
|
case models.AgentProtocolAnthropic:
|
||||||
endpoint = base + "/messages"
|
|
||||||
headers["x-api-key"] = apiKey.Key
|
headers["x-api-key"] = apiKey.Key
|
||||||
headers["anthropic-version"] = "2023-06-01"
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
body, err = json.Marshal(map[string]interface{}{
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
@@ -762,17 +813,25 @@ func probeAgentApi(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
case protocolGemini:
|
case models.AgentProtocolGemini:
|
||||||
endpoint = fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, model, apiKey.Key)
|
// 密钥已放在 query 中,无需鉴权头
|
||||||
body, err = json.Marshal(map[string]interface{}{
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
"contents": []map[string]interface{}{
|
"contents": []map[string]interface{}{
|
||||||
{"parts": []map[string]string{{"text": prompt}}},
|
{"parts": []map[string]string{{"text": prompt}}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
case models.AgentProtocolResponses:
|
||||||
|
// Responses API 用 input 字段替代 messages
|
||||||
|
headers["Authorization"] = "Bearer " + apiKey.Key
|
||||||
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"input": prompt,
|
||||||
|
"max_output_tokens": 64,
|
||||||
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// OpenAI 兼容协议,覆盖大多数官方接口、国产网关与自建中转
|
// Chat Completions 与 custom 均按 OpenAI 标准报文发送
|
||||||
endpoint = base + "/chat/completions"
|
|
||||||
headers["Authorization"] = "Bearer " + apiKey.Key
|
headers["Authorization"] = "Bearer " + apiKey.Key
|
||||||
body, err = json.Marshal(map[string]interface{}{
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -795,12 +854,15 @@ func probeAgentApi(
|
|||||||
return agentApiTestResult{
|
return agentApiTestResult{
|
||||||
Success: false, Message: "构造请求失败", Detail: err.Error(),
|
Success: false, Message: "构造请求失败", Detail: err.Error(),
|
||||||
Model: model, KeyRemark: apiKey.Remark,
|
Model: model, KeyRemark: apiKey.Remark,
|
||||||
|
Endpoint: redactKeyInURL(endpoint, apiKey.Key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
safeEndpoint := redactKeyInURL(endpoint, apiKey.Key)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := agentApiHTTPClient.Do(req)
|
resp, err := agentApiHTTPClient.Do(req)
|
||||||
latency := time.Since(start).Milliseconds()
|
latency := time.Since(start).Milliseconds()
|
||||||
@@ -813,6 +875,7 @@ func probeAgentApi(
|
|||||||
LatencyMs: latency,
|
LatencyMs: latency,
|
||||||
Model: model,
|
Model: model,
|
||||||
KeyRemark: apiKey.Remark,
|
KeyRemark: apiKey.Remark,
|
||||||
|
Endpoint: safeEndpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -822,13 +885,19 @@ func probeAgentApi(
|
|||||||
preview := string(respBody)
|
preview := string(respBody)
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
msg := fmt.Sprintf("上游返回 HTTP %d", resp.StatusCode)
|
||||||
|
// 404 多半是接口地址少了版本前缀,给出可操作的提示
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
msg += ":接口地址可能不正确,请检查是否缺少版本路径(如 /v1、/compatible-mode/v2)"
|
||||||
|
}
|
||||||
return agentApiTestResult{
|
return agentApiTestResult{
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: fmt.Sprintf("上游返回 HTTP %d", resp.StatusCode),
|
Message: msg,
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
LatencyMs: latency,
|
LatencyMs: latency,
|
||||||
Model: model,
|
Model: model,
|
||||||
KeyRemark: apiKey.Remark,
|
KeyRemark: apiKey.Remark,
|
||||||
|
Endpoint: safeEndpoint,
|
||||||
Detail: preview,
|
Detail: preview,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,14 +909,15 @@ func probeAgentApi(
|
|||||||
LatencyMs: latency,
|
LatencyMs: latency,
|
||||||
Model: model,
|
Model: model,
|
||||||
KeyRemark: apiKey.Remark,
|
KeyRemark: apiKey.Remark,
|
||||||
|
Endpoint: safeEndpoint,
|
||||||
Response: extractAgentReply(protocol, respBody, preview),
|
Response: extractAgentReply(protocol, respBody, preview),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractAgentReply 从上游响应里提取模型回复文本,解析失败则回退为原始预览
|
// extractAgentReply 从上游响应里提取模型回复文本,解析失败则回退为原始预览
|
||||||
func extractAgentReply(protocol agentApiProtocol, respBody []byte, fallback string) string {
|
func extractAgentReply(protocol string, respBody []byte, fallback string) string {
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case protocolAnthropic:
|
case models.AgentProtocolAnthropic:
|
||||||
var r struct {
|
var r struct {
|
||||||
Content []struct {
|
Content []struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -857,7 +927,7 @@ func extractAgentReply(protocol agentApiProtocol, respBody []byte, fallback stri
|
|||||||
return r.Content[0].Text
|
return r.Content[0].Text
|
||||||
}
|
}
|
||||||
|
|
||||||
case protocolGemini:
|
case models.AgentProtocolGemini:
|
||||||
var r struct {
|
var r struct {
|
||||||
Candidates []struct {
|
Candidates []struct {
|
||||||
Content struct {
|
Content struct {
|
||||||
@@ -872,6 +942,25 @@ func extractAgentReply(protocol agentApiProtocol, respBody []byte, fallback stri
|
|||||||
return r.Candidates[0].Content.Parts[0].Text
|
return r.Candidates[0].Content.Parts[0].Text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case models.AgentProtocolResponses:
|
||||||
|
// Responses API 优先取聚合字段 output_text,回退到 output 数组
|
||||||
|
var r struct {
|
||||||
|
OutputText string `json:"output_text"`
|
||||||
|
Output []struct {
|
||||||
|
Content []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"output"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &r) == nil {
|
||||||
|
if r.OutputText != "" {
|
||||||
|
return r.OutputText
|
||||||
|
}
|
||||||
|
if len(r.Output) > 0 && len(r.Output[0].Content) > 0 {
|
||||||
|
return r.Output[0].Content[0].Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
var r struct {
|
var r struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// PlatformAgentApi 智能体API管理表: yz_platform_agent_api
|
// PlatformAgentApi 智能体API管理表: yz_platform_agent_api
|
||||||
// 按上游接口存储调用地址、密钥列表、模型列表
|
// 按上游接口存储调用地址、请求协议、密钥列表、模型列表
|
||||||
// Provider 为用户自由填写的上游名称,不做枚举约束
|
// Provider 为用户自由填写的上游名称,不做枚举约束
|
||||||
|
// Protocol 显式指定请求协议,决定拼哪个端点路径与用哪种鉴权头
|
||||||
// ApiKeys 存 JSON 数组,支持同一上游下挂多个账号的密钥,每条可带备注
|
// ApiKeys 存 JSON 数组,支持同一上游下挂多个账号的密钥,每条可带备注
|
||||||
type PlatformAgentApi struct {
|
type PlatformAgentApi struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
Provider string `orm:"column(provider);size(100)" json:"provider"`
|
Provider string `orm:"column(provider);size(100)" json:"provider"`
|
||||||
|
Protocol string `orm:"column(protocol);size(30);default(chat_completions)" json:"protocol"`
|
||||||
BaseURL string `orm:"column(base_url);size(500)" json:"base_url"`
|
BaseURL string `orm:"column(base_url);size(500)" json:"base_url"`
|
||||||
UseCustomURL int8 `orm:"column(use_custom_url);default(0)" json:"use_custom_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"`
|
CustomURL string `orm:"column(custom_url);size(500)" json:"custom_url"`
|
||||||
@@ -42,3 +44,17 @@ type AgentApiKey struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求协议枚举,与前端 constants.js 的 PROTOCOLS 保持一致
|
||||||
|
const (
|
||||||
|
// AgentProtocolChatCompletions OpenAI Chat Completions API,端点 {base}/chat/completions
|
||||||
|
AgentProtocolChatCompletions = "chat_completions"
|
||||||
|
// AgentProtocolResponses OpenAI Responses API,端点 {base}/responses
|
||||||
|
AgentProtocolResponses = "responses"
|
||||||
|
// AgentProtocolAnthropic Anthropic Messages API,端点 {base}/messages
|
||||||
|
AgentProtocolAnthropic = "anthropic"
|
||||||
|
// AgentProtocolGemini Google Gemini generateContent,端点 {base}/models/{model}:generateContent
|
||||||
|
AgentProtocolGemini = "gemini"
|
||||||
|
// AgentProtocolCustom 自定义:直接把填写的地址当完整端点请求,不追加任何路径
|
||||||
|
AgentProtocolCustom = "custom"
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
{{ detail.id }}
|
{{ detail.id }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="请求协议" label-width="130px">
|
||||||
|
<el-tag size="small" type="primary" effect="plain">
|
||||||
|
{{ protocolLabel(detail.protocol) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="接口地址" label-width="130px">
|
<el-descriptions-item label="接口地址" label-width="130px">
|
||||||
<div class="url-text">{{ detail.base_url || '—' }}</div>
|
<div class="url-text">{{ detail.base_url || '—' }}</div>
|
||||||
<el-tag
|
<el-tag
|
||||||
@@ -43,6 +49,10 @@
|
|||||||
<div class="url-text">{{ detail.custom_url || '—' }}</div>
|
<div class="url-text">{{ detail.custom_url || '—' }}</div>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="实际请求" label-width="130px">
|
||||||
|
<div class="endpoint-preview">{{ endpointPreview || '—' }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="API Key" label-width="130px">
|
<el-descriptions-item label="API Key" label-width="130px">
|
||||||
<div v-if="apiKeys.length > 0" class="key-list">
|
<div v-if="apiKeys.length > 0" class="key-list">
|
||||||
<div v-for="(k, idx) in apiKeys" :key="idx" class="key-item">
|
<div v-for="(k, idx) in apiKeys" :key="idx" class="key-item">
|
||||||
@@ -120,6 +130,9 @@ import {
|
|||||||
providerInitial,
|
providerInitial,
|
||||||
normalizeModels,
|
normalizeModels,
|
||||||
normalizeApiKeys,
|
normalizeApiKeys,
|
||||||
|
protocolLabel,
|
||||||
|
previewEndpoint,
|
||||||
|
effectiveUrl,
|
||||||
} from '../constants'
|
} from '../constants'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -129,6 +142,12 @@ const detail = ref(null)
|
|||||||
const models = computed(() => normalizeModels(detail.value?.models))
|
const models = computed(() => normalizeModels(detail.value?.models))
|
||||||
const apiKeys = computed(() => normalizeApiKeys(detail.value?.api_keys))
|
const apiKeys = computed(() => normalizeApiKeys(detail.value?.api_keys))
|
||||||
|
|
||||||
|
// 实际请求地址,规则与后端一致
|
||||||
|
const endpointPreview = computed(() => {
|
||||||
|
if (!detail.value) return ''
|
||||||
|
return previewEndpoint(detail.value.protocol, effectiveUrl(detail.value))
|
||||||
|
})
|
||||||
|
|
||||||
async function open(id) {
|
async function open(id) {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -219,6 +238,18 @@ defineExpose({ open })
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.endpoint-preview {
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border: 1px dashed var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #409eff;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.mt6 {
|
.mt6 {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,22 @@
|
|||||||
<span class="form-tip">用于区分不同上游来源,内容完全由你决定</span>
|
<span class="form-tip">用于区分不同上游来源,内容完全由你决定</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="请求协议" prop="protocol">
|
||||||
|
<el-select v-model="form.protocol" placeholder="请选择请求协议" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="p in PROTOCOLS"
|
||||||
|
:key="p.value"
|
||||||
|
:label="p.label"
|
||||||
|
:value="p.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span class="form-tip">{{ currentProtocolDesc }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="接口地址" prop="base_url">
|
<el-form-item label="接口地址" prop="base_url">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.base_url"
|
v-model="form.base_url"
|
||||||
placeholder="例如:https://api.openai.com/v1"
|
:placeholder="urlPlaceholder"
|
||||||
:disabled="form.use_custom_url"
|
:disabled="form.use_custom_url"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
@@ -56,6 +68,11 @@
|
|||||||
<span class="form-tip">勾选自定义后,实际调用将使用此地址,上方地址仅作留档</span>
|
<span class="form-tip">勾选自定义后,实际调用将使用此地址,上方地址仅作留档</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 实时预览最终请求地址,避免拼错到测试时才发现 -->
|
||||||
|
<el-form-item v-if="endpointPreview" label="实际请求">
|
||||||
|
<div class="endpoint-preview">{{ endpointPreview }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="API Key" prop="api_keys">
|
<el-form-item label="API Key" prop="api_keys">
|
||||||
<div class="keys-box">
|
<div class="keys-box">
|
||||||
<div v-if="form.api_keys.length === 0" class="no-key">暂未添加密钥</div>
|
<div v-if="form.api_keys.length === 0" class="no-key">暂未添加密钥</div>
|
||||||
@@ -168,7 +185,14 @@ import { ref, reactive, computed, nextTick } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Plus, Delete } from '@element-plus/icons-vue'
|
import { Plus, Delete } from '@element-plus/icons-vue'
|
||||||
import { getAgentApiDetail, createAgentApi, updateAgentApi } from '@/api/agentapimanagement'
|
import { getAgentApiDetail, createAgentApi, updateAgentApi } from '@/api/agentapimanagement'
|
||||||
import { normalizeModels, normalizeApiKeys } from '../constants'
|
import {
|
||||||
|
normalizeModels,
|
||||||
|
normalizeApiKeys,
|
||||||
|
PROTOCOLS,
|
||||||
|
PROTOCOL_MAP,
|
||||||
|
DEFAULT_PROTOCOL,
|
||||||
|
previewEndpoint,
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
const emit = defineEmits(['saved'])
|
const emit = defineEmits(['saved'])
|
||||||
|
|
||||||
@@ -193,6 +217,7 @@ const inputRef = ref(null)
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
provider: '',
|
provider: '',
|
||||||
|
protocol: DEFAULT_PROTOCOL,
|
||||||
base_url: '',
|
base_url: '',
|
||||||
use_custom_url: false,
|
use_custom_url: false,
|
||||||
custom_url: '',
|
custom_url: '',
|
||||||
@@ -202,8 +227,34 @@ const form = reactive({
|
|||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 当前协议的说明文案
|
||||||
|
const currentProtocolDesc = computed(
|
||||||
|
() => PROTOCOL_MAP[form.protocol]?.desc || ''
|
||||||
|
)
|
||||||
|
|
||||||
|
// 地址输入框的占位提示随协议变化
|
||||||
|
const urlPlaceholder = computed(() => {
|
||||||
|
if (form.protocol === 'custom') {
|
||||||
|
return '填写完整端点,例如:https://your-gateway.com/invoke'
|
||||||
|
}
|
||||||
|
if (form.protocol === 'anthropic') {
|
||||||
|
return '填根地址,例如:https://api.anthropic.com/v1'
|
||||||
|
}
|
||||||
|
if (form.protocol === 'gemini') {
|
||||||
|
return '填根地址,例如:https://generativelanguage.googleapis.com/v1beta'
|
||||||
|
}
|
||||||
|
return '填根地址,例如:https://api.openai.com/v1'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 最终请求地址预览,规则与后端一致
|
||||||
|
const endpointPreview = computed(() => {
|
||||||
|
const url = form.use_custom_url ? form.custom_url : form.base_url
|
||||||
|
return previewEndpoint(form.protocol, url)
|
||||||
|
})
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
provider: [{ required: true, message: '请输入上游接口名称', trigger: 'blur' }],
|
provider: [{ required: true, message: '请输入上游接口名称', trigger: 'blur' }],
|
||||||
|
protocol: [{ required: true, message: '请选择请求协议', trigger: 'change' }],
|
||||||
base_url: [
|
base_url: [
|
||||||
{
|
{
|
||||||
validator: (rule, value, callback) => {
|
validator: (rule, value, callback) => {
|
||||||
@@ -329,6 +380,7 @@ function validateKeys() {
|
|||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.id = 0
|
form.id = 0
|
||||||
form.provider = ''
|
form.provider = ''
|
||||||
|
form.protocol = DEFAULT_PROTOCOL
|
||||||
form.base_url = ''
|
form.base_url = ''
|
||||||
form.use_custom_url = false
|
form.use_custom_url = false
|
||||||
form.custom_url = ''
|
form.custom_url = ''
|
||||||
@@ -394,6 +446,7 @@ async function loadInto(id) {
|
|||||||
const d = res.data
|
const d = res.data
|
||||||
form.id = d.id
|
form.id = d.id
|
||||||
form.provider = d.provider || ''
|
form.provider = d.provider || ''
|
||||||
|
form.protocol = d.protocol || DEFAULT_PROTOCOL
|
||||||
form.base_url = d.base_url || ''
|
form.base_url = d.base_url || ''
|
||||||
form.use_custom_url = Number(d.use_custom_url) === 1
|
form.use_custom_url = Number(d.use_custom_url) === 1
|
||||||
form.custom_url = d.custom_url || ''
|
form.custom_url = d.custom_url || ''
|
||||||
@@ -430,6 +483,7 @@ async function submit() {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
provider: form.provider.trim(),
|
provider: form.provider.trim(),
|
||||||
|
protocol: form.protocol,
|
||||||
base_url: form.base_url || '',
|
base_url: form.base_url || '',
|
||||||
use_custom_url: form.use_custom_url ? 1 : 0,
|
use_custom_url: form.use_custom_url ? 1 : 0,
|
||||||
custom_url: form.use_custom_url ? form.custom_url : '',
|
custom_url: form.use_custom_url ? form.custom_url : '',
|
||||||
@@ -479,6 +533,19 @@ defineExpose({ open, openCopy })
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.endpoint-preview {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border: 1px dashed var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #409eff;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.keys-box {
|
.keys-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<el-form-item label="上游接口">
|
<el-form-item label="上游接口">
|
||||||
<el-input v-model="info.provider_label" disabled />
|
<el-input v-model="info.provider_label" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="请求协议">
|
||||||
|
<el-input v-model="info.protocol_label" disabled />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="生效地址">
|
<el-form-item label="生效地址">
|
||||||
<el-input v-model="info.url" disabled />
|
<el-input v-model="info.url" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -88,6 +91,9 @@
|
|||||||
<div class="result-msg" :class="{ error: !r.success }">
|
<div class="result-msg" :class="{ error: !r.success }">
|
||||||
{{ r.message || (r.success ? '连接正常' : '连接异常') }}
|
{{ r.message || (r.success ? '连接正常' : '连接异常') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="r.endpoint" class="result-endpoint">
|
||||||
|
实际请求:{{ r.endpoint }}
|
||||||
|
</div>
|
||||||
<pre v-if="r.response" class="result-response">{{ r.response }}</pre>
|
<pre v-if="r.response" class="result-response">{{ r.response }}</pre>
|
||||||
<pre v-if="!r.success && r.detail" class="result-response error">{{ r.detail }}</pre>
|
<pre v-if="!r.success && r.detail" class="result-response error">{{ r.detail }}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +117,7 @@ import {
|
|||||||
normalizeModels,
|
normalizeModels,
|
||||||
normalizeApiKeys,
|
normalizeApiKeys,
|
||||||
maskKey,
|
maskKey,
|
||||||
|
protocolLabel,
|
||||||
} from '../constants'
|
} from '../constants'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -128,6 +135,7 @@ const results = ref([])
|
|||||||
|
|
||||||
const info = reactive({
|
const info = reactive({
|
||||||
provider_label: '',
|
provider_label: '',
|
||||||
|
protocol_label: '',
|
||||||
url: '',
|
url: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,6 +160,7 @@ async function open(id) {
|
|||||||
if (res?.code === 200 && res.data) {
|
if (res?.code === 200 && res.data) {
|
||||||
const d = res.data
|
const d = res.data
|
||||||
info.provider_label = providerLabel(d.provider)
|
info.provider_label = providerLabel(d.provider)
|
||||||
|
info.protocol_label = protocolLabel(d.protocol)
|
||||||
info.url = effectiveUrl(d)
|
info.url = effectiveUrl(d)
|
||||||
models.value = normalizeModels(d.models)
|
models.value = normalizeModels(d.models)
|
||||||
selectedModel.value = models.value[0] || ''
|
selectedModel.value = models.value[0] || ''
|
||||||
@@ -308,6 +317,15 @@ defineExpose({ open })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-endpoint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
color: #909399;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.result-response {
|
.result-response {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -2,9 +2,75 @@
|
|||||||
* 智能体 API 管理 - 共享工具
|
* 智能体 API 管理 - 共享工具
|
||||||
*
|
*
|
||||||
* 上游接口(provider)为用户自由填写的文本,不做枚举限制。
|
* 上游接口(provider)为用户自由填写的文本,不做枚举限制。
|
||||||
* 这里只提供展示辅助(配色、首字母徽标)与数据规整能力。
|
* 请求协议(protocol)显式选择,决定实际请求哪个端点路径。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求协议枚举,与后端 models.AgentProtocol* 常量保持一致
|
||||||
|
* path 为在接口地址之后追加的路径,custom 与 gemini 特殊处理
|
||||||
|
*/
|
||||||
|
export const PROTOCOLS = [
|
||||||
|
{
|
||||||
|
value: 'chat_completions',
|
||||||
|
label: 'Chat Completions API',
|
||||||
|
path: '/chat/completions',
|
||||||
|
desc: 'OpenAI 标准对话接口,绝大多数上游与中转都兼容',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'responses',
|
||||||
|
label: 'Responses API',
|
||||||
|
path: '/responses',
|
||||||
|
desc: 'OpenAI 新版接口,请求体用 input 替代 messages',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'anthropic',
|
||||||
|
label: 'Anthropic Messages API',
|
||||||
|
path: '/messages',
|
||||||
|
desc: 'Claude 官方协议,鉴权头为 x-api-key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gemini',
|
||||||
|
label: 'Google Gemini',
|
||||||
|
path: '/models/{model}:generateContent',
|
||||||
|
desc: '模型名嵌在路径中,密钥通过 query 传递',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'custom',
|
||||||
|
label: '自定义(直接请求填写的地址)',
|
||||||
|
path: '',
|
||||||
|
desc: '不追加任何路径,把填写的地址当完整端点请求',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROTOCOL_MAP = Object.fromEntries(PROTOCOLS.map((p) => [p.value, p]));
|
||||||
|
|
||||||
|
export const DEFAULT_PROTOCOL = 'chat_completions';
|
||||||
|
|
||||||
|
/** 协议展示名 */
|
||||||
|
export function protocolLabel(value) {
|
||||||
|
return PROTOCOL_MAP[value]?.label || value || '未设置协议';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览实际请求地址,与后端 buildAgentEndpoint 的规则一致
|
||||||
|
* 供表单实时提示用,避免地址拼错才在测试时发现
|
||||||
|
*/
|
||||||
|
export function previewEndpoint(protocol, url, model = '{model}') {
|
||||||
|
const base = String(url || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!base) return '';
|
||||||
|
|
||||||
|
if (protocol === 'custom') return base;
|
||||||
|
|
||||||
|
if (protocol === 'gemini') {
|
||||||
|
return `${base}/models/${model}:generateContent?key=***`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = PROTOCOL_MAP[protocol]?.path || '/chat/completions';
|
||||||
|
// 地址已以该路径结尾时不重复追加
|
||||||
|
if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
|
||||||
|
return base + path;
|
||||||
|
}
|
||||||
|
|
||||||
/** 徽标配色池,按上游名称哈希稳定取色,保证同一上游每次渲染颜色一致 */
|
/** 徽标配色池,按上游名称哈希稳定取色,保证同一上游每次渲染颜色一致 */
|
||||||
const BADGE_COLORS = [
|
const BADGE_COLORS = [
|
||||||
'#10a37f',
|
'#10a37f',
|
||||||
|
|||||||
@@ -108,6 +108,9 @@
|
|||||||
<div class="card-section">
|
<div class="card-section">
|
||||||
<div class="section-label">
|
<div class="section-label">
|
||||||
接口地址
|
接口地址
|
||||||
|
<el-tag size="small" type="primary" effect="plain">
|
||||||
|
{{ protocolLabel(row.protocol) }}
|
||||||
|
</el-tag>
|
||||||
<el-tag v-if="Number(row.use_custom_url) === 1" type="warning" size="small" effect="plain">
|
<el-tag v-if="Number(row.use_custom_url) === 1" type="warning" size="small" effect="plain">
|
||||||
自定义
|
自定义
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -264,6 +267,7 @@ import {
|
|||||||
normalizeModels,
|
normalizeModels,
|
||||||
normalizeApiKeys,
|
normalizeApiKeys,
|
||||||
maskKey,
|
maskKey,
|
||||||
|
protocolLabel,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import AgentApiEdit from './components/edit.vue'
|
import AgentApiEdit from './components/edit.vue'
|
||||||
import AgentApiDetail from './components/detail.vue'
|
import AgentApiDetail from './components/detail.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user