463 lines
17 KiB
Go
463 lines
17 KiB
Go
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 ""
|
||
}
|