feat(ai): 阶段 2 Ask|Agent 共用 aitools Runtime 真查库
- 侧栏 Ask|Agent 分段;Ask 仍走 /api/ai/chat,Agent 走 /api/ai/agent - RunAgent 多轮 tool call → 进程内 aitools(与 MCP 同源只读/白名单/审计) - 回复下步骤时间线;openai-chat tools(DeepSeek);agent-accept 冒烟
This commit is contained in:
1 parent
b8134c4503
commit
cd319ce7bd
14 files changed
+1270
-182
No files matched your search
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user