Files
xk-ai-agent/internal/router/router.go
2026-08-14 21:50:48 +08:00

463 lines
17 KiB
Go
Raw Permalink 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 router
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"tcm-agent/internal/agent"
"tcm-agent/internal/config"
"tcm-agent/internal/handler"
"tcm-agent/internal/kb"
"tcm-agent/internal/llm"
"tcm-agent/internal/middleware"
"tcm-agent/internal/service"
"tcm-agent/internal/tool"
tool_types "tcm-agent/internal/types"
"github.com/gin-gonic/gin"
)
// ========================================================================
// 路由注册
// ========================================================================
// 完整初始化链:
// config → llm.InitLLM (工厂+路由+降级) → agent.InitRunner → router.Setup
//
// 不同场景的 Agent 自动使用不同模型:
// /emr/generate → "emr-generator" 场景 → DeepSeek/gpt-4o
// /prescription/... → "prescription" 场景 → GPT-4o/DeepSeek
// /agent/chat → 通用对话 → 默认模型
// ========================================================================
// Setup 注册所有 HTTP 路由
//
// 参数:
// router - Agent 引擎(Runner)
// cfg - 全局配置
// llmRouter - 模型路由器(各 Handler 可按需使用)
//
// 返回:
// http.Handler 可直接传给 http.Server
func Setup(runner *agent.Runner, cfg *config.Config, llmRouter *llm.ModelRouter) http.Handler {
// 创建 Gin 引擎
r := gin.New()
// 全局中间件
r.Use(gin.CustomRecovery(func(c *gin.Context, recovered any) {
// 把 panic 的完整堆栈打到日志,方便排查(默认 Recovery 会吞掉堆栈)
log.Printf("[PANIC] %v", recovered)
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": fmt.Sprintf("服务器内部错误: %v", recovered),
})
c.Abort()
}))
r.Use(middleware.Logger()) // 请求日志
r.Use(middleware.CORS()) // 跨域支持
r.Use(middleware.Auth(cfg)) // JWT 鉴权
// 健康检查(无需鉴权)
r.GET("/health", func(c *gin.Context) {
// 顺便返回当前模型路由信息,方便排查
sceneCount := 0
if llmRouter != nil {
sceneCount = len(llmRouter.ListRoutes())
}
c.JSON(200, gin.H{
"status": "ok",
"service": "tcm-agent",
"model_routes": sceneCount,
})
})
// KB DB 健康检查(无需鉴权):方便排查 DB 是否就绪
// 用法:curl http://localhost:18123/kb/health
r.GET("/kb/health", func(c *gin.Context) {
// 直接调一次 KBListLibraries 验证 dao.DB 是否真的可用
// (内部有 if DB == nil 拦截)
_, err := service.KBHealthCheck()
if err != nil {
c.JSON(200, gin.H{"status": "fail", "error": err.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
})
// ========================================================================
// API v1 路由组
// ========================================================================
v1 := r.Group("/api/v1")
// ========== 管理前端登录鉴权 ==========
// /admin SPA 的登录体系:login 换 JWT(中间件放行)、refresh 续签、profile 会话校验
// 签发的 JWT 与业务 API 的 JWT 校验共用一把密钥,token 天然可调所有接口
authHandler := handler.NewAuthHandler(cfg)
authGroup := v1.Group("/auth")
{
authGroup.POST("/login", authHandler.Login) // 中间件已放行,无需 token
authGroup.POST("/refresh", authHandler.Refresh) // 需带仍有效的 JWT
authGroup.GET("/profile", authHandler.Profile) // 需带仍有效的 JWT
}
// ========== 病历相关接口 ==========
emr := v1.Group("/emr")
{
// 场景名从配置中读取,决定用哪个模型
emrScene := getScene(cfg, "emr-generator")
emrHandler := handler.NewEMRHandler(runner, emrScene)
emr.POST("/generate", emrHandler.Generate)
emr.POST("/qa", emrHandler.KnowledgeQA)
emr.GET("/:id", emrHandler.GetByID)
emr.PUT("/:id", emrHandler.Update)
}
// ========== 处方相关接口 ==========
rx := v1.Group("/prescription")
{
rxScene := getScene(cfg, "prescription")
rxHandler := handler.NewPrescriptionHandler(runner, rxScene)
rx.POST("/generate", rxHandler.Generate)
rx.POST("/validate", rxHandler.Validate)
rx.GET("/:id", rxHandler.GetByID)
rx.POST("/:id/approve", rxHandler.Approve)
}
// ========== 知识库相关接口 ==========
know := v1.Group("/knowledge")
{
kbHandler := handler.NewKnowledgeHandler(runner)
know.POST("/search", kbHandler.Search)
know.POST("/ingest", kbHandler.Ingest)
}
// ========== 本地知识库后台管理 ==========
// V1 不依赖 MaxKB:直接读写 z_xk.xk_kb_* 表,前端单页放在 /kb/view
//
// Embedder 在 V1 是 NoopEmbedder(不做向量化),
// V2 接入 BGE-M3 时在这里换 NewEmbedder(cfg.KB.EmbeddingProvider, cfg.KB.EmbeddingAPIKey)
kbEmbedder := kb.NewEmbedder("noop", "")
kbLibSvc := kb.NewLibraryService(kbEmbedder)
kbSearcher := kb.NewSearcher(kbEmbedder)
kbAdminHandler := handler.NewKBAdminHandler(kbLibSvc, kbSearcher)
kbGroup := v1.Group("/kb/admin")
{
// 库管理
kbGroup.GET("/libraries", kbAdminHandler.ListLibraries)
kbGroup.GET("/libraries/:id", kbAdminHandler.GetLibrary)
kbGroup.POST("/libraries", kbAdminHandler.CreateLibrary)
kbGroup.DELETE("/libraries/:id", kbAdminHandler.DeleteLibrary)
// 文档管理
kbGroup.GET("/libraries/:id/docs", kbAdminHandler.ListDocs)
kbGroup.POST("/docs/import", kbAdminHandler.ImportDocument)
kbGroup.GET("/docs/:id", kbAdminHandler.GetDoc)
kbGroup.DELETE("/docs/:id", kbAdminHandler.DeleteDoc)
// 分段管理
kbGroup.GET("/docs/:id/chunks", kbAdminHandler.ListChunks)
kbGroup.PUT("/chunks/:id", kbAdminHandler.UpdateChunk)
// 分段批量启停/删除(管理前端多选操作;gin 1.6+ 支持静态段与 :id 参数段共存)
kbGroup.PUT("/chunks/batch", kbAdminHandler.BatchUpdateChunks)
// 文档重新分段(用新 max_len/overlap 重切,不用删除重传)
kbGroup.POST("/docs/:id/rechunk", kbAdminHandler.RechunkDocument)
// 工具
kbGroup.POST("/search", kbAdminHandler.Search)
kbGroup.POST("/embed", kbAdminHandler.Embed)
// 药品抓取定时任务(中药材+别名 → 知识库;调度器在 main 启动)
crawlHandler := handler.NewKBCrawlHandler()
kbGroup.GET("/crawl/sources", crawlHandler.ListSources)
kbGroup.GET("/crawl/tasks", crawlHandler.ListTasks)
kbGroup.POST("/crawl/tasks", crawlHandler.CreateTask)
kbGroup.PUT("/crawl/tasks/:id", crawlHandler.UpdateTask)
kbGroup.DELETE("/crawl/tasks/:id", crawlHandler.DeleteTask)
kbGroup.POST("/crawl/tasks/:id/run", crawlHandler.RunTask)
kbGroup.GET("/crawl/tasks/:id/logs", crawlHandler.ListLogs)
}
// ========== 本地知识库前端单页(HTML+Vue CDN) ==========
// 访问 /kb/view 直接打开 view/index.html(开发阶段无需鉴权)
viewDir := filepath.Join("view")
r.StaticFS("/kb/view", http.Dir(viewDir))
// ========== Agent 运行观测面板(HTML+Vue CDN 单页) ==========
// "/" 是面板主入口(首页仪表盘 + 左侧菜单),/agent/view 保留兼容旧入口。
// 页面本身公开(无敏感数据),数据 API 走口令(X-KB-Admin-Password,与 KB 页共用)
// 或 Bearer SharedSecret(PHP 对接用)
r.StaticFile("/", filepath.Join("view", "agent.html"))
r.StaticFile("/agent/view", filepath.Join("view", "agent.html"))
// ========== 独立管理前端 /admin(Vue3 SPA 构建产物) ==========
// 前端项目 nl-tcm-agent-admin 执行 pnpm build 后产物落在 view/admin-dist,
// 这里不用 r.Static 挂 wildcard——SPA 的 history 路由(如 /admin/runs)刷新时
// 磁盘上没有对应文件,静态文件服务器会直接 404;
// 改在 NoRoute 里统一处理:文件存在发文件,不存在回退 index.html(history 路由刷新可用)
adminDist := filepath.Join("view", "admin-dist")
r.NoRoute(func(c *gin.Context) {
p := c.Request.URL.Path
if p != "/admin" && !strings.HasPrefix(p, "/admin/") {
c.JSON(404, gin.H{"code": 404, "message": "路由不存在: " + p})
return
}
// 尝试把路径映射到构建产物里的真实文件(js/css/图片等资源)
rel := strings.TrimPrefix(strings.TrimPrefix(p, "/admin"), "/")
if rel != "" {
full := filepath.Join(adminDist, filepath.Clean(rel))
// 防路径穿越:Clean 后必须仍在 admin-dist 目录内
if strings.HasPrefix(full, adminDist) {
if st, err := os.Stat(full); err == nil && !st.IsDir() {
c.File(full)
return
}
}
}
// 非真实文件(首页或 history 深链):回退 index.html
index := filepath.Join(adminDist, "index.html")
if _, err := os.Stat(index); err == nil {
c.File(index)
return
}
c.JSON(404, gin.H{
"code": 404,
"message": "管理前端未构建:请在 nl-tcm-agent-admin 目录执行 pnpm build(产物输出到 view/admin-dist)",
})
})
// ========== Agent 会话接口 ==========
// registerModelTest:模型连通性测试的 handler 在 agentGroup 块内创建
// (依赖 enhancerSvc),但按 REST 语义注册到下方 modelGroup
var registerModelTest gin.HandlerFunc
agentGroup := v1.Group("/agent")
{
agentHandler := handler.NewAgentHandler(runner)
agentGroup.POST("/chat", agentHandler.Chat)
agentGroup.GET("/session/:id", agentHandler.GetSession)
// 知识增强端点(给 PHP 端 TcmAgentClient 调用)
// PHP 拼好 messages 后调这个端点:Go 端负责 KB 检索 + LLM 调用 + 步骤记录
maxkbClient := tool.NewMaxKBClient(cfg.MaxKB)
enhancerSvc := service.NewEnhancerService(maxkbClient, llmRouter, nil, cfg)
// V1:注入本地知识库检索器(默认走 ai_kb_source=local)
enhancerSvc.WithLocalSearcher(kbSearcher)
// 把 Runner 注册的工具集注入 EnhancerService,让 ReactLoop 路径可触发 Function Calling
// 注意:这里转换 []agent.Tool → map[string]types.Tool(去重按 Tool.Name())
if runner != nil {
toolMap := make(map[string]tool_types.Tool)
for _, t := range runner.GetTools() {
toolMap[t.Name()] = t
}
enhancerSvc.WithTools(toolMap)
}
enhancerHandler := handler.NewEnhancerHandler(enhancerSvc)
// ---------- 稳定性加固:enhance 并发限流(信号量) ----------
// 每个 enhance 请求要占用一整条 LLM 调用链(10s~2min),
// 上游 PHP 若出现重试风暴,无限并发会打爆 LLM 配额并拖垮本服务。
// 用带缓冲 channel 做信号量:满 16 个并发时直接 503 快速失败,
// 让 PHP 端走自己的降级逻辑(直连模式),而不是排队堆积超时
enhanceSem := make(chan struct{}, 16)
// 注册并发探针:面板「系统状态」Tab 通过 /agent/system 读当前并发/上限
// 用回调注入避免 service 反向依赖 router
service.SetConcurrencyProbe(func() (int, int) {
return len(enhanceSem), cap(enhanceSem)
})
agentGroup.POST("/enhance", func(c *gin.Context) {
select {
case enhanceSem <- struct{}{}:
defer func() { <-enhanceSem }()
enhancerHandler.Enhance(c)
default:
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": 503,
"message": "Agent 并发已满(16),请稍后重试或走直连模式",
})
}
})
// ========== 运行轨迹观测接口(/agent/view 面板数据源) ==========
// 数据来自 Go 进程内的 RunLog 环形缓冲(最近 200 次运行),
// 走全局 Auth 中间件:面板带 X-KB-Admin-Password 口令头(与 KB 后台共用),
// PHP 对接走 Bearer SharedSecret,两条鉴权路径互不影响
// 运行列表(摘要,不含 step detail 全文)
// GET /api/v1/agent/runs?limit=50&scene=medical_record&status=1
agentGroup.GET("/runs", func(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
status, _ := strconv.Atoi(c.DefaultQuery("status", "0"))
scene := c.Query("scene")
c.JSON(200, gin.H{
"code": 200,
"data": service.RunLogList(limit, scene, status),
})
})
// 单次运行详情(含完整 steps 时间线)
// GET /api/v1/agent/runs/123
agentGroup.GET("/runs/:id", func(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"code": 400, "message": "id 必须是数字"})
return
}
rec, ok := service.RunLogGet(id)
if !ok {
c.JSON(404, gin.H{"code": 404, "message": "记录不存在(可能已被环形缓冲覆盖或服务重启)"})
return
}
c.JSON(200, gin.H{"code": 200, "data": rec})
})
// 聚合统计(成功率/平均耗时/token 消耗/按场景分布)
// GET /api/v1/agent/stats
agentGroup.GET("/stats", func(c *gin.Context) {
c.JSON(200, gin.H{"code": 200, "data": service.RunLogStats()})
})
// ========== AI 生成历史(DB 长期数据,只读) ==========
// 与 /runs(内存环形缓冲,最近 200 条)互补:
// 这里读 PHP 落库的 xk_ai_generation(_step),管理前端「历史记录」页数据源
historyHandler := handler.NewHistoryHandler()
agentGroup.GET("/history", historyHandler.List)
agentGroup.GET("/history/scenes", historyHandler.Scenes)
agentGroup.GET("/history/:id", historyHandler.Detail)
// ========== 观测与调试接口(面板「实时日志/系统状态/调试工具/配置总览」数据源) ==========
// 全部只读或无持久副作用;日志可能含 PHI(debug 开关打开时),必须走 Auth
observeHandler := handler.NewObserveHandler(enhancerSvc)
agentGroup.GET("/logs", observeHandler.Logs) // 增量日志
agentGroup.GET("/system", observeHandler.System) // 进程运行时状态
agentGroup.GET("/config", observeHandler.Config) // Agent 配置只读视图
agentGroup.POST("/guard-test", observeHandler.GuardTest) // 医疗守卫测试台
agentGroup.POST("/kb-test", observeHandler.KBTest) // 知识库检索测试
// 模型连通性测试挂在 modelGroup 语义更合适,但 handler 需要 enhancerSvc,
// 而 modelGroup 在下方定义——这里先存引用,注册放到 modelGroup 代码块里
registerModelTest = observeHandler.ModelTest
}
// ========== 模型管理接口(运维用) ==========
modelGroup := v1.Group("/models")
{
// 模型连通性测试(面板「调试工具」Tab):真实调一次 LLM(max_tokens 64)
// POST /api/v1/models/test body: {"provider":"", "message":""}
if registerModelTest != nil {
modelGroup.POST("/test", registerModelTest)
}
modelGroup.GET("/routes", func(c *gin.Context) {
if llmRouter == nil {
c.JSON(503, gin.H{"error": "模型路由未初始化"})
return
}
// fallbacks / default_provider 一并返回:管理前端「模型管理」页
// 需要画降级链可视化(主模型 → 降级1 → 降级2),数据来自 config.yaml
fallbacks := map[string][]string{}
defaultProvider := ""
if cfg != nil {
fallbacks = cfg.LLM.FallbackChains
defaultProvider = cfg.LLM.DefaultProvider
}
c.JSON(200, gin.H{
"code": 200,
"routes": llmRouter.ListRoutes(),
"fallbacks": fallbacks,
"default_provider": defaultProvider,
})
})
modelGroup.POST("/route", func(c *gin.Context) {
// 动态注册新路由(热更新,无需重启)
var req struct {
Scene string `json:"scene" binding:"required"`
Provider string `json:"provider" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
llmRouter.RegisterRoute(req.Scene, req.Provider)
c.JSON(200, gin.H{
"code": 200,
"message": "路由已更新",
"scene": req.Scene,
"provider": req.Provider,
})
})
// ★ 主动失效"生效 LLM 配置"缓存
// 使用场景:PHP 后台"模型配置"Tab 改完 provider/model/key 后调一下本接口,
// 让 Go 端下一次请求时立刻从 DB 读取最新配置(无需等 60s TTL 自然过期)
// 鉴权:复用全局 middleware.Auth(需带 Authorization)
modelGroup.POST("/invalidate-cache", func(c *gin.Context) {
llm.InvalidateConfigClientCache()
c.JSON(200, gin.H{
"code": 200,
"message": "Go 端 LLM 配置缓存已失效,下次请求将重新从 DB 读取",
})
})
// ★ 查询当前生效的 LLM 配置(运维排查用)
// 走全局 Auth(口令头或 Bearer 均可),返回值已脱敏(api_key 只出后 4 位)
modelGroup.GET("/active-config", func(c *gin.Context) {
// 直读 DB(绕过缓存),保证看到的是最新数据
fallback := ""
if cfg != nil {
fallback = cfg.LLM.DefaultProvider
}
resolved, err := llm.PeekActiveLLMConfig(fallback)
if err != nil {
c.JSON(200, gin.H{
"code": 500,
"message": err.Error(),
"hint": "DB 解析失败,请检查 dao.DB 是否就绪或 xk_system_config 是否配置",
})
return
}
// 脱敏:不返回完整 api_key,仅返回后 4 位
keyTail := ""
if len(resolved.APIKey) > 4 {
keyTail = resolved.APIKey[len(resolved.APIKey)-4:]
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"provider": resolved.Provider,
"model": resolved.Model,
"api_url": resolved.APIURL,
"api_key_id": resolved.APIKeyID,
"api_key_tail": keyTail,
"source": resolved.Source,
},
})
})
}
return r
}
// getScene 从配置中获取场景对应的模型名
//
// 优先级:
// config.LLM.Routes[sceneName] → 默认值
func getScene(cfg *config.Config, defaultScene string) string {
if cfg == nil || cfg.LLM.Routes == nil {
return ""
}
// 尝试常见变体
candidates := []string{
defaultScene,
defaultScene + "-model",
defaultScene + "-scene",
}
for _, c := range candidates {
if v, ok := cfg.LLM.Routes[c]; ok {
return v
}
}
return ""
}