Files
yunzerwebsiteallinone/go/controllers/backend_ai_chat.go
T

1261 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
beego "github.com/beego/beego/v2/server/web"
)
// BackendAiChatController AI聊天消息控制器
type BackendAiChatController struct {
beego.Controller
}
func (c *BackendAiChatController) chatClaims() (*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 != "backend" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *BackendAiChatController) chatJsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendAiChatController) chatOk(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
type chatSendPayload struct {
SessionID uint64 `json:"session_id"`
Content string `json:"content"`
ProviderID uint64 `json:"provider_id"`
Model string `json:"model"`
}
// ============ 通用消息/工具结构 ============
// openaiMessage 通用消息(OpenAI 与 Anthropic 共用内部表示)
type openaiMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type openaiToolCall struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
} `json:"function,omitempty"`
}
type openaiTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
} `json:"function"`
}
// openaiDeltaToolCall 流式增量工具调用
type openaiDeltaToolCall struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
} `json:"function,omitempty"`
}
// pendingToolCall 一轮中 AI 请求调用的工具
type pendingToolCall struct {
ID string
Name string // 唯一工具key(mcp_{serverID}_{toolName})
Args map[string]interface{}
}
// toolRef 工具key -> 实际 MCP 服务器与工具
type toolRef struct {
ServerID uint64
ServerName string
ToolName string
}
// mcpToolSummary 前端展示用工具摘要
type mcpToolSummary struct {
Key string `json:"key"`
ServerID uint64 `json:"server_id"`
ServerName string `json:"server_name"`
ToolName string `json:"tool_name"`
Description string `json:"description"`
}
const maxToolRounds = 6
// sanitizeToolName 工具名规范化为 OpenAI 允许的字符集
func sanitizeToolName(s string) string {
var sb strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
sb.WriteRune(r)
} else {
sb.WriteRune('_')
}
}
if sb.Len() == 0 {
return "tool"
}
return sb.String()
}
// mcpToolKey 生成 LLM 调用使用的唯一工具名
func mcpToolKey(serverID uint64, name string) string {
key := fmt.Sprintf("mcp_%d_%s", serverID, sanitizeToolName(name))
if len(key) > 64 {
key = key[:64]
}
return key
}
// collectMcpTools 汇总当前用户「已启用」的 MCP 服务器工具,转换为 LLM 工具列表
func collectMcpTools(claims *jwtutil.Claims) ([]openaiTool, map[string]toolRef, []mcpToolSummary, error) {
var servers []models.BackendMcpServer
_, err := models.Orm.QueryTable(new(models.BackendMcpServer)).
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
Filter("user_id", uint64(claims.UserID)).
Filter("enabled", 1).
Filter("delete_time__isnull", true).
OrderBy("id").
All(&servers)
if err != nil {
return nil, nil, nil, err
}
var tools []openaiTool
refMap := make(map[string]toolRef)
var summaries []mcpToolSummary
for i := range servers {
srv := servers[i]
toolInfos, err := services.McpClientManager.EnsureConnected(&srv)
if err != nil {
// 单个服务连接失败不阻断整体,跳过并记录到摘要(标记不可用)
summaries = append(summaries, mcpToolSummary{
ServerID: srv.ID,
ServerName: srv.Name,
Description: "连接失败:" + err.Error(),
})
continue
}
for _, ti := range toolInfos {
key := mcpToolKey(srv.ID, ti.Name)
var fn openaiTool
fn.Type = "function"
fn.Function.Name = key
fn.Function.Description = ti.Description
if ti.InputSchema == nil {
fn.Function.Parameters = map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
} else {
fn.Function.Parameters = ti.InputSchema
}
tools = append(tools, fn)
refMap[key] = toolRef{ServerID: srv.ID, ServerName: srv.Name, ToolName: ti.Name}
summaries = append(summaries, mcpToolSummary{
Key: key,
ServerID: srv.ID,
ServerName: srv.Name,
ToolName: ti.Name,
Description: ti.Description,
})
}
}
return tools, refMap, summaries, nil
}
// executePendingTools 执行 AI 请求的工具调用,返回工具结果消息
func executePendingTools(claims *jwtutil.Claims, pending []pendingToolCall, refMap map[string]toolRef, onStart func(pc pendingToolCall), onResult func(pc pendingToolCall, text string, ok bool, errMsg string)) []openaiMessage {
var toolMsgs []openaiMessage
for _, pc := range pending {
ref, ok := refMap[pc.Name]
if !ok {
msg := fmt.Sprintf("工具 %s 未找到对应 MCP 服务", pc.Name)
if onStart != nil {
onStart(pc)
}
if onResult != nil {
onResult(pc, "", false, msg)
}
toolMsgs = append(toolMsgs, openaiMessage{Role: "tool", ToolCallID: pc.ID, Content: msg})
continue
}
if onStart != nil {
onStart(pc)
}
text, isErr, err := services.McpClientManager.CallTool(ref.ServerID, ref.ToolName, pc.Args)
if err != nil {
errMsg := "工具调用失败: " + err.Error()
if onResult != nil {
onResult(pc, "", false, errMsg)
}
toolMsgs = append(toolMsgs, openaiMessage{Role: "tool", ToolCallID: pc.ID, Content: errMsg})
continue
}
if onResult != nil {
onResult(pc, text, !isErr, "")
}
toolMsgs = append(toolMsgs, openaiMessage{Role: "tool", ToolCallID: pc.ID, Content: text})
}
return toolMsgs
}
// buildMcpAssistantMessage 构造携带 tool_calls 的 assistant 消息
func buildMcpAssistantMessage(text string, pending []pendingToolCall) openaiMessage {
msg := openaiMessage{Role: "assistant", Content: text}
for _, pc := range pending {
argsStr := "{}"
if pc.Args != nil {
if b, err := json.Marshal(pc.Args); err == nil {
argsStr = string(b)
}
}
tc := openaiToolCall{ID: pc.ID, Type: "function"}
tc.Function.Name = pc.Name
tc.Function.Arguments = argsStr
msg.ToolCalls = append(msg.ToolCalls, tc)
}
return msg
}
// runToolLoop 运行带已启用 MCP 工具的完整对话循环(非流式),返回最终文本。
// 供聊天非流式接口与智能生成等模块复用。
func runToolLoop(claims *jwtutil.Claims, provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage) (string, error) {
llmTools, refMap, _, _ := collectMcpTools(claims)
current := messages
rounds := 0
for {
rounds++
text, pending, err := callAITools(provider, model, systemPrompt, current, llmTools)
if err != nil {
return "", err
}
if len(pending) == 0 {
return text, nil
}
if rounds >= maxToolRounds {
return text, nil
}
current = append(current, buildMcpAssistantMessage(text, pending))
toolMsgs := executePendingTools(claims, pending, refMap, nil, nil)
current = append(current, toolMsgs...)
}
}
// ============ 会话/消息接口 ============
// MessageList GET /backend/ai/chat/message/list?session_id=xxx
func (c *BackendAiChatController) MessageList() {
claims, err := c.chatClaims()
if err != nil {
c.chatJsonErr(401, 401, err.Error())
return
}
sessionIDStr := strings.TrimSpace(c.GetString("session_id"))
if sessionIDStr == "" {
c.chatJsonErr(400, 400, "缺少会话ID")
return
}
sessionID, err := strconv.ParseUint(sessionIDStr, 10, 64)
if err != nil {
c.chatJsonErr(400, 400, "会话ID格式错误")
return
}
session := models.BackendAiChatSession{ID: sessionID}
if err := models.Orm.Read(&session); err != nil {
c.chatJsonErr(404, 404, "会话不存在")
return
}
if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) {
c.chatJsonErr(403, 403, "无权访问")
return
}
var list []models.BackendAiChatMessage
_, err = models.Orm.QueryTable(new(models.BackendAiChatMessage)).
Filter("session_id", sessionID).
OrderBy("id").
All(&list)
if err != nil {
c.chatJsonErr(500, 500, "查询失败: "+err.Error())
return
}
c.chatOk(map[string]interface{}{"list": list, "title": session.Title})
}
// DeleteMessage DELETE /backend/ai/chat/message/:id
func (c *BackendAiChatController) DeleteMessage() {
claims, err := c.chatClaims()
if err != nil {
c.chatJsonErr(401, 401, err.Error())
return
}
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.chatJsonErr(400, 400, "消息ID格式错误")
return
}
msg := models.BackendAiChatMessage{ID: id}
if err := models.Orm.Read(&msg); err != nil {
c.chatJsonErr(404, 404, "消息不存在")
return
}
session := models.BackendAiChatSession{ID: msg.SessionID}
if err := models.Orm.Read(&session); err != nil {
c.chatJsonErr(404, 404, "会话不存在")
return
}
if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) {
c.chatJsonErr(403, 403, "无权操作")
return
}
if _, err := models.Orm.Delete(&msg); err != nil {
c.chatJsonErr(500, 500, "删除失败: "+err.Error())
return
}
c.chatOk(nil)
}
// ============ 公共:会话/接入/预设解析 ============
// resolveOrCreateSession 查找或创建会话
func resolveOrCreateSession(c *BackendAiChatController, claims *jwtutil.Claims, p *chatSendPayload) (models.BackendAiChatSession, error) {
var session models.BackendAiChatSession
if p.SessionID > 0 {
session = models.BackendAiChatSession{ID: p.SessionID}
if err := models.Orm.Read(&session); err != nil {
return session, fmt.Errorf("会话不存在")
}
if session.TenantID != fmt.Sprintf("%d", claims.TenantId) || session.UserID != uint64(claims.UserID) {
return session, fmt.Errorf("无权操作")
}
} else {
title := p.Content
if len([]rune(title)) > 20 {
title = string([]rune(title)[:20]) + "..."
}
session = models.BackendAiChatSession{
TenantID: fmt.Sprintf("%d", claims.TenantId),
UserID: uint64(claims.UserID),
ProviderID: p.ProviderID,
Title: title,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
id, err := models.Orm.Insert(&session)
if err != nil {
return session, fmt.Errorf("创建会话失败: " + err.Error())
}
session.ID = uint64(id)
}
return session, nil
}
// resolveProvider 解析 AI 接入配置
func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models.BackendAiChatSession) (models.BackendAiProvider, error) {
providerID := p.ProviderID
if providerID == 0 {
providerID = session.ProviderID
}
var provider models.BackendAiProvider
if providerID > 0 {
provider = models.BackendAiProvider{ID: providerID}
if err := models.Orm.Read(&provider); err != nil {
return provider, fmt.Errorf("指定的AI接入配置不存在,请先在设置中配置")
}
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) || provider.UserID != uint64(claims.UserID) {
return provider, fmt.Errorf("无权使用该配置")
}
if provider.Status != 1 {
return provider, fmt.Errorf("该AI接入配置已被禁用")
}
} else {
var providers []models.BackendAiProvider
_, err := models.Orm.QueryTable(new(models.BackendAiProvider)).
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
Filter("user_id", uint64(claims.UserID)).
Filter("status", 1).
Filter("delete_time__isnull", true).
OrderBy("-id").
Limit(1).
All(&providers)
if err != nil || len(providers) == 0 {
return provider, fmt.Errorf("尚未配置AI接入,请先在设置中配置OpenAI或Anthropic接入")
}
provider = providers[0]
}
return provider, nil
}
// resolveModel 确定使用的模型
func resolveModel(p *chatSendPayload, provider *models.BackendAiProvider) (string, error) {
useModel := strings.TrimSpace(p.Model)
if useModel == "" {
providerModels := parseProviderModels(provider.Models)
if len(providerModels) > 0 {
useModel = providerModels[0]
}
}
if useModel == "" {
return "", fmt.Errorf("未指定模型且该接入配置无可用模型")
}
return useModel, nil
}
// loadSystemPrompt 加载默认角色预设
func loadSystemPrompt(claims *jwtutil.Claims) string {
var presets []models.BackendAiChatPreset
_, _ = models.Orm.QueryTable(new(models.BackendAiChatPreset)).
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
Filter("user_id", uint64(claims.UserID)).
Filter("is_default", 1).
Filter("delete_time__isnull", true).
Limit(1).
All(&presets)
if len(presets) > 0 {
return presets[0].Content
}
return ""
}
// loadHistory 加载会话历史(最近20条,正序)
func loadHistory(sessionID uint64) []openaiMessage {
var history []models.BackendAiChatMessage
_, _ = models.Orm.QueryTable(new(models.BackendAiChatMessage)).
Filter("session_id", sessionID).
OrderBy("-id").
Limit(20).
All(&history)
out := make([]openaiMessage, 0, len(history)+1)
for i := len(history) - 1; i >= 0; i-- {
out = append(out, openaiMessage{Role: history[i].Role, Content: history[i].Content})
}
return out
}
// ============ Send(非流式,含 MCP 工具调用) ============
// Send POST /backend/ai/chat/send
func (c *BackendAiChatController) Send() {
claims, err := c.chatClaims()
if err != nil {
c.chatJsonErr(401, 401, err.Error())
return
}
body, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.chatJsonErr(400, 400, "读取请求体失败")
return
}
var p chatSendPayload
if err := json.Unmarshal(body, &p); err != nil {
c.chatJsonErr(400, 400, "参数格式错误")
return
}
if strings.TrimSpace(p.Content) == "" {
c.chatJsonErr(400, 400, "消息内容不能为空")
return
}
session, err := resolveOrCreateSession(c, claims, &p)
if err != nil {
c.chatJsonErr(400, 400, err.Error())
return
}
provider, err := resolveProvider(claims, &p, &session)
if err != nil {
c.chatJsonErr(400, 400, err.Error())
return
}
useModel, err := resolveModel(&p, &provider)
if err != nil {
c.chatJsonErr(400, 400, err.Error())
return
}
systemPrompt := loadSystemPrompt(claims)
messages := loadHistory(session.ID)
messages = append(messages, openaiMessage{Role: "user", Content: p.Content})
// 保存用户消息
userMsg := models.BackendAiChatMessage{
TenantID: fmt.Sprintf("%d", claims.TenantId),
SessionID: session.ID,
Role: "user",
Content: p.Content,
CreateTime: time.Now(),
}
_, _ = models.Orm.Insert(&userMsg)
// 汇总启用的 MCP 工具
llmTools, refMap, _, _ := collectMcpTools(claims)
current := messages
reply := ""
rounds := 0
for {
rounds++
text, pending, callErr := callAITools(provider, useModel, systemPrompt, current, llmTools)
if callErr != nil {
c.chatJsonErr(500, 500, "AI调用失败: "+callErr.Error())
return
}
if len(pending) == 0 {
reply = text
break
}
if rounds >= maxToolRounds {
reply = text
break
}
current = append(current, buildMcpAssistantMessage(text, pending))
toolMsgs := executePendingTools(claims, pending, refMap, nil, nil)
current = append(current, toolMsgs...)
}
assistantMsg := models.BackendAiChatMessage{
TenantID: fmt.Sprintf("%d", claims.TenantId),
SessionID: session.ID,
Role: "assistant",
Content: reply,
CreateTime: time.Now(),
}
_, _ = models.Orm.Insert(&assistantMsg)
session.UpdateTime = time.Now()
_, _ = models.Orm.Update(&session, "update_time")
c.chatOk(map[string]interface{}{
"session_id": session.ID,
"reply": reply,
"message_id": assistantMsg.ID,
})
}
// ============ SendStream(SSE 流式,含 MCP 工具调用) ============
// SendStream POST /backend/ai/chat/send-stream
func (c *BackendAiChatController) SendStream() {
// SSE 手动写入响应体,禁用框架自动渲染模板
c.EnableRender = false
claims, err := c.chatClaims()
if err != nil {
c.chatJsonErr(401, 401, err.Error())
return
}
body, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.chatJsonErr(400, 400, "读取请求体失败")
return
}
var p chatSendPayload
if err := json.Unmarshal(body, &p); err != nil {
c.chatJsonErr(400, 400, "参数格式错误")
return
}
if strings.TrimSpace(p.Content) == "" {
c.chatJsonErr(400, 400, "消息内容不能为空")
return
}
session, err := resolveOrCreateSession(c, claims, &p)
if err != nil {
c.chatJsonErr(400, 400, err.Error())
return
}
provider, err := resolveProvider(claims, &p, &session)
if err != nil {
c.chatJsonErr(400, 400, err.Error())
return
}
useModel, err := resolveModel(&p, &provider)
if err != nil {
c.chatJsonErr(400, 400, err.Error())
return
}
systemPrompt := loadSystemPrompt(claims)
messages := loadHistory(session.ID)
messages = append(messages, openaiMessage{Role: "user", Content: p.Content})
// 保存用户消息
userMsg := models.BackendAiChatMessage{
TenantID: fmt.Sprintf("%d", claims.TenantId),
SessionID: session.ID,
Role: "user",
Content: p.Content,
CreateTime: time.Now(),
}
_, _ = models.Orm.Insert(&userMsg)
// 汇总启用的 MCP 工具
llmTools, refMap, toolSummaries, _ := collectMcpTools(claims)
// 设置SSE响应头
rw := c.Ctx.ResponseWriter.ResponseWriter
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Connection", "keep-alive")
rw.Header().Set("X-Accel-Buffering", "no")
rw.WriteHeader(200)
var flusher http.Flusher
if f, ok := rw.(http.Flusher); ok {
flusher = f
}
writeSSE := func(event string, data string) {
_, _ = rw.Write([]byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)))
if flusher != nil {
flusher.Flush()
}
}
writeJSON := func(event string, v interface{}) {
b, _ := json.Marshal(v)
writeSSE(event, string(b))
}
// session 事件
writeJSON("session", map[string]interface{}{"session_id": session.ID})
// 当前启用的工具列表(前端展示)
if len(toolSummaries) > 0 {
writeJSON("tool_list", map[string]interface{}{"tools": toolSummaries})
}
current := messages
rounds := 0
fullReply := ""
streamErr := error(nil)
for {
rounds++
var text string
var pending []pendingToolCall
text, pending, streamErr = callAIStreamTools(provider, useModel, systemPrompt, current, llmTools, func(chunk string) {
writeJSON("content", map[string]string{"content": chunk})
})
if streamErr != nil {
break
}
if len(pending) == 0 {
fullReply += text
break
}
if rounds >= maxToolRounds {
fullReply += text
break
}
current = append(current, buildMcpAssistantMessage(text, pending))
toolMsgs := executePendingTools(claims, pending, refMap,
func(pc pendingToolCall) {
ref, _ := refMap[pc.Name]
writeJSON("tool_start", map[string]interface{}{
"id": pc.ID,
"key": pc.Name,
"tool": ref.ToolName,
"server_id": ref.ServerID,
"server_name": ref.ServerName,
"args": pc.Args,
})
},
func(pc pendingToolCall, result string, ok bool, errMsg string) {
writeJSON("tool_result", map[string]interface{}{
"id": pc.ID,
"key": pc.Name,
"ok": ok,
"result": result,
"error": errMsg,
})
})
current = append(current, toolMsgs...)
}
if streamErr != nil {
writeJSON("error", map[string]string{"error": streamErr.Error()})
return
}
// 保存AI回复
assistantMsg := models.BackendAiChatMessage{
TenantID: fmt.Sprintf("%d", claims.TenantId),
SessionID: session.ID,
Role: "assistant",
Content: fullReply,
CreateTime: time.Now(),
}
_, _ = models.Orm.Insert(&assistantMsg)
session.UpdateTime = time.Now()
_, _ = models.Orm.Update(&session, "update_time")
writeJSON("done", map[string]interface{}{
"session_id": session.ID,
"message_id": assistantMsg.ID,
})
}
// ============ AI 调用(非流式,含工具) ============
// callAI 兼容入口(无 MCP 工具),供智能生成等模块使用
func callAI(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage) (string, error) {
text, pending, err := callAITools(provider, model, systemPrompt, messages, nil)
if err != nil {
return "", err
}
// 一般场景不包含工具调用;若模型仍返回工具调用(未注入工具不应发生),仅返回文本
_ = pending
return text, nil
}
func callAITools(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage, tools []openaiTool) (string, []pendingToolCall, error) {
client := &http.Client{Timeout: 180 * time.Second}
if provider.ProviderType == "openai" {
reqMessages := messages
if systemPrompt != "" {
reqMessages = append([]openaiMessage{{Role: "system", Content: systemPrompt}}, messages...)
}
reqBody := map[string]interface{}{
"model": model,
"messages": reqMessages,
}
if len(tools) > 0 {
reqBody["tools"] = tools
}
jsonData, _ := json.Marshal(reqBody)
url := strings.TrimRight(provider.ApiBase, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url = url + "/chat/completions"
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+provider.ApiKey)
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", nil, fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []openaiToolCall `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", nil, fmt.Errorf("解析响应失败: %s", string(bodyBytes))
}
if result.Error != nil {
return "", nil, fmt.Errorf(result.Error.Message)
}
if len(result.Choices) == 0 {
return "", nil, fmt.Errorf("AI未返回内容")
}
content := result.Choices[0].Message.Content
var pending []pendingToolCall
for _, tc := range result.Choices[0].Message.ToolCalls {
pending = append(pending, parseOpenAIToolCall(tc))
}
return content, pending, nil
}
// Anthropic 非流式
reqBody := map[string]interface{}{
"model": model,
"max_tokens": 4096,
"messages": toAnthropicMessages(messages),
}
if systemPrompt != "" {
reqBody["system"] = systemPrompt
}
if len(tools) > 0 {
reqBody["tools"] = toAnthropicTools(tools)
}
jsonData, _ := json.Marshal(reqBody)
url := strings.TrimRight(provider.ApiBase, "/")
if !strings.HasSuffix(url, "/v1/messages") {
url = url + "/v1/messages"
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", provider.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", nil, fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
} `json:"content"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", nil, fmt.Errorf("解析响应失败: %s", string(bodyBytes))
}
if result.Error != nil {
return "", nil, fmt.Errorf(result.Error.Message)
}
var text strings.Builder
var pending []pendingToolCall
for _, block := range result.Content {
if block.Type == "text" {
text.WriteString(block.Text)
}
if block.Type == "tool_use" {
pending = append(pending, pendingToolCall{ID: block.ID, Name: block.Name, Args: block.Input})
}
}
return text.String(), pending, nil
}
// parseOpenAIToolCall 解析非流式 OpenAI 工具调用
func parseOpenAIToolCall(tc openaiToolCall) pendingToolCall {
pc := pendingToolCall{ID: tc.ID, Name: tc.Function.Name}
if tc.Function.Arguments != "" {
var m map[string]interface{}
if err := json.Unmarshal([]byte(tc.Function.Arguments), &m); err == nil {
pc.Args = m
}
}
if pc.Args == nil {
pc.Args = map[string]interface{}{}
}
return pc
}
// ============ AI 调用(流式,含工具) ============
func callAIStreamTools(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage, tools []openaiTool, onChunk func(string)) (string, []pendingToolCall, error) {
if provider.ProviderType == "openai" {
return callOpenAIStreamTools(provider, model, systemPrompt, messages, tools, onChunk)
}
return callAnthropicStreamTools(provider, model, systemPrompt, messages, tools, onChunk)
}
// callOpenAIStreamTools OpenAI 兼容流式工具调用
func callOpenAIStreamTools(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage, tools []openaiTool, onChunk func(string)) (string, []pendingToolCall, error) {
client := &http.Client{Timeout: 180 * time.Second}
reqMessages := messages
if systemPrompt != "" {
reqMessages = append([]openaiMessage{{Role: "system", Content: systemPrompt}}, messages...)
}
reqBody := map[string]interface{}{
"model": model,
"messages": reqMessages,
"stream": true,
}
if len(tools) > 0 {
reqBody["tools"] = tools
}
jsonData, _ := json.Marshal(reqBody)
url := strings.TrimRight(provider.ApiBase, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url = url + "/chat/completions"
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+provider.ApiKey)
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return "", nil, fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(body))
}
var text strings.Builder
acc := make(map[int]*struct {
ID string
Name string
Args string
})
var order []int
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []openaiDeltaToolCall `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if chunk.Error != nil {
return "", nil, fmt.Errorf(chunk.Error.Message)
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.Delta.Content != "" {
text.WriteString(choice.Delta.Content)
if onChunk != nil {
onChunk(choice.Delta.Content)
}
}
for _, tc := range choice.Delta.ToolCalls {
if _, ok := acc[tc.Index]; !ok {
acc[tc.Index] = &struct {
ID string
Name string
Args string
}{}
order = append(order, tc.Index)
}
cur := acc[tc.Index]
if tc.ID != "" {
cur.ID = tc.ID
}
if tc.Function.Name != "" {
cur.Name += tc.Function.Name
}
if tc.Function.Arguments != "" {
cur.Args += tc.Function.Arguments
}
}
}
var pending []pendingToolCall
for _, idx := range order {
cur := acc[idx]
id := cur.ID
if id == "" {
id = fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), idx)
}
pc := pendingToolCall{ID: id, Name: cur.Name}
if cur.Args != "" {
var m map[string]interface{}
if err := json.Unmarshal([]byte(cur.Args), &m); err == nil {
pc.Args = m
}
}
if pc.Args == nil {
pc.Args = map[string]interface{}{}
}
pending = append(pending, pc)
}
return text.String(), pending, nil
}
// callAnthropicStreamTools Anthropic 流式工具调用
func callAnthropicStreamTools(provider models.BackendAiProvider, model string, systemPrompt string, messages []openaiMessage, tools []openaiTool, onChunk func(string)) (string, []pendingToolCall, error) {
client := &http.Client{Timeout: 180 * time.Second}
reqBody := map[string]interface{}{
"model": model,
"max_tokens": 4096,
"messages": toAnthropicMessages(messages),
"stream": true,
}
if systemPrompt != "" {
reqBody["system"] = systemPrompt
}
if len(tools) > 0 {
reqBody["tools"] = toAnthropicTools(tools)
}
jsonData, _ := json.Marshal(reqBody)
url := strings.TrimRight(provider.ApiBase, "/")
if !strings.HasSuffix(url, "/v1/messages") {
url = url + "/v1/messages"
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", provider.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := client.Do(req)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return "", nil, fmt.Errorf("API返回错误状态 %d: %s", resp.StatusCode, string(body))
}
var text strings.Builder
type toolUseAcc struct {
ID string
Name string
Input strings.Builder
}
acc := make(map[int]*toolUseAcc)
var order []int
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
currentEvent := ""
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
currentEvent = strings.TrimPrefix(line, "event: ")
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
switch currentEvent {
case "content_block_start":
var block struct {
Index int `json:"index"`
ContentBlock struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"content_block"`
}
if err := json.Unmarshal([]byte(data), &block); err != nil {
continue
}
if block.ContentBlock.Type == "tool_use" {
if _, ok := acc[block.Index]; !ok {
acc[block.Index] = &toolUseAcc{}
order = append(order, block.Index)
}
acc[block.Index].ID = block.ContentBlock.ID
acc[block.Index].Name = block.ContentBlock.Name
}
case "content_block_delta":
var delta struct {
Index int `json:"index"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
}
if err := json.Unmarshal([]byte(data), &delta); err != nil {
continue
}
if delta.Delta.Type == "text_delta" && delta.Delta.Text != "" {
text.WriteString(delta.Delta.Text)
if onChunk != nil {
onChunk(delta.Delta.Text)
}
}
if delta.Delta.Type == "input_json_delta" && delta.Delta.PartialJSON != "" {
if _, ok := acc[delta.Index]; !ok {
acc[delta.Index] = &toolUseAcc{}
order = append(order, delta.Index)
}
acc[delta.Index].Input.WriteString(delta.Delta.PartialJSON)
}
case "message_delta":
var md struct {
Delta struct {
StopReason string `json:"stop_reason"`
} `json:"delta"`
}
if err := json.Unmarshal([]byte(data), &md); err == nil {
_ = md.Delta.StopReason
}
case "message_stop":
// 结束
}
}
var pending []pendingToolCall
for _, idx := range order {
cur := acc[idx]
id := cur.ID
if id == "" {
id = fmt.Sprintf("toolu_%d_%d", time.Now().UnixNano(), idx)
}
pc := pendingToolCall{ID: id, Name: cur.Name}
inputStr := cur.Input.String()
if inputStr != "" {
var m map[string]interface{}
if err := json.Unmarshal([]byte(inputStr), &m); err == nil {
pc.Args = m
}
}
if pc.Args == nil {
pc.Args = map[string]interface{}{}
}
pending = append(pending, pc)
}
return text.String(), pending, nil
}
// ============ Anthropic 消息/工具格式转换 ============
func toAnthropicTools(tools []openaiTool) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]interface{}{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": t.Function.Parameters,
})
}
return out
}
// toAnthropicMessages 将内部 openaiMessage 列表转为 Anthropic 消息格式
func toAnthropicMessages(messages []openaiMessage) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(messages))
for _, m := range messages {
switch m.Role {
case "assistant":
if len(m.ToolCalls) > 0 {
blocks := make([]map[string]interface{}, 0, len(m.ToolCalls)+1)
if m.Content != "" {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": m.Content})
}
for _, tc := range m.ToolCalls {
var input map[string]interface{}
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
}
if input == nil {
input = map[string]interface{}{}
}
blocks = append(blocks, map[string]interface{}{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
out = append(out, map[string]interface{}{"role": "assistant", "content": blocks})
} else {
out = append(out, map[string]interface{}{"role": "assistant", "content": m.Content})
}
case "tool":
out = append(out, map[string]interface{}{
"role": "user",
"content": []map[string]interface{}{
{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": m.Content},
},
})
default:
out = append(out, map[string]interface{}{"role": m.Role, "content": m.Content})
}
}
return out
}