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:
logan committed 2026-10-15 13:00:40 +08:00
1 parent 1d6e89449a
commit 2336c9433c
17 files changed
+1324 -197

No files matched your search

+74
View File
@@ -475,6 +475,7 @@ func NewRouter(token string) *gin.Engine {
})
// 侧栏 Agent:多轮 tool call → 同一 aitools.Runtime(进程内,不绕 stdio)。Ask 仍走 /ai/chat。
// stream=true 或 Accept: text/event-stream 时走 SSE(步骤边推 + 正文分块);否则 JSON fallback。
api.POST("/ai/agent", func(c *gin.Context) {
var req ai.AgentReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -491,6 +492,30 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rt.Source = "agent"
rt.RejectCallerConfirm = true
wantStream := req.Stream || strings.Contains(c.GetHeader("Accept"), "text/event-stream")
if wantStream {
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
flusher.Flush()
_, err := ai.RunAgentStream(c.Request.Context(), llm, rt, req, func(ev ai.AgentEvent) {
_ = ai.WriteAgentSSE(c.Writer, flusher, ev)
})
if err != nil && c.Request.Context().Err() == nil {
_ = ai.WriteAgentSSE(c.Writer, flusher, ai.AgentEvent{Type: ai.AgentEventError, Error: err.Error()})
}
return
}
res, err := ai.RunAgent(c.Request.Context(), llm, rt, req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error(), "steps": res.Steps, "rounds": res.Rounds})
@@ -499,6 +524,55 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusOK, res)
})
// 用户在气泡确认条点「执行」后:单次写操作(AllowConfirmedWrite),绝不由模型静默触发。
api.POST("/ai/agent/confirm", func(c *gin.Context) {
var req struct {
ConnectionID string `json:"connectionId"`
SQL string `json:"sql"`
Command string `json:"command"`
Database string `json:"database"`
Kind string `json:"kind"` // query | redis
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rt, err := aitools.DefaultRuntime()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !rt.Policy.WritesEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "writes disabled: set mode=read_write in ~/.opsxdb/mcp.json"})
return
}
rt.Source = "agent"
rt.RejectCallerConfirm = false
rt.AllowConfirmedWrite = true
kind := strings.ToLower(strings.TrimSpace(req.Kind))
args := map[string]any{"connection_id": req.ConnectionID, "database": req.Database, "confirm_danger": true}
var out any
if kind == "redis" || (kind == "" && req.Command != "") {
args["command"] = req.Command
if args["command"] == "" || args["command"] == nil {
args["command"] = req.SQL
}
out, err = rt.Call(c.Request.Context(), aitools.ToolExecuteRedisCommand, args)
} else {
args["sql"] = req.SQL
out, err = rt.Call(c.Request.Context(), aitools.ToolExecuteQuery, args)
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
})
api.GET("/ai/mcp-status", func(c *gin.Context) {
c.JSON(http.StatusOK, aitools.GetMCPStatus())
})
api.POST("/ai/generate", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {