feat(ai): Agent SSE + Anthropic/responses tools + P1 确认写
- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback - tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌) - P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌 - GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
This commit is contained in:
1 parent
1d6e89449a
commit
2336c9433c
17 files changed
+1324
-197
No files matched your search
@@ -0,0 +1,292 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
defaultOpenAIModel = "gpt-4o-mini"
|
||||
remoteMaxTokens = 2048
|
||||
)
|
||||
|
||||
// 协议:openai-chat 覆盖绝大多数兼容服务;openai-responses 为 OpenAI 官方;anthropic 为 Claude。
|
||||
const (
|
||||
ProtoChat = "openai-chat"
|
||||
ProtoResponses = "openai-responses"
|
||||
ProtoAnthropic = "anthropic"
|
||||
)
|
||||
|
||||
// 鉴权风格:bearer = Authorization: Bearer;x-api-key = Anthropic 风格头;none = 本地服务免鉴权。
|
||||
const (
|
||||
AuthBearer = "bearer"
|
||||
AuthXAPIKey = "x-api-key"
|
||||
AuthNone = "none"
|
||||
)
|
||||
|
||||
// RemoteProvider 远端 HTTP 模型实现,按协议分派 chat/responses/anthropic(对齐 opsxshell AiService)。
|
||||
type RemoteProvider struct {
|
||||
protocol string
|
||||
authStyle string
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
allowHTTP bool
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// NewRemoteProvider 按配置构造;protocol 缺省按 provider 推断(anthropic → 原生协议)。
|
||||
func NewRemoteProvider(cfg Config) *RemoteProvider {
|
||||
protocol := strings.TrimSpace(cfg.Protocol)
|
||||
if protocol == "" {
|
||||
protocol = ProtoChat
|
||||
if strings.EqualFold(cfg.Provider, ProtoAnthropic) {
|
||||
protocol = ProtoAnthropic
|
||||
}
|
||||
}
|
||||
authStyle := strings.TrimSpace(cfg.AuthStyle)
|
||||
if authStyle == "" {
|
||||
authStyle = AuthBearer
|
||||
if protocol == ProtoAnthropic {
|
||||
authStyle = AuthXAPIKey
|
||||
}
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultOpenAIBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(cfg.Model)
|
||||
if model == "" {
|
||||
model = defaultOpenAIModel
|
||||
}
|
||||
return &RemoteProvider{
|
||||
protocol: protocol,
|
||||
authStyle: authStyle,
|
||||
baseURL: base,
|
||||
apiKey: cfg.APIKey,
|
||||
model: model,
|
||||
allowHTTP: cfg.AllowInsecureHTTP,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RemoteProvider) Name() string { return p.protocol }
|
||||
|
||||
// validateEndpoint 默认只放行 https;本地/内网服务(Ollama、vLLM)需显式 AllowInsecureHTTP。
|
||||
func validateEndpoint(baseURL string, allowInsecure bool) error {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("Base URL 格式无效")
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
if allowInsecure {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("非 HTTPS Base URL 已拒绝;本地/内网服务请在设置中开启「允许 HTTP」")
|
||||
default:
|
||||
return fmt.Errorf("Base URL 仅支持 HTTP/HTTPS")
|
||||
}
|
||||
}
|
||||
|
||||
// endpoint 拼接 basePath + suffix,已含 suffix 时不重复。
|
||||
func endpoint(base, suffix string) string {
|
||||
b := strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
s := strings.TrimLeft(suffix, "/")
|
||||
if strings.HasSuffix(b, "/"+s) {
|
||||
return b
|
||||
}
|
||||
return b + "/" + s
|
||||
}
|
||||
|
||||
// anthropicEndpoint base 未含 /v1 时补 /v1/messages,否则直接 /messages。
|
||||
func anthropicEndpoint(base string) string {
|
||||
b := strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if strings.HasSuffix(b, "/v1") {
|
||||
return endpoint(b, "messages")
|
||||
}
|
||||
return endpoint(b, "v1/messages")
|
||||
}
|
||||
|
||||
// Complete 单次非流式调用;req.Model 可临时覆盖配置模型。
|
||||
func (p *RemoteProvider) Complete(ctx context.Context, req ChatReq) (ChatRes, error) {
|
||||
if err := validateEndpoint(p.baseURL, p.allowHTTP); err != nil {
|
||||
return ChatRes{}, err
|
||||
}
|
||||
model := p.model
|
||||
if strings.TrimSpace(req.Model) != "" {
|
||||
model = strings.TrimSpace(req.Model)
|
||||
}
|
||||
msgs := BuildMessages(req)
|
||||
var (
|
||||
urlStr string
|
||||
payload any
|
||||
)
|
||||
switch p.protocol {
|
||||
case ProtoAnthropic:
|
||||
urlStr = anthropicEndpoint(p.baseURL)
|
||||
payload = map[string]any{
|
||||
"model": model,
|
||||
"system": msgs[0].Content,
|
||||
"messages": []Message{{Role: "user", Content: msgs[1].Content}},
|
||||
"max_tokens": remoteMaxTokens,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
case ProtoResponses:
|
||||
urlStr = endpoint(p.baseURL, "responses")
|
||||
payload = map[string]any{
|
||||
"model": model,
|
||||
"instructions": msgs[0].Content,
|
||||
"input": msgs[1].Content,
|
||||
"max_output_tokens": remoteMaxTokens,
|
||||
}
|
||||
default: // openai-chat 及未知值按兼容协议处理
|
||||
urlStr = endpoint(p.baseURL, "chat/completions")
|
||||
payload = map[string]any{
|
||||
"model": model,
|
||||
"messages": msgs,
|
||||
"max_tokens": remoteMaxTokens,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ChatRes{}, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return ChatRes{}, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
switch p.authStyle {
|
||||
case AuthXAPIKey:
|
||||
httpReq.Header.Set("x-api-key", p.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
case AuthNone: // 本地免鉴权服务
|
||||
default:
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
|
||||
resp, err := p.hc.Do(httpReq)
|
||||
if err != nil {
|
||||
return ChatRes{}, fmt.Errorf("AI 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return ChatRes{}, fmt.Errorf("读取 AI 响应失败: %w", err)
|
||||
}
|
||||
content, err := parseContent(p.protocol, raw, resp.StatusCode)
|
||||
if err != nil {
|
||||
return ChatRes{}, err
|
||||
}
|
||||
return ChatRes{Content: content, SQL: extractSQL(content)}, nil
|
||||
}
|
||||
|
||||
// parseContent 按协议解析响应体;非 2xx 时尽量提取服务端错误信息。
|
||||
func parseContent(protocol string, raw []byte, status int) (string, error) {
|
||||
var errBody struct {
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &errBody)
|
||||
if status < 200 || status >= 300 {
|
||||
msg := "未知错误"
|
||||
if errBody.Error != nil && errBody.Error.Message != "" {
|
||||
msg = errBody.Error.Message
|
||||
} else if errBody.Message != "" {
|
||||
msg = errBody.Message
|
||||
}
|
||||
return "", fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
|
||||
}
|
||||
switch protocol {
|
||||
case ProtoAnthropic:
|
||||
var out struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "text" {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", fmt.Errorf("AI 服务未返回文本内容")
|
||||
}
|
||||
return sb.String(), nil
|
||||
case ProtoResponses:
|
||||
var out struct {
|
||||
OutputText string `json:"output_text"`
|
||||
Output []struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"output"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
||||
}
|
||||
if out.OutputText != "" {
|
||||
return out.OutputText, nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, o := range out.Output {
|
||||
for _, c := range o.Content {
|
||||
sb.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", fmt.Errorf("AI 服务未返回文本内容")
|
||||
}
|
||||
return sb.String(), nil
|
||||
default:
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
||||
}
|
||||
if out.Error != nil {
|
||||
return "", fmt.Errorf("AI 服务返回错误: %s", out.Error.Message)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("AI 服务未返回候选结果")
|
||||
}
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
}
|
||||
|
||||
var sqlFence = regexp.MustCompile("(?s)```sql\\s*\n(.+?)```")
|
||||
|
||||
// extractSQL 从模型回复中提取 ```sql 代码块内容。
|
||||
func extractSQL(content string) string {
|
||||
m := sqlFence.FindStringSubmatch(content)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
Reference in new issue
Block a user