- 侧栏 Ask|Agent 分段;Ask 仍走 /api/ai/chat,Agent 走 /api/ai/agent - RunAgent 多轮 tool call → 进程内 aitools(与 MCP 同源只读/白名单/审计) - 回复下步骤时间线;openai-chat tools(DeepSeek);agent-accept 冒烟
144 lines
4.3 KiB
Go
144 lines
4.3 KiB
Go
package ai
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"opsxdb/server/internal/aitools"
|
||
)
|
||
|
||
// ToolsTurn 一轮带 tools 的模型回复:要么最终文本,要么一组 tool_calls。
|
||
type ToolsTurn struct {
|
||
Content string
|
||
ToolCalls []ToolCall
|
||
RawFinish string
|
||
}
|
||
|
||
// ToolChat 可挂载 tools 的 LLM(Agent 循环依赖此接口,便于单测注入)。
|
||
type ToolChat interface {
|
||
ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error)
|
||
}
|
||
|
||
// ChatWithTools openai-chat 兼容协议的 tools 调用(DeepSeek / OpenAI / 多数兼容网关)。
|
||
// anthropic / responses 本轮不支持 Agent tool calling,返回明确错误。
|
||
func (p *RemoteProvider) ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
|
||
if err := validateEndpoint(p.baseURL, p.allowHTTP); err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
if p.protocol != "" && p.protocol != ProtoChat {
|
||
return ToolsTurn{}, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s);请改用 DeepSeek / OpenAI 兼容端点", p.protocol)
|
||
}
|
||
m := strings.TrimSpace(model)
|
||
if m == "" {
|
||
m = p.model
|
||
}
|
||
urlStr := endpoint(p.baseURL, "chat/completions")
|
||
payload := map[string]any{
|
||
"model": m,
|
||
"messages": messages,
|
||
"tools": tools,
|
||
"tool_choice": "auto",
|
||
"max_tokens": remoteMaxTokens,
|
||
"temperature": 0.2,
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return ToolsTurn{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
|
||
if err != nil {
|
||
return ToolsTurn{}, 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 ToolsTurn{}, fmt.Errorf("AI 请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if err != nil {
|
||
return ToolsTurn{}, fmt.Errorf("读取 AI 响应失败: %w", err)
|
||
}
|
||
return parseToolsTurn(raw, resp.StatusCode)
|
||
}
|
||
|
||
func parseToolsTurn(raw []byte, status int) (ToolsTurn, 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 ToolsTurn{}, fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
|
||
}
|
||
var out struct {
|
||
Choices []struct {
|
||
FinishReason string `json:"finish_reason"`
|
||
Message struct {
|
||
Role string `json:"role"`
|
||
Content *string `json:"content"`
|
||
ToolCalls []ToolCall `json:"tool_calls"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return ToolsTurn{}, fmt.Errorf("解析 AI 响应失败(HTTP %d)", status)
|
||
}
|
||
if out.Error != nil {
|
||
return ToolsTurn{}, fmt.Errorf("AI 服务返回错误: %s", out.Error.Message)
|
||
}
|
||
if len(out.Choices) == 0 {
|
||
return ToolsTurn{}, fmt.Errorf("AI 服务未返回候选结果")
|
||
}
|
||
ch := out.Choices[0]
|
||
content := ""
|
||
if ch.Message.Content != nil {
|
||
content = strings.TrimSpace(*ch.Message.Content)
|
||
}
|
||
return ToolsTurn{
|
||
Content: content,
|
||
ToolCalls: ch.Message.ToolCalls,
|
||
RawFinish: ch.FinishReason,
|
||
}, nil
|
||
}
|
||
|
||
// DefaultToolChat 返回可跑 Agent 的远端 Provider;stub / 缺 key 时返回错误说明。
|
||
func DefaultToolChat() (ToolChat, error) {
|
||
cfg := LoadConfig()
|
||
if cfg.Provider == "" || cfg.Provider == "stub" {
|
||
return nil, fmt.Errorf("Agent 需要真实 LLM:请在设置中配置 DeepSeek / OpenAI 等(stub 仅支持 Ask 生成)")
|
||
}
|
||
if cfg.APIKey == "" && cfg.AuthStyle != AuthNone {
|
||
return nil, fmt.Errorf("Agent 需要 API Key:请在设置中填写后重试")
|
||
}
|
||
p := NewRemoteProvider(cfg)
|
||
if p.protocol != "" && p.protocol != ProtoChat {
|
||
return nil, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s)", p.protocol)
|
||
}
|
||
return p, nil
|
||
}
|