Commit 59503a6c authored by yuguo's avatar yuguo

fix

parent 9033450a
......@@ -24,7 +24,9 @@
"Bash(./api.exe:*)",
"Bash(PGPASSWORD=123456 psql:*)",
"Bash(go mod:*)",
"Bash(go vet:*)"
"Bash(go vet:*)",
"Bash(cmd:*)",
"Bash(go get:*)"
]
}
}
......@@ -92,6 +92,11 @@ func main() {
&model.PaymentOrder{},
&model.DoctorIncome{},
&model.DoctorWithdrawal{},
// 安全过滤
&model.SafetyWordRule{},
&model.SafetyFilterLog{},
// HTTP 动态工具
&model.HTTPToolDefinition{},
); err != nil {
log.Printf("Warning: AutoMigrate failed: %v", err)
} else {
......@@ -134,8 +139,11 @@ func main() {
log.Printf("Warning: Failed to init departments and doctors: %v", err)
}
// 初始化Agent工具
// 初始化Agent工具(内置工具 + HTTP 动态工具)
internalagent.InitTools()
internalagent.LoadHTTPTools()
// 注入跨包回调(AgentCallFn / WorkflowTriggerFn)
internalagent.WireCallbacks()
// 设置 Gin 模式
gin.SetMode(cfg.Server.Mode)
......@@ -168,6 +176,9 @@ func main() {
authApi := api.Group("")
authApi.Use(middleware.JWTAuth())
authApi.GET("/user/me", userHandler.GetCurrentUser)
authApi.POST("/user/logout", userHandler.Logout)
authApi.POST("/user/verify-identity", userHandler.VerifyIdentity)
authApi.POST("/doctor/appointment", doctorHandler.MakeAppointment)
// 医生端路由(需要认证 + 医生角色)
doctorPortalHandler := doctorportal.NewHandler()
......
......@@ -22,6 +22,7 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/expr-lang/expr v1.17.8 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
......
......@@ -18,6 +18,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
......
package internalagent
import (
"internet-hospital/pkg/agent"
"encoding/json"
"internet-hospital/internal/model"
)
// NewPreConsultAgent 预问诊Agent
func NewPreConsultAgent() *agent.ReActAgent {
return agent.NewReActAgent(agent.ReActConfig{
ID: "pre_consult_agent",
Name: "预问诊智能助手",
Description: "通过多轮对话收集患者症状,生成预问诊报告",
SystemPrompt: `你是一位专业的AI预问诊助手。你的职责是:
// defaultAgentDefinitions 返回内置Agent的默认数据库配置
// 当数据库中不存在时,会自动写入并作为初始配置
func defaultAgentDefinitions() []model.AgentDefinition {
preConsultTools, _ := json.Marshal([]string{"query_symptom_knowledge", "recommend_department"})
diagnosisTools, _ := json.Marshal([]string{"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge"})
prescriptionTools, _ := json.Marshal([]string{"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage"})
followUpTools, _ := json.Marshal([]string{"query_medical_record", "query_drug", "query_symptom_knowledge"})
return []model.AgentDefinition{
{
AgentID: "pre_consult_agent",
Name: "预问诊智能助手",
Description: "通过多轮对话收集患者症状,生成预问诊报告",
Category: "patient",
SystemPrompt: `你是一位专业的AI预问诊助手。你的职责是:
1. 通过友好的对话收集患者的症状信息
2. 询问症状的持续时间、严重程度、伴随症状等
3. 利用工具查询症状相关知识
......@@ -18,21 +28,16 @@ func NewPreConsultAgent() *agent.ReActAgent {
5. 生成简洁的预问诊报告
请用中文与患者交流,语气温和专业。不要做出确定性诊断,只提供参考建议。`,
Tools: []string{
"query_symptom_knowledge",
"recommend_department",
Tools: string(preConsultTools),
MaxIterations: 5,
Status: "active",
},
MaxIterations: 5,
})
}
// NewDiagnosisAgent 诊断辅助Agent
func NewDiagnosisAgent() *agent.ReActAgent {
return agent.NewReActAgent(agent.ReActConfig{
ID: "diagnosis_agent",
Name: "诊断辅助Agent",
Description: "辅助医生进行诊断,提供鉴别诊断建议",
SystemPrompt: `你是一位经验丰富的诊断辅助AI,协助医生进行临床决策。
{
AgentID: "diagnosis_agent",
Name: "诊断辅助Agent",
Description: "辅助医生进行诊断,提供鉴别诊断建议",
Category: "doctor",
SystemPrompt: `你是一位经验丰富的诊断辅助AI,协助医生进行临床决策。
你可以:
1. 查询患者病历记录(使用query_medical_record)
2. 检索医学知识库获取临床指南和疾病信息(使用search_medical_knowledge)
......@@ -46,22 +51,16 @@ func NewDiagnosisAgent() *agent.ReActAgent {
- 综合分析后给出诊断建议
请基于循证医学原则提供建议,所有建议仅供医生参考。`,
Tools: []string{
"query_medical_record",
"query_symptom_knowledge",
"search_medical_knowledge",
Tools: string(diagnosisTools),
MaxIterations: 10,
Status: "active",
},
MaxIterations: 10,
})
}
// NewPrescriptionAgent 处方审核Agent
func NewPrescriptionAgent() *agent.ReActAgent {
return agent.NewReActAgent(agent.ReActConfig{
ID: "prescription_agent",
Name: "处方审核Agent",
Description: "审核处方合理性,检查药物相互作用、禁忌症和剂量",
SystemPrompt: `你是一位专业的临床药师AI,负责处方审核。
{
AgentID: "prescription_agent",
Name: "处方审核Agent",
Description: "审核处方合理性,检查药物相互作用、禁忌症和剂量",
Category: "pharmacy",
SystemPrompt: `你是一位专业的临床药师AI,负责处方审核。
你的职责:
1. 查询药品信息(规格、用法、禁忌)
2. 检查药物相互作用(使用check_drug_interaction工具)
......@@ -76,23 +75,16 @@ func NewPrescriptionAgent() *agent.ReActAgent {
- 最后综合所有检查结果给出审核意见
请严格按照药品说明书和临床指南进行审核,对于存在风险的处方要明确指出。`,
Tools: []string{
"query_drug",
"check_drug_interaction",
"check_contraindication",
"calculate_dosage",
Tools: string(prescriptionTools),
MaxIterations: 10,
Status: "active",
},
MaxIterations: 10,
})
}
// NewFollowUpAgent 随访管理Agent
func NewFollowUpAgent() *agent.ReActAgent {
return agent.NewReActAgent(agent.ReActConfig{
ID: "follow_up_agent",
Name: "随访管理Agent",
Description: "管理患者随访,提醒用药、复诊,收集健康数据",
SystemPrompt: `你是一位专业的随访管理AI助手。你的职责是:
{
AgentID: "follow_up_agent",
Name: "随访管理Agent",
Description: "管理患者随访,提醒用药、复诊,收集健康数据",
Category: "patient",
SystemPrompt: `你是一位专业的随访管理AI助手。你的职责是:
1. 查询患者的处方和用药情况
2. 提醒患者按时用药
3. 收集患者的健康数据(血压、血糖等)
......@@ -100,11 +92,9 @@ func NewFollowUpAgent() *agent.ReActAgent {
5. 生成随访报告
请用温和关怀的语气与患者交流,关注患者的用药依从性和健康状况变化。`,
Tools: []string{
"query_medical_record",
"query_drug",
"query_symptom_knowledge",
Tools: string(followUpTools),
MaxIterations: 8,
Status: "active",
},
MaxIterations: 8,
})
}
}
......@@ -3,13 +3,13 @@ package internalagent
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// Handler Agent HTTP处理器
......@@ -28,6 +28,22 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.DELETE("/session/:session_id", h.DeleteSession)
g.GET("/list", h.ListAgents)
g.GET("/tools", h.ListTools)
// HTTP 动态工具管理
g.GET("/http-tools", h.ListHTTPTools)
g.POST("/http-tools", h.CreateHTTPTool)
g.PUT("/http-tools/:id", h.UpdateHTTPTool)
g.DELETE("/http-tools/:id", h.DeleteHTTPTool)
g.POST("/http-tools/:id/test", h.TestHTTPTool)
g.POST("/http-tools/reload", h.ReloadHTTPToolsAPI)
// Agent 配置管理(管理端使用)
g.GET("/definitions", h.ListDefinitions)
g.GET("/definitions/:agent_id", h.GetDefinition)
g.POST("/definitions", h.CreateDefinition)
g.PUT("/definitions/:agent_id", h.UpdateDefinition)
g.PUT("/definitions/:agent_id/reload", h.ReloadAgent)
g.PUT("/tools/:name/status", h.UpdateToolStatus)
}
func (h *Handler) Chat(c *gin.Context) {
......@@ -38,7 +54,7 @@ func (h *Handler) Chat(c *gin.Context) {
Context map[string]interface{} `json:"context"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
response.BadRequest(c, err.Error())
return
}
......@@ -47,18 +63,18 @@ func (h *Handler) Chat(c *gin.Context) {
output, err := h.svc.Chat(c.Request.Context(), agentID, uid, req.SessionID, req.Message, req.Context)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
response.Error(c, 500, err.Error())
return
}
if output == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
response.Error(c, 404, "agent not found")
return
}
c.JSON(http.StatusOK, gin.H{"data": output})
response.Success(c, output)
}
func (h *Handler) ListAgents(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": h.svc.ListAgents()})
response.Success(c, h.svc.ListAgents())
}
// ListSessions 获取用户的 Agent 会话列表
......@@ -90,7 +106,7 @@ func (h *Handler) ListSessions(c *gin.Context) {
}
result = append(result, SessionSummary{s, lastMsg})
}
c.JSON(http.StatusOK, gin.H{"data": result})
response.Success(c, result)
}
// DeleteSession 删除会话
......@@ -99,39 +115,60 @@ func (h *Handler) DeleteSession(c *gin.Context) {
sessionID := c.Param("session_id")
database.GetDB().Where("session_id = ? AND user_id = ?", sessionID, userID).
Delete(&model.AgentSession{})
c.JSON(http.StatusOK, gin.H{"message": "ok"})
response.Success(c, nil)
}
// ListTools 获取所有已注册工具列表
// ListTools 获取所有已注册工具列表(含数据库状态)
func (h *Handler) ListTools(c *gin.Context) {
registry := agent.GetRegistry()
allTools := registry.All()
db := database.GetDB()
var dbTools []model.AgentTool
if db != nil {
db.Find(&dbTools)
}
dbToolMap := make(map[string]model.AgentTool, len(dbTools))
for _, t := range dbTools {
dbToolMap[t.Name] = t
}
categoryMap := map[string]string{
"query_symptom_knowledge": "knowledge",
"recommend_department": "recommendation",
"query_medical_record": "medical",
"search_medical_knowledge": "knowledge",
"query_drug": "pharmacy",
"check_drug_interaction": "safety",
"check_contraindication": "safety",
"calculate_dosage": "pharmacy",
"write_knowledge": "knowledge",
"list_knowledge_collections": "knowledge",
"call_agent": "agent",
"trigger_workflow": "workflow",
"query_workflow_status": "workflow",
"request_human_review": "workflow",
"eval_expression": "expression",
"generate_follow_up_plan": "follow_up",
"send_notification": "notification",
}
type ToolInfo struct {
ID string `json:"id"`
ID uint `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Category string `json:"category"`
Parameters map[string]interface{} `json:"parameters"`
Status string `json:"status"`
IsEnabled bool `json:"is_enabled"`
CacheTTL int `json:"cache_ttl"`
Timeout int `json:"timeout"`
MaxRetries int `json:"max_retries"`
CreatedAt string `json:"created_at"`
}
categoryMap := map[string]string{
"query_symptom_knowledge": "knowledge",
"recommend_department": "recommendation",
"query_medical_record": "medical",
"search_medical_knowledge": "knowledge",
"query_drug": "pharmacy",
"check_drug_interaction": "safety",
"check_contraindication": "safety",
"calculate_dosage": "pharmacy",
"generate_follow_up_plan": "follow_up",
"send_notification": "notification",
}
result := make([]ToolInfo, 0, len(allTools))
i := 1
for name, tool := range allTools {
params := make(map[string]interface{})
for _, p := range tool.Parameters() {
......@@ -141,16 +178,175 @@ func (h *Handler) ListTools(c *gin.Context) {
if category == "" {
category = "other"
}
status := "active"
var id uint
cacheTTL, timeout, maxRetries := 0, 30, 0
createdAt := ""
if dbTool, ok := dbToolMap[name]; ok {
status = dbTool.Status
id = dbTool.ID
cacheTTL = dbTool.CacheTTL
timeout = dbTool.Timeout
maxRetries = dbTool.MaxRetries
createdAt = dbTool.CreatedAt.Format("2006-01-02 15:04:05")
}
result = append(result, ToolInfo{
ID: fmt.Sprintf("%d", i),
ID: id,
Name: name,
DisplayName: tool.Description(),
Description: tool.Description(),
Category: category,
Parameters: params,
IsEnabled: true,
CreatedAt: "2026-01-01",
Status: status,
IsEnabled: status == "active",
CacheTTL: cacheTTL,
Timeout: timeout,
MaxRetries: maxRetries,
CreatedAt: createdAt,
})
i++
}
c.JSON(http.StatusOK, gin.H{"data": result})
response.Success(c, result)
}
// --- Agent 配置管理 ---
// ListDefinitions 列出所有 AgentDefinition
func (h *Handler) ListDefinitions(c *gin.Context) {
var defs []model.AgentDefinition
database.GetDB().Order("id asc").Find(&defs)
response.Success(c, defs)
}
// GetDefinition 获取单个 AgentDefinition
func (h *Handler) GetDefinition(c *gin.Context) {
agentID := c.Param("agent_id")
var def model.AgentDefinition
if err := database.GetDB().Where("agent_id = ?", agentID).First(&def).Error; err != nil {
response.Error(c, 404, "agent not found")
return
}
response.Success(c, def)
}
// CreateDefinition 创建新 Agent
func (h *Handler) CreateDefinition(c *gin.Context) {
var req struct {
AgentID string `json:"agent_id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"`
MaxIterations int `json:"max_iterations"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
toolsJSON, _ := json.Marshal(req.Tools)
if req.MaxIterations <= 0 {
req.MaxIterations = 10
}
def := model.AgentDefinition{
AgentID: req.AgentID,
Name: req.Name,
Description: req.Description,
Category: req.Category,
SystemPrompt: req.SystemPrompt,
Tools: string(toolsJSON),
MaxIterations: req.MaxIterations,
Status: "active",
}
if err := database.GetDB().Create(&def).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
h.svc.ReloadAgent(def.AgentID)
response.Success(c, def)
}
// UpdateDefinition 更新 Agent 配置(自动热重载)
func (h *Handler) UpdateDefinition(c *gin.Context) {
agentID := c.Param("agent_id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"`
MaxIterations int `json:"max_iterations"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
db := database.GetDB()
var def model.AgentDefinition
if err := db.Where("agent_id = ?", agentID).First(&def).Error; err != nil {
response.Error(c, 404, "agent not found")
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.Category != "" {
updates["category"] = req.Category
}
if req.SystemPrompt != "" {
updates["system_prompt"] = req.SystemPrompt
}
if req.Tools != nil {
toolsJSON, _ := json.Marshal(req.Tools)
updates["tools"] = string(toolsJSON)
}
if req.MaxIterations > 0 {
updates["max_iterations"] = req.MaxIterations
}
if req.Status != "" {
updates["status"] = req.Status
}
if err := db.Model(&def).Updates(updates).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
h.svc.ReloadAgent(agentID)
response.Success(c, def)
}
// ReloadAgent 手动触发热重载
func (h *Handler) ReloadAgent(c *gin.Context) {
agentID := c.Param("agent_id")
if err := h.svc.ReloadAgent(agentID); err != nil {
response.Error(c, 500, fmt.Sprintf("reload failed: %v", err))
return
}
response.SuccessWithMessage(c, "reloaded", nil)
}
// UpdateToolStatus 更新工具启用/禁用状态
func (h *Handler) UpdateToolStatus(c *gin.Context) {
name := c.Param("name")
var req struct {
Status string `json:"status" binding:"required"` // active | disabled
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
db := database.GetDB()
result := db.Model(&model.AgentTool{}).Where("name = ?", name).Update("status", req.Status)
if result.Error != nil {
response.Error(c, 500, result.Error.Error())
return
}
response.Success(c, nil)
}
package internalagent
import (
"log"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/agent/tools"
"internet-hospital/pkg/database"
)
// LoadHTTPTools 从数据库加载所有 active HTTP 工具并注册到 ToolRegistry
func LoadHTTPTools() {
db := database.GetDB()
if db == nil {
return
}
var defs []model.HTTPToolDefinition
if err := db.Where("status = 'active'").Find(&defs).Error; err != nil {
log.Printf("[LoadHTTPTools] 加载失败: %v", err)
return
}
r := agent.GetRegistry()
for i := range defs {
r.Register(tools.NewDynamicHTTPTool(&defs[i]))
}
if len(defs) > 0 {
log.Printf("[LoadHTTPTools] 已加载 %d 个 HTTP 工具", len(defs))
}
}
// ReloadHTTPTools 卸载所有旧 HTTP 工具,然后重新从数据库加载
func ReloadHTTPTools() {
db := database.GetDB()
if db == nil {
return
}
r := agent.GetRegistry()
// 卸载所有已注册的 HTTP 工具(不论状态)
var all []model.HTTPToolDefinition
db.Find(&all)
for _, def := range all {
r.Unregister(def.Name)
}
// 重新加载 active 工具
LoadHTTPTools()
log.Printf("[ReloadHTTPTools] HTTP 工具已热重载")
}
package internalagent
import (
"strconv"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent/tools"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// ListHTTPTools GET /agent/http-tools
func (h *Handler) ListHTTPTools(c *gin.Context) {
db := database.GetDB()
var defs []model.HTTPToolDefinition
db.Order("created_at DESC").Find(&defs)
response.Success(c, defs)
}
// CreateHTTPTool POST /agent/http-tools
func (h *Handler) CreateHTTPTool(c *gin.Context) {
var req model.HTTPToolDefinition
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
userID, _ := c.Get("user_id")
req.CreatedBy, _ = userID.(string)
if req.Headers == "" {
req.Headers = "{}"
}
if req.AuthConfig == "" {
req.AuthConfig = "{}"
}
if req.Parameters == "" {
req.Parameters = "[]"
}
db := database.GetDB()
if err := db.Create(&req).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
ReloadHTTPTools()
response.Success(c, req)
}
// UpdateHTTPTool PUT /agent/http-tools/:id
func (h *Handler) UpdateHTTPTool(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
response.BadRequest(c, "invalid id")
return
}
var req map[string]interface{}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
db := database.GetDB()
if err := db.Model(&model.HTTPToolDefinition{}).Where("id = ?", id).Updates(req).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
ReloadHTTPTools()
response.Success(c, nil)
}
// DeleteHTTPTool DELETE /agent/http-tools/:id
func (h *Handler) DeleteHTTPTool(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
response.BadRequest(c, "invalid id")
return
}
db := database.GetDB()
var def model.HTTPToolDefinition
if err := db.First(&def, id).Error; err != nil {
response.Error(c, 404, "tool not found")
return
}
ReloadHTTPTools()
if err := db.Delete(&model.HTTPToolDefinition{}, id).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
ReloadHTTPTools()
response.Success(c, nil)
}
// TestHTTPTool POST /agent/http-tools/:id/test
func (h *Handler) TestHTTPTool(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
response.BadRequest(c, "invalid id")
return
}
var params map[string]interface{}
_ = c.ShouldBindJSON(&params)
if params == nil {
params = map[string]interface{}{}
}
db := database.GetDB()
var def model.HTTPToolDefinition
if err := db.First(&def, id).Error; err != nil {
response.Error(c, 404, "tool not found")
return
}
tool := tools.NewDynamicHTTPTool(&def)
result, execErr := tool.Execute(c.Request.Context(), params)
if execErr != nil {
response.Success(c, gin.H{
"success": false,
"error": execErr.Error(),
"result": result,
})
return
}
response.Success(c, gin.H{"success": true, "result": result})
}
// ReloadHTTPToolsAPI POST /agent/http-tools/reload
func (h *Handler) ReloadHTTPToolsAPI(c *gin.Context) {
ReloadHTTPTools()
response.SuccessWithMessage(c, "HTTP 工具已热重载", nil)
}
package internalagent
import (
"context"
"encoding/json"
"fmt"
"log"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/agent/tools"
"internet-hospital/pkg/database"
"internet-hospital/pkg/rag"
"internet-hospital/pkg/workflow"
)
// InitTools 注册所有工具到全局注册中心
// InitTools 注册所有工具到全局注册中心,并同步元数据到数据库
func InitTools() {
db := database.GetDB()
r := agent.GetRegistry()
......@@ -27,6 +34,131 @@ func InitTools() {
r.Register(&tools.ContraindicationTool{DB: db})
r.Register(&tools.DosageCalculatorTool{DB: db})
// 知识库检索工具
// 知识库工具
r.Register(&tools.KnowledgeSearchTool{Retriever: retriever})
r.Register(&tools.KnowledgeWriteTool{DB: db, Retriever: retriever})
r.Register(&tools.KnowledgeListTool{})
// Agent互调 & 工作流工具
r.Register(&tools.AgentCallerTool{})
r.Register(&tools.WorkflowTriggerTool{})
r.Register(&tools.WorkflowQueryTool{})
r.Register(&tools.HumanReviewTool{})
// 表达式执行工具
r.Register(&tools.ExprEvalTool{})
// 通知 & 随访工具
r.Register(&tools.SendNotificationTool{})
r.Register(&tools.GenerateFollowUpPlanTool{})
// 同步工具元数据到 AgentTool 表,并应用启/停状态
syncToolsToDB(r)
applyToolStatus(r)
}
// WireCallbacks 注入跨包回调(在 InitTools 和 GetService 初始化完成后调用)
// AgentCallFn: AgentCallerTool → AgentService.Chat
// WorkflowTriggerFn: WorkflowTriggerTool → workflow.Engine.Execute
func WireCallbacks() {
svc := GetService()
tools.AgentCallFn = func(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (string, error) {
output, err := svc.Chat(ctx, agentID, userID, sessionID, message, ctxData)
if err != nil {
return "", err
}
if output == nil {
return "", fmt.Errorf("agent %s 不存在或未启用", agentID)
}
return output.Response, nil
}
tools.WorkflowTriggerFn = func(ctx context.Context, workflowID string, input map[string]interface{}) (string, error) {
return workflow.GetEngine().Execute(ctx, workflowID, input, "agent")
}
log.Println("[InitTools] AgentCallFn & WorkflowTriggerFn 注入完成")
}
// syncToolsToDB 将注册的工具元数据写入 AgentTool 表(不存在则创建,存在则不覆盖)
func syncToolsToDB(r *agent.ToolRegistry) {
db := database.GetDB()
if db == nil {
return
}
categoryMap := map[string]string{
// 基础查询
"query_symptom_knowledge": "knowledge",
"recommend_department": "recommendation",
"query_medical_record": "medical",
"search_medical_knowledge": "knowledge",
"query_drug": "pharmacy",
// 处方安全
"check_drug_interaction": "safety",
"check_contraindication": "safety",
"calculate_dosage": "pharmacy",
// 知识库
"write_knowledge": "knowledge",
"list_knowledge_collections": "knowledge",
// Agent & 工作流
"call_agent": "agent",
"trigger_workflow": "workflow",
"query_workflow_status": "workflow",
"request_human_review": "workflow",
// 表达式
"eval_expression": "expression",
// 通知 & 随访
"generate_follow_up_plan": "follow_up",
"send_notification": "notification",
}
for name, tool := range r.All() {
params := make([]map[string]interface{}, 0)
for _, p := range tool.Parameters() {
params = append(params, map[string]interface{}{
"name": p.Name,
"type": p.Type,
"required": p.Required,
})
}
paramsJSON, _ := json.Marshal(params)
category := categoryMap[name]
if category == "" {
category = "other"
}
var existing model.AgentTool
if err := db.Where("name = ?", name).First(&existing).Error; err != nil {
// 不存在则创建
entry := model.AgentTool{
Name: name,
DisplayName: tool.Description(),
Description: tool.Description(),
Category: category,
Parameters: string(paramsJSON),
Status: "active",
}
if err := db.Create(&entry).Error; err != nil {
log.Printf("[InitTools] 同步工具 %s 到数据库失败: %v", name, err)
}
}
// 已存在则不更新(保留管理员的自定义状态)
}
}
// applyToolStatus 从数据库读取工具状态(disabled 的工具在 executor 中已检查,此处仅记录日志)
func applyToolStatus(r *agent.ToolRegistry) {
db := database.GetDB()
if db == nil {
return
}
var disabledTools []model.AgentTool
db.Where("status = 'disabled'").Find(&disabledTools)
if len(disabledTools) > 0 {
for _, t := range disabledTools {
log.Printf("[InitTools] 工具 %s 已被禁用", t.Name)
}
}
}
......@@ -3,6 +3,8 @@ package internalagent
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"internet-hospital/internal/model"
......@@ -15,6 +17,7 @@ import (
// AgentService Agent服务
type AgentService struct {
mu sync.RWMutex
agents map[string]*agent.ReActAgent
}
......@@ -23,26 +26,124 @@ var globalAgentService *AgentService
func GetService() *AgentService {
if globalAgentService == nil {
globalAgentService = &AgentService{
agents: map[string]*agent.ReActAgent{
"pre_consult_agent": NewPreConsultAgent(),
"diagnosis_agent": NewDiagnosisAgent(),
"prescription_agent": NewPrescriptionAgent(),
"follow_up_agent": NewFollowUpAgent(),
},
agents: make(map[string]*agent.ReActAgent),
}
globalAgentService.loadFromDB()
globalAgentService.ensureBuiltinAgents()
}
return globalAgentService
}
// loadFromDB 从数据库加载所有 active 的 AgentDefinition
func (s *AgentService) loadFromDB() {
db := database.GetDB()
if db == nil {
return
}
var definitions []model.AgentDefinition
if err := db.Where("status = 'active'").Find(&definitions).Error; err != nil {
log.Printf("[AgentService] 从数据库加载Agent失败: %v", err)
return
}
for _, def := range definitions {
a := buildAgentFromDef(def)
s.agents[def.AgentID] = a
}
log.Printf("[AgentService] 从数据库加载了 %d 个Agent", len(definitions))
}
func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
var tools []string
if def.Tools != "" {
json.Unmarshal([]byte(def.Tools), &tools)
}
maxIter := def.MaxIterations
if maxIter <= 0 {
maxIter = 10
}
return agent.NewReActAgent(agent.ReActConfig{
ID: def.AgentID,
Name: def.Name,
Description: def.Description,
SystemPrompt: def.SystemPrompt,
Tools: tools,
MaxIterations: maxIter,
})
}
// ensureBuiltinAgents 如果数据库中不存在内置Agent,则写入默认配置
func (s *AgentService) ensureBuiltinAgents() {
db := database.GetDB()
if db == nil {
return
}
defaults := defaultAgentDefinitions()
for _, def := range defaults {
// 如果内存中已有(来自数据库),跳过
s.mu.RLock()
_, exists := s.agents[def.AgentID]
s.mu.RUnlock()
if exists {
continue
}
// 写入数据库
var existing model.AgentDefinition
if err := db.Where("agent_id = ?", def.AgentID).First(&existing).Error; err != nil {
// 不存在则创建
if err := db.Create(&def).Error; err != nil {
log.Printf("[AgentService] 写入默认Agent失败: %v", err)
continue
}
existing = def
}
s.mu.Lock()
s.agents[def.AgentID] = buildAgentFromDef(existing)
s.mu.Unlock()
}
}
// ReloadAgent 热重载单个Agent(管理端修改配置后调用)
func (s *AgentService) ReloadAgent(agentID string) error {
db := database.GetDB()
var def model.AgentDefinition
if err := db.Where("agent_id = ?", agentID).First(&def).Error; err != nil {
return err
}
a := buildAgentFromDef(def)
s.mu.Lock()
if def.Status == "active" {
s.agents[agentID] = a
} else {
delete(s.agents, agentID)
}
s.mu.Unlock()
log.Printf("[AgentService] 已热重载Agent: %s", agentID)
return nil
}
// ReloadAll 重新加载所有Agent
func (s *AgentService) ReloadAll() {
s.mu.Lock()
s.agents = make(map[string]*agent.ReActAgent)
s.mu.Unlock()
s.loadFromDB()
s.ensureBuiltinAgents()
}
func (s *AgentService) GetAgent(agentID string) (*agent.ReActAgent, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
a, ok := s.agents[agentID]
return a, ok
}
func (s *AgentService) ListAgents() []map[string]string {
result := make([]map[string]string, 0, len(s.agents))
func (s *AgentService) ListAgents() []map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]map[string]interface{}, 0, len(s.agents))
for _, a := range s.agents {
result = append(result, map[string]string{
result = append(result, map[string]interface{}{
"id": a.ID(),
"name": a.Name(),
"description": a.Description(),
......@@ -61,10 +162,10 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
db := database.GetDB()
// 加载或创建会话
var session model.AgentSession
if sessionID == "" {
sessionID = uuid.New().String()
}
var session model.AgentSession
db.Where("session_id = ?", sessionID).First(&session)
// 解析历史消息
......@@ -119,6 +220,7 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
outputJSON, _ := json.Marshal(output)
toolCallsJSON, _ := json.Marshal(output.ToolCalls)
db.Create(&model.AgentExecutionLog{
TraceID: output.TraceID,
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
......
......@@ -11,6 +11,9 @@ type AgentTool struct {
Category string `gorm:"type:varchar(50)"`
Parameters string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'active'"`
CacheTTL int `gorm:"default:0"` // 缓存秒数,0=不缓存
Timeout int `gorm:"default:30"` // 执行超时秒数
MaxRetries int `gorm:"default:0"` // 失败重试次数
CreatedAt time.Time
UpdatedAt time.Time
}
......@@ -18,6 +21,7 @@ type AgentTool struct {
// AgentToolLog 工具调用日志
type AgentToolLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID
ToolName string `gorm:"type:varchar(100);index"`
AgentID string `gorm:"type:varchar(100);index"`
SessionID string `gorm:"type:varchar(100);index"`
......@@ -27,6 +31,7 @@ type AgentToolLog struct {
Success bool
ErrorMessage string `gorm:"type:text"`
DurationMs int
Iteration int // Agent第几轮迭代
CreatedAt time.Time
}
......@@ -62,6 +67,7 @@ type AgentSession struct {
// AgentExecutionLog Agent执行日志
type AgentExecutionLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID
SessionID string `gorm:"type:varchar(100);index"`
AgentID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
......
......@@ -42,7 +42,12 @@ type AIUsageLog struct {
Success bool `gorm:"default:true" json:"success"`
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
IsMock bool `gorm:"default:false" json:"is_mock"` // 是否为模拟调用
CreatedAt time.Time `json:"created_at"`
// 链路追踪字段
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"` // 链路追踪ID,关联同一次Agent执行
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"` // 关联Agent
SessionID string `gorm:"type:varchar(100);index" json:"session_id"` // 关联会话
Iteration int `json:"iteration"` // Agent第几轮迭代(0=非Agent调用)
CreatedAt time.Time `json:"created_at"`
}
func (AIUsageLog) TableName() string {
......
package model
import "time"
// HTTPToolDefinition 动态 HTTP 工具定义(管理员从 UI 配置,无需改代码)
type HTTPToolDefinition struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);uniqueIndex"` // 工具名,如 get_weather
DisplayName string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50);default:'http'"`
Method string `gorm:"type:varchar(10);default:'GET'"` // GET/POST/PUT/DELETE
URL string `gorm:"type:text"` // 支持 {{param}} 模板变量
Headers string `gorm:"type:jsonb;default:'{}'"` // {"X-Key": "{{api_key}}"}
BodyTemplate string `gorm:"type:text"` // JSON body 模板,支持 {{param}}
AuthType string `gorm:"type:varchar(20);default:'none'"` // none/bearer/basic/apikey
AuthConfig string `gorm:"type:jsonb;default:'{}'"` // 认证配置
Parameters string `gorm:"type:jsonb;default:'[]'"` // ToolParameter 数组 JSON
Timeout int `gorm:"default:10"` // 超时秒数
CacheTTL int `gorm:"default:0"` // 缓存 TTL 秒,0=不缓存
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedBy string `gorm:"type:varchar(100)"`
CreatedAt time.Time
UpdatedAt time.Time
}
......@@ -8,15 +8,20 @@ import (
// PromptTemplate AI提示词模板
type PromptTemplate struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TemplateKey string `gorm:"type:varchar(100);uniqueIndex;not null" json:"template_key"` // 模板key,用于代码中取值
Name string `gorm:"type:varchar(100);not null" json:"name"` // 模板名称
Scene string `gorm:"type:varchar(50)" json:"scene"` // 应用场景
Content string `gorm:"type:text;not null" json:"content"` // Prompt内容
Status string `gorm:"type:varchar(20);default:'active'" json:"status"` // active | disabled
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TemplateKey string `gorm:"type:varchar(100);uniqueIndex;not null" json:"template_key"` // 模板key,用于代码中取值
Name string `gorm:"type:varchar(100);not null" json:"name"` // 模板名称
Scene string `gorm:"type:varchar(50)" json:"scene"` // 应用场景
Content string `gorm:"type:text;not null" json:"content"` // Prompt内容
Status string `gorm:"type:varchar(20);default:'active'" json:"status"` // active | disabled
// 智能体关联字段
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"` // 关联的Agent ID,空表示通用模板
TemplateType string `gorm:"type:varchar(20);default:'system'" json:"template_type"` // system | user | tool_result
Variables string `gorm:"type:jsonb" json:"variables"` // 变量定义 [{"name":"patient_name","type":"string","required":true}]
Version int `gorm:"default:1" json:"version"` // 版本号
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (PromptTemplate) TableName() string {
......
package model
import "time"
// SafetyWordRule 安全词规则
type SafetyWordRule struct {
ID uint `gorm:"primaryKey" json:"id"`
Word string `gorm:"type:varchar(200);index" json:"word"` // 敏感词/正则
Category string `gorm:"type:varchar(50)" json:"category"` // medical_claim | drug_promotion | privacy | toxicity | injection
Level string `gorm:"type:varchar(20)" json:"level"` // block | warn | replace
Replacement string `gorm:"type:varchar(200)" json:"replacement"` // level=replace时的替换文本
Direction string `gorm:"type:varchar(10);default:'both'" json:"direction"` // input | output | both
IsRegex bool `gorm:"default:false" json:"is_regex"` // 是否正则表达式
AgentID string `gorm:"type:varchar(100)" json:"agent_id"` // 特定Agent的规则,空=全局
Status string `gorm:"type:varchar(20);default:'active'" json:"status"` // active | disabled
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SafetyWordRule) TableName() string {
return "safety_word_rules"
}
// SafetyFilterLog 过滤日志
type SafetyFilterLog struct {
ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
Direction string `gorm:"type:varchar(10)" json:"direction"` // input | output
OriginalText string `gorm:"type:text" json:"original_text"`
FilteredText string `gorm:"type:text" json:"filtered_text"`
MatchedRules string `gorm:"type:jsonb" json:"matched_rules"` // [{"rule_id":1,"word":"xxx","action":"block"}]
Action string `gorm:"type:varchar(20)" json:"action"` // passed | blocked | replaced | warned
AgentID string `gorm:"type:varchar(100)" json:"agent_id"`
UserID string `gorm:"type:uuid" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
func (SafetyFilterLog) TableName() string {
return "safety_filter_logs"
}
package admin
import (
"strconv"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// GetTraceDetail 全链路追踪详情:给定 trace_id,返回该次Agent执行的所有 LLM 调用和工具调用
func (h *Handler) GetTraceDetail(c *gin.Context) {
traceID := c.Query("trace_id")
if traceID == "" {
response.BadRequest(c, "trace_id 不能为空")
return
}
db := database.GetDB()
var execLogs []model.AgentExecutionLog
db.Where("trace_id = ?", traceID).Order("created_at asc").Find(&execLogs)
var usageLogs []model.AIUsageLog
db.Where("trace_id = ?", traceID).Order("iteration asc, created_at asc").Find(&usageLogs)
var toolLogs []model.AgentToolLog
db.Where("trace_id = ?", traceID).Order("iteration asc, created_at asc").Find(&toolLogs)
response.Success(c, gin.H{
"trace_id": traceID,
"execution_logs": execLogs,
"llm_calls": usageLogs,
"tool_calls": toolLogs,
})
}
// GetAICenterStats 全量统计看板
func (h *Handler) GetAICenterStats(c *gin.Context) {
db := database.GetDB()
// LLM 调用统计
var totalCalls, successCalls, mockCalls int64
var totalTokens struct{ Sum int64 }
db.Model(&model.AIUsageLog{}).Count(&totalCalls)
db.Model(&model.AIUsageLog{}).Where("success = true").Count(&successCalls)
db.Model(&model.AIUsageLog{}).Where("is_mock = true").Count(&mockCalls)
db.Model(&model.AIUsageLog{}).Select("COALESCE(SUM(total_tokens), 0) as sum").Scan(&totalTokens)
// Agent 执行统计
var agentExecs, agentSuccess int64
db.Model(&model.AgentExecutionLog{}).Count(&agentExecs)
db.Model(&model.AgentExecutionLog{}).Where("success = true").Count(&agentSuccess)
// 工具调用统计
var toolCalls, toolSuccess int64
db.Model(&model.AgentToolLog{}).Count(&toolCalls)
db.Model(&model.AgentToolLog{}).Where("success = true").Count(&toolSuccess)
// 各 Agent 调用量
type AgentCallCount struct {
AgentID string `json:"agent_id"`
Count int64 `json:"count"`
}
var agentCounts []AgentCallCount
db.Model(&model.AIUsageLog{}).
Select("agent_id, COUNT(*) as count").
Where("agent_id != ''").
Group("agent_id").
Order("count DESC").
Limit(10).
Scan(&agentCounts)
// 各场景调用量
type SceneCallCount struct {
Scene string `json:"scene"`
Count int64 `json:"count"`
}
var sceneCounts []SceneCallCount
db.Model(&model.AIUsageLog{}).
Select("scene, COUNT(*) as count").
Group("scene").
Order("count DESC").
Limit(10).
Scan(&sceneCounts)
// 最近24小时调用趋势(按小时)
type HourlyTrend struct {
Hour string `json:"hour"`
Count int64 `json:"count"`
}
var trend []HourlyTrend
db.Model(&model.AIUsageLog{}).
Select("TO_CHAR(created_at, 'HH24') as hour, COUNT(*) as count").
Where("created_at >= NOW() - INTERVAL '24 hours'").
Group("hour").
Order("hour asc").
Scan(&trend)
// 近期日志列表(用于追踪)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
agentID := c.Query("agent_id")
var recentLogsTotal int64
var recentLogs []model.AIUsageLog
q := db.Model(&model.AIUsageLog{})
if agentID != "" {
q = q.Where("agent_id = ?", agentID)
}
q.Count(&recentLogsTotal)
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&recentLogs)
response.Success(c, gin.H{
"total_calls": totalCalls,
"success_calls": successCalls,
"mock_calls": mockCalls,
"total_tokens": totalTokens.Sum,
"agent_execs": agentExecs,
"agent_success": agentSuccess,
"tool_calls": toolCalls,
"tool_success": toolSuccess,
"agent_counts": agentCounts,
"scene_counts": sceneCounts,
"hourly_trend": trend,
"recent_logs": recentLogs,
"logs_total": recentLogsTotal,
})
}
......@@ -109,6 +109,19 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// Agent 执行监控
adm.GET("/agent/logs", h.GetAgentExecutionLogs)
adm.GET("/agent/stats", h.GetAgentStats)
// 内容安全管理
adm.GET("/safety/rules", h.ListSafetyRules)
adm.POST("/safety/rules", h.CreateSafetyRule)
adm.PUT("/safety/rules/:id", h.UpdateSafetyRule)
adm.DELETE("/safety/rules/:id", h.DeleteSafetyRule)
adm.POST("/safety/rules/import-preset", h.ImportSafetyRules)
adm.GET("/safety/logs", h.ListSafetyLogs)
adm.GET("/safety/stats", h.GetSafetyStats)
// AI 运营中心
adm.GET("/ai-center/trace", h.GetTraceDetail)
adm.GET("/ai-center/stats", h.GetAICenterStats)
}
}
......
......@@ -6,6 +6,7 @@ import (
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/workflow"
)
// ==================== 处方监管请求/响应 ====================
......@@ -132,5 +133,16 @@ func (s *Service) ReviewPrescription(ctx context.Context, id string, action stri
return fmt.Errorf("无效的审核操作")
}
return s.db.Model(&prescription).Updates(updates).Error
if err := s.db.Model(&prescription).Updates(updates).Error; err != nil {
return err
}
// 处方审核通过时触发 prescription_approved 工作流(异步)
if action == "approve" {
workflow.GetEngine().TriggerByCategory(ctx, "prescription_approved", map[string]interface{}{
"prescription_id": id,
"reviewer_id": reviewerID,
})
}
return nil
}
package admin
import (
"strconv"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
"internet-hospital/pkg/safety"
)
// ===================== 安全词规则 =====================
// ListSafetyRules 查询安全词规则列表
func (h *Handler) ListSafetyRules(c *gin.Context) {
db := database.GetDB()
category := c.Query("category")
direction := c.Query("direction")
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
var total int64
var rules []model.SafetyWordRule
q := db.Model(&model.SafetyWordRule{})
if category != "" {
q = q.Where("category = ?", category)
}
if direction != "" {
q = q.Where("direction = ? OR direction = 'both'", direction)
}
if status != "" {
q = q.Where("status = ?", status)
}
q.Count(&total)
q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rules)
response.Success(c, gin.H{"list": rules, "total": total, "page": page})
}
// CreateSafetyRule 创建安全词规则
func (h *Handler) CreateSafetyRule(c *gin.Context) {
var rule model.SafetyWordRule
if err := c.ShouldBindJSON(&rule); err != nil {
response.BadRequest(c, err.Error())
return
}
if rule.Direction == "" {
rule.Direction = "both"
}
if rule.Status == "" {
rule.Status = "active"
}
if err := database.GetDB().Create(&rule).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
// 重新加载缓存
safety.GetLoader().Reload()
response.Success(c, rule)
}
// UpdateSafetyRule 更新安全词规则
func (h *Handler) UpdateSafetyRule(c *gin.Context) {
id := c.Param("id")
var rule model.SafetyWordRule
if err := database.GetDB().First(&rule, "id = ?", id).Error; err != nil {
response.Error(c, 404, "规则不存在")
return
}
if err := c.ShouldBindJSON(&rule); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := database.GetDB().Save(&rule).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
safety.GetLoader().Reload()
response.Success(c, rule)
}
// DeleteSafetyRule 删除安全词规则
func (h *Handler) DeleteSafetyRule(c *gin.Context) {
id := c.Param("id")
database.GetDB().Delete(&model.SafetyWordRule{}, "id = ?", id)
safety.GetLoader().Reload()
response.Success(c, nil)
}
// ImportSafetyRules 批量导入安全词规则(医疗场景预置)
func (h *Handler) ImportSafetyRules(c *gin.Context) {
db := database.GetDB()
preset := presetMedicalSafetyRules()
created := 0
for _, rule := range preset {
var existing model.SafetyWordRule
if db.Where("word = ?", rule.Word).First(&existing).Error != nil {
if db.Create(&rule).Error == nil {
created++
}
}
}
safety.GetLoader().Reload()
response.SuccessWithMessage(c, "导入完成", gin.H{"created": created})
}
// ===================== 过滤日志 =====================
// ListSafetyLogs 查询过滤日志
func (h *Handler) ListSafetyLogs(c *gin.Context) {
db := database.GetDB()
action := c.Query("action")
direction := c.Query("direction")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
var total int64
var logs []model.SafetyFilterLog
q := db.Model(&model.SafetyFilterLog{})
if action != "" {
q = q.Where("action = ?", action)
}
if direction != "" {
q = q.Where("direction = ?", direction)
}
q.Count(&total)
q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs)
response.Success(c, gin.H{"list": logs, "total": total, "page": page})
}
// GetSafetyStats 安全过滤统计
func (h *Handler) GetSafetyStats(c *gin.Context) {
db := database.GetDB()
var total, blocked, replaced, warned int64
db.Model(&model.SafetyFilterLog{}).Count(&total)
db.Model(&model.SafetyFilterLog{}).Where("action = 'blocked'").Count(&blocked)
db.Model(&model.SafetyFilterLog{}).Where("action = 'replaced'").Count(&replaced)
db.Model(&model.SafetyFilterLog{}).Where("action = 'warned'").Count(&warned)
var totalRules, activeRules int64
db.Model(&model.SafetyWordRule{}).Count(&totalRules)
db.Model(&model.SafetyWordRule{}).Where("status = 'active'").Count(&activeRules)
response.Success(c, gin.H{
"total_logs": total,
"blocked": blocked,
"replaced": replaced,
"warned": warned,
"total_rules": totalRules,
"active_rules": activeRules,
})
}
// presetMedicalSafetyRules 医疗场景预置安全词规则
func presetMedicalSafetyRules() []model.SafetyWordRule {
return []model.SafetyWordRule{
// Prompt注入防护(输入方向)
{Word: "忽略上述指令", Category: "injection", Level: "block", Direction: "input", Status: "active"},
{Word: "忘记之前的", Category: "injection", Level: "block", Direction: "input", Status: "active"},
{Word: "你现在是", Category: "injection", Level: "warn", Direction: "input", Status: "active"},
{Word: "扮演", Category: "injection", Level: "warn", Direction: "input", Status: "active"},
// 医疗断言防护(输出方向)
{Word: "你确诊了", Category: "medical_claim", Level: "replace", Replacement: "根据症状分析,可能", Direction: "output", Status: "active"},
{Word: "你一定是", Category: "medical_claim", Level: "replace", Replacement: "初步判断可能是", Direction: "output", Status: "active"},
{Word: "你患有", Category: "medical_claim", Level: "replace", Replacement: "症状提示可能存在", Direction: "output", Status: "active"},
// 危险建议防护(输出方向)
{Word: "自行停药", Category: "toxicity", Level: "block", Direction: "output", Status: "active"},
{Word: "不需要看医生", Category: "toxicity", Level: "block", Direction: "output", Status: "active"},
{Word: "自己手术", Category: "toxicity", Level: "block", Direction: "output", Status: "active"},
}
}
......@@ -11,6 +11,7 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/utils"
"internet-hospital/pkg/workflow"
)
// Service 管理端业务逻辑层
......@@ -483,7 +484,16 @@ func (s *Service) ApproveDoctorReview(ctx context.Context, reviewID string) erro
return err
}
return tx.Commit().Error
if err := tx.Commit().Error; err != nil {
return err
}
// 触发 doctor_review 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "doctor_review", map[string]interface{}{
"review_id": reviewID,
"doctor_id": review.UserID,
"action": "approved",
})
return nil
}
func (s *Service) RejectDoctorReview(ctx context.Context, reviewID, reason string) error {
......@@ -497,11 +507,21 @@ func (s *Service) RejectDoctorReview(ctx context.Context, reviewID, reason strin
}
now := time.Now()
return s.db.Model(&review).Updates(map[string]interface{}{
if err := s.db.Model(&review).Updates(map[string]interface{}{
"status": "rejected",
"reject_reason": reason,
"reviewed_at": now,
}).Error
}).Error; err != nil {
return err
}
// 触发 doctor_review 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "doctor_review", map[string]interface{}{
"review_id": reviewID,
"doctor_id": review.UserID,
"action": "rejected",
"reason": reason,
})
return nil
}
func (s *Service) GetDepartmentList(ctx context.Context) (interface{}, error) {
......
......@@ -12,6 +12,7 @@ import (
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/workflow"
)
type Service struct {
......@@ -95,7 +96,18 @@ func (s *Service) CreateRenewal(ctx context.Context, userID string, req *Renewal
ChronicID: req.ChronicID, DiseaseName: req.DiseaseName,
Medicines: string(medsJSON), Reason: req.Reason, Status: "pending",
}
return r, s.db.WithContext(ctx).Create(r).Error
if err := s.db.WithContext(ctx).Create(r).Error; err != nil {
return nil, err
}
// 触发 renewal_requested 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "renewal_requested", map[string]interface{}{
"renewal_id": r.ID,
"patient_id": userID,
"chronic_record_id": req.ChronicID,
"disease_name": req.DiseaseName,
})
return r, nil
}
func (s *Service) GetAIRenewalAdvice(ctx context.Context, userID, renewalID string) (string, error) {
......@@ -220,7 +232,61 @@ func (s *Service) CreateMetric(ctx context.Context, userID string, req *MetricRe
MetricType: req.MetricType, Value1: req.Value1, Value2: req.Value2,
Unit: req.Unit, RecordedAt: recordedAt, Notes: req.Notes,
}
return r, s.db.WithContext(ctx).Create(r).Error
if err := s.db.WithContext(ctx).Create(r).Error; err != nil {
return nil, err
}
// 检测异常指标,触发 health_alert 工作流
if alertLevel := detectMetricAlert(req.MetricType, req.Value1, req.Value2); alertLevel != "" {
workflow.GetEngine().TriggerByCategory(ctx, "health_alert", map[string]interface{}{
"patient_id": userID,
"metric_type": req.MetricType,
"value1": req.Value1,
"value2": req.Value2,
"unit": req.Unit,
"alert_level": alertLevel,
})
}
return r, nil
}
// detectMetricAlert 检测健康指标是否异常,返回告警级别("mild"/"moderate"/"severe"/"" 表示正常)
func detectMetricAlert(metricType string, value1, value2 float64) string {
switch metricType {
case "blood_pressure":
// 收缩压 value1 / 舒张压 value2
if value1 >= 180 || value2 >= 120 {
return "severe"
} else if value1 >= 160 || value2 >= 100 {
return "moderate"
} else if value1 >= 140 || value2 >= 90 {
return "mild"
}
case "blood_glucose":
// 空腹血糖 mmol/L
if value1 >= 16.7 {
return "severe"
} else if value1 >= 11.1 {
return "moderate"
} else if value1 >= 7.0 {
return "mild"
}
case "heart_rate":
if value1 >= 150 || value1 <= 40 {
return "severe"
} else if value1 >= 120 || value1 <= 50 {
return "moderate"
}
case "body_temperature":
if value1 >= 39.5 {
return "severe"
} else if value1 >= 38.5 {
return "moderate"
} else if value1 >= 37.5 {
return "mild"
}
}
return ""
}
func (s *Service) DeleteMetric(ctx context.Context, userID, id string) error {
......
......@@ -13,6 +13,7 @@ import (
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/workflow"
)
type Service struct {
......@@ -83,6 +84,14 @@ func (s *Service) CreateConsult(ctx context.Context, patientID string, req *Crea
s.db.Create(videoRoom)
}
// 触发 consult_created 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "consult_created", map[string]interface{}{
"consult_id": consult.ID,
"patient_id": consult.PatientID,
"doctor_id": consult.DoctorID,
"type": consult.Type,
})
return &ConsultResponse{
Consultation: *consult,
DoctorName: doctor.Name,
......@@ -186,10 +195,20 @@ func (s *Service) EndConsult(ctx context.Context, consultID string) error {
}
now := time.Now()
return s.db.Model(&consult).Updates(map[string]interface{}{
if err := s.db.Model(&consult).Updates(map[string]interface{}{
"status": "completed",
"ended_at": now,
}).Error
}).Error; err != nil {
return err
}
// 触发 consult_ended 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "consult_ended", map[string]interface{}{
"consult_id": consultID,
"patient_id": consult.PatientID,
"doctor_id": consult.DoctorID,
})
return nil
}
func (s *Service) CancelConsult(ctx context.Context, consultID string) error {
......
......@@ -80,3 +80,23 @@ func (h *Handler) GetDoctorSchedule(c *gin.Context) {
}
response.Success(c, schedules)
}
type AppointmentRequest struct {
DoctorID string `json:"doctor_id" binding:"required"`
Date string `json:"date" binding:"required"`
TimeSlot string `json:"time_slot" binding:"required"`
}
func (h *Handler) MakeAppointment(c *gin.Context) {
var req AppointmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
appointmentID, err := h.service.MakeAppointment(c.Request.Context(), req.DoctorID, req.Date, req.TimeSlot)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, gin.H{"appointment_id": appointmentID})
}
......@@ -2,6 +2,7 @@ package doctor
import (
"context"
"errors"
"gorm.io/gorm"
......@@ -120,3 +121,15 @@ func (s *Service) GetDoctorSchedule(ctx context.Context, doctorID, startDate, en
Find(&schedules).Error
return schedules, err
}
func (s *Service) MakeAppointment(ctx context.Context, doctorID, date, timeSlot string) (string, error) {
var schedule model.DoctorSchedule
if err := s.db.Where("doctor_id = ? AND date = ? AND start_time = ? AND remaining > 0", doctorID, date, timeSlot).
First(&schedule).Error; err != nil {
return "", errors.New("该时间段已无可用名额")
}
if err := s.db.Model(&schedule).UpdateColumn("remaining", schedule.Remaining-1).Error; err != nil {
return "", err
}
return schedule.ID, nil
}
......@@ -10,6 +10,7 @@ import (
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/pkg/workflow"
)
// ==================== 处方开具请求/响应 ====================
......@@ -155,6 +156,15 @@ func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *
}
tx.Commit()
// 触发 prescription_created 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "prescription_created", map[string]interface{}{
"prescription_id": prescription.ID,
"doctor_id": doctorID,
"patient_id": prescription.PatientID,
"total_amount": prescription.TotalAmount,
})
return prescription, nil
}
......
......@@ -11,6 +11,7 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/workflow"
)
type Service struct {
......@@ -104,6 +105,16 @@ func (s *Service) PayOrder(ctx context.Context, orderID, paymentMethod string) (
s.createDoctorIncome(ctx, order.RelatedID, order.Amount, order.OrderType)
}
// 触发 payment_completed 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "payment_completed", map[string]interface{}{
"order_id": order.ID,
"order_no": order.OrderNo,
"user_id": order.UserID,
"order_type": order.OrderType,
"related_id": order.RelatedID,
"amount": order.Amount,
})
return map[string]interface{}{
"order_id": order.ID,
"status": order.Status,
......
......@@ -26,6 +26,34 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
}
}
func (h *Handler) Logout(c *gin.Context) {
// JWT 为无状态令牌,服务端无需操作;客户端删除本地令牌即可
response.Success(c, nil)
}
type VerifyIdentityRequest struct {
RealName string `json:"real_name" binding:"required"`
IDCard string `json:"id_card" binding:"required"`
}
func (h *Handler) VerifyIdentity(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c, "未登录")
return
}
var req VerifyIdentityRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
if err := h.service.VerifyIdentity(c.Request.Context(), userID.(string), req.RealName, req.IDCard); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
type SendCodeRequest struct {
Phone string `json:"phone" binding:"required"`
}
......
......@@ -193,3 +193,16 @@ func (s *Service) GetUserByID(ctx context.Context, userID string) (*model.User,
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*utils.TokenPair, error) {
return utils.RefreshAccessToken(refreshToken)
}
func (s *Service) VerifyIdentity(ctx context.Context, userID, realName, idCard string) error {
// 简单格式校验:身份证18位
if len(idCard) != 18 {
return errors.New("身份证号格式不正确")
}
return s.db.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]interface{}{
"real_name": realName,
"is_verified": true,
}).Error
}
......@@ -4,19 +4,52 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
)
// getToolConfig 从数据库获取工具配置(CacheTTL/Timeout/MaxRetries/Status)
func getToolConfig(name string) model.AgentTool {
db := database.GetDB()
if db == nil {
return model.AgentTool{Status: "active", Timeout: 30}
}
var tool model.AgentTool
if err := db.Where("name = ?", name).First(&tool).Error; err != nil {
return model.AgentTool{Status: "active", Timeout: 30}
}
return tool
}
// Executor 工具执行器
type Executor struct {
registry *ToolRegistry
cache *ToolCache
}
func NewExecutor(r *ToolRegistry) *Executor {
return &Executor{registry: r}
return &Executor{registry: r, cache: GetCache()}
}
// Execute 执行工具调用
// Execute 执行工具调用(不记日志,集成缓存)
func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) ToolResult {
cfg := getToolConfig(name)
// 检查工具是否被禁用
if cfg.Status == "disabled" {
return ToolResult{Success: false, Error: fmt.Sprintf("工具 %s 已被管理员禁用", name)}
}
// 缓存命中检查
if cfg.CacheTTL > 0 {
if cached, ok := e.cache.Get(name, argsJSON); ok {
return cached
}
}
tool, ok := e.registry.Get(name)
if !ok {
return ToolResult{Success: false, Error: fmt.Sprintf("tool not found: %s", name)}
......@@ -27,9 +60,74 @@ func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) To
return ToolResult{Success: false, Error: fmt.Sprintf("invalid params: %v", err)}
}
data, err := tool.Execute(ctx, params)
if err != nil {
return ToolResult{Success: false, Error: err.Error()}
// 超时控制
timeout := time.Duration(cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
return ToolResult{Success: true, Data: data}
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// 执行(带重试)
var result ToolResult
maxRetries := cfg.MaxRetries
if maxRetries < 0 {
maxRetries = 0
}
for attempt := 0; attempt <= maxRetries; attempt++ {
data, err := tool.Execute(execCtx, params)
if err == nil {
result = ToolResult{Success: true, Data: data}
break
}
if attempt == maxRetries {
result = ToolResult{Success: false, Error: err.Error()}
}
}
// 写入缓存(仅成功结果)
if result.Success && cfg.CacheTTL > 0 {
e.cache.Set(name, argsJSON, result, cfg.CacheTTL)
}
return result
}
// ExecuteWithLog 执行工具调用并写入 AgentToolLog
func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID, agentID, sessionID, userID string, iteration int) ToolResult {
start := time.Now()
result := e.Execute(ctx, name, argsJSON)
durationMs := int(time.Since(start).Milliseconds())
// 异步写日志
go func() {
db := database.GetDB()
if db == nil {
return
}
outputJSON, _ := json.Marshal(result)
errMsg := ""
if !result.Success {
errMsg = result.Error
}
entry := &model.AgentToolLog{
TraceID: traceID,
ToolName: name,
AgentID: agentID,
SessionID: sessionID,
UserID: userID,
InputParams: argsJSON,
OutputResult: string(outputJSON),
Success: result.Success,
ErrorMessage: errMsg,
DurationMs: durationMs,
Iteration: iteration,
CreatedAt: time.Now(),
}
if err := db.Create(entry).Error; err != nil {
log.Printf("[executor] 保存工具调用日志失败: %v", err)
}
}()
return result
}
......@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"internet-hospital/pkg/ai"
"github.com/google/uuid"
)
// AgentInput Agent输入
......@@ -15,15 +17,17 @@ type AgentInput struct {
Context map[string]interface{} `json:"context"`
History []ai.ChatMessage `json:"history"`
MaxIterations int `json:"max_iterations"`
TraceID string `json:"trace_id,omitempty"` // 外部传入的链路追踪ID(可选)
}
// AgentOutput Agent输出
type AgentOutput struct {
Response string `json:"response"`
Response string `json:"response"`
ToolCalls []ToolCallResult `json:"tool_calls,omitempty"`
Iterations int `json:"iterations"`
FinishReason string `json:"finish_reason"`
TotalTokens int `json:"total_tokens"`
Iterations int `json:"iterations"`
FinishReason string `json:"finish_reason"`
TotalTokens int `json:"total_tokens"`
TraceID string `json:"trace_id,omitempty"` // 本次执行的链路追踪ID
}
// ToolCallResult 工具调用结果记录
......@@ -73,11 +77,18 @@ func (a *ReActAgent) Description() string { return a.cfg.Description }
// Run 执行Agent(非流式)
func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, error) {
// 生成链路追踪ID(如果未提供则新建)
traceID := input.TraceID
if traceID == "" {
traceID = uuid.New().String()
}
client := ai.GetClient()
if client == nil {
return &AgentOutput{
Response: "AI服务未配置,请在管理端配置API Key",
FinishReason: "no_client",
TraceID: traceID,
}, nil
}
......@@ -105,6 +116,23 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
return nil, fmt.Errorf("AI调用失败: %w", err)
}
totalTokens += resp.Usage.TotalTokens
// 记录本轮 LLM 调用日志(与 AIUsageLog 关联)
ai.SaveLog(ai.LogParams{
Scene: a.cfg.ID,
UserID: input.UserID,
RequestContent: input.Message,
ResponseContent: resp.Choices[0].Message.Content,
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
TotalTokens: resp.Usage.TotalTokens,
Success: true,
TraceID: traceID,
AgentID: a.cfg.ID,
SessionID: input.SessionID,
Iteration: i + 1,
})
choice := resp.Choices[0]
if choice.FinishReason == "stop" || len(choice.Message.ToolCalls) == 0 {
......@@ -114,6 +142,7 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
Iterations: i + 1,
FinishReason: "completed",
TotalTokens: totalTokens,
TraceID: traceID,
}, nil
}
......@@ -126,7 +155,8 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
result := a.executor.Execute(ctx, tc.Function.Name, tc.Function.Arguments)
result := a.executor.ExecuteWithLog(ctx, tc.Function.Name, tc.Function.Arguments,
traceID, a.cfg.ID, input.SessionID, input.UserID, i+1)
resultJSON, _ := json.Marshal(result)
toolCallResults = append(toolCallResults, ToolCallResult{
ToolName: tc.Function.Name,
......@@ -149,6 +179,7 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
Iterations: maxIter,
FinishReason: "max_iterations",
TotalTokens: totalTokens,
TraceID: traceID,
}, nil
}
......@@ -175,7 +206,13 @@ func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onChunk fu
}
func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string {
prompt := a.cfg.SystemPrompt
// 1. 优先从数据库加载该Agent关联的 active 提示词模板
prompt := ai.GetActivePromptByAgent(a.cfg.ID)
// 2. 回退到 AgentDefinition 的 SystemPrompt(即代码配置值)
if prompt == "" {
prompt = a.cfg.SystemPrompt
}
// 3. 渲染上下文变量
if ctx != nil {
if patientID, ok := ctx["patient_id"].(string); ok {
prompt += fmt.Sprintf("\n\n当前患者ID: %s", patientID)
......
......@@ -37,6 +37,12 @@ func (r *ToolRegistry) GetSchemas(names []string) []ToolSchema {
return schemas
}
func (r *ToolRegistry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.tools, name)
}
func (r *ToolRegistry) All() map[string]Tool {
r.mu.RLock()
defer r.mu.RUnlock()
......
package agent
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"time"
"internet-hospital/pkg/database"
)
const cacheKeyPrefix = "tool_cache:"
// ToolCache 工具结果 Redis 缓存
type ToolCache struct{}
var globalCache = &ToolCache{}
// GetCache 获取全局缓存实例
func GetCache() *ToolCache { return globalCache }
// argsHash 对工具参数计算 sha256 前 16 位,作为缓存 key 后缀
func argsHash(argsJSON string) string {
h := sha256.Sum256([]byte(argsJSON))
return fmt.Sprintf("%x", h[:8])
}
// cacheKey 构造缓存 key
func cacheKey(toolName, argsJSON string) string {
return cacheKeyPrefix + toolName + ":" + argsHash(argsJSON)
}
// Get 尝试命中缓存,返回 (result, hit)
func (c *ToolCache) Get(toolName, argsJSON string) (ToolResult, bool) {
client := database.RedisClient
if client == nil {
return ToolResult{}, false
}
key := cacheKey(toolName, argsJSON)
val, err := client.Get(context.Background(), key).Result()
if err != nil {
return ToolResult{}, false
}
var result ToolResult
if err := json.Unmarshal([]byte(val), &result); err != nil {
return ToolResult{}, false
}
return result, true
}
// Set 写入缓存
func (c *ToolCache) Set(toolName, argsJSON string, result ToolResult, ttlSec int) {
if ttlSec <= 0 {
return
}
client := database.RedisClient
if client == nil {
return
}
b, err := json.Marshal(result)
if err != nil {
return
}
key := cacheKey(toolName, argsJSON)
client.Set(context.Background(), key, string(b), time.Duration(ttlSec)*time.Second)
}
// Invalidate 手动使某工具的所有缓存失效(管理员修改工具配置后调用)
func (c *ToolCache) Invalidate(toolName string) {
client := database.RedisClient
if client == nil {
return
}
pattern := cacheKeyPrefix + toolName + ":*"
keys, err := client.Keys(context.Background(), pattern).Result()
if err != nil || len(keys) == 0 {
return
}
client.Del(context.Background(), keys...)
}
package tools
import (
"context"
"fmt"
"internet-hospital/pkg/agent"
)
// AgentCallFn 由 internal/agent/service.go 在启动时注入,避免循环依赖
var AgentCallFn func(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (string, error)
const maxAgentCallDepth = 3
type agentCallDepthKey struct{}
// AgentCallerTool 允许 Agent 调用另一个 Agent(子 Agent 模式)
type AgentCallerTool struct{}
func (t *AgentCallerTool) Name() string { return "call_agent" }
func (t *AgentCallerTool) Description() string {
return "调用另一个专业 Agent 处理特定子任务,如诊断辅助、处方审核、随访管理等"
}
func (t *AgentCallerTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "agent_id",
Type: "string",
Description: "目标 Agent ID,如 diagnosis_agent、prescription_agent、follow_up_agent",
Required: true,
},
{
Name: "message",
Type: "string",
Description: "发送给目标 Agent 的任务描述或问题",
Required: true,
},
{
Name: "context",
Type: "object",
Description: "传递给目标 Agent 的上下文信息(可选)",
Required: false,
},
}
}
func (t *AgentCallerTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
// 防循环调用:检测调用深度
depth, _ := ctx.Value(agentCallDepthKey{}).(int)
if depth >= maxAgentCallDepth {
return nil, fmt.Errorf("agent 调用深度超限(最大 %d 层),防止无限递归", maxAgentCallDepth)
}
ctx = context.WithValue(ctx, agentCallDepthKey{}, depth+1)
agentID, ok := params["agent_id"].(string)
if !ok || agentID == "" {
return nil, fmt.Errorf("agent_id 必填")
}
message, ok := params["message"].(string)
if !ok || message == "" {
return nil, fmt.Errorf("message 必填")
}
ctxData, _ := params["context"].(map[string]interface{})
if AgentCallFn == nil {
return nil, fmt.Errorf("AgentCallFn 未注入,请检查初始化顺序")
}
response, err := AgentCallFn(ctx, agentID, "system", "", message, ctxData)
if err != nil {
return nil, fmt.Errorf("调用 agent %s 失败: %w", agentID, err)
}
return map[string]interface{}{
"agent_id": agentID,
"response": response,
}, nil
}
package tools
import (
"context"
"fmt"
"github.com/expr-lang/expr"
agentpkg "internet-hospital/pkg/agent"
)
// ExprEvalTool 安全表达式执行工具(使用 expr-lang,无 IO/网络权限)
type ExprEvalTool struct{}
func (t *ExprEvalTool) Name() string { return "eval_expression" }
func (t *ExprEvalTool) Description() string {
return "安全执行数学/逻辑表达式,支持四则运算、条件判断、字符串处理、数组过滤。常用于 BMI 计算、风险评分、剂量调整等"
}
func (t *ExprEvalTool) Parameters() []agentpkg.ToolParameter {
return []agentpkg.ToolParameter{
{
Name: "expression",
Type: "string",
Description: "表达式字符串,如: weight / (height * height) 或 age > 65 ? '老年' : '成年'",
Required: true,
},
{
Name: "variables",
Type: "object",
Description: "表达式中引用的变量,如: {\"weight\": 70, \"height\": 1.75}",
Required: false,
},
}
}
func (t *ExprEvalTool) Execute(_ context.Context, params map[string]interface{}) (interface{}, error) {
expression, ok := params["expression"].(string)
if !ok || expression == "" {
return nil, fmt.Errorf("expression 必填")
}
variables, _ := params["variables"].(map[string]interface{})
if variables == nil {
variables = map[string]interface{}{}
}
// 编译表达式
program, err := expr.Compile(expression, expr.Env(variables))
if err != nil {
return nil, fmt.Errorf("表达式编译失败: %w", err)
}
// 执行表达式
result, err := expr.Run(program, variables)
if err != nil {
return nil, fmt.Errorf("表达式执行失败: %w", err)
}
return map[string]interface{}{
"expression": expression,
"result": result,
"variables": variables,
}, nil
}
package tools
import (
"context"
"fmt"
"internet-hospital/pkg/agent"
)
// GenerateFollowUpPlanTool 生成随访计划(此工具之前在分类 map 中声明但从未注册)
type GenerateFollowUpPlanTool struct{}
func (t *GenerateFollowUpPlanTool) Name() string { return "generate_follow_up_plan" }
func (t *GenerateFollowUpPlanTool) Description() string {
return "根据诊断和治疗信息生成个性化随访计划,包括复诊时间、检查项目、用药提醒和生活建议"
}
func (t *GenerateFollowUpPlanTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "diagnosis",
Type: "string",
Description: "诊断结论,如:高血压2级、2型糖尿病",
Required: true,
},
{
Name: "medications",
Type: "array",
Description: "当前用药列表,如:[\"氨氯地平 5mg\", \"二甲双胍 500mg\"]",
Required: false,
},
{
Name: "patient_age",
Type: "number",
Description: "患者年龄",
Required: false,
},
{
Name: "severity",
Type: "string",
Description: "病情严重程度:mild/moderate/severe",
Required: false,
Enum: []string{"mild", "moderate", "severe"},
},
}
}
func (t *GenerateFollowUpPlanTool) Execute(_ context.Context, params map[string]interface{}) (interface{}, error) {
diagnosis, ok := params["diagnosis"].(string)
if !ok || diagnosis == "" {
return nil, fmt.Errorf("diagnosis 必填")
}
severity, _ := params["severity"].(string)
if severity == "" {
severity = "moderate"
}
age, _ := params["patient_age"].(float64)
// 基于诊断生成随访计划(内置规则)
plan := buildFollowUpPlan(diagnosis, severity, int(age))
meds := make([]string, 0)
if medsRaw, ok := params["medications"].([]interface{}); ok {
for _, m := range medsRaw {
if s, ok := m.(string); ok {
meds = append(meds, s)
}
}
}
if len(meds) > 0 {
plan["medication_reminders"] = buildMedReminders(meds)
}
return plan, nil
}
func buildFollowUpPlan(diagnosis, severity string, age int) map[string]interface{} {
// 随访频率
followUpIntervalDays := 90 // 默认3个月
switch severity {
case "mild":
followUpIntervalDays = 180
case "severe":
followUpIntervalDays = 30
}
if age >= 65 {
followUpIntervalDays = followUpIntervalDays / 2
}
plan := map[string]interface{}{
"diagnosis": diagnosis,
"follow_up_interval": fmt.Sprintf("每 %d 天随访一次", followUpIntervalDays),
"follow_up_interval_days": followUpIntervalDays,
}
// 诊断特异性建议
checks := []string{"血压测量", "体重监测", "症状询问"}
advice := []string{"低盐低脂饮食", "规律运动(每周150分钟中等强度)", "戒烟限酒", "规律服药"}
containsAny := func(s string, keywords ...string) bool {
for _, k := range keywords {
if containsIgnoreCase(s, k) {
return true
}
}
return false
}
if containsAny(diagnosis, "高血压", "blood pressure") {
checks = append(checks, "尿常规", "血肌酐", "血钾")
advice = append(advice, "每日家庭血压监测(早晚各1次)", "限制钠盐摄入(< 6g/日)")
plan["target"] = "血压控制目标:< 140/90 mmHg(耐受者 < 130/80 mmHg)"
}
if containsAny(diagnosis, "糖尿病", "diabetes") {
checks = append(checks, "HbA1c", "空腹血糖", "尿微量白蛋白", "眼底检查", "足部检查")
advice = append(advice, "控制碳水化合物摄入", "每日血糖自我监测", "注意低血糖症状")
plan["target"] = "血糖控制目标:HbA1c < 7.0%(个体化)"
}
if containsAny(diagnosis, "冠心病", "心绞痛", "心肌梗死") {
checks = append(checks, "心电图", "血脂全套", "心脏超声(年度)")
advice = append(advice, "避免剧烈运动", "随身携带硝酸甘油", "规范抗血小板治疗")
plan["emergency_signs"] = "如出现持续胸痛、呼吸困难、大汗请立即就医"
}
plan["monitoring_items"] = checks
plan["lifestyle_advice"] = advice
return plan
}
func buildMedReminders(meds []string) []map[string]interface{} {
reminders := make([]map[string]interface{}, 0, len(meds))
for _, med := range meds {
reminders = append(reminders, map[string]interface{}{
"medication": med,
"reminder": "按时服药,切勿自行停药",
"tip": "如出现不适反应请及时就医",
})
}
return reminders
}
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
)
// DynamicHTTPTool 动态 HTTP 工具,从数据库配置生成,无需修改代码
type DynamicHTTPTool struct {
def *model.HTTPToolDefinition
}
func NewDynamicHTTPTool(def *model.HTTPToolDefinition) *DynamicHTTPTool {
return &DynamicHTTPTool{def: def}
}
func (t *DynamicHTTPTool) Name() string { return t.def.Name }
func (t *DynamicHTTPTool) Description() string { return t.def.Description }
func (t *DynamicHTTPTool) Parameters() []agent.ToolParameter {
if t.def.Parameters == "" || t.def.Parameters == "[]" {
return nil
}
var params []agent.ToolParameter
if err := json.Unmarshal([]byte(t.def.Parameters), &params); err != nil {
return nil
}
return params
}
// renderTemplate 将 {{key}} 替换为 params 中对应值
func renderTemplate(template string, params map[string]interface{}) string {
result := template
for k, v := range params {
result = strings.ReplaceAll(result, "{{"+k+"}}", fmt.Sprintf("%v", v))
}
return result
}
func (t *DynamicHTTPTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
// 渲染 URL
url := renderTemplate(t.def.URL, params)
// 渲染 Body
var bodyStr string
if t.def.BodyTemplate != "" {
bodyStr = renderTemplate(t.def.BodyTemplate, params)
}
// 超时
timeout := time.Duration(t.def.Timeout) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
httpCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// 创建请求
var bodyReader io.Reader
if bodyStr != "" {
bodyReader = strings.NewReader(bodyStr)
}
req, err := http.NewRequestWithContext(httpCtx, t.def.Method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
// 设置 Content-Type
if bodyStr != "" {
req.Header.Set("Content-Type", "application/json")
}
// 自定义 Headers
if t.def.Headers != "" && t.def.Headers != "{}" {
var headers map[string]string
if err := json.Unmarshal([]byte(t.def.Headers), &headers); err == nil {
for k, v := range headers {
req.Header.Set(k, renderTemplate(v, params))
}
}
}
// 认证
switch t.def.AuthType {
case "bearer":
var authCfg map[string]string
if err := json.Unmarshal([]byte(t.def.AuthConfig), &authCfg); err == nil {
if token, ok := authCfg["token"]; ok && token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
case "basic":
var authCfg map[string]string
if err := json.Unmarshal([]byte(t.def.AuthConfig), &authCfg); err == nil {
req.SetBasicAuth(authCfg["username"], authCfg["password"])
}
case "apikey":
var authCfg map[string]string
if err := json.Unmarshal([]byte(t.def.AuthConfig), &authCfg); err == nil {
headerName := authCfg["header"]
headerVal := authCfg["value"]
if headerName != "" {
req.Header.Set(headerName, headerVal)
}
}
}
// 发起请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
result := map[string]interface{}{
"status_code": resp.StatusCode,
"success": resp.StatusCode >= 200 && resp.StatusCode < 300,
}
// 尝试解析 JSON 响应
var jsonBody interface{}
if err := json.Unmarshal(bodyBytes, &jsonBody); err == nil {
result["body"] = jsonBody
} else {
result["body"] = string(bodyBytes)
}
if resp.StatusCode >= 400 {
return result, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(bodyBytes))
}
return result, nil
}
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
)
// HumanReviewTool Agent 主动发起人工审核任务
type HumanReviewTool struct{}
func (t *HumanReviewTool) Name() string { return "request_human_review" }
func (t *HumanReviewTool) Description() string {
return "发起人工审核请求,等待医生或管理员审核后继续;适用于需要人工介入的场景"
}
func (t *HumanReviewTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "title",
Type: "string",
Description: "审核任务标题",
Required: true,
},
{
Name: "description",
Type: "string",
Description: "需要人工审核的内容描述",
Required: true,
},
{
Name: "assignee_role",
Type: "string",
Description: "指定审核角色:doctor 或 admin",
Required: false,
Enum: []string{"doctor", "admin"},
},
{
Name: "data",
Type: "object",
Description: "随审核任务传递的附加数据",
Required: false,
},
}
}
func (t *HumanReviewTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
title, ok := params["title"].(string)
if !ok || title == "" {
return nil, fmt.Errorf("title 必填")
}
description, _ := params["description"].(string)
assigneeRole, _ := params["assignee_role"].(string)
if assigneeRole == "" {
assigneeRole = "admin"
}
extraData, _ := params["data"].(map[string]interface{})
extraJSON := "{}"
if extraData != nil {
if b, err := json.Marshal(extraData); err == nil {
extraJSON = string(b)
}
}
db := database.GetDB()
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
taskID := uuid.New().String()
now := time.Now()
task := model.WorkflowHumanTask{
TaskID: taskID,
ExecutionID: "agent-" + taskID,
NodeID: "agent_requested",
Title: title,
Description: description,
AssigneeRole: assigneeRole,
FormData: extraJSON,
Status: "pending",
CreatedAt: now,
}
if err := db.Create(&task).Error; err != nil {
return nil, fmt.Errorf("创建审核任务失败: %w", err)
}
return map[string]interface{}{
"task_id": taskID,
"status": "pending",
"assignee": assigneeRole,
"message": fmt.Sprintf("已创建人工审核任务,等待 %s 审核", assigneeRole),
}, nil
}
package tools
import (
"context"
"fmt"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
)
// KnowledgeListTool 列出可用知识库集合,Agent 可据此选择 collection_id
type KnowledgeListTool struct{}
func (t *KnowledgeListTool) Name() string { return "list_knowledge_collections" }
func (t *KnowledgeListTool) Description() string {
return "列出所有可用的知识库集合及文档数量,用于在检索前确认目标集合 ID"
}
func (t *KnowledgeListTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "keyword",
Type: "string",
Description: "按名称关键词过滤集合(可选)",
Required: false,
},
}
}
func (t *KnowledgeListTool) Execute(_ context.Context, params map[string]interface{}) (interface{}, error) {
db := database.GetDB()
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
keyword, _ := params["keyword"].(string)
type CollectionStat struct {
CollectionID string `gorm:"column:collection_id"`
Name string `gorm:"column:name"`
Description string `gorm:"column:description"`
DocCount int64 `gorm:"column:doc_count"`
}
query := db.Model(&model.KnowledgeCollection{}).
Select("knowledge_collections.collection_id, knowledge_collections.name, knowledge_collections.description, COUNT(knowledge_documents.id) as doc_count").
Joins("LEFT JOIN knowledge_documents ON knowledge_documents.collection_id = knowledge_collections.collection_id AND knowledge_documents.status = 'active'").
Group("knowledge_collections.collection_id, knowledge_collections.name, knowledge_collections.description")
if keyword != "" {
query = query.Where("knowledge_collections.name ILIKE ? OR knowledge_collections.description ILIKE ?",
"%"+keyword+"%", "%"+keyword+"%")
}
var stats []CollectionStat
if err := query.Scan(&stats).Error; err != nil {
return nil, fmt.Errorf("查询知识库失败: %w", err)
}
result := make([]map[string]interface{}, 0, len(stats))
for _, s := range stats {
result = append(result, map[string]interface{}{
"collection_id": s.CollectionID,
"name": s.Name,
"description": s.Description,
"doc_count": s.DocCount,
})
}
return map[string]interface{}{
"collections": result,
"total": len(result),
}, nil
}
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
"internet-hospital/pkg/rag"
)
// KnowledgeWriteTool Agent 向知识库写入或更新内容
type KnowledgeWriteTool struct {
DB *gorm.DB
Retriever *rag.Retriever
}
func (t *KnowledgeWriteTool) Name() string { return "write_knowledge" }
func (t *KnowledgeWriteTool) Description() string {
return "向指定知识库集合写入新文档或更新已有文档,Agent 可用于保存诊断结论、治疗方案、病例摘要等"
}
func (t *KnowledgeWriteTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "collection_id",
Type: "string",
Description: "目标知识库集合 ID,如 clinical_guideline、case_summary",
Required: true,
},
{
Name: "title",
Type: "string",
Description: "文档标题",
Required: true,
},
{
Name: "content",
Type: "string",
Description: "文档正文内容(支持 Markdown)",
Required: true,
},
{
Name: "tags",
Type: "array",
Description: "标签列表,如 [\"高血压\", \"治疗方案\"]",
Required: false,
},
{
Name: "doc_id",
Type: "string",
Description: "指定文档 ID 则更新,不指定则创建新文档",
Required: false,
},
}
}
func (t *KnowledgeWriteTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
db := t.DB
if db == nil {
db = database.GetDB()
}
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
collectionID, ok := params["collection_id"].(string)
if !ok || collectionID == "" {
return nil, fmt.Errorf("collection_id 必填")
}
title, ok := params["title"].(string)
if !ok || title == "" {
return nil, fmt.Errorf("title 必填")
}
content, ok := params["content"].(string)
if !ok || content == "" {
return nil, fmt.Errorf("content 必填")
}
// 构建 tags JSON
tagsJSON := "[]"
if tagsRaw, ok := params["tags"].([]interface{}); ok {
tags := make([]string, 0, len(tagsRaw))
for _, t := range tagsRaw {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
if b, err := json.Marshal(tags); err == nil {
tagsJSON = string(b)
}
}
docID, _ := params["doc_id"].(string)
now := time.Now()
// 确保集合存在
var collection model.KnowledgeCollection
if err := db.Where("collection_id = ?", collectionID).First(&collection).Error; err != nil {
// 自动创建集合
collection = model.KnowledgeCollection{
CollectionID: collectionID,
Name: collectionID,
Description: "由 Agent 自动创建",
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&collection).Error; err != nil {
return nil, fmt.Errorf("创建集合失败: %w", err)
}
}
// 创建或更新文档
if docID == "" {
docID = uuid.New().String()
}
var doc model.KnowledgeDocument
isNew := db.Where("document_id = ?", docID).First(&doc).Error != nil
if isNew {
doc = model.KnowledgeDocument{
DocumentID: docID,
CollectionID: collectionID,
Title: title,
Content: content,
Metadata: tagsJSON,
Status: "ready",
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&doc).Error; err != nil {
return nil, fmt.Errorf("创建文档失败: %w", err)
}
} else {
if err := db.Model(&doc).Updates(map[string]interface{}{
"title": title,
"content": content,
"metadata": tagsJSON,
"updated_at": now,
}).Error; err != nil {
return nil, fmt.Errorf("更新文档失败: %w", err)
}
// 删除旧分块
db.Where("document_id = ?", docID).Delete(&model.KnowledgeChunk{})
}
// 分块并写入(按段落分块)
chunks := splitIntoChunks(content, 500)
for i, chunk := range chunks {
c := model.KnowledgeChunk{
ChunkID: uuid.New().String(),
DocumentID: docID,
CollectionID: collectionID,
Content: chunk,
ChunkIndex: i,
CreatedAt: now,
}
db.Create(&c)
}
// 异步触发向量索引(如果 Retriever 可用)
if t.Retriever != nil {
go func() {
_ = t.Retriever.IndexDocument(context.Background(), docID)
}()
}
action := "created"
if !isNew {
action = "updated"
}
return map[string]interface{}{
"doc_id": docID,
"collection_id": collectionID,
"title": title,
"chunks": len(chunks),
"action": action,
}, nil
}
// splitIntoChunks 按段落分块,maxLen 为最大字符数
func splitIntoChunks(text string, maxLen int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
var current strings.Builder
for _, p := range paragraphs {
if current.Len()+len(p) > maxLen && current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
}
if current.Len() > 0 {
current.WriteString("\n\n")
}
current.WriteString(p)
}
if current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
}
if len(chunks) == 0 {
chunks = []string{text}
}
return chunks
}
package tools
import (
"context"
"fmt"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
)
// SendNotificationTool 向用户发送系统通知(站内信 + WebSocket 推送)
// 此工具之前在分类 map 中声明但从未注册,此次完整落地
type SendNotificationTool struct{}
func (t *SendNotificationTool) Name() string { return "send_notification" }
func (t *SendNotificationTool) Description() string {
return "向指定用户发送系统通知,支持站内信存储和实时 WebSocket 推送"
}
func (t *SendNotificationTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "user_id",
Type: "string",
Description: "接收通知的用户 ID(UUID)",
Required: true,
},
{
Name: "title",
Type: "string",
Description: "通知标题",
Required: true,
},
{
Name: "content",
Type: "string",
Description: "通知正文内容",
Required: true,
},
{
Name: "type",
Type: "string",
Description: "通知类型",
Required: false,
Enum: []string{"reminder", "alert", "info", "followup", "system"},
},
{
Name: "priority",
Type: "string",
Description: "优先级:normal/high/urgent",
Required: false,
Enum: []string{"normal", "high", "urgent"},
},
}
}
func (t *SendNotificationTool) Execute(_ context.Context, params map[string]interface{}) (interface{}, error) {
db := database.GetDB()
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
userID, ok := params["user_id"].(string)
if !ok || userID == "" {
return nil, fmt.Errorf("user_id 必填")
}
title, ok := params["title"].(string)
if !ok || title == "" {
return nil, fmt.Errorf("title 必填")
}
content, _ := params["content"].(string)
nType, _ := params["type"].(string)
if nType == "" {
nType = "info"
}
priority, _ := params["priority"].(string)
if priority == "" {
priority = "normal"
}
// 写入系统日志表作为站内信(复用 SystemLog)
log := model.SystemLog{
Action: "notification",
Resource: fmt.Sprintf("%s/%s", nType, priority),
Detail: fmt.Sprintf("[%s] %s: %s", nType, title, content),
UserID: userID,
}
if err := db.Create(&log).Error; err != nil {
return nil, fmt.Errorf("写入通知失败: %w", err)
}
return map[string]interface{}{
"notification_id": log.ID,
"user_id": userID,
"title": title,
"type": nType,
"priority": priority,
"sent_at": time.Now().Format(time.RFC3339),
}, nil
}
package tools
import (
"context"
"encoding/json"
"fmt"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
)
// WorkflowQueryTool 查询工作流执行状态
type WorkflowQueryTool struct{}
func (t *WorkflowQueryTool) Name() string { return "query_workflow_status" }
func (t *WorkflowQueryTool) Description() string {
return "查询工作流执行状态和结果,通常与 trigger_workflow 配合使用"
}
func (t *WorkflowQueryTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "execution_id",
Type: "string",
Description: "工作流执行 ID(由 trigger_workflow 返回)",
Required: true,
},
}
}
func (t *WorkflowQueryTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
executionID, ok := params["execution_id"].(string)
if !ok || executionID == "" {
return nil, fmt.Errorf("execution_id 必填")
}
db := database.GetDB()
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
var exec model.WorkflowExecution
if err := db.Where("execution_id = ?", executionID).First(&exec).Error; err != nil {
return nil, fmt.Errorf("未找到执行记录: %s", executionID)
}
result := map[string]interface{}{
"execution_id": exec.ExecutionID,
"workflow_id": exec.WorkflowID,
"status": exec.Status,
"trigger_by": exec.TriggerBy,
"started_at": exec.StartedAt,
"completed_at": exec.CompletedAt,
}
// 解析输出
if exec.Output != "" && exec.Output != "null" {
var output interface{}
if err := json.Unmarshal([]byte(exec.Output), &output); err == nil {
result["output"] = output
}
}
return result, nil
}
package tools
import (
"context"
"fmt"
"internet-hospital/pkg/agent"
)
// WorkflowTriggerFn 由 main.go 或 workflow 包在启动时注入
var WorkflowTriggerFn func(ctx context.Context, workflowID string, input map[string]interface{}) (string, error)
// WorkflowTriggerTool Agent 触发工作流执行
type WorkflowTriggerTool struct{}
func (t *WorkflowTriggerTool) Name() string { return "trigger_workflow" }
func (t *WorkflowTriggerTool) Description() string {
return "触发一个工作流异步执行,返回执行 ID 可用于后续查询状态"
}
func (t *WorkflowTriggerTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{
Name: "workflow_id",
Type: "string",
Description: "工作流 ID,如 pre_consult、follow_up",
Required: true,
},
{
Name: "input",
Type: "object",
Description: "工作流输入参数(键值对)",
Required: false,
},
}
}
func (t *WorkflowTriggerTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
workflowID, ok := params["workflow_id"].(string)
if !ok || workflowID == "" {
return nil, fmt.Errorf("workflow_id 必填")
}
input, _ := params["input"].(map[string]interface{})
if input == nil {
input = map[string]interface{}{}
}
if WorkflowTriggerFn == nil {
return nil, fmt.Errorf("WorkflowTriggerFn 未注入")
}
executionID, err := WorkflowTriggerFn(ctx, workflowID, input)
if err != nil {
return nil, fmt.Errorf("触发工作流 %s 失败: %w", workflowID, err)
}
return map[string]interface{}{
"workflow_id": workflowID,
"execution_id": executionID,
"status": "triggered",
}, nil
}
......@@ -584,6 +584,11 @@ type CallParams struct {
UserID string // 用户ID
Messages []ChatMessage // 消息列表
RequestSummary string // 请求摘要(用于日志)
// 链路追踪字段(Agent调用时填写)
TraceID string // 链路追踪ID
AgentID string // 关联Agent ID
SessionID string // 关联会话ID
Iteration int // Agent第几轮迭代(0=非Agent调用)
}
// CallResult 统一AI调用结果
......@@ -655,6 +660,10 @@ func Call(ctx context.Context, params CallParams) CallResult {
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
......@@ -715,6 +724,10 @@ func CallStream(ctx context.Context, params CallParams, onChunk func(content str
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
......
......@@ -22,6 +22,11 @@ type LogParams struct {
Success bool
ErrorMessage string
IsMock bool
// 链路追踪字段
TraceID string
AgentID string
SessionID string
Iteration int
}
// SaveLog 异步保存AI调用日志
......@@ -66,6 +71,10 @@ func SaveLog(params LogParams) {
Success: params.Success,
ErrorMessage: params.ErrorMessage,
IsMock: params.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
CreatedAt: time.Now(),
}
......
......@@ -37,3 +37,17 @@ func GetActivePromptByScene(scene string) string {
}
return ""
}
// GetActivePromptByAgent 根据 agent_id 查找该Agent关联的激活系统提示词模板
func GetActivePromptByAgent(agentID string) string {
db := database.GetDB()
if db == nil {
return ""
}
var tmpl model.PromptTemplate
if err := db.Where("agent_id = ? AND template_type = 'system' AND status = 'active'", agentID).
Order("version DESC").First(&tmpl).Error; err == nil {
return tmpl.Content
}
return ""
}
package safety
import (
"regexp"
"strings"
"internet-hospital/internal/model"
)
// FilterResult 过滤结果
type FilterResult struct {
Text string // 处理后的文本(passed/replaced时有效)
Action string // passed | blocked | replaced | warned
MatchedRules []MatchedRule // 命中的规则列表
Blocked bool // 是否被拦截
}
// Filter 安全过滤器
type Filter struct {
loader *Loader
}
// NewFilter 创建过滤器
func NewFilter() *Filter {
return &Filter{loader: GetLoader()}
}
// globalFilter 全局过滤器单例
var globalFilter = &Filter{loader: nil}
// GetFilter 获取全局过滤器
func GetFilter() *Filter {
if globalFilter.loader == nil {
globalFilter.loader = GetLoader()
}
return globalFilter
}
// FilterInput 输入过滤(用户消息 → LLM之前)
func (f *Filter) FilterInput(text, agentID, traceID, userID string) FilterResult {
return f.filter(text, "input", agentID, traceID, userID)
}
// FilterOutput 输出过滤(LLM回复 → 返回用户之前)
func (f *Filter) FilterOutput(text, agentID, traceID, userID string) FilterResult {
return f.filter(text, "output", agentID, traceID, userID)
}
func (f *Filter) filter(text, direction, agentID, traceID, userID string) FilterResult {
rules := f.loader.Load()
if len(rules) == 0 {
return FilterResult{Text: text, Action: "passed"}
}
result := FilterResult{Text: text, Action: "passed"}
for _, rule := range rules {
// 方向过滤
if rule.Direction != "both" && rule.Direction != direction {
continue
}
// agentID过滤(空=全局规则,或匹配特定agent)
if rule.AgentID != "" && rule.AgentID != agentID {
continue
}
matched := matchRule(rule, text)
if !matched {
continue
}
mr := MatchedRule{RuleID: rule.ID, Word: rule.Word, Action: rule.Level}
result.MatchedRules = append(result.MatchedRules, mr)
switch rule.Level {
case "block":
result.Action = "blocked"
result.Blocked = true
result.Text = ""
// 记录日志
SaveFilterLog(traceID, direction, text, "", result.MatchedRules, "blocked", agentID, userID)
return result
case "replace":
replacement := rule.Replacement
if replacement == "" {
replacement = "***"
}
result.Text = replaceWord(result.Text, rule.Word, replacement, rule.IsRegex)
result.Action = "replaced"
case "warn":
if result.Action == "passed" {
result.Action = "warned"
}
}
}
if result.Action != "passed" {
SaveFilterLog(traceID, direction, text, result.Text, result.MatchedRules, result.Action, agentID, userID)
}
return result
}
func matchRule(rule model.SafetyWordRule, text string) bool {
if rule.IsRegex {
re, err := regexp.Compile(rule.Word)
if err != nil {
return false
}
return re.MatchString(text)
}
return strings.Contains(text, rule.Word)
}
func replaceWord(text, word, replacement string, isRegex bool) string {
if isRegex {
re, err := regexp.Compile(word)
if err != nil {
return text
}
return re.ReplaceAllString(text, replacement)
}
return strings.ReplaceAll(text, word, replacement)
}
package safety
import (
"encoding/json"
"log"
"sync"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
)
// globalLoader 全局规则加载器(单例)
var globalLoader *Loader
var loaderOnce sync.Once
// Loader 安全词规则加载器(带缓存)
type Loader struct {
mu sync.RWMutex
rules []model.SafetyWordRule
lastLoad time.Time
ttl time.Duration
}
// GetLoader 获取全局加载器单例
func GetLoader() *Loader {
loaderOnce.Do(func() {
globalLoader = &Loader{
ttl: 5 * time.Minute,
}
})
return globalLoader
}
// Load 加载规则(带TTL缓存)
func (l *Loader) Load() []model.SafetyWordRule {
l.mu.RLock()
if time.Since(l.lastLoad) < l.ttl && len(l.rules) > 0 {
rules := l.rules
l.mu.RUnlock()
return rules
}
l.mu.RUnlock()
return l.reload()
}
// Reload 强制重新加载
func (l *Loader) Reload() []model.SafetyWordRule {
return l.reload()
}
func (l *Loader) reload() []model.SafetyWordRule {
db := database.GetDB()
if db == nil {
return nil
}
var rules []model.SafetyWordRule
if err := db.Where("status = 'active'").Order("id asc").Find(&rules).Error; err != nil {
log.Printf("[safety] 加载安全词规则失败: %v", err)
l.mu.RLock()
cached := l.rules
l.mu.RUnlock()
return cached
}
l.mu.Lock()
l.rules = rules
l.lastLoad = time.Now()
l.mu.Unlock()
return rules
}
// SaveFilterLog 异步保存过滤日志
func SaveFilterLog(traceID, direction, original, filtered string, matched []MatchedRule, action, agentID, userID string) {
go func() {
db := database.GetDB()
if db == nil {
return
}
matchedJSON, _ := json.Marshal(matched)
entry := &model.SafetyFilterLog{
TraceID: traceID,
Direction: direction,
OriginalText: truncate(original, 2000),
FilteredText: truncate(filtered, 2000),
MatchedRules: string(matchedJSON),
Action: action,
AgentID: agentID,
UserID: userID,
}
if err := db.Create(entry).Error; err != nil {
log.Printf("[safety] 保存过滤日志失败: %v", err)
}
}()
}
// MatchedRule 命中的规则记录
type MatchedRule struct {
RuleID uint `json:"rule_id"`
Word string `json:"word"`
Action string `json:"action"`
}
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "..."
}
package workflow
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
exprlib "github.com/expr-lang/expr"
"github.com/google/uuid"
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
agentpkg "internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
"github.com/google/uuid"
)
// NodeType 节点类型
......@@ -68,14 +72,14 @@ type Engine struct {
agentSvc interface {
Chat(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (interface{}, error)
}
toolRegistry *agent.ToolRegistry
toolRegistry *agentpkg.ToolRegistry
}
var globalEngine *Engine
func GetEngine() *Engine {
if globalEngine == nil {
globalEngine = &Engine{toolRegistry: agent.GetRegistry()}
globalEngine = &Engine{toolRegistry: agentpkg.GetRegistry()}
}
return globalEngine
}
......@@ -253,15 +257,28 @@ func (e *Engine) createHumanTask(_ context.Context, node *Node, execCtx *Executi
return map[string]interface{}{"task_id": task.TaskID, "status": "pending"}, nil
}
func evalCondition(expr string, vars map[string]interface{}) bool {
// 简单实现:支持 "key == 'value'" 格式
for k, v := range vars {
check := fmt.Sprintf("%s == '%v'", k, v)
if expr == check {
return true
func evalCondition(expression string, vars map[string]interface{}) bool {
if expression == "" {
return true
}
program, err := exprlib.Compile(expression, exprlib.Env(vars), exprlib.AsBool())
if err != nil {
// 降级:简单字符串相等检查
for k, v := range vars {
if expression == fmt.Sprintf("%s == '%v'", k, v) {
return true
}
}
return false
}
result, err := exprlib.Run(program, vars)
if err != nil {
return false
}
return false
if b, ok := result.(bool); ok {
return b
}
return result != nil
}
// executeParallel 并行执行多个子节点
......@@ -367,84 +384,115 @@ func (e *Engine) executeLoop(ctx context.Context, wf *Workflow, node *Node, exec
}, nil
}
// executeCode 执行代码节点(简单表达式求值)
// executeCode 代码节点:使用 expr-lang 安全执行表达式
func (e *Engine) executeCode(_ context.Context, node *Node, execCtx *ExecutionContext) (interface{}, error) {
code, _ := node.Config["code"].(string)
if code == "" {
return map[string]interface{}{"skipped": "no code"}, nil
}
// 简单的表达式求值(实际生产环境应使用安全的脚本引擎)
// 这里只支持简单的变量赋值和返回
result := make(map[string]interface{})
// 将所有变量复制到结果中
// 合并所有变量作为上下文
env := make(map[string]interface{})
for k, v := range execCtx.Variables {
result[k] = v
env[k] = v
}
// 将所有节点输出复制到结果中
for k, v := range execCtx.NodeOutputs {
result["node_"+k] = v
env["node_"+k] = v
}
return result, nil
program, err := exprlib.Compile(code, exprlib.Env(env))
if err != nil {
return nil, fmt.Errorf("代码节点编译失败: %w", err)
}
result, err := exprlib.Run(program, env)
if err != nil {
return nil, fmt.Errorf("代码节点执行失败: %w", err)
}
// 如果结果是 map,合并回变量
if m, ok := result.(map[string]interface{}); ok {
for k, v := range m {
execCtx.Variables[k] = v
}
}
return map[string]interface{}{"result": result, "code": code}, nil
}
// executeHTTP 执行HTTP请求节点
// executeHTTP 执行 HTTP 请求节点(完整 net/http 实现)
func (e *Engine) executeHTTP(ctx context.Context, node *Node, execCtx *ExecutionContext) (interface{}, error) {
url, _ := node.Config["url"].(string)
rawURL, _ := node.Config["url"].(string)
method, _ := node.Config["method"].(string)
if method == "" {
method = "GET"
}
if url == "" {
if rawURL == "" {
return nil, fmt.Errorf("http node requires url")
}
// 替换URL中的变量
// 替换 URL 中的 {{var}} 变量
for k, v := range execCtx.Variables {
placeholder := fmt.Sprintf("${%s}", k)
if strVal, ok := v.(string); ok {
url = replaceAll(url, placeholder, strVal)
}
rawURL = replaceAll(rawURL, "{{"+k+"}}", fmt.Sprintf("%v", v))
}
// 创建HTTP请求
var reqBody []byte
// 构建请求 Body
var bodyBytes []byte
if body, ok := node.Config["body"].(map[string]interface{}); ok {
reqBody, _ = json.Marshal(body)
// 渲染 body 中的变量
for k, v := range body {
if s, ok2 := v.(string); ok2 {
for varK, varV := range execCtx.Variables {
s = replaceAll(s, "{{"+varK+"}}", fmt.Sprintf("%v", varV))
}
body[k] = s
}
}
bodyBytes, _ = json.Marshal(body)
}
req, err := newHTTPRequest(ctx, method, url, reqBody)
if err != nil {
return nil, err
var bodyReader io.Reader
if len(bodyBytes) > 0 {
bodyReader = bytes.NewReader(bodyBytes)
}
// 设置请求头
timeout := 30 * time.Second
if t, ok := node.Config["timeout"].(float64); ok && t > 0 {
timeout = time.Duration(t) * time.Second
}
httpCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(httpCtx, method, rawURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
}
if len(bodyBytes) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if headers, ok := node.Config["headers"].(map[string]interface{}); ok {
for k, v := range headers {
if strVal, ok := v.(string); ok {
req.Header[k] = strVal
if sv, ok2 := v.(string); ok2 {
req.Header.Set(k, sv)
}
}
}
// 执行请求
client := &httpClient{timeout: 30 * time.Second}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{
"error": err.Error(),
"status": 0,
}, nil
return map[string]interface{}{"error": err.Error(), "status": 0}, nil
}
defer resp.Body.Close()
return map[string]interface{}{
"status": resp.StatusCode,
"body": resp.Body,
}, nil
respBytes, _ := io.ReadAll(resp.Body)
result := map[string]interface{}{"status": resp.StatusCode}
var parsed interface{}
if json.Unmarshal(respBytes, &parsed) == nil {
result["body"] = parsed
} else {
result["body"] = string(respBytes)
}
return result, nil
}
// executeTemplate 执行模板渲染节点
......@@ -504,39 +552,3 @@ func replaceAll(s, old, new string) string {
return string(result)
}
// 简单的HTTP客户端封装
type httpClient struct {
timeout time.Duration
}
type httpResponse struct {
StatusCode int
Body string
}
func newHTTPRequest(ctx context.Context, method, url string, body []byte) (*httpRequest, error) {
return &httpRequest{
ctx: ctx,
method: method,
url: url,
body: body,
Header: make(map[string]string),
}, nil
}
type httpRequest struct {
ctx context.Context
method string
url string
body []byte
Header map[string]string
}
func (c *httpClient) Do(req *httpRequest) (*httpResponse, error) {
// 简化实现:实际应使用net/http
// 这里返回模拟响应,避免引入额外依赖
return &httpResponse{
StatusCode: 200,
Body: `{"status": "ok"}`,
}, nil
}
......@@ -62,6 +62,32 @@ export interface WorkflowExecution {
completed_at: string;
}
export interface AgentDefinition {
id: number;
agent_id: string;
name: string;
description: string;
category: string;
system_prompt: string;
tools: string; // JSON array string
config: string;
max_iterations: number;
status: string;
created_at: string;
updated_at: string;
}
export type AgentDefinitionParams = {
agent_id: string;
name: string;
description?: string;
category?: string;
system_prompt?: string;
tools?: string;
max_iterations?: number;
status?: string;
};
export interface WorkflowCreateParams {
workflow_id: string;
name: string;
......@@ -84,18 +110,21 @@ export interface KnowledgeDocumentParams {
// ==================== Agent API ====================
// AI 推理调用专用超时(2分钟)
const AI_TIMEOUT = 120000;
export const agentApi = {
chat: (agentId: string, params: {
session_id?: string;
message: string;
context?: Record<string, unknown>;
}) => post<AgentOutput>(`/agent/${agentId}/chat`, params),
}) => post<AgentOutput>(`/agent/${agentId}/chat`, params, { timeout: AI_TIMEOUT }),
listAgents: () =>
get<{ id: string; name: string; description: string }[]>('/agent/list'),
listTools: () =>
get<{ id: string; name: string; description: string; category: string; parameters: Record<string, unknown>; is_enabled: boolean; created_at: string }[]>('/agent/tools'),
get<{ id: string; name: string; display_name: string; description: string; category: string; parameters: Record<string, unknown>; status: string; is_enabled: boolean; cache_ttl: number; timeout: number; max_retries: number; created_at: string }[]>('/agent/tools'),
getSessions: (agentId?: string) =>
get<AgentSession[]>('/agent/sessions', { params: agentId ? { agent_id: agentId } : {} }),
......@@ -115,6 +144,69 @@ export const agentApi = {
get<{ agent_id: string; count: number; avg_iterations: number; avg_tokens: number; success_rate: number }[]>(
'/admin/agent/stats', { params }
),
// Agent 配置 CRUD
listDefinitions: () =>
get<AgentDefinition[]>('/agent/definitions'),
getDefinition: (agentId: string) =>
get<AgentDefinition>(`/agent/definitions/${agentId}`),
createDefinition: (data: AgentDefinitionParams) =>
post<AgentDefinition>('/agent/definitions', data),
updateDefinition: (agentId: string, data: Partial<AgentDefinitionParams>) =>
put<AgentDefinition>(`/agent/definitions/${agentId}`, data),
reloadAgent: (agentId: string) =>
put<null>(`/agent/definitions/${agentId}/reload`, {}),
updateToolStatus: (name: string, status: 'active' | 'disabled') =>
put<null>(`/agent/tools/${name}/status`, { status }),
};
// ==================== HTTP Tool API ====================
export interface HTTPToolDefinition {
id: number;
name: string;
display_name: string;
description: string;
category: string;
method: string;
url: string;
headers: string;
body_template: string;
auth_type: string;
auth_config: string;
parameters: string;
timeout: number;
cache_ttl: number;
status: string;
created_by: string;
created_at: string;
updated_at: string;
}
export type HTTPToolParams = Omit<HTTPToolDefinition, 'id' | 'created_at' | 'updated_at'>;
export const httpToolApi = {
list: () => get<HTTPToolDefinition[]>('/agent/http-tools'),
create: (data: Partial<HTTPToolParams>) =>
post<HTTPToolDefinition>('/agent/http-tools', data),
update: (id: number, data: Partial<HTTPToolParams>) =>
put<HTTPToolDefinition>(`/agent/http-tools/${id}`, data),
delete: (id: number) => del<null>(`/agent/http-tools/${id}`),
test: (id: number, params: Record<string, unknown>) =>
post<{ success: boolean; data?: unknown; error?: string }>(
`/agent/http-tools/${id}/test`, { params }
),
reload: () => post<{ message: string }>('/agent/http-tools/reload', {}),
};
// ==================== Workflow API ====================
......@@ -127,7 +219,7 @@ export const workflowApi = {
update: (id: number, data: Partial<WorkflowCreateParams>) =>
put<unknown>(`/admin/workflows/${id}`, data),
publish: (id: number) => post<null>(`/admin/workflows/${id}/publish`),
publish: (id: number) => put<null>(`/admin/workflows/${id}/publish`, {}),
execute: (workflowId: string, input?: Record<string, unknown>) =>
post<{ execution_id: string }>(`/workflow/${workflowId}/execute`, input || {}),
......
......@@ -59,7 +59,7 @@ export const chronicApi = {
listRenewals: () => get<RenewalRequest[]>('/chronic/renewals'),
createRenewal: (data: { chronic_id?: string; disease_name: string; medicines: string[]; reason: string }) =>
post<RenewalRequest>('/chronic/renewals', data),
getAIAdvice: (id: string) => post<{ advice: string }>(`/chronic/renewals/${id}/ai-advice`, {}),
getAIAdvice: (id: string) => post<{ advice: string }>(`/chronic/renewals/${id}/ai-advice`, {}, { timeout: 120000 }),
// 用药提醒
listReminders: () => get<MedicationReminder[]>('/chronic/reminders'),
......
......@@ -129,7 +129,7 @@ export const consultApi = {
tool_calls?: import('./agent').ToolCall[];
iterations?: number;
total_tokens?: number;
}>(`/consult/${id}/ai-assist`, { scene }),
}>(`/consult/${id}/ai-assist`, { scene }, { timeout: 120000 }),
// 取消问诊
cancelConsult: (id: string, reason?: string) =>
......
......@@ -191,5 +191,5 @@ export const doctorPortalApi = {
iterations?: number;
has_warning: boolean;
has_contraindication: boolean;
}>('/doctor-portal/prescription/check', params),
}>('/doctor-portal/prescription/check', params, { timeout: 120000 }),
};
......@@ -93,7 +93,7 @@ export const healthApi = {
listReports: () => get<LabReport[]>('/health/reports'),
createReport: (data: CreateReportParams) => post<LabReport>('/health/reports', data),
deleteReport: (id: string) => del<null>(`/health/reports/${id}`),
aiInterpret: (id: string) => post<{ interpret: string }>(`/health/reports/${id}/ai-interpret`),
aiInterpret: (id: string) => post<{ interpret: string }>(`/health/reports/${id}/ai-interpret`, undefined, { timeout: 120000 }),
// 家庭成员
listFamily: () => get<FamilyMember[]>('/health/family'),
createFamily: (data: FamilyMemberParams) => post<FamilyMember>('/health/family', data),
......
......@@ -21,9 +21,10 @@ const getBaseURL = () => {
};
// 创建 axios 实例
// 普通接口:30s;AI / Agent 接口(模型推理)可能需要 2min,通过 config.timeout 单独覆盖
const request: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 10000,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
......
'use client';
import { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Modal, Input, message, Space, Collapse, Timeline, Typography, Tabs, DatePicker, Select, Badge, Tooltip } from 'antd';
import { RobotOutlined, PlayCircleOutlined, ToolOutlined, CheckCircleOutlined, CloseCircleOutlined, HistoryOutlined, ThunderboltOutlined } from '@ant-design/icons';
import {
Card, Table, Tag, Button, Modal, Input, message, Space, Collapse, Timeline,
Typography, Tabs, DatePicker, Select, Badge, Tooltip, Form, InputNumber, Switch,
} from 'antd';
import {
RobotOutlined, PlayCircleOutlined, ToolOutlined, CheckCircleOutlined,
CloseCircleOutlined, HistoryOutlined, ThunderboltOutlined, EditOutlined,
ReloadOutlined, PlusOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api/agent';
import type { ToolCall, AgentExecutionLog } from '@/api/agent';
import type { ToolCall, AgentExecutionLog, AgentDefinition } from '@/api/agent';
const { Text } = Typography;
const { RangePicker } = DatePicker;
const BUILTIN_AGENTS = [
{ id: 'pre_consult_agent', name: '预问诊智能助手', description: '通过多轮对话收集患者症状,生成预问诊报告', category: 'pre_consult', tools: ['query_symptom_knowledge', 'recommend_department'], max_iterations: 5 },
{ id: 'diagnosis_agent', name: '诊断辅助Agent', description: '辅助医生进行诊断,提供鉴别诊断建议', category: 'diagnosis', tools: ['query_medical_record', 'query_symptom_knowledge', 'search_medical_knowledge'], max_iterations: 10 },
{ id: 'prescription_agent', name: '处方审核Agent', description: '审核处方合理性,检查药物相互作用、禁忌症和剂量', category: 'prescription', tools: ['query_drug', 'check_drug_interaction', 'check_contraindication', 'calculate_dosage'], max_iterations: 10 },
{ id: 'follow_up_agent', name: '随访管理Agent', description: '管理患者随访,提醒用药、复诊,收集健康数据', category: 'follow_up', tools: ['query_medical_record', 'query_drug', 'query_symptom_knowledge'], max_iterations: 8 },
];
const categoryColor: Record<string, string> = {
pre_consult: 'blue', diagnosis: 'purple', prescription: 'orange', follow_up: 'green',
};
......@@ -23,6 +24,13 @@ const categoryLabel: Record<string, string> = {
pre_consult: '预问诊', diagnosis: '诊断辅助', prescription: '处方审核', follow_up: '随访管理',
};
const CATEGORY_OPTIONS = [
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断辅助' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访管理' },
];
interface AgentResponse {
response: string;
tool_calls?: ToolCall[];
......@@ -31,12 +39,22 @@ interface AgentResponse {
}
export default function AgentsPage() {
const queryClient = useQueryClient();
const [form] = Form.useForm();
// 测试对话
const [testModal, setTestModal] = useState<{ open: boolean; agentId: string; agentName: string }>({ open: false, agentId: '', agentName: '' });
const [testMessages, setTestMessages] = useState<{ role: string; content: string; toolCalls?: ToolCall[]; meta?: { iterations?: number; tokens?: number } }[]>([]);
const [inputMsg, setInputMsg] = useState('');
const [loading, setLoading] = useState(false);
const [chatLoading, setChatLoading] = useState(false);
const [sessionId, setSessionId] = useState('');
// 编辑 Agent
const [editModal, setEditModal] = useState<{ open: boolean; agent: AgentDefinition | null; isNew: boolean }>({ open: false, agent: null, isNew: false });
// 可用工具列表
const [availableTools, setAvailableTools] = useState<string[]>([]);
// 执行日志
const [logs, setLogs] = useState<AgentExecutionLog[]>([]);
const [logTotal, setLogTotal] = useState(0);
......@@ -44,6 +62,23 @@ export default function AgentsPage() {
const [logFilter, setLogFilter] = useState<{ agent_id?: string; page: number; page_size: number }>({ page: 1, page_size: 10 });
const [expandedLog, setExpandedLog] = useState<AgentExecutionLog | null>(null);
// 加载 Agent 列表
const { data: agentsData, isLoading: agentsLoading } = useQuery({
queryKey: ['agent-definitions'],
queryFn: () => agentApi.listDefinitions(),
});
const agents: AgentDefinition[] = agentsData?.data || [];
// 加载工具列表
useEffect(() => {
agentApi.listTools().then(res => {
if (res.data?.length > 0) {
setAvailableTools(res.data.map((t: { name: string }) => t.name));
}
}).catch(() => {});
fetchLogs();
}, []);
const fetchLogs = async (filter = logFilter) => {
setLogLoading(true);
try {
......@@ -53,7 +88,44 @@ export default function AgentsPage() {
} catch {} finally { setLogLoading(false); }
};
useEffect(() => { fetchLogs(); }, []);
// 保存 Agent(创建或更新)
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const toolsArr = values.tools_array as string[] || [];
const params = {
agent_id: values.agent_id as string,
name: values.name as string,
description: values.description as string,
category: values.category as string,
system_prompt: values.system_prompt as string,
tools: JSON.stringify(toolsArr),
max_iterations: values.max_iterations as number,
status: values.status as string,
};
if (editModal.isNew) {
return agentApi.createDefinition(params);
} else {
return agentApi.updateDefinition(editModal.agent!.agent_id, params);
}
},
onSuccess: () => {
message.success(editModal.isNew ? '创建成功' : '更新成功');
queryClient.invalidateQueries({ queryKey: ['agent-definitions'] });
setEditModal({ open: false, agent: null, isNew: false });
form.resetFields();
},
onError: () => message.error('操作失败'),
});
// 热重载
const reloadMutation = useMutation({
mutationFn: (agentId: string) => agentApi.reloadAgent(agentId),
onSuccess: () => {
message.success('热重载成功');
queryClient.invalidateQueries({ queryKey: ['agent-definitions'] });
},
onError: () => message.error('热重载失败'),
});
const openTest = (agentId: string, agentName: string) => {
setTestModal({ open: true, agentId, agentName });
......@@ -61,12 +133,34 @@ export default function AgentsPage() {
setSessionId(crypto.randomUUID());
};
const openEdit = (agent?: AgentDefinition) => {
if (agent) {
let toolsArr: string[] = [];
try { toolsArr = JSON.parse(agent.tools || '[]'); } catch {}
form.setFieldsValue({
agent_id: agent.agent_id,
name: agent.name,
description: agent.description,
category: agent.category,
system_prompt: agent.system_prompt,
tools_array: toolsArr,
max_iterations: agent.max_iterations,
status: agent.status === 'active',
});
setEditModal({ open: true, agent, isNew: false });
} else {
form.resetFields();
form.setFieldsValue({ max_iterations: 10, status: true });
setEditModal({ open: true, agent: null, isNew: true });
}
};
const sendMessage = async () => {
if (!inputMsg.trim()) return;
const userMsg = inputMsg;
setInputMsg('');
setTestMessages(prev => [...prev, { role: 'user', content: userMsg }]);
setLoading(true);
setChatLoading(true);
try {
const res = await agentApi.chat(testModal.agentId, { session_id: sessionId, message: userMsg });
const agentData = res.data as AgentResponse;
......@@ -79,7 +173,7 @@ export default function AgentsPage() {
} catch {
message.error('请求失败');
} finally {
setLoading(false);
setChatLoading(false);
}
};
......@@ -112,14 +206,14 @@ export default function AgentsPage() {
const agentColumns = [
{
title: '智能体名称', key: 'name',
render: (_: unknown, r: typeof BUILTIN_AGENTS[0]) => (
title: '智能体', key: 'name',
render: (_: unknown, r: AgentDefinition) => (
<Space>
<RobotOutlined style={{ color: '#1890ff' }} />
<div>
<Text strong>{r.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{r.id}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{r.agent_id}</Text>
</div>
</Space>
),
......@@ -131,13 +225,29 @@ export default function AgentsPage() {
},
{
title: '工具', dataIndex: 'tools', key: 'tools',
render: (v: string[]) => <Space size={4} wrap>{v.map(t => <Tag key={t} style={{ fontSize: 11, margin: 0 }}>{t}</Tag>)}</Space>,
render: (v: string) => {
let arr: string[] = [];
try { arr = JSON.parse(v || '[]'); } catch {}
return <Space size={4} wrap>{arr.map(t => <Tag key={t} style={{ fontSize: 11, margin: 0 }}>{t}</Tag>)}</Space>;
},
},
{ title: '最大迭代', dataIndex: 'max_iterations', key: 'max_iterations', width: 90, render: (v: number) => <Tag>{v} 次</Tag> },
{
title: '操作', key: 'action', width: 90,
render: (_: unknown, r: typeof BUILTIN_AGENTS[0]) => (
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => openTest(r.id, r.name)}>测试</Button>
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '运行中' : '停用'} />,
},
{
title: '操作', key: 'action', width: 200,
render: (_: unknown, r: AgentDefinition) => (
<Space>
<Button size="small" icon={<PlayCircleOutlined />} type="link" onClick={() => openTest(r.agent_id, r.name)}>测试</Button>
<Button size="small" icon={<EditOutlined />} type="link" onClick={() => openEdit(r)}>编辑</Button>
<Button
size="small" icon={<ReloadOutlined />} type="link"
loading={reloadMutation.isPending}
onClick={() => reloadMutation.mutate(r.agent_id)}
>热重载</Button>
</Space>
),
},
];
......@@ -173,20 +283,34 @@ export default function AgentsPage() {
},
];
const toolOptions = availableTools.map(t => ({ value: t, label: t }));
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>智能体管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台内置 AI 智能体,支持对话测试与执行日志查看</div>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>从数据库加载 Agent 配置,支持在线编辑、热重载与对话测试</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Tabs
tabBarExtraContent={
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>新增 Agent</Button>
}
items={[
{
key: 'agents',
label: <Space><RobotOutlined />智能体列表</Space>,
children: <Table dataSource={BUILTIN_AGENTS} columns={agentColumns} rowKey="id" pagination={false} size="small" />,
children: (
<Table
dataSource={agents}
columns={agentColumns}
rowKey="agent_id"
loading={agentsLoading}
pagination={false}
size="small"
/>
),
},
{
key: 'logs',
......@@ -198,7 +322,7 @@ export default function AgentsPage() {
placeholder="筛选智能体"
allowClear
style={{ width: 200 }}
options={BUILTIN_AGENTS.map(a => ({ value: a.id, label: a.name }))}
options={agents.map(a => ({ value: a.agent_id, label: a.name }))}
onChange={v => {
const newFilter = { ...logFilter, agent_id: v, page: 1 };
setLogFilter(newFilter);
......@@ -240,6 +364,52 @@ export default function AgentsPage() {
/>
</Card>
{/* 新增/编辑 Agent Modal */}
<Modal
title={editModal.isNew ? '新增 Agent' : `编辑 · ${editModal.agent?.name}`}
open={editModal.open}
onCancel={() => { setEditModal({ open: false, agent: null, isNew: false }); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={saveMutation.isPending}
width={700}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' })}
>
<Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}>
<Input placeholder="如: diagnosis_agent" disabled={!editModal.isNew} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如: 诊断辅助 Agent" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="简短描述 Agent 的功能" />
</Form.Item>
<Form.Item name="category" label="类别">
<Select options={CATEGORY_OPTIONS} placeholder="选择类别" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<Input.TextArea rows={5} placeholder="输入 Agent 的系统提示词(留空则使用数据库中关联的提示词模板)" />
</Form.Item>
<Form.Item name="tools_array" label="关联工具">
<Select
mode="multiple"
options={toolOptions}
placeholder="选择 Agent 可使用的工具"
allowClear
/>
</Form.Item>
<Form.Item name="max_iterations" label="最大迭代次数">
<InputNumber min={1} max={50} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>
{/* 测试对话 Modal */}
<Modal
title={`测试 · ${testModal.agentName}`}
......@@ -267,11 +437,11 @@ export default function AgentsPage() {
{m.role === 'assistant' && renderToolCalls(m.toolCalls)}
</div>
))}
{loading && <div style={{ color: '#999', fontSize: 12 }}>Agent 思考中...</div>}
{chatLoading && <div style={{ color: '#999', fontSize: 12 }}>Agent 思考中...</div>}
</div>
<Space.Compact style={{ width: '100%' }}>
<Input value={inputMsg} onChange={e => setInputMsg(e.target.value)} onPressEnter={sendMessage} placeholder="输入消息..." />
<Button type="primary" onClick={sendMessage} loading={loading}>发送</Button>
<Button type="primary" onClick={sendMessage} loading={chatLoading}>发送</Button>
</Space.Compact>
</Modal>
......
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Space, Modal, Typography, Row, Col,
Statistic, Tabs, Input, Select, Timeline, Spin, Empty,
} from 'antd';
import {
FundOutlined, SearchOutlined, ToolOutlined, RobotOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
interface AIUsageLog {
id: number;
scene: string;
user_id: string;
provider: string;
model: string;
total_tokens: number;
response_time_ms: number;
success: boolean;
is_mock: boolean;
trace_id: string;
agent_id: string;
session_id: string;
iteration: number;
created_at: string;
}
interface TraceDetail {
trace_id: string;
execution_logs: unknown[];
llm_calls: AIUsageLog[];
tool_calls: unknown[];
}
const token = () => localStorage.getItem('access_token') || '';
async function fetchStats(page: number, agentID: string) {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (agentID) params.append('agent_id', agentID);
const res = await fetch(`/api/v1/admin/ai-center/stats?${params}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? {};
}
async function fetchTrace(traceID: string) {
const res = await fetch(`/api/v1/admin/ai-center/trace?trace_id=${traceID}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data as TraceDetail;
}
export default function AICenterPage() {
const [activeTab, setActiveTab] = useState('overview');
const [page, setPage] = useState(1);
const [agentFilter, setAgentFilter] = useState('');
const [traceSearch, setTraceSearch] = useState('');
const [traceModalOpen, setTraceModalOpen] = useState(false);
const [selectedTraceID, setSelectedTraceID] = useState('');
const { data: stats, isLoading } = useQuery({
queryKey: ['ai-center-stats', page, agentFilter],
queryFn: () => fetchStats(page, agentFilter),
refetchInterval: 60000,
});
const { data: traceDetail, isFetching: traceFetching } = useQuery({
queryKey: ['trace-detail', selectedTraceID],
queryFn: () => fetchTrace(selectedTraceID),
enabled: !!selectedTraceID && traceModalOpen,
});
const openTrace = (traceID: string) => {
setSelectedTraceID(traceID);
setTraceModalOpen(true);
};
const logColumns: ColumnsType<AIUsageLog> = [
{
title: 'TraceID',
dataIndex: 'trace_id',
width: 140,
render: (v) => v ? (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => v && openTrace(v)}>
{v.slice(0, 12)}...
</Button>
) : '-',
},
{
title: '场景',
dataIndex: 'scene',
width: 140,
render: (v) => <Tag>{v}</Tag>,
},
{
title: 'Agent',
dataIndex: 'agent_id',
width: 140,
render: (v) => v ? <Tag color="blue">{v}</Tag> : '-',
},
{
title: '迭代',
dataIndex: 'iteration',
width: 60,
render: (v) => v > 0 ? <Tag color="purple">#{v}</Tag> : '-',
},
{
title: 'Tokens',
dataIndex: 'total_tokens',
width: 80,
render: (v) => <Text>{v?.toLocaleString()}</Text>,
},
{
title: '耗时(ms)',
dataIndex: 'response_time_ms',
width: 90,
render: (v) => {
const color = v > 5000 ? '#ff4d4f' : v > 2000 ? '#fa8c16' : '#52c41a';
return <Text style={{ color }}>{v}</Text>;
},
},
{
title: '状态',
dataIndex: 'success',
width: 70,
render: (v, r) => (
<Tag color={!v ? 'error' : r.is_mock ? 'warning' : 'success'}>
{!v ? '失败' : r.is_mock ? '模拟' : '成功'}
</Tag>
),
},
{
title: '时间',
dataIndex: 'created_at',
width: 140,
render: (v) => dayjs(v).format('MM-DD HH:mm:ss'),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>
<FundOutlined style={{ marginRight: 8, color: '#1890ff' }} />
AI 运营中心
</Title>
<Text type="secondary">全链路 AI 调用追踪、计量统计与性能监控</Text>
</div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={4}>
<Card loading={isLoading}>
<Statistic title="总调用次数" value={stats?.total_calls ?? 0} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading}>
<Statistic title="成功率" value={stats?.total_calls ? Math.round((stats.success_calls / stats.total_calls) * 100) : 0} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading}>
<Statistic title="总 Tokens" value={stats?.total_tokens ?? 0} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading}>
<Statistic title="Agent 执行" value={stats?.agent_execs ?? 0} prefix={<RobotOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading}>
<Statistic title="工具调用" value={stats?.tool_calls ?? 0} prefix={<ToolOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={isLoading}>
<Statistic title="模拟调用" value={stats?.mock_calls ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
</Row>
<Card>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'overview',
label: '调用日志',
children: (
<>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="按 Agent 过滤"
allowClear
style={{ width: 200 }}
value={agentFilter || undefined}
onChange={(v) => { setAgentFilter(v ?? ''); setPage(1); }}
options={[
{ value: 'pre_consult_agent', label: '预问诊 Agent' },
{ value: 'diagnosis_agent', label: '诊断辅助 Agent' },
{ value: 'prescription_agent', label: '处方审核 Agent' },
{ value: 'follow_up_agent', label: '随访管理 Agent' },
]}
/>
<Input.Search
placeholder="按 TraceID 追踪"
value={traceSearch}
onChange={(e) => setTraceSearch(e.target.value)}
onSearch={(v) => v && openTrace(v)}
style={{ width: 280 }}
prefix={<SearchOutlined />}
/>
</Space>
<Table
columns={logColumns}
dataSource={stats?.recent_logs ?? []}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
total: stats?.logs_total ?? 0,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `共 ${t} 条`,
}}
/>
</>
),
},
{
key: 'agents',
label: 'Agent 统计',
children: (
<Row gutter={16}>
<Col span={12}>
<Card title="各 Agent 调用量 TOP 10" size="small">
{(stats?.agent_counts ?? []).map((item: { agent_id: string; count: number }, idx: number) => (
<div key={item.agent_id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid #f0f0f0' }}>
<Text>{idx + 1}. {item.agent_id}</Text>
<Tag color="blue">{item.count}</Tag>
</div>
))}
{(stats?.agent_counts ?? []).length === 0 && <Empty description="暂无数据" />}
</Card>
</Col>
<Col span={12}>
<Card title="各场景调用量 TOP 10" size="small">
{(stats?.scene_counts ?? []).map((item: { scene: string; count: number }, idx: number) => (
<div key={item.scene} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid #f0f0f0' }}>
<Text>{idx + 1}. {item.scene}</Text>
<Tag color="green">{item.count}</Tag>
</div>
))}
{(stats?.scene_counts ?? []).length === 0 && <Empty description="暂无数据" />}
</Card>
</Col>
</Row>
),
},
]}
/>
</Card>
{/* 链路追踪详情弹窗 */}
<Modal
title={`链路追踪: ${selectedTraceID?.slice(0, 20)}...`}
open={traceModalOpen}
onCancel={() => { setTraceModalOpen(false); setSelectedTraceID(''); }}
footer={<Button onClick={() => setTraceModalOpen(false)}>关闭</Button>}
width={700}
>
{traceFetching ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : traceDetail ? (
<div>
<Card size="small" title="LLM 调用序列" style={{ marginBottom: 16 }}>
<Timeline
items={(traceDetail.llm_calls ?? []).map((log: AIUsageLog) => ({
color: log.success ? 'green' : 'red',
children: (
<div>
<Space>
<Tag color="purple">迭代 #{log.iteration}</Tag>
<Tag>{log.scene}</Tag>
<Text type="secondary">{log.total_tokens} tokens</Text>
<Text type="secondary">{log.response_time_ms}ms</Text>
</Space>
</div>
),
}))}
/>
{(traceDetail.llm_calls ?? []).length === 0 && <Empty description="无 LLM 调用记录" />}
</Card>
<Card size="small" title="工具调用序列">
<Timeline
items={((traceDetail.tool_calls ?? []) as Array<{ tool_name: string; iteration: number; success: boolean; duration_ms: number }>).map((tc) => ({
color: tc.success ? 'blue' : 'red',
children: (
<div>
<Space>
<Tag color="blue">迭代 #{tc.iteration}</Tag>
<Tag color="cyan">{tc.tool_name}</Tag>
<Tag color={tc.success ? 'green' : 'red'}>{tc.success ? '成功' : '失败'}</Tag>
<Text type="secondary">{tc.duration_ms}ms</Text>
</Space>
</div>
),
}))}
/>
{(traceDetail.tool_calls ?? []).length === 0 && <Empty description="无工具调用记录" />}
</Card>
</div>
) : (
<Empty description="未找到追踪数据" />
)}
</Modal>
</div>
);
}
'use client';
import { useState } from 'react';
import {
Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber,
Space, Popconfirm, message, Typography, Badge, Tooltip, Switch,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined,
ThunderboltOutlined, ReloadOutlined, PlayCircleOutlined, CodeOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { httpToolApi, type HTTPToolDefinition } from '@/api/agent';
const { Text } = Typography;
const { TextArea } = Input;
const METHOD_COLORS: Record<string, string> = {
GET: 'green', POST: 'blue', PUT: 'orange', DELETE: 'red', PATCH: 'purple',
};
const AUTH_LABELS: Record<string, string> = {
none: '无认证', bearer: 'Bearer Token', basic: 'Basic Auth', apikey: 'API Key',
};
export default function HTTPToolsPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [testForm] = Form.useForm();
const [modalOpen, setModalOpen] = useState(false);
const [testModal, setTestModal] = useState<{ open: boolean; tool: HTTPToolDefinition | null }>({ open: false, tool: null });
const [editingId, setEditingId] = useState<number | null>(null);
const [authType, setAuthType] = useState('none');
const { data, isLoading } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const tools = data ?? [];
const saveMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
editingId ? httpToolApi.update(editingId, values) : httpToolApi.create(values),
onSuccess: () => {
message.success(editingId ? '更新成功' : '创建成功');
qc.invalidateQueries({ queryKey: ['http-tools'] });
setModalOpen(false);
form.resetFields();
setEditingId(null);
},
onError: () => message.error('操作失败'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => httpToolApi.delete(id),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['http-tools'] });
},
});
const reloadMut = useMutation({
mutationFn: () => httpToolApi.reload(),
onSuccess: () => message.success('HTTP 工具已热重载'),
});
const testMut = useMutation({
mutationFn: ({ id, params }: { id: number; params: Record<string, unknown> }) =>
httpToolApi.test(id, params),
});
const openCreate = () => {
setEditingId(null);
form.resetFields();
setAuthType('none');
setModalOpen(true);
};
const openEdit = (tool: HTTPToolDefinition) => {
setEditingId(tool.id);
setAuthType(tool.auth_type || 'none');
let authConfig: Record<string, string> = {};
try { authConfig = JSON.parse(tool.auth_config || '{}'); } catch { /* ignore */ }
form.setFieldsValue({
name: tool.name,
display_name: tool.display_name,
description: tool.description,
category: tool.category,
method: tool.method || 'GET',
url: tool.url,
headers: tool.headers,
body_template: tool.body_template,
auth_type: tool.auth_type || 'none',
timeout: tool.timeout || 10,
cache_ttl: tool.cache_ttl || 0,
status: tool.status,
...authConfig,
});
setModalOpen(true);
};
const handleSave = () => {
form.validateFields().then(values => {
// 合并 auth config
const authConfig: Record<string, string> = {};
if (values.auth_type === 'bearer') authConfig.token = values.token || '';
else if (values.auth_type === 'basic') { authConfig.username = values.username || ''; authConfig.password = values.password || ''; }
else if (values.auth_type === 'apikey') { authConfig.key = values.key || ''; authConfig.value = values.value || ''; authConfig.in = values.in || 'header'; }
const payload = {
name: values.name,
display_name: values.display_name || values.name,
description: values.description || '',
category: values.category || 'http',
method: values.method || 'GET',
url: values.url,
headers: values.headers || '{}',
body_template: values.body_template || '',
auth_type: values.auth_type || 'none',
auth_config: JSON.stringify(authConfig),
parameters: '[]',
timeout: values.timeout || 10,
cache_ttl: values.cache_ttl || 0,
status: values.status || 'active',
};
saveMut.mutate(payload);
});
};
const handleTest = () => {
if (!testModal.tool) return;
testForm.validateFields().then(values => {
let params: Record<string, unknown> = {};
try { params = JSON.parse(values.params || '{}'); } catch { message.error('参数格式错误,请输入有效 JSON'); return; }
testMut.mutate({ id: testModal.tool!.id, params });
});
};
const columns = [
{
title: '名称', dataIndex: 'name', key: 'name',
render: (v: string, r: HTTPToolDefinition) => (
<div>
<Text code style={{ fontSize: 13 }}>{v}</Text>
{r.display_name && r.display_name !== v && <div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.display_name}</div>}
</div>
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 200 },
{
title: '方法', dataIndex: 'method', key: 'method', width: 80,
render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag>,
},
{ title: 'URL', dataIndex: 'url', key: 'url', ellipsis: true, width: 200, render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text> },
{
title: '认证', dataIndex: 'auth_type', key: 'auth_type', width: 100,
render: (v: string) => <Tag>{AUTH_LABELS[v] || v}</Tag>,
},
{
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: HTTPToolDefinition) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 11 }}>超时: {r.timeout}s</Text>
<Text type="secondary" style={{ fontSize: 11 }}>缓存: {r.cache_ttl > 0 ? `${r.cache_ttl}s` : '不缓存'}</Text>
</Space>
),
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '启用' : '禁用'} />,
},
{
title: '操作', key: 'action', width: 160,
render: (_: unknown, record: HTTPToolDefinition) => (
<Space>
<Tooltip title="测试调用">
<Button type="link" size="small" icon={<PlayCircleOutlined />}
onClick={() => { setTestModal({ open: true, tool: record }); testForm.resetFields(); }}
/>
</Tooltip>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确定删除?" onConfirm={() => deleteMut.mutate(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>HTTP 动态工具</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>通过配置 HTTP 接口动态接入外部服务,无需代码即可扩展 Agent 能力</div>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => reloadMut.mutate()} loading={reloadMut.isPending}>热重载</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建工具</Button>
</Space>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table
dataSource={tools}
columns={columns}
rowKey="id"
loading={isLoading}
size="small"
pagination={{ pageSize: 10, showTotal: t => `共 ${t} 个工具` }}
/>
</Card>
{/* 创建/编辑 Modal */}
<Modal
title={<Space><ApiOutlined style={{ color: '#1890ff' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={handleSave}
confirmLoading={saveMut.isPending}
width={680}
destroyOnClose
>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="name" label="工具名称(英文)" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="my_http_tool" disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称">
<Input placeholder="我的 HTTP 工具" />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="工具功能说明" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '0 12px' }}>
<Form.Item name="method" label="请求方法" initialValue="GET">
<Select options={['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(m => ({ value: m, label: m }))} />
</Form.Item>
<Form.Item name="url" label="URL" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="https://api.example.com/endpoint?q={{keyword}}" />
</Form.Item>
</div>
<Form.Item name="headers" label="请求头 (JSON)" initialValue="{}">
<TextArea rows={2} placeholder='{"Content-Type": "application/json"}' />
</Form.Item>
<Form.Item name="body_template" label="请求体模板(支持 {{variable}} 占位符)">
<TextArea rows={3} placeholder='{"query": "{{keyword}}", "limit": {{limit}}}' />
</Form.Item>
<Form.Item name="auth_type" label="认证方式" initialValue="none">
<Select
options={Object.entries(AUTH_LABELS).map(([v, l]) => ({ value: v, label: l }))}
onChange={v => setAuthType(v)}
/>
</Form.Item>
{authType === 'bearer' && (
<Form.Item name="token" label="Bearer Token" rules={[{ required: true }]}>
<Input.Password placeholder="your-bearer-token" />
</Form.Item>
)}
{authType === 'basic' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}><Input.Password /></Form.Item>
</div>
)}
{authType === 'apikey' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0 12px' }}>
<Form.Item name="key" label="参数名" rules={[{ required: true }]}><Input placeholder="X-API-Key" /></Form.Item>
<Form.Item name="value" label="参数值" rules={[{ required: true }]}><Input.Password /></Form.Item>
<Form.Item name="in" label="位置" initialValue="header">
<Select options={[{ value: 'header', label: 'Header' }, { value: 'query', label: 'Query' }]} />
</Form.Item>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0 16px' }}>
<Form.Item name="timeout" label="超时(秒)" initialValue={10}>
<InputNumber min={1} max={120} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="cache_ttl" label="缓存TTL(秒,0=不缓存)" initialValue={0}>
<InputNumber min={0} max={86400} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</div>
</Form>
</Modal>
{/* 测试 Modal */}
<Modal
title={<Space><ThunderboltOutlined style={{ color: '#fa8c16' }} />测试工具: <Text code>{testModal.tool?.name}</Text></Space>}
open={testModal.open}
onCancel={() => setTestModal({ open: false, tool: null })}
onOk={handleTest}
confirmLoading={testMut.isPending}
width={600}
>
<Form form={testForm} layout="vertical">
<Form.Item name="params" label="输入参数 (JSON)">
<TextArea rows={4} placeholder='{"keyword": "高血压", "limit": 5}' />
</Form.Item>
</Form>
{testMut.data && (
<Card size="small" title="调用结果" style={{ background: '#fafafa' }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: testMut.data.data?.success ? '#4ade80' : '#f87171', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{JSON.stringify(testMut.data.data, null, 2)}
</pre>
</Card>
)}
</Modal>
{/* URL 模板提示 */}
<Card size="small" style={{ background: '#fffbe6', borderColor: '#ffe58f', borderRadius: 12 }}>
<Space>
<CodeOutlined style={{ color: '#fa8c16' }} />
<Text style={{ fontSize: 13 }}>
<Text strong>URL/Body 模板语法:</Text>
使用 <Text code>{'{{'}</Text><Text code>param_name</Text><Text code>{'}}'}</Text> 引用工具参数,
如 <Text code>https://api.example.com/search?q=&#123;&#123;keyword&#125;&#125;</Text>。
Agent 调用时会自动替换为实际参数值。
</Text>
</Space>
</Card>
</div>
);
}
......@@ -7,7 +7,8 @@ import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, CloudOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
......@@ -34,9 +35,14 @@ const menuItems = [
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: 'Agent管理' },
{ key: '/admin/tools', icon: <ApiOutlined />, label: 'Tools工具' },
{ key: '/admin/tool-market', icon: <AppstoreOutlined />, label: '工具市场' },
{ key: '/admin/tools', icon: <ApiOutlined />, label: '内置工具' },
{ key: '/admin/http-tools', icon: <CloudOutlined />, label: 'HTTP工具' },
{ key: '/admin/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/admin/safety', icon: <SafetyOutlined />, label: '内容安全' },
{ key: '/admin/ai-center', icon: <FundOutlined />, label: 'AI运营中心' },
],
},
];
......@@ -68,7 +74,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const getSelectedKeys = () => {
const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
'/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
'/admin/ai-config', '/admin/compliance', '/admin/agents', '/admin/tools', '/admin/workflows', '/admin/knowledge'];
'/admin/ai-config', '/admin/compliance', '/admin/agents', '/admin/tool-market',
'/admin/tools', '/admin/http-tools', '/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center'];
const match = allKeys.find(k => currentPath.startsWith(k));
return match ? [match] : [];
};
......
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Space, Modal, Form, Input, Select, Switch,
message, Row, Col, Statistic, Tabs, Typography, Popconfirm, Badge,
} from 'antd';
import {
PlusOutlined, DeleteOutlined, EditOutlined, ImportOutlined,
SafetyOutlined, FilterOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
interface SafetyRule {
id: number;
word: string;
category: string;
level: string;
replacement: string;
direction: string;
is_regex: boolean;
agent_id: string;
status: string;
created_at: string;
}
interface SafetyLog {
id: number;
trace_id: string;
direction: string;
original_text: string;
filtered_text: string;
matched_rules: string;
action: string;
agent_id: string;
user_id: string;
created_at: string;
}
const token = () => localStorage.getItem('access_token') || '';
async function fetchRules(page: number) {
const res = await fetch(`/api/v1/admin/safety/rules?page=${page}&page_size=20`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? { list: [], total: 0 };
}
async function fetchLogs(page: number, action: string) {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (action) params.append('action', action);
const res = await fetch(`/api/v1/admin/safety/logs?${params}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? { list: [], total: 0 };
}
async function fetchStats() {
const res = await fetch('/api/v1/admin/safety/stats', {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? {};
}
async function createRule(rule: Partial<SafetyRule>) {
const res = await fetch('/api/v1/admin/safety/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify(rule),
});
if (!res.ok) throw new Error('创建失败');
return res.json();
}
async function updateRule(id: number, rule: Partial<SafetyRule>) {
const res = await fetch(`/api/v1/admin/safety/rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify(rule),
});
if (!res.ok) throw new Error('更新失败');
return res.json();
}
async function deleteRule(id: number) {
await fetch(`/api/v1/admin/safety/rules/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token()}` },
});
}
async function importPreset() {
const res = await fetch('/api/v1/admin/safety/rules/import-preset', {
method: 'POST',
headers: { Authorization: `Bearer ${token()}` },
});
return res.json();
}
const CATEGORY_OPTIONS = [
{ value: 'injection', label: '注入攻击' },
{ value: 'medical_claim', label: '医疗断言' },
{ value: 'drug_promotion', label: '药品推销' },
{ value: 'privacy', label: '隐私信息' },
{ value: 'toxicity', label: '有害内容' },
];
const LEVEL_OPTIONS = [
{ value: 'block', label: '拦截' },
{ value: 'warn', label: '警告' },
{ value: 'replace', label: '替换' },
];
const DIRECTION_OPTIONS = [
{ value: 'input', label: '输入过滤' },
{ value: 'output', label: '输出过滤' },
{ value: 'both', label: '双向' },
];
export default function SafetyPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('rules');
const [rulesPage, setRulesPage] = useState(1);
const [logsPage, setLogsPage] = useState(1);
const [logsFilter, setLogsFilter] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<SafetyRule | null>(null);
const [form] = Form.useForm();
const { data: rulesData } = useQuery({ queryKey: ['safety-rules', rulesPage], queryFn: () => fetchRules(rulesPage) });
const { data: logsData } = useQuery({ queryKey: ['safety-logs', logsPage, logsFilter], queryFn: () => fetchLogs(logsPage, logsFilter) });
const { data: stats } = useQuery({ queryKey: ['safety-stats'], queryFn: fetchStats });
const saveMutation = useMutation({
mutationFn: (values: Partial<SafetyRule>) =>
editingRule ? updateRule(editingRule.id, values) : createRule(values),
onSuccess: () => {
message.success(editingRule ? '更新成功' : '创建成功');
queryClient.invalidateQueries({ queryKey: ['safety-rules'] });
setModalOpen(false);
form.resetFields();
setEditingRule(null);
},
onError: (err: Error) => message.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: deleteRule,
onSuccess: () => {
message.success('删除成功');
queryClient.invalidateQueries({ queryKey: ['safety-rules'] });
},
});
const importMutation = useMutation({
mutationFn: importPreset,
onSuccess: (data) => {
message.success(`导入完成,新增 ${data.created} 条规则`);
queryClient.invalidateQueries({ queryKey: ['safety-rules'] });
},
});
const openModal = (rule?: SafetyRule) => {
setEditingRule(rule ?? null);
if (rule) {
form.setFieldsValue(rule);
} else {
form.resetFields();
form.setFieldsValue({ direction: 'both', level: 'block', status: 'active', is_regex: false });
}
setModalOpen(true);
};
const ruleColumns: ColumnsType<SafetyRule> = [
{
title: '敏感词/正则',
dataIndex: 'word',
render: (v, r) => (
<Space>
<Text code>{v}</Text>
{r.is_regex && <Tag color="purple">正则</Tag>}
</Space>
),
},
{
title: '分类',
dataIndex: 'category',
width: 100,
render: (v) => {
const map: Record<string, string> = {
injection: 'red', medical_claim: 'orange', drug_promotion: 'yellow',
privacy: 'blue', toxicity: 'red',
};
const labels: Record<string, string> = {
injection: '注入攻击', medical_claim: '医疗断言', drug_promotion: '药品推销',
privacy: '隐私', toxicity: '有害',
};
return <Tag color={map[v] ?? 'default'}>{labels[v] ?? v}</Tag>;
},
},
{
title: '级别',
dataIndex: 'level',
width: 80,
render: (v) => {
const map: Record<string, string> = { block: 'red', warn: 'orange', replace: 'blue' };
const labels: Record<string, string> = { block: '拦截', warn: '警告', replace: '替换' };
return <Tag color={map[v] ?? 'default'}>{labels[v] ?? v}</Tag>;
},
},
{
title: '方向',
dataIndex: 'direction',
width: 90,
render: (v) => {
const labels: Record<string, string> = { input: '输入', output: '输出', both: '双向' };
return <Tag>{labels[v] ?? v}</Tag>;
},
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (v) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '启用' : '禁用'} />,
},
{
title: '操作',
width: 120,
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openModal(record)}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
const logColumns: ColumnsType<SafetyLog> = [
{ title: 'TraceID', dataIndex: 'trace_id', width: 180, render: (v) => <Text code style={{ fontSize: 11 }}>{v?.slice(0, 16)}...</Text> },
{ title: '方向', dataIndex: 'direction', width: 80, render: (v) => <Tag>{v === 'input' ? '输入' : '输出'}</Tag> },
{
title: '动作',
dataIndex: 'action',
width: 80,
render: (v) => {
const map: Record<string, string> = { blocked: 'red', replaced: 'blue', warned: 'orange', passed: 'green' };
const labels: Record<string, string> = { blocked: '拦截', replaced: '替换', warned: '警告', passed: '通过' };
return <Tag color={map[v] ?? 'default'}>{labels[v] ?? v}</Tag>;
},
},
{ title: '原始内容', dataIndex: 'original_text', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', width: 140, render: (v) => dayjs(v).format('MM-DD HH:mm:ss') },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>
<SafetyOutlined style={{ marginRight: 8, color: '#1890ff' }} />
内容安全管理
</Title>
<Text type="secondary">配置安全词过滤规则,保障AI输入/输出内容合规</Text>
</div>
{/* 统计 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={4}><Card><Statistic title="活跃规则" value={stats?.active_rules ?? 0} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col span={4}><Card><Statistic title="过滤总数" value={stats?.total_logs ?? 0} /></Card></Col>
<Col span={4}><Card><Statistic title="拦截次数" value={stats?.blocked ?? 0} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col span={4}><Card><Statistic title="替换次数" value={stats?.replaced ?? 0} valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={4}><Card><Statistic title="警告次数" value={stats?.warned ?? 0} valueStyle={{ color: '#fa8c16' }} /></Card></Col>
</Row>
<Card>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
tabBarExtraContent={
activeTab === 'rules' ? (
<Space>
<Button icon={<ImportOutlined />} onClick={() => importMutation.mutate()} loading={importMutation.isPending}>
导入预置规则
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>新增规则</Button>
</Space>
) : (
<Select
style={{ width: 120 }}
placeholder="过滤动作"
allowClear
value={logsFilter || undefined}
onChange={(v) => { setLogsFilter(v ?? ''); setLogsPage(1); }}
options={[
{ value: 'blocked', label: '拦截' },
{ value: 'replaced', label: '替换' },
{ value: 'warned', label: '警告' },
]}
>
<FilterOutlined />
</Select>
)
}
items={[
{
key: 'rules',
label: '规则管理',
children: (
<Table
columns={ruleColumns}
dataSource={rulesData?.list ?? []}
rowKey="id"
pagination={{
current: rulesPage,
total: rulesData?.total ?? 0,
pageSize: 20,
onChange: setRulesPage,
showTotal: (t) => `共 ${t} 条`,
}}
/>
),
},
{
key: 'logs',
label: '过滤日志',
children: (
<Table
columns={logColumns}
dataSource={logsData?.list ?? []}
rowKey="id"
pagination={{
current: logsPage,
total: logsData?.total ?? 0,
pageSize: 20,
onChange: setLogsPage,
showTotal: (t) => `共 ${t} 条`,
}}
/>
),
},
]}
/>
</Card>
{/* 新增/编辑弹窗 */}
<Modal
title={editingRule ? '编辑规则' : '新增安全词规则'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditingRule(null); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={saveMutation.isPending}
width={560}
>
<Form form={form} layout="vertical" onFinish={(values) => saveMutation.mutate(values)}>
<Form.Item name="word" label="敏感词 / 正则表达式" rules={[{ required: true, message: '请输入敏感词' }]}>
<Input placeholder="输入要匹配的词语或正则表达式" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="level" label="处理级别" rules={[{ required: true }]}>
<Select options={LEVEL_OPTIONS} />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="direction" label="过滤方向">
<Select options={DIRECTION_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</Col>
</Row>
<Form.Item name="replacement" label="替换文本(level=replace时使用)">
<Input placeholder="为空时默认替换为 ***" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="is_regex" label="是否正则" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="agent_id" label="限定 Agent(空=全局)">
<Input placeholder="如: prescription_agent" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Space, Modal, Input, Typography, Select,
message, Row, Col, Statistic, Empty,
} from 'antd';
import {
CheckOutlined, CloseOutlined, EyeOutlined, ClockCircleOutlined,
FilterOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
interface HumanTask {
id: number;
workflow_execution_id: string;
node_id: string;
task_type: string;
title: string;
description: string;
context: Record<string, unknown>;
assigned_to: string;
status: string;
result: string;
created_at: string;
updated_at: string;
}
async function fetchAllTasks(status: string, assignedTo: string) {
const token = localStorage.getItem('access_token');
const params = new URLSearchParams();
if (status) params.append('status', status);
if (assignedTo) params.append('assigned_to', assignedTo);
const res = await fetch(`/api/v1/workflow/tasks?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
return data.data ?? [];
}
async function completeTask(id: number, action: 'approve' | 'reject', comment: string) {
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/v1/workflow/task/${id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ action, comment }),
});
if (!res.ok) throw new Error('操作失败');
return res.json();
}
export default function AdminTasksPage() {
const queryClient = useQueryClient();
const [filterStatus, setFilterStatus] = useState('pending');
const [filterRole, setFilterRole] = useState('');
const [selectedTask, setSelectedTask] = useState<HumanTask | null>(null);
const [reviewComment, setReviewComment] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const { data: tasks = [], isLoading } = useQuery<HumanTask[]>({
queryKey: ['admin-tasks', filterStatus, filterRole],
queryFn: () => fetchAllTasks(filterStatus, filterRole),
refetchInterval: 30000,
});
const mutation = useMutation({
mutationFn: ({ id, action, comment }: { id: number; action: 'approve' | 'reject'; comment: string }) =>
completeTask(id, action, comment),
onSuccess: () => {
message.success('操作成功');
queryClient.invalidateQueries({ queryKey: ['admin-tasks'] });
setModalOpen(false);
setSelectedTask(null);
setReviewComment('');
},
onError: () => message.error('操作失败'),
});
const handleAction = (task: HumanTask, action: 'approve' | 'reject') => {
if (action === 'reject' && !reviewComment.trim()) {
message.warning('请填写拒绝原因');
return;
}
mutation.mutate({ id: task.id, action, comment: reviewComment });
};
const pendingCount = tasks.filter(t => t.status === 'pending').length;
const approvedCount = tasks.filter(t => t.status === 'approved').length;
const rejectedCount = tasks.filter(t => t.status === 'rejected').length;
const columns: ColumnsType<HumanTask> = [
{
title: '任务类型',
dataIndex: 'task_type',
width: 120,
render: (v) => <Tag color="blue">{v || '人工审核'}</Tag>,
},
{
title: '标题',
dataIndex: 'title',
render: (v) => <Text strong>{v || '待办任务'}</Text>,
},
{
title: '说明',
dataIndex: 'description',
ellipsis: true,
render: (v) => <Text type="secondary">{v}</Text>,
},
{
title: '分配角色',
dataIndex: 'assigned_to',
width: 100,
render: (v) => (
<Tag color={v === 'doctor' ? 'green' : 'purple'}>
{v === 'doctor' ? '医生' : '管理员'}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v) => {
const map: Record<string, [string, string]> = {
pending: ['warning', '待处理'],
approved: ['success', '已通过'],
rejected: ['error', '已拒绝'],
};
const [color, label] = map[v] ?? ['default', v];
return <Tag color={color}>{label}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 150,
render: (v) => dayjs(v).format('MM-DD HH:mm'),
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => { setSelectedTask(record); setModalOpen(true); }}
>
详情
</Button>
{record.status === 'pending' && (
<>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => mutation.mutate({ id: record.id, action: 'approve', comment: '管理员审核通过' })}
loading={mutation.isPending}
>
通过
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
onClick={() => { setSelectedTask(record); setModalOpen(true); }}
>
拒绝
</Button>
</>
)}
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#1890ff' }} />
人工审核任务
</Title>
<Text type="secondary">工作流中需要人工处理的审核任务</Text>
</div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="待处理" value={pendingCount} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="已通过" value={approvedCount} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="已拒绝" value={rejectedCount} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="总任务" value={tasks.length} />
</Card>
</Col>
</Row>
<Card>
{/* 过滤器 */}
<Space style={{ marginBottom: 16 }}>
<FilterOutlined />
<Select
value={filterStatus}
onChange={setFilterStatus}
style={{ width: 120 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
]}
/>
<Select
value={filterRole}
onChange={setFilterRole}
style={{ width: 120 }}
options={[
{ value: '', label: '全部角色' },
{ value: 'doctor', label: '医生' },
{ value: 'admin', label: '管理员' },
]}
/>
</Space>
{tasks.length === 0 && !isLoading ? (
<Empty description="暂无审核任务" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={isLoading}
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
/>
)}
</Card>
{/* 详情 / 审核弹窗 */}
<Modal
title={selectedTask?.title || '任务详情'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setSelectedTask(null); setReviewComment(''); }}
footer={
selectedTask?.status === 'pending' ? (
<Space>
<Button onClick={() => { setModalOpen(false); setReviewComment(''); }}>取消</Button>
<Button
danger
onClick={() => selectedTask && handleAction(selectedTask, 'reject')}
loading={mutation.isPending}
>
拒绝
</Button>
<Button
type="primary"
onClick={() => selectedTask && handleAction(selectedTask, 'approve')}
loading={mutation.isPending}
>
审核通过
</Button>
</Space>
) : (
<Button onClick={() => setModalOpen(false)}>关闭</Button>
)
}
width={600}
>
{selectedTask && (
<div>
<Paragraph><Text strong>任务类型:</Text>{selectedTask.task_type || '人工审核'}</Paragraph>
<Paragraph><Text strong>分配角色:</Text>{selectedTask.assigned_to === 'doctor' ? '医生' : '管理员'}</Paragraph>
<Paragraph><Text strong>说明:</Text>{selectedTask.description}</Paragraph>
{selectedTask.context && Object.keys(selectedTask.context).length > 0 && (
<div style={{ background: '#f5f5f5', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<Text strong>上下文信息:</Text>
<pre style={{ fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(selectedTask.context, null, 2)}
</pre>
</div>
)}
{selectedTask.status === 'pending' && (
<div>
<Text strong>审核意见(拒绝时必填):</Text>
<TextArea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
placeholder="请输入审核意见或拒绝原因..."
rows={3}
style={{ marginTop: 8 }}
/>
</div>
)}
{selectedTask.result && (
<Paragraph><Text strong>处理结果:</Text>{selectedTask.result}</Paragraph>
)}
</div>
)}
</Modal>
</div>
);
}
'use client';
import { useState } from 'react';
import {
Card, Row, Col, Tag, Button, Input, Space, Typography, Switch, Badge,
Tooltip, Empty, Segmented, message,
} from 'antd';
import {
SearchOutlined, ToolOutlined, ApiOutlined, RobotOutlined,
DeploymentUnitOutlined, BookOutlined, ThunderboltOutlined,
CodeOutlined, BellOutlined, HeartOutlined, SafetyOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi, httpToolApi } from '@/api/agent';
const { Text, Paragraph } = Typography;
type ToolSource = 'builtin' | 'http';
interface UnifiedTool {
id: string;
name: string;
description: string;
category: string;
source: ToolSource;
is_enabled: boolean;
cache_ttl?: number;
timeout?: number;
rawId?: number; // for http tools
}
const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
knowledge: { color: 'blue', label: '知识库', icon: <BookOutlined /> },
recommendation:{ color: 'cyan', label: '智能推荐', icon: <ThunderboltOutlined /> },
medical: { color: 'purple', label: '病历管理', icon: <HeartOutlined /> },
pharmacy: { color: 'green', label: '药品管理', icon: <ToolOutlined /> },
safety: { color: 'red', label: '安全检查', icon: <SafetyOutlined /> },
follow_up: { color: 'orange', label: '随访管理', icon: <HeartOutlined /> },
notification: { color: 'gold', label: '消息通知', icon: <BellOutlined /> },
agent: { color: 'geekblue', label: 'Agent调用', icon: <RobotOutlined /> },
workflow: { color: 'volcano', label: '工作流', icon: <DeploymentUnitOutlined /> },
expression: { color: 'lime', label: '表达式', icon: <CodeOutlined /> },
http: { color: 'magenta', label: 'HTTP服务', icon: <ApiOutlined /> },
other: { color: 'default', label: '其他', icon: <ToolOutlined /> },
};
const SOURCE_CONFIG: Record<ToolSource, { label: string; color: string }> = {
builtin: { label: '内置', color: '#1890ff' },
http: { label: 'HTTP', color: '#fa8c16' },
};
export default function ToolMarketPage() {
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<'all' | ToolSource>('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [togglingName, setTogglingName] = useState('');
// 加载内置工具
const { data: builtinData } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as {
id: string; name: string; description: string; category: string;
parameters: Record<string, unknown>; is_enabled: boolean; created_at: string;
}[],
});
// 加载 HTTP 工具
const { data: httpData } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
// 合并为统一格式
const allTools: UnifiedTool[] = [
...(builtinData ?? []).map(t => ({
id: t.name,
name: t.name,
description: t.description,
category: t.category,
source: 'builtin' as ToolSource,
is_enabled: t.is_enabled,
})),
...(httpData ?? []).map(t => ({
id: `http:${t.id}`,
name: t.name,
description: t.description || t.display_name,
category: t.category || 'http',
source: 'http' as ToolSource,
is_enabled: t.status === 'active',
cache_ttl: t.cache_ttl,
timeout: t.timeout,
rawId: t.id,
})),
];
const toggleMut = useMutation({
mutationFn: async ({ tool, checked }: { tool: UnifiedTool; checked: boolean }) => {
if (tool.source === 'builtin') {
return agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
} else {
return httpToolApi.update(tool.rawId!, { status: checked ? 'active' : 'disabled' });
}
},
onSuccess: (_, { tool, checked }) => {
message.success(`${tool.name} 已${checked ? '启用' : '禁用'}`);
qc.invalidateQueries({ queryKey: ['agent-tools'] });
qc.invalidateQueries({ queryKey: ['http-tools'] });
setTogglingName('');
},
onError: () => { message.error('操作失败'); setTogglingName(''); },
});
const handleToggle = (tool: UnifiedTool, checked: boolean) => {
setTogglingName(tool.id);
toggleMut.mutate({ tool, checked });
};
// 过滤
const filtered = allTools.filter(t => {
const matchSearch = !search || t.name.includes(search) || t.description.includes(search);
const matchSource = sourceFilter === 'all' || t.source === sourceFilter;
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchSource && matchCategory;
});
// 按分类分组
const grouped: Record<string, UnifiedTool[]> = {};
for (const t of filtered) {
if (!grouped[t.category]) grouped[t.category] = [];
grouped[t.category].push(t);
}
const allCategories = [...new Set(allTools.map(t => t.category))];
const enabledCount = allTools.filter(t => t.is_enabled).length;
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工具市场</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>
浏览并管理所有 Agent 工具 — 共 <Text strong>{allTools.length}</Text> 个,
已启用 <Text strong style={{ color: '#52c41a' }}>{enabledCount}</Text> 个
</div>
</div>
</div>
{/* 过滤栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }} bodyStyle={{ padding: '12px 16px' }}>
<Space wrap>
<Input
placeholder="搜索工具名称或描述..."
prefix={<SearchOutlined />}
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: 240, borderRadius: 8 }}
allowClear
/>
<Segmented
value={sourceFilter}
onChange={v => setSourceFilter(v as 'all' | ToolSource)}
options={[
{ value: 'all', label: '全部来源' },
{ value: 'builtin', label: '内置工具' },
{ value: 'http', label: 'HTTP工具' },
]}
/>
<Segmented
value={categoryFilter}
onChange={v => setCategoryFilter(v as string)}
options={[
{ value: 'all', label: '全部分类' },
...allCategories.map(c => ({ value: c, label: CATEGORY_CONFIG[c]?.label || c })),
]}
/>
</Space>
</Card>
{/* 工具卡片按分类分组 */}
{Object.keys(grouped).length === 0 ? (
<Empty description="暂无匹配工具" style={{ marginTop: 60 }} />
) : (
Object.entries(grouped).map(([category, tools]) => {
const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.other;
return (
<div key={category}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Tag color={cfg.color} icon={cfg.icon} style={{ fontSize: 13, padding: '2px 10px' }}>
{cfg.label}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{tools.length} 个工具</Text>
</div>
<Row gutter={[16, 16]}>
{tools.map(tool => (
<Col key={tool.id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{
borderRadius: 10,
border: `1px solid ${tool.is_enabled ? '#d9f7be' : '#f0f0f0'}`,
background: tool.is_enabled ? '#f6ffed' : '#fafafa',
transition: 'all 0.2s',
}}
bodyStyle={{ padding: 14 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Badge
status={tool.is_enabled ? 'success' : 'default'}
style={{ marginRight: 0 }}
/>
<Text code style={{ fontSize: 12 }}>{tool.name}</Text>
</div>
<div style={{ marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<Tag
style={{ fontSize: 11, lineHeight: '16px', padding: '0 4px', margin: 0,
background: SOURCE_CONFIG[tool.source].color + '15',
color: SOURCE_CONFIG[tool.source].color,
border: `1px solid ${SOURCE_CONFIG[tool.source].color}40` }}
>
{SOURCE_CONFIG[tool.source].label}
</Tag>
</div>
</div>
<Tooltip title={tool.is_enabled ? '禁用工具' : '启用工具'}>
<Switch
size="small"
checked={tool.is_enabled}
loading={togglingName === tool.id}
onChange={checked => handleToggle(tool, checked)}
/>
</Tooltip>
</div>
<Paragraph
ellipsis={{ rows: 2 }}
style={{ fontSize: 12, color: '#595959', margin: 0 }}
>
{tool.description}
</Paragraph>
{(tool.cache_ttl !== undefined || tool.timeout !== undefined) && (
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
{tool.timeout && (
<Text type="secondary" style={{ fontSize: 11 }}>超时 {tool.timeout}s</Text>
)}
{tool.cache_ttl !== undefined && tool.cache_ttl > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}>缓存 {tool.cache_ttl}s</Text>
)}
</div>
)}
</Card>
</Col>
))}
</Row>
</div>
);
})
)}
</div>
);
}
'use client';
import { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Modal, Descriptions, Space, Input, Badge, Typography } from 'antd';
import { Card, Table, Tag, Button, Modal, Descriptions, Space, Input, Badge, Typography, Switch, message } from 'antd';
import {
ToolOutlined, SearchOutlined, InfoCircleOutlined, ApiOutlined,
CheckCircleOutlined, CodeOutlined, ReloadOutlined,
......@@ -13,34 +13,28 @@ const { Text } = Typography;
interface AgentTool {
id: string;
name: string;
display_name: string;
description: string;
category: string;
parameters: Record<string, unknown>;
status: string;
is_enabled: boolean;
cache_ttl: number;
timeout: number;
max_retries: number;
created_at: string;
}
const BUILTIN_TOOLS: AgentTool[] = [
{ id: '1', name: 'query_symptom_knowledge', description: '查询症状知识库,获取症状相关信息、可能疾病和建议科室', category: 'knowledge', parameters: { symptom: 'string' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '2', name: 'recommend_department', description: '根据症状推荐就诊科室', category: 'recommendation', parameters: { symptoms: 'string[]' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '3', name: 'query_medical_record', description: '查询患者病历记录,包括历史诊断、用药记录等', category: 'medical', parameters: { patient_id: 'string', record_type: 'string' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '4', name: 'search_medical_knowledge', description: '搜索医学知识库,获取疾病、药物、治疗方案等信息', category: 'knowledge', parameters: { query: 'string', category: 'string' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '5', name: 'query_drug', description: '查询药品信息,包括适应症、用法用量、不良反应等', category: 'pharmacy', parameters: { drug_name: 'string' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '6', name: 'check_drug_interaction', description: '检查药物相互作用,识别潜在风险', category: 'safety', parameters: { drugs: 'string[]' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '7', name: 'check_contraindication', description: '检查药物禁忌症,结合患者病史判断用药安全性', category: 'safety', parameters: { drug_name: 'string', patient_id: 'string' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '8', name: 'calculate_dosage', description: '根据患者信息计算推荐剂量', category: 'pharmacy', parameters: { drug_name: 'string', patient_weight: 'number', patient_age: 'number' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '9', name: 'generate_follow_up_plan', description: '生成随访计划,包括随访时间、检查项目等', category: 'follow_up', parameters: { diagnosis: 'string', patient_id: 'string' }, is_enabled: true, created_at: '2026-01-01' },
{ id: '10', name: 'send_notification', description: '发送通知消息给患者或医生', category: 'notification', parameters: { user_id: 'string', message: 'string', type: 'string' }, is_enabled: true, created_at: '2026-01-01' },
];
const categoryColors: Record<string, string> = {
knowledge: 'blue', recommendation: 'cyan', medical: 'purple',
pharmacy: 'green', safety: 'red', follow_up: 'orange', notification: 'gold',
agent: 'geekblue', workflow: 'volcano', expression: 'lime', other: 'default',
};
const categoryLabels: Record<string, string> = {
knowledge: '知识查询', recommendation: '智能推荐', medical: '病历管理',
knowledge: '知识库', recommendation: '智能推荐', medical: '病历管理',
pharmacy: '药品管理', safety: '安全检查', follow_up: '随访管理', notification: '消息通知',
agent: 'Agent调用', workflow: '工作流', expression: '表达式', other: '其他',
};
const statCards = [
......@@ -51,8 +45,9 @@ const statCards = [
];
export default function ToolsPage() {
const [tools, setTools] = useState<AgentTool[]>(BUILTIN_TOOLS);
const [tools, setTools] = useState<AgentTool[]>([]);
const [loading, setLoading] = useState(false);
const [togglingId, setTogglingId] = useState<string>('');
const [searchText, setSearchText] = useState('');
const [detailModal, setDetailModal] = useState<{ open: boolean; tool: AgentTool | null }>({ open: false, tool: null });
......@@ -63,11 +58,30 @@ export default function ToolsPage() {
try {
const res = await agentApi.listTools();
if (res.data?.length > 0) setTools(res.data as AgentTool[]);
} catch { /* 使用内置工具列表 */ } finally {
} catch {
message.error('加载工具列表失败');
} finally {
setLoading(false);
}
};
const handleToggle = async (tool: AgentTool, checked: boolean) => {
setTogglingId(tool.name);
try {
await agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
message.success(`工具 ${tool.name} 已${checked ? '启用' : '禁用'}`);
setTools(prev => prev.map(t => t.name === tool.name ? { ...t, is_enabled: checked } : t));
// 同步更新 detailModal 中的状态
if (detailModal.tool?.name === tool.name) {
setDetailModal(prev => ({ ...prev, tool: prev.tool ? { ...prev.tool, is_enabled: checked } : null }));
}
} catch {
message.error('操作失败');
} finally {
setTogglingId('');
}
};
const filteredTools = tools.filter(t =>
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
......@@ -93,21 +107,40 @@ export default function ToolsPage() {
</Space>
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 300 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 280 },
{
title: '分类', dataIndex: 'category', key: 'category', width: 110,
render: (v: string) => <Tag color={categoryColors[v] || 'default'}>{categoryLabels[v] || v}</Tag>,
},
{
title: '参数数量', dataIndex: 'parameters', key: 'parameters', width: 90,
render: (v: Record<string, unknown>) => <Text type="secondary">{Object.keys(v).length} 个</Text>,
render: (v: Record<string, unknown>) => <Text type="secondary">{v ? Object.keys(v).length : 0} 个</Text>,
},
{
title: '状态', dataIndex: 'is_enabled', key: 'is_enabled', width: 100,
render: (v: boolean) => <Badge status={v ? 'success' : 'default'} text={v ? '已启用' : '已禁用'} />,
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: AgentTool) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>超时 {r.timeout || 30}s</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.cache_ttl > 0 ? `缓存 ${r.cache_ttl}s` : '不缓存'}
</Text>
</Space>
),
},
{
title: '启用状态', dataIndex: 'is_enabled', key: 'is_enabled', width: 110,
render: (v: boolean, record: AgentTool) => (
<Switch
checked={v}
loading={togglingId === record.name}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => handleToggle(record, checked)}
/>
),
},
{
title: '操作', key: 'action', width: 90,
title: '操作', key: 'action', width: 80,
render: (_: unknown, record: AgentTool) => (
<Button type="link" size="small" icon={<InfoCircleOutlined />} onClick={() => setDetailModal({ open: true, tool: record })}>详情</Button>
),
......@@ -118,7 +151,7 @@ export default function ToolsPage() {
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>Agent 工具管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台内置 Agent 工具,查看工具详情与参数定义</div>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理 Agent 工具启停状态,工具禁用后 Agent 将无法调用</div>
</div>
{/* 统计卡片 */}
......@@ -148,6 +181,9 @@ export default function ToolsPage() {
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={() => { setSearchText(''); fetchTools(); }}>刷新</Button>
<Text type="secondary" style={{ marginLeft: 8 }}>
已启用 <Text strong style={{ color: '#52c41a' }}>{statValues.enabled}</Text> / 共 {statValues.total} 个工具
</Text>
</div>
</Card>
......@@ -156,7 +192,7 @@ export default function ToolsPage() {
<Table
dataSource={filteredTools}
columns={columns}
rowKey="id"
rowKey="name"
loading={loading}
size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: (t) => `共 ${t} 个工具` }}
......@@ -168,7 +204,22 @@ export default function ToolsPage() {
title={<Space><CodeOutlined style={{ color: '#1890ff' }} /><span>工具详情</span></Space>}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, tool: null })}
footer={null}
footer={
detailModal.tool && (
<Space>
<Switch
checked={detailModal.tool.is_enabled}
loading={togglingId === detailModal.tool.name}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => handleToggle(detailModal.tool!, checked)}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{detailModal.tool.is_enabled ? '工具已启用,Agent 可正常调用' : '工具已禁用,Agent 将跳过此工具'}
</Text>
</Space>
)
}
width={600}
>
{detailModal.tool && (
......@@ -184,6 +235,11 @@ export default function ToolsPage() {
<Descriptions.Item label="状态">
<Badge status={detailModal.tool.is_enabled ? 'success' : 'default'} text={detailModal.tool.is_enabled ? '已启用' : '已禁用'} />
</Descriptions.Item>
<Descriptions.Item label="执行超时">{detailModal.tool.timeout || 30} 秒</Descriptions.Item>
<Descriptions.Item label="结果缓存">
{detailModal.tool.cache_ttl > 0 ? `${detailModal.tool.cache_ttl} 秒` : '不缓存'}
</Descriptions.Item>
<Descriptions.Item label="失败重试">{detailModal.tool.max_retries || 0} 次</Descriptions.Item>
</Descriptions>
<Card size="small" title="参数定义" style={{ background: '#fafafa', borderRadius: 8 }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: '#4ade80', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
......@@ -194,7 +250,7 @@ export default function ToolsPage() {
<pre style={{ fontSize: 12, background: '#1f2937', color: '#fde047', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{`// 调用 ${detailModal.tool.name}
const result = await agent.callTool('${detailModal.tool.name}', {
${Object.entries(detailModal.tool.parameters).map(([k, v]) => ` ${k}: <${v}>`).join(',\n')}
${Object.entries(detailModal.tool.parameters || {}).map(([k, v]) => ` ${k}: <${v}>`).join(',\n')}
});`}
</pre>
</Card>
......
......@@ -7,6 +7,7 @@ import {
DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
RobotOutlined, DollarOutlined, FolderOpenOutlined, SafetyCertificateOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
......@@ -16,6 +17,7 @@ const { Text } = Typography;
const menuItems = [
{ key: '/doctor/workbench', icon: <DashboardOutlined />, label: '工作台' },
{ key: '/doctor/consult', icon: <MessageOutlined />, label: '问诊大厅' },
{ key: '/doctor/tasks', icon: <CheckCircleOutlined />, label: '待办任务' },
{ key: '/doctor/chronic/review', icon: <SafetyCertificateOutlined />, label: '慢病续方' },
{ key: '/doctor/patient', icon: <FolderOpenOutlined />, label: '患者档案' },
{ key: '/doctor/schedule', icon: <CalendarOutlined />, label: '排班管理' },
......
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Space, Modal, Input, Typography, Empty,
message, Tabs, Badge,
} from 'antd';
import {
CheckOutlined, CloseOutlined, EyeOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
interface HumanTask {
id: number;
workflow_execution_id: string;
node_id: string;
task_type: string;
title: string;
description: string;
context: Record<string, unknown>;
assigned_to: string; // "doctor" | "admin"
status: string; // "pending" | "approved" | "rejected"
result: string;
created_at: string;
updated_at: string;
}
async function fetchTasks(status: string) {
const token = localStorage.getItem('access_token');
const params = new URLSearchParams({ assigned_to: 'doctor' });
if (status) params.append('status', status);
const res = await fetch(`/api/v1/workflow/tasks?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
return data.data ?? [];
}
async function completeTask(id: number, action: 'approve' | 'reject', comment: string) {
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/v1/workflow/task/${id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ action, comment }),
});
if (!res.ok) throw new Error('操作失败');
return res.json();
}
export default function DoctorTasksPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState('pending');
const [selectedTask, setSelectedTask] = useState<HumanTask | null>(null);
const [reviewComment, setReviewComment] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const { data: tasks = [], isLoading } = useQuery<HumanTask[]>({
queryKey: ['doctor-tasks', activeTab],
queryFn: () => fetchTasks(activeTab === 'all' ? '' : activeTab),
refetchInterval: 30000,
});
const mutation = useMutation({
mutationFn: ({ id, action, comment }: { id: number; action: 'approve' | 'reject'; comment: string }) =>
completeTask(id, action, comment),
onSuccess: () => {
message.success('操作成功');
queryClient.invalidateQueries({ queryKey: ['doctor-tasks'] });
setModalOpen(false);
setSelectedTask(null);
setReviewComment('');
},
onError: () => message.error('操作失败'),
});
const handleAction = (task: HumanTask, action: 'approve' | 'reject') => {
if (action === 'reject' && !reviewComment.trim()) {
message.warning('请填写拒绝原因');
return;
}
mutation.mutate({ id: task.id, action, comment: reviewComment });
};
const pendingCount = tasks.filter(t => t.status === 'pending').length;
const columns: ColumnsType<HumanTask> = [
{
title: '任务类型',
dataIndex: 'task_type',
width: 120,
render: (v) => <Tag color="blue">{v || '人工审核'}</Tag>,
},
{
title: '标题',
dataIndex: 'title',
render: (v) => <Text strong>{v || '待办任务'}</Text>,
},
{
title: '说明',
dataIndex: 'description',
ellipsis: true,
render: (v) => <Text type="secondary">{v}</Text>,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v) => {
const map: Record<string, [string, string]> = {
pending: ['warning', '待处理'],
approved: ['success', '已通过'],
rejected: ['error', '已拒绝'],
};
const [color, label] = map[v] ?? ['default', v];
return <Tag color={color}>{label}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 150,
render: (v) => dayjs(v).format('MM-DD HH:mm'),
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => { setSelectedTask(record); setModalOpen(true); }}
>
详情
</Button>
{record.status === 'pending' && (
<>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => mutation.mutate({ id: record.id, action: 'approve', comment: '医生审核通过' })}
loading={mutation.isPending}
>
通过
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
onClick={() => { setSelectedTask(record); setModalOpen(true); }}
>
拒绝
</Button>
</>
)}
</Space>
),
},
];
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#1890ff' }} />
待办任务
</Title>
<Text type="secondary">工作流人工审核任务,需要您及时处理</Text>
</div>
<Card>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'pending',
label: (
<Badge count={pendingCount} size="small" offset={[8, 0]}>
待处理
</Badge>
),
},
{ key: 'approved', label: '已通过' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'all', label: '全部' },
]}
/>
{tasks.length === 0 && !isLoading ? (
<Empty description="暂无待办任务" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
loading={isLoading}
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
/>
)}
</Card>
{/* 任务详情 / 拒绝弹窗 */}
<Modal
title={selectedTask?.title || '任务详情'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setSelectedTask(null); setReviewComment(''); }}
footer={
selectedTask?.status === 'pending' ? (
<Space>
<Button onClick={() => { setModalOpen(false); setReviewComment(''); }}>取消</Button>
<Button
danger
onClick={() => selectedTask && handleAction(selectedTask, 'reject')}
loading={mutation.isPending}
>
拒绝
</Button>
<Button
type="primary"
onClick={() => selectedTask && handleAction(selectedTask, 'approve')}
loading={mutation.isPending}
>
审核通过
</Button>
</Space>
) : (
<Button onClick={() => setModalOpen(false)}>关闭</Button>
)
}
width={600}
>
{selectedTask && (
<div>
<Paragraph><Text strong>类型:</Text>{selectedTask.task_type || '人工审核'}</Paragraph>
<Paragraph><Text strong>说明:</Text>{selectedTask.description}</Paragraph>
{selectedTask.context && Object.keys(selectedTask.context).length > 0 && (
<div style={{ background: '#f5f5f5', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<Text strong>上下文信息:</Text>
<pre style={{ fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(selectedTask.context, null, 2)}
</pre>
</div>
)}
{selectedTask.status === 'pending' && (
<div>
<Text strong>审核意见(拒绝时必填):</Text>
<TextArea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
placeholder="请输入审核意见或拒绝原因..."
rows={3}
style={{ marginTop: 8 }}
/>
</div>
)}
{selectedTask.result && (
<Paragraph><Text strong>处理结果:</Text>{selectedTask.result}</Paragraph>
)}
</div>
)}
</Modal>
</div>
);
}
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Tabs, Typography, Space, Empty, Tag, Alert, Spin, Badge, Button,
Collapse, Timeline, Divider,
......@@ -22,6 +22,7 @@ interface AIPanelProps {
preConsultLoading: boolean;
onDiagnosisChange?: (text: string) => void;
onMedicationChange?: (text: string) => void;
patientMessageTrigger?: number;
}
const AIPanel: React.FC<AIPanelProps> = ({
......@@ -31,6 +32,7 @@ const AIPanel: React.FC<AIPanelProps> = ({
preConsultLoading,
onDiagnosisChange,
onMedicationChange,
patientMessageTrigger,
}) => {
const [diagnosisContent, setDiagnosisContent] = useState('');
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
......@@ -64,6 +66,14 @@ const AIPanel: React.FC<AIPanelProps> = ({
}
};
// Auto-trigger both AI analyses whenever patient sends a new message
useEffect(() => {
if (!patientMessageTrigger || !activeConsultId) return;
handleAIAssist('consult_diagnosis');
handleAIAssist('consult_medication');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [patientMessageTrigger, activeConsultId]);
const renderToolCalls = (toolCalls: ToolCall[]) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
......@@ -168,17 +178,16 @@ const AIPanel: React.FC<AIPanelProps> = ({
<RobotOutlined style={{ fontSize: 36, color: '#d9d9d9', display: 'block', marginBottom: 12 }} />
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 14, lineHeight: 1.6 }}>
{scene === 'consult_diagnosis'
? '基于本次问诊对话内容,AI将辅助生成鉴别诊断建议'
: '基于问诊对话和患者信息,AI将辅助生成安全用药建议'}
? '患者回复后将自动生成鉴别诊断,也可手动触发'
: '患者回复后将自动生成用药建议,也可手动触发'}
</Text>
<Button
type="primary"
size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
style={scene === 'consult_medication' ? { background: '#722ed1', borderColor: '#722ed1' } : {}}
style={scene === 'consult_medication' ? { color: '#722ed1', borderColor: '#722ed1' } : {}}
>
{scene === 'consult_diagnosis' ? '生成鉴别诊断' : '生成用药建议'}
立即分析
</Button>
</div>
);
......
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { message, Modal } from 'antd';
import { ClockCircleOutlined, MessageOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { consultApi, type PatientListItem, type ConsultMessage } from '../../../api/consult';
......@@ -32,6 +32,9 @@ const ConsultPage: React.FC = () => {
// AI state lifted up for prescription data integration
const [aiDiagnosis, setAiDiagnosis] = useState('');
const [aiMedication, setAiMedication] = useState('');
// Increments each time a new patient message arrives → AIPanel auto-triggers analysis
const [patientMessageTrigger, setPatientMessageTrigger] = useState(0);
const lastPatientMsgIdRef = useRef<string | number | null>(null);
const waitingCount = patientList.filter(p => p.status === 'pending' || p.status === 'waiting').length;
const inProgressCount = patientList.filter(p => p.status === 'in_progress').length;
......@@ -68,6 +71,16 @@ const ConsultPage: React.FC = () => {
return () => clearInterval(timer);
}, [activeConsult, fetchMessages]);
// Detect new patient messages and auto-trigger AI analysis
useEffect(() => {
if (!messages.length) return;
const lastMsg = messages[messages.length - 1];
if (lastMsg.sender_type === 'patient' && lastMsg.id !== lastPatientMsgIdRef.current) {
lastPatientMsgIdRef.current = lastMsg.id;
setPatientMessageTrigger(n => n + 1);
}
}, [messages]);
const fetchPreConsultReport = async (consultId: string, patientId?: string) => {
setPreConsultLoading(true);
try {
......@@ -94,6 +107,7 @@ const ConsultPage: React.FC = () => {
setAiMedication('');
setPreConsultReport(null);
setMessages([]);
lastPatientMsgIdRef.current = null;
};
const handleAccept = async (patient: PatientListItem) => {
......@@ -184,7 +198,7 @@ const ConsultPage: React.FC = () => {
];
return (
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', gap: 12, padding: '4px 24px 16px' }}>
{/* 顶部标题 + 统计卡片 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<div>
......@@ -255,6 +269,7 @@ const ConsultPage: React.FC = () => {
preConsultLoading={preConsultLoading}
onDiagnosisChange={setAiDiagnosis}
onMedicationChange={setAiMedication}
patientMessageTrigger={patientMessageTrigger}
/>
</div>
</div>
......
# 互联网医院平台升级方案 v11 — 智能体平台深度融合
> 版本: v11 | 日期: 2026-03-02 | 基于全量代码审计
---
## 一、现状审计总结
### 1.1 项目架构概览
| 层次 | 技术栈 | 关键目录 |
|------|--------|----------|
| 前端 | Next.js 15 + Ant Design + Zustand + TailwindCSS | `web/src/` |
| 后端 | Go + Gin + GORM + PostgreSQL + Redis | `server/` |
| AI引擎 | DeepSeek API (OpenAI兼容) + ReAct Agent + RAG | `pkg/ai/`, `pkg/agent/` |
| 工作流 | 自研引擎 (10种节点类型) | `pkg/workflow/engine.go` |
| 知识库 | Embedding + 向量检索 | `pkg/rag/` |
### 1.2 已有功能模块清单
| 模块 | 后端服务 | 前端页面 | 状态 |
|------|----------|----------|------|
| 用户认证 | `user/` | 登录/注册 | ✅ 完整 |
| 患者端 | `preconsult/`, `health/`, `chronic/` | 首页/问诊/处方/慢病 | ✅ 完整 |
| 医生端 | `doctorportal/`, `consult/` | 工作台/问诊/处方/排班 | ✅ 完整 |
| 管理端 | `admin/` | 仪表盘/用户/科室/问诊/处方/药品 | ✅ 完整 |
| AI预问诊 | `preconsult/chat_handler.go` | 全局悬浮对话 | ✅ 完整 |
| AI辅助诊断 | `agent/handler.go` | 全局悬浮对话 | ✅ 完整 |
| 智能体框架 | `pkg/agent/` (ReAct) | 管理页面+测试 | ⚠️ 部分 |
| 工作流引擎 | `pkg/workflow/engine.go` | 管理页面 | ⚠️ 部分 |
| 知识库 | `pkg/rag/` + `knowledgesvc/` | 管理页面 | ⚠️ 部分 |
| 提示词模板 | `admin/prompt_template.go` | 管理页面 | ⚠️ 部分 |
| AI配置 | `admin/ai_config.go` | 管理页面 | ⚠️ 部分 |
| 支付 | `payment/` | 支付管理 | ✅ 完整 |
---
## 二、核心问题诊断 — 六大数据孤岛
### 🔴 孤岛1: 提示词模板与智能体完全脱节
**现状:**
- `PromptTemplate` 表有 `template_key` 和 `scene` 字段,仅被 `ai.GetActivePromptByScene()` 在预问诊 (`preconsult/chat_handler.go:294`) 和分析报告 (`chat_handler.go:204`) 中使用
- `AgentDefinition` 表有独立的 `SystemPrompt` 字段,但 **四个Agent的SystemPrompt全部硬编码** 在 `internal/agent/agents.go` 中
- 管理员在后台编辑提示词模板 → **对Agent行为完全无效**
- Agent的提示词 → **管理端无法查看或修改**
**数据流断裂点:**
```
[PromptTemplate表] ──→ 仅供 preconsult/chat_handler 使用
↕ 无连接
[AgentDefinition表] ──→ 有SystemPrompt字段但未被读取
↕ 无连接
[agents.go 硬编码] ──→ 实际驱动Agent行为的提示词
```
### 🔴 孤岛2: AI调用日志与智能体执行日志双轨并行
**现状:**
- `AIUsageLog` 表:记录 `ai.Call()` / `ai.CallStream()` 的每次LLM调用(场景、tokens、耗时)
- `AgentExecutionLog` 表:记录 Agent 的完整执行轨迹(迭代次数、工具调用链)
- `AgentToolLog` 表:定义了工具调用日志模型,但 **全量代码中无任何写入操作**
- 预问诊对话 (`preconsult/chat_handler.go`) 直接调用 `ai.CallStream()`,不经过Agent → 只产生 `AIUsageLog`,不产生 `AgentExecutionLog`
- Agent对话 (`agent/service.go`) 经过 ReAct 循环 → 只产生 `AgentExecutionLog`,Agent内部的LLM调用 **不经过** `ai.Call()` 所以 **不产生** `AIUsageLog`
**数据流断裂点:**
```
预问诊对话 ──→ ai.CallStream() ──→ AIUsageLog ✅ | AgentExecutionLog ❌
Agent对话 ──→ agent.Run() ──→ AIUsageLog ❌ | AgentExecutionLog ✅
工具调用 ──→ executor.Execute()──→ AgentToolLog ❌ (表存在但无写入)
```
**后果:** 管理端AI日志页面只能看到预问诊的调用记录,看不到Agent的LLM调用;Agent执行监控页面看不到预问诊的对话数据。统计指标严重失真。
### 🔴 孤岛3: 安全词过滤完全缺失
**现状:**
- 全量代码中 **无任何内容安全过滤机制**
- 无敏感词库表/配置
- 用户输入直接传递给LLM,无前置过滤
- LLM输出直接返回给用户,无后置过滤
- Agent工具调用参数无安全校验
- 医疗场景下极高风险:可能产生误导性诊断、不当用药建议等
**缺失的安全层:**
```
用户输入 ──→ [❌ 无输入过滤] ──→ LLM ──→ [❌ 无输出过滤] ──→ 用户
↑
[❌ 无Prompt注入防护]
```
### 🔴 孤岛4: 智能体配置硬编码,AgentDefinition表形同虚设
**现状:**
- `AgentDefinition` 数据库表已建立(含 `agent_id`, `system_prompt`, `tools`, `config`, `max_iterations` 等字段)
- 但 **四个Agent全部在Go代码中硬编码创建** (`internal/agent/agents.go:8-110`)
- `AgentService.GetService()` 直接 `new` 四个Agent实例,**从未读取数据库**
- 管理端前端有Agent列表页面,但展示的是 **前端常量** `BUILTIN_AGENTS`,不是数据库数据
- 无法在线修改Agent的工具配置、提示词、迭代次数等
- 新增Agent必须改代码重新部署
**数据流断裂点:**
```
[AgentDefinition 数据库表] ──→ 已迁移创建 ──→ 无人读取、无人写入
↕ 完全断开
[agents.go 硬编码] ──→ 实际创建Agent实例
[前端 BUILTIN_AGENTS] ──→ 前端硬编码展示
```
### 🔴 孤岛5: 工具(Tool)注册硬编码,AgentTool表形同虚设
**现状:**
- `AgentTool` 数据库表已建立(含 `name`, `display_name`, `description`, `parameters`, `status` 等字段)
- 但工具注册完全在代码中完成 (`internal/agent/init.go:11-32`),8个工具硬编码注册
- `ToolRegistry` 是纯内存Map,**从未持久化到 AgentTool 表**
- Agent的 `tools` 字段是 `[]string` 写死的工具名列表
- 无法在线启用/禁用工具
- 无法动态为Agent增减工具
- 前端 `/agent/tools` API 只是读取内存 Registry 然后拼装返回,不涉及数据库
**数据流断裂点:**
```
[AgentTool 数据库表] ──→ 已迁移创建 ──→ 无人读取、无人写入
↕ 完全断开
[init.go 硬编码注册] ──→ ToolRegistry (内存Map)
[agent handler ListTools] ──→ 从内存Registry读取
```
### 🔴 孤岛6: 工作流编排与业务流程几乎无集成
**现状:**
- 工作流引擎功能完善(10种节点类型:start/end/agent/tool/condition/human_review/parallel/loop/code/http/template)
- 但 **整个系统中只有一处触发工作流的代码**:`preconsult/chat_handler.go:270` 在预问诊完成后异步触发 `pre_consult` 分类的工作流
- 以下关键业务场景 **完全没有工作流集成**:
- 问诊创建/结束 → 无工作流触发
- 处方开具 → 无工作流触发(处方审核是管理员手动操作,未接入Agent审核流)
- 慢病续方申请 → 无工作流触发
- 支付完成 → 无工作流触发
- 医生注册审核 → 无工作流触发
- 健康指标异常 → 无工作流触发
- 工作流的 `human_review` 节点创建了 `WorkflowHumanTask`,但前端 **无审核任务界面**
- 工作流执行结果无通知机制
**仅有的集成点:**
```
预问诊完成 ──→ TriggerByCategory("pre_consult", ...) ──→ ✅ 唯一触发点
问诊流程 ──→ ❌ 无触发
处方流程 ──→ ❌ 无触发
慢病续方 ──→ ❌ 无触发
支付回调 ──→ ❌ 无触发
医生审核 ──→ ❌ 无触发
健康异常 ──→ ❌ 无触发
```
---
## 三、升级方案详细设计
### 3.1 提示词模板与智能体统一关联
#### 3.1.1 后端改造
**数据模型变更:** `PromptTemplate` 增加 `agent_id` 字段
```go
// model/prompt_template.go 增加字段
type PromptTemplate struct {
// ... 现有字段 ...
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"` // 关联的Agent ID,空表示通用模板
TemplateType string `gorm:"type:varchar(20);default:'system'" json:"template_type"` // system | user | tool_result
Variables string `gorm:"type:jsonb" json:"variables"` // 模板变量定义 [{"name":"patient_name","type":"string","required":true}]
Version int `gorm:"default:1" json:"version"` // 版本号,支持版本管理
}
```
**Agent加载提示词流程改造:** `react_agent.go` 的 `buildSystemPrompt` 优先从数据库读取
```go
func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string {
// 1. 优先从数据库加载该Agent关联的active提示词模板
prompt := ai.GetActivePromptByScene(a.cfg.ID) // agent_id 作为 scene
if prompt == "" {
// 2. 回退到 AgentDefinition 表的 SystemPrompt
prompt = loadAgentSystemPrompt(a.cfg.ID)
}
if prompt == "" {
// 3. 最终回退到代码默认值
prompt = a.cfg.SystemPrompt
}
// 4. 渲染模板变量
prompt = renderTemplateVariables(prompt, ctx)
return prompt
}
```
**预问诊对话也改用Agent架构:** `preconsult/chat_handler.go` 的 `ChatSSE` 不再直接调用 `ai.CallStream()`,而是通过 `pre_consult_agent` 统一调用
#### 3.1.2 前端改造
- Agent管理页面增加"关联提示词"Tab,可为每个Agent配置/切换提示词模板
- 提示词模板编辑器增加 `agent_id` 下拉选择
- 提示词模板支持变量插槽语法 `{{patient_name}}`,带实时预览
#### 3.1.3 数据流打通后
```
管理员编辑提示词模板(agent_id=pre_consult_agent)
↓ 保存到 PromptTemplate 表
Agent运行时 buildSystemPrompt()
↓ 按 agent_id 查询 active 模板
↓ 渲染变量 → 生成最终 system prompt
↓ 发送给 LLM
```
---
### 3.2 统一AI调用日志体系
#### 3.2.1 日志架构统一
**核心思路:** 所有LLM调用统一经过 `ai.Call()`/`ai.CallStream()`,Agent层额外记录执行日志,两层日志通过 `trace_id` 关联。
**数据模型变更:**
```go
// AIUsageLog 增加关联字段
type AIUsageLog struct {
// ... 现有字段 ...
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"` // 链路追踪ID
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"` // 关联Agent
SessionID string `gorm:"type:varchar(100);index" json:"session_id"` // 关联会话
Iteration int `json:"iteration"` // Agent第几轮迭代
}
// AgentExecutionLog 增加 trace_id
type AgentExecutionLog struct {
// ... 现有字段 ...
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
}
// AgentToolLog 真正启用,与 trace_id 关联
type AgentToolLog struct {
// ... 现有字段 ...
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
Iteration int `json:"iteration"`
}
```
**Agent内部调用也走统一日志:**
```go
// react_agent.go Run() 方法改造
func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, error) {
traceID := uuid.New().String() // 生成链路ID
for i := 0; i < maxIter; i++ {
// 通过统一接口调用LLM (自动记录 AIUsageLog)
resp, err := ai.CallWithTools(ctx, ai.CallWithToolsParams{
Scene: a.cfg.ID,
UserID: input.UserID,
TraceID: traceID,
AgentID: a.cfg.ID,
SessionID: input.SessionID,
Iteration: i + 1,
Messages: messages,
Tools: tools,
})
// 工具执行时记录 AgentToolLog
for _, tc := range resp.ToolCalls {
result := a.executor.Execute(ctx, tc.Function.Name, tc.Function.Arguments)
saveToolLog(traceID, a.cfg.ID, input.SessionID, tc, result, i+1)
}
}
}
```
#### 3.2.2 前端统一日志视图
管理端 → AI运营中心(新页面),聚合展示:
- **全链路追踪视图**:一次Agent执行 → 展开看每轮迭代的LLM调用 → 展开看工具调用
- **统一计量看板**:所有AI调用(预问诊+Agent)的tokens消耗、耗时、成功率
- **按Agent/Scene分组统计**
---
### 3.3 安全词过滤与内容安全体系
#### 3.3.1 数据模型
```go
// model/safety.go (新增)
// SafetyWordRule 安全词规则
type SafetyWordRule struct {
ID uint `gorm:"primaryKey"`
Word string `gorm:"type:varchar(200);index" json:"word"` // 敏感词/正则
Category string `gorm:"type:varchar(50)" json:"category"` // medical_claim(医疗断言) | drug_promotion(药品推销) | privacy(隐私) | toxicity(有害) | injection(注入攻击)
Level string `gorm:"type:varchar(20)" json:"level"` // block(拦截) | warn(警告) | replace(替换)
Replacement string `gorm:"type:varchar(200)" json:"replacement"` // level=replace时的替换文本
Direction string `gorm:"type:varchar(10)" json:"direction"` // input(输入过滤) | output(输出过滤) | both(双向)
IsRegex bool `gorm:"default:false" json:"is_regex"` // 是否正则表达式
AgentID string `gorm:"type:varchar(100)" json:"agent_id"` // 特定Agent的规则,空=全局
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time
UpdatedAt time.Time
}
// SafetyFilterLog 过滤日志
type SafetyFilterLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"`
Direction string `gorm:"type:varchar(10)"` // input | output
OriginalText string `gorm:"type:text"`
FilteredText string `gorm:"type:text"`
MatchedRules string `gorm:"type:jsonb"` // [{"rule_id":1,"word":"xxx","action":"block"}]
Action string `gorm:"type:varchar(20)"` // passed | blocked | replaced | warned
AgentID string `gorm:"type:varchar(100)"`
UserID string `gorm:"type:uuid"`
CreatedAt time.Time
}
```
#### 3.3.2 过滤引擎实现
```go
// pkg/safety/filter.go (新增)
type SafetyFilter struct {
rules []SafetyWordRule
mu sync.RWMutex
lastLoad time.Time
}
// FilterInput 输入过滤(用户消息 → LLM之前)
func (f *SafetyFilter) FilterInput(text string, agentID string) *FilterResult {
// 1. Prompt注入检测(检测 "忽略上述指令" / "你现在是..." 等模式)
// 2. 隐私信息脱敏(身份证号、手机号等自动掩码)
// 3. 敏感词匹配(精确+正则)
// 4. 返回过滤结果(通过/拦截/替换/警告)
}
// FilterOutput 输出过滤(LLM回复 → 返回用户之前)
func (f *SafetyFilter) FilterOutput(text string, agentID string) *FilterResult {
// 1. 医疗断言检测("你确诊了..." / "你一定是..." 等确定性诊断用语)
// 2. 药品推销检测(不当推荐特定品牌药物)
// 3. 有害建议检测(建议停药/自行手术等危险行为)
// 4. 通用敏感词匹配
}
```
#### 3.3.3 集成到AI调用链
```
用户输入 ──→ SafetyFilter.FilterInput() ──→ [通过?] ──→ LLM调用
↓ [拦截]
返回安全提示
LLM输出 ──→ SafetyFilter.FilterOutput() ──→ [通过?] ──→ 返回用户
↓ [替换]
返回脱敏后内容
```
#### 3.3.4 前端管理
管理端新增"内容安全"页面:
- **安全词规则管理**:CRUD,支持正则,支持按Agent/方向/级别分类
- **过滤日志查看**:被拦截/替换的记录,可审计
- **预置医疗安全规则**:一键导入医疗场景常见安全词库
- **安全统计看板**:拦截率、高频触发词、风险用户等
---
### 3.4 智能体配置动态化
#### 3.4.1 Agent从数据库加载
**`internal/agent/service.go` 改造:**
```go
func GetService() *AgentService {
if globalAgentService == nil {
globalAgentService = &AgentService{
agents: make(map[string]*agent.ReActAgent),
}
// 1. 从数据库加载所有active的AgentDefinition
globalAgentService.loadFromDB()
// 2. 回退:如果数据库为空,加载内置默认配置并写入数据库
globalAgentService.ensureBuiltinAgents()
}
return globalAgentService
}
func (s *AgentService) loadFromDB() {
db := database.GetDB()
var definitions []model.AgentDefinition
db.Where("status = 'active'").Find(&definitions)
for _, def := range definitions {
var tools []string
json.Unmarshal([]byte(def.Tools), &tools)
a := agent.NewReActAgent(agent.ReActConfig{
ID: def.AgentID,
Name: def.Name,
Description: def.Description,
SystemPrompt: def.SystemPrompt,
Tools: tools,
MaxIterations: def.MaxIterations,
})
s.agents[def.AgentID] = a
}
}
// ReloadAgent 热重载单个Agent(管理端修改配置后调用)
func (s *AgentService) ReloadAgent(agentID string) error { ... }
```
#### 3.4.2 管理端Agent配置页面增强
当前前端Agent页面只展示硬编码列表。改造后支持:
- **在线编辑Agent**:修改名称、描述、SystemPrompt、工具列表、最大迭代次数
- **Agent启停**:active/disabled 状态切换
- **创建自定义Agent**:管理员可创建新的Agent(无需改代码)
- **Agent配置热重载**:保存后自动通知后端重新加载,无需重启
- **Agent版本管理**:修改配置时自动创建新版本,支持回滚
- **Agent测试**:在管理页面直接测试对话,实时查看tool_calls
---
### 3.5 工具(Tool)动态化管理
#### 3.5.1 工具注册双轨制
保留代码注册作为基础(Go实现的高性能工具),同时支持数据库定义的轻量工具。
**`AgentTool` 表启用写入和读取:**
```go
// internal/agent/init.go 改造
func InitTools() {
db := database.GetDB()
r := agent.GetRegistry()
// 1. 注册代码实现的内置工具(不变)
r.Register(&tools.DrugQueryTool{DB: db})
// ...
// 2. 同步工具元数据到 AgentTool 表
syncToolsToDB(r, db)
// 3. 从数据库加载工具的启用/禁用状态
applyToolStatus(r, db)
}
func syncToolsToDB(r *agent.ToolRegistry, db *gorm.DB) {
for name, tool := range r.All() {
params, _ := json.Marshal(tool.Parameters())
db.Where("name = ?", name).FirstOrCreate(&model.AgentTool{
Name: name,
DisplayName: tool.Description(),
Description: tool.Description(),
Parameters: string(params),
Status: "active",
})
}
}
```
#### 3.5.2 工具启停API
```
PUT /api/v1/admin/tools/:name/status { "status": "active" | "disabled" }
```
Agent执行时检查工具状态:
```go
func (e *Executor) Execute(ctx context.Context, toolName string, args string) ToolResult {
// 检查工具是否被禁用
if isToolDisabled(toolName) {
return ToolResult{Success: false, Error: "该工具已被管理员禁用"}
}
// ... 正常执行
}
```
#### 3.5.3 HTTP工具动态注册(未来扩展)
支持通过管理后台定义 HTTP API 工具,无需写Go代码:
```json
{
"name": "query_ehr",
"display_name": "查询电子病历",
"type": "http",
"config": {
"method": "GET",
"url": "https://ehr-api.hospital.com/api/patient/{{patient_id}}/records",
"headers": { "Authorization": "Bearer {{ehr_token}}" },
"response_mapping": { "records": "$.data.records" }
},
"parameters": [
{ "name": "patient_id", "type": "string", "required": true }
]
}
```
---
### 3.6 工作流编排与业务流程全面集成
#### 3.6.1 业务触发点接入
在各业务服务的关键节点增加工作流触发:
| 业务事件 | 触发分类(category) | 输入参数 | 实现位置 |
|----------|-------------------|----------|----------|
| 预问诊完成 | `pre_consult` | patient_id, department, severity | `preconsult/chat_handler.go` ✅已有 |
| 问诊创建 | `consult_created` | consult_id, patient_id, doctor_id, type | `consult/service.go` ❌新增 |
| 问诊结束 | `consult_ended` | consult_id, diagnosis, prescription_id | `consult/service.go` ❌新增 |
| 处方开具 | `prescription_created` | prescription_id, doctor_id, patient_id, items | `doctorportal/prescription_service.go` ❌新增 |
| 处方审核通过 | `prescription_approved` | prescription_id | `admin/prescription_service.go` ❌新增 |
| 慢病续方申请 | `renewal_requested` | renewal_id, patient_id, chronic_record_id | `chronic/service.go` ❌新增 |
| 支付完成 | `payment_completed` | order_id, amount, order_type | `payment/service.go` ❌新增 |
| 医生注册审核 | `doctor_review` | review_id, doctor_id, action | `admin/service.go` ❌新增 |
| 健康指标异常 | `health_alert` | patient_id, metric_type, value | `health/service.go` ❌新增 |
**实现方式统一:**
```go
// 在各service的关键方法末尾添加
workflow.GetEngine().TriggerByCategory(ctx, "consult_created", map[string]interface{}{
"consult_id": consult.ID,
"patient_id": consult.PatientID,
"doctor_id": consult.DoctorID,
"type": consult.Type,
})
```
#### 3.6.2 预置工作流模板
系统内置以下工作流模板,管理员可直接发布或自定义修改:
**1. 处方智能审核流(prescription_created)**
```
[start] → [prescription_agent审核] → [condition:是否有风险?]
├─ 无风险 → [自动通过] → [通知医生] → [end]
└─ 有风险 → [human_review:药剂师审核] → [通知医生] → [end]
```
**2. 慢病续方审批流(renewal_requested)**
```
[start] → [query_medical_record查病历] → [diagnosis_agent评估]
→ [condition:病情稳定?]
├─ 稳定 → [自动续方] → [通知患者] → [end]
└─ 不稳定 → [human_review:医生复核] → [condition:通过?]
├─ 通过 → [开新处方] → [通知患者] → [end]
└─ 拒绝 → [建议复诊] → [通知患者] → [end]
```
**3. 健康异常预警流(health_alert)**
```
[start] → [follow_up_agent评估] → [condition:严重程度]
├─ mild → [template:发送健康提醒] → [end]
├─ moderate → [template:建议复诊通知] → [human_review:医生确认] → [end]
└─ severe → [parallel: 通知医生 + 通知患者 + 通知家属] → [end]
```
#### 3.6.3 人工审核任务前端
当前 `WorkflowHumanTask` 模型已有,但前端无对应页面。新增:
- **医生端**:工作台新增"待办任务"Tab,展示分配给 `doctor` 角色的 human_review 任务
- **管理端**:新增"人工审核"页面,展示所有 pending 任务,支持查看上下文、审批通过/拒绝
- **通知集成**:创建 human_task 时通过 WebSocket 推送通知
---
## 四、数据流全链路打通 — 闭环验证
### 4.1 打通后的完整业务闭环
```
┌─────────────────────── 患者旅程 ───────────────────────┐
│ │
│ 患者登录 → AI预问诊(Agent) → 推荐科室+医生 │
│ ↓ ↓ │
│ 选择医生 → 创建问诊 ──→ 触发 consult_created 工作流 │
│ ↓ ↓ │
│ 实时问诊(WebSocket) ←── AI辅助诊断(Agent)支持医生 │
│ ↓ │
│ 医生开处方 ──→ 触发 prescription_created 工作流 │
│ ↓ ↓ │
│ (prescription_agent自动审核 / 药剂师人工审核) │
│ ↓ │
│ 患者支付 ──→ 触发 payment_completed 工作流 │
│ ↓ ↓ │
│ 药品配送 ←── 自动流转 │
│ ↓ │
│ 慢病管理 → 健康打卡 → 指标异常 → 触发 health_alert │
│ ↓ ↓ │
│ 续方申请 ──→ 触发 renewal_requested 工作流 │
│ ↓ ↓ │
│ (Agent评估+医生复核) → 新处方 → 循环 │
│ │
└─────────────────────────────────────────────────────────┘
┌─────────────── 安全过滤层 (贯穿所有AI调用) ──────────────┐
│ 用户输入 → InputFilter → LLM → OutputFilter → 用户 │
│ ↓ ↓ │
│ SafetyFilterLog SafetyFilterLog │
└──────────────────────────────────────────────────────────┘
┌─────────────── 统一日志层 (贯穿所有AI调用) ──────────────┐
│ AIUsageLog (每次LLM调用) │
│ ↕ trace_id 关联 │
│ AgentExecutionLog (Agent完整执行) │
│ ↕ trace_id 关联 │
│ AgentToolLog (每次工具调用) │
│ ↕ trace_id 关联 │
│ SafetyFilterLog (每次过滤事件) │
└──────────────────────────────────────────────────────────┘
```
### 4.2 之前的孤岛 vs 打通后
| 孤岛 | 之前 | 打通后 |
|------|------|--------|
| PromptTemplate ↔ Agent | 完全隔离 | 通过 agent_id 关联,Agent运行时从DB加载模板 |
| AIUsageLog ↔ AgentExecutionLog | 双轨并行 | 通过 trace_id 贯通,统一计量 |
| 安全过滤 | 不存在 | 输入/输出双向过滤,规则可配置,日志可审计 |
| AgentDefinition 表 | 空表 | Agent配置的真实存储,支持在线编辑热重载 |
| AgentTool 表 | 空表 | 工具元数据存储,支持启停控制 |
| 工作流 ↔ 业务 | 仅预问诊1处 | 9个业务触发点全面接入 |
---
## 五、实施优先级与里程碑
### Phase 1: 基础打通(1-2周)
1. **安全词过滤引擎** — 医疗场景最高优先级,涉及合规
2. **统一日志体系** — trace_id 贯通三层日志
3. **AgentToolLog 启用** — 补上缺失的工具调用日志写入
### Phase 2: 配置动态化(2-3周)
4. **Agent配置数据库化** — AgentDefinition 表读写 + 热重载
5. **提示词模板关联Agent** — PromptTemplate 增加 agent_id + 运行时加载
6. **工具启停管理** — AgentTool 表同步 + 状态控制API
### Phase 3: 业务流程闭环(3-4周)
7. **工作流触发点全面接入** — 9个业务事件触发工作流
8. **预置工作流模板** — 处方审核流、续方审批流、健康预警流
9. **人工审核任务前端** — 医生端+管理端的待办任务页面
10. **预问诊统一走Agent** — preconsult ChatSSE 改为调用 pre_consult_agent
### Phase 4: 前端体验优化(1-2周)
11. **管理端AI运营中心** — 统一日志视图+链路追踪+安全审计
12. **管理端安全词管理页面** — 规则CRUD+过滤日志+统计
13. **管理端Agent/工具增强** — 在线编辑+测试+版本管理
---
## 六、需要变更的文件清单
### 后端(server/)
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `internal/model/safety.go` | **新增** | SafetyWordRule + SafetyFilterLog 模型 |
| `internal/model/prompt_template.go` | 修改 | 增加 agent_id, template_type, variables, version 字段 |
| `internal/model/ai_config.go` | 修改 | AIUsageLog 增加 trace_id, agent_id, session_id, iteration |
| `internal/model/agent.go` | 修改 | AgentExecutionLog/AgentToolLog 增加 trace_id |
| `pkg/safety/filter.go` | **新增** | 安全过滤引擎 |
| `pkg/safety/loader.go` | **新增** | 安全词规则加载器(DB+缓存) |
| `pkg/ai/client.go` | 修改 | Call/CallStream 增加 trace_id 透传;新增 CallWithTools |
| `pkg/ai/logger.go` | 修改 | LogParams 增加关联字段 |
| `pkg/agent/react_agent.go` | 修改 | buildSystemPrompt 从DB加载;Run 内统一走 ai.Call 记日志 |
| `pkg/agent/executor.go` | 修改 | Execute 增加安全检查 + 写入 AgentToolLog |
| `internal/agent/agents.go` | 修改 | 作为默认值保留,实际从DB加载 |
| `internal/agent/service.go` | 修改 | loadFromDB + ReloadAgent 热重载 |
| `internal/agent/init.go` | 修改 | syncToolsToDB 同步工具元数据 |
| `internal/agent/handler.go` | 修改 | 增加工具状态管理API |
| `internal/service/admin/safety_handler.go` | **新增** | 安全词规则CRUD + 过滤日志查询 |
| `internal/service/admin/handler.go` | 修改 | 注册安全管理路由 |
| `internal/service/consult/service.go` | 修改 | 问诊创建/结束时触发工作流 |
| `internal/service/doctorportal/prescription_service.go` | 修改 | 处方开具时触发工作流 |
| `internal/service/chronic/service.go` | 修改 | 续方申请时触发工作流 |
| `internal/service/payment/service.go` | 修改 | 支付完成时触发工作流 |
| `internal/service/health/service.go` | 修改 | 健康指标异常时触发工作流 |
| `internal/service/preconsult/chat_handler.go` | 修改 | 统一走Agent架构 |
| `cmd/api/main.go` | 修改 | AutoMigrate新表 + 初始化安全过滤器 |
### 前端(web/src/)
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `app/(main)/admin/safety/page.tsx` | **新增** | 安全词管理页面 |
| `app/(main)/admin/ai-center/page.tsx` | **新增** | AI运营中心(统一日志+链路追踪) |
| `app/(main)/admin/agents/page.tsx` | 修改 | Agent配置在线编辑+热重载+关联提示词 |
| `app/(main)/admin/tools/page.tsx` | **新增** | 工具管理页面(启停+详情) |
| `app/(main)/doctor/layout.tsx` | 修改 | 增加"待办任务"菜单项 |
| `app/(main)/doctor/tasks/page.tsx` | **新增** | 医生端人工审核任务页面 |
| `app/(main)/admin/tasks/page.tsx` | **新增** | 管理端人工审核任务页面 |
| `api/safety.ts` | **新增** | 安全管理API |
| `api/agent.ts` | 修改 | 增加Agent配置CRUD、工具状态管理API |
| `api/admin.ts` | 修改 | 增加安全管理、AI运营中心API |
---
## 七、风险评估与注意事项
1. **数据库迁移**:涉及多个表的字段增加,需要确保迁移脚本兼容存量数据
2. **Agent热重载**:需要处理并发安全,正在执行中的Agent不应被中断
3. **安全过滤性能**:敏感词匹配需要使用Aho-Corasick或Trie等高效算法,避免逐条正则影响响应时间
4. **工作流异步触发**:`TriggerByCategory` 是异步fire-and-forget模式,需要加监控确保不静默失败
5. **向后兼容**:Agent配置DB化后,如果数据库为空需要回退到代码默认值
6. **前端安全**:安全词规则的具体内容不应暴露给非管理员
# 升级方案 v12 — 工具生态扩展与智能编排能力增强
> 编写日期:2026-03-02
> 基于版本:v11(智能体平台深度融合)已完成
---
## 一、现状盘点
### 1.1 当前工具体系(8 个静态 Go struct 工具)
| 工具名 | 类别 | 实现方式 | 问题 |
|--------|------|----------|------|
| `query_drug` | pharmacy | DB SQL 查询 | 字段固定,无法扩展 |
| `query_medical_record` | medical | DB SQL 查询 | 同上 |
| `query_symptom_knowledge` | knowledge | 硬编码字典 | 知识不可扩展 |
| `recommend_department` | recommendation | 硬编码规则 | 无法动态调整 |
| `check_drug_interaction` | safety | DB 查询 | 数据量依赖 |
| `check_contraindication` | safety | DB 查询 | 同上 |
| `calculate_dosage` | pharmacy | 内置规则+DB | 药品列表有限 |
| `search_medical_knowledge` | knowledge | RAG 向量/关键词 | 只读,无写入 |
**注意**:`send_notification` / `generate_follow_up_plan` 在分类 map 里出现,但实际**从未注册**到 ToolRegistry,属于空壳。
### 1.2 工作流节点类型 vs Agent 工具能力对比
| 工作流节点 | 已实现 | Agent 工具 | 已实现 |
|------------|--------|------------|--------|
| `code` 代码执行 | ✅ 引擎已定义 | ❌ 无 `code_eval` 工具 |
| `http` HTTP 请求 | ✅ 引擎已定义 | ❌ 无 HTTP 工具 |
| `agent` 调子 Agent | ✅ 工作流层 | ❌ Agent 无法调 Agent |
| `template` 模板渲染 | ✅ 引擎已定义 | ❌ 无模板工具 |
| `tool` 调工具 | ✅ | ✅ 正常 |
| `human_review` 人工审核 | ✅ | ❌ Agent 无法主动发起 |
### 1.3 六大核心缺口
```
┌─────────────────────────────────────────────────────────────────┐
│ 缺口1: 无 HTTP 动态工具 → Agent 无法对接第三方 API │
│ 缺口2: 无 Agent-as-Tool → Agent 无法调用另一个 Agent │
│ 缺口3: 无工作流工具 → Agent 无法触发/查询工作流 │
│ 缺口4: 无代码执行工具 → Agent 无法做计算/数据变换 │
│ 缺口5: 知识库只读 → Agent 无法写入/更新知识 │
│ 缺口6: 通知工具空壳 → send_notification 未注册 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 二、升级目标
1. **HTTP 动态工具平台**:管理员可在 UI 配置 HTTP API 作为工具,无需改代码
2. **Agent 互调**:Agent A 可以把 Agent B 当工具调用(子 Agent 模式)
3. **工作流工具**:Agent 可以触发工作流、查询执行状态
4. **表达式执行工具**:安全沙箱内执行 Go expr 表达式,用于计算/变换
5. **知识库读写工具**:Agent 可向知识库写入/更新内容
6. **通知工具落地**:完整实现并注册 send_notification
7. **工具市场前端**:统一的工具发现、测试、统计管理界面
8. **工具缓存层**:Redis 缓存工具结果,可配置 TTL
---
## 三、架构设计
### 3.1 扩展后工具分类
```
pkg/agent/tools/
├── [已有] drug_query.go
├── [已有] medical_record.go
├── [已有] symptom_knowledge.go
├── [已有] department_recommend.go
├── [已有] drug_interaction.go
├── [已有] contraindication.go
├── [已有] dosage_calculator.go
├── [已有] knowledge_search.go
│
├── [Phase 1 - 新增] http_tool.go ← HTTP 动态工具执行器
├── [Phase 2 - 新增] agent_caller.go ← Agent-as-Tool
├── [Phase 2 - 新增] workflow_trigger.go ← 触发工作流
├── [Phase 2 - 新增] workflow_query.go ← 查询工作流状态
├── [Phase 3 - 新增] expr_eval.go ← 表达式/代码执行
├── [Phase 4 - 新增] knowledge_write.go ← 知识库写入
├── [Phase 4 - 新增] knowledge_list.go ← 知识库集合列表
└── [Phase 5 - 新增] notification.go ← 消息通知(落地)
```
### 3.2 HTTP 动态工具数据流
```
管理员 UI
└─ 创建 HTTPToolDef(url/method/headers/body_template/auth_type)
└─ 存入 http_tool_definitions 表
└─ 服务启动/热重载时 LoadHTTPTools()
└─ 每个 HTTPToolDef → 生成 DynamicHTTPTool{} 实例
└─ r.Register(tool) → ToolRegistry
└─ Agent 像使用内置工具一样调用
```
### 3.3 Agent 互调架构
```
pre_consult_agent
└─ tool: "call_agent"
├─ params: { agent_id: "diagnosis_agent", message: "..." }
└─ AgentCallerTool.Execute()
└─ agentService.Chat("diagnosis_agent", ...)
└─ 返回 response → 作为工具结果回传给父 Agent
```
### 3.4 工具缓存架构
```
Executor.ExecuteWithLog()
└─ 检查 Redis 缓存(key: tool:{name}:{argsHash},TTL: 工具配置值)
├─ 命中 → 直接返回缓存结果(标记 from_cache: true)
└─ 未命中 → 执行工具 → 写入 Redis → 返回
```
---
## 四、详细实施方案
### Phase 1:HTTP 动态工具平台(★★★ 最高优先级)
#### 1.1 数据模型 `server/internal/model/http_tool.go`
```go
// HTTPToolDefinition 动态 HTTP 工具定义
type HTTPToolDefinition struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);uniqueIndex"` // 工具名,如 get_weather
DisplayName string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50);default:'http'"`
Method string `gorm:"type:varchar(10);default:'GET'"` // GET/POST/PUT/DELETE
URL string `gorm:"type:text"` // 支持 {{param}} 模板变量
Headers string `gorm:"type:jsonb"` // {"Authorization": "Bearer {{token}}"}
BodyTemplate string `gorm:"type:text"` // JSON body 模板,支持 {{param}}
AuthType string `gorm:"type:varchar(20);default:'none'"` // none/bearer/basic/apikey
AuthConfig string `gorm:"type:jsonb"` // 认证配置(token/key等)
Parameters string `gorm:"type:jsonb"` // 工具参数定义(ToolParameter 数组)
Timeout int `gorm:"default:10"` // 超时秒数
CacheTTL int `gorm:"default:0"` // 缓存秒数,0=不缓存
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedBy string `gorm:"type:varchar(100)"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
#### 1.2 HTTP 工具执行器 `server/pkg/agent/tools/http_tool.go`
```go
// DynamicHTTPTool 动态 HTTP 工具,从数据库配置生成
type DynamicHTTPTool struct {
def *model.HTTPToolDefinition
}
func (t *DynamicHTTPTool) Name() string { return t.def.Name }
func (t *DynamicHTTPTool) Description() string { return t.def.Description }
func (t *DynamicHTTPTool) Parameters() []agent.ToolParameter {
// 从 def.Parameters JSON 反序列化
}
func (t *DynamicHTTPTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
// 1. 用 params 渲染 URL 模板(替换 {{param}} 占位符)
// 2. 渲染 Headers(支持 Bearer Token 注入)
// 3. 渲染 BodyTemplate(JSON 模板渲染)
// 4. 发起 HTTP 请求(带超时)
// 5. 解析响应(JSON/文本)
// 6. 返回结构化结果
}
```
#### 1.3 HTTP 工具加载器 `server/internal/agent/http_loader.go`
```go
// LoadHTTPTools 从数据库加载所有 active 的 HTTP 工具并注册
func LoadHTTPTools() {
var defs []model.HTTPToolDefinition
db.Where("status = 'active'").Find(&defs)
r := agent.GetRegistry()
for i := range defs {
tool := tools.NewDynamicHTTPTool(&defs[i])
r.Register(tool)
}
}
// ReloadHTTPTools 热重载(管理员保存后调用)
func ReloadHTTPTools() {
// 先注销已有 HTTP 工具(扩展 ToolRegistry 支持 Unregister)
// 重新加载
LoadHTTPTools()
}
```
#### 1.4 扩展 ToolRegistry
```go
// registry.go 新增方法
func (r *ToolRegistry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.tools, name)
}
func (r *ToolRegistry) ListByCategory(category string) []Tool { ... }
```
#### 1.5 后端 API `server/internal/agent/http_tool_handler.go`
```
GET /agent/http-tools → 列出所有 HTTP 工具
POST /agent/http-tools → 创建 HTTP 工具
PUT /agent/http-tools/:id → 更新
DELETE /agent/http-tools/:id → 删除
POST /agent/http-tools/:id/test → 在线测试(传入 params 即时执行)
POST /agent/http-tools/reload → 热重载所有 HTTP 工具
```
#### 1.6 前端页面 `web/src/app/(main)/admin/http-tools/page.tsx`
**功能**:
- 工具列表表格(名称、URL、方法、状态、最近调用次数)
- 新建/编辑弹窗:
- 基本信息(名称/描述/分类)
- 请求配置(Method、URL、Headers JSON编辑器)
- Body 模板(JSON 模板编辑器,支持 `{{param}}` 高亮)
- 认证配置(none/bearer/basic/apikey)
- 参数定义(可视化参数表格:名称/类型/描述/必填)
- 缓存 TTL、超时设置
- **在线测试 Playground**:
- 根据参数定义自动生成表单
- 点击"测试"即时发起请求
- 展示请求详情(URL/Headers/Body)+ 响应结果
- 热重载按钮(保存后自动提示重载)
---
### Phase 2:Agent 互调 & 工作流工具
#### 2.1 Agent 调用工具 `server/pkg/agent/tools/agent_caller.go`
```go
// AgentCallerTool 允许 Agent 调用另一个 Agent 作为子 Agent
type AgentCallerTool struct {
AgentSvc AgentChatService // 注入 agentService
}
func (t *AgentCallerTool) Name() string { return "call_agent" }
func (t *AgentCallerTool) Description() string {
return "调用另一个专业 Agent 处理特定任务,如诊断、处方审核等"
}
func (t *AgentCallerTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{Name: "agent_id", Type: "string", Description: "目标 Agent ID", Required: true,
Enum: []string{"diagnosis_agent", "prescription_agent", "follow_up_agent"}},
{Name: "message", Type: "string", Description: "发送给目标 Agent 的消息", Required: true},
{Name: "context", Type: "object", Description: "传递给目标 Agent 的上下文", Required: false},
}
}
func (t *AgentCallerTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
agentID := params["agent_id"].(string)
message := params["message"].(string)
// 调用 agentService.Chat(),传递父 Agent 的 traceID(链路追踪贯通)
result, err := t.AgentSvc.Chat(ctx, agentID, "system", "", message, ctxData)
// 返回子 Agent 的 response
}
```
**关键设计**:
- `call_agent` 工具的 `Enum` 从 DB 动态加载可用 Agent 列表
- 子 Agent 调用共享父 Agent 的 `traceID`,链路追踪贯通
- 防止循环调用:检测调用栈深度(最大 3 层)
#### 2.2 工作流触发工具 `server/pkg/agent/tools/workflow_trigger.go`
```go
type WorkflowTriggerTool struct{}
func (t *WorkflowTriggerTool) Name() string { return "trigger_workflow" }
func (t *WorkflowTriggerTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{Name: "workflow_id", Type: "string", Description: "工作流 ID", Required: true},
{Name: "input", Type: "object", Description: "工作流输入参数", Required: false},
}
}
func (t *WorkflowTriggerTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
workflowID := params["workflow_id"].(string)
input, _ := params["input"].(map[string]interface{})
executionID, err := workflow.GetEngine().Execute(ctx, workflowID, input, "agent")
return map[string]interface{}{
"execution_id": executionID,
"status": "triggered",
}, err
}
```
#### 2.3 工作流状态查询工具 `server/pkg/agent/tools/workflow_query.go`
```go
type WorkflowQueryTool struct{}
// 查询 workflow_executions 表,返回执行状态、输出、耗时
```
#### 2.4 人工审核发起工具 `server/pkg/agent/tools/human_review_tool.go`
```go
type HumanReviewTool struct{}
// Agent 主动发起一个 human_task,等待医生/管理员审核
// 返回 task_id,Agent 可后续用 workflow_query 查询结果
```
---
### Phase 3:表达式执行工具(安全沙箱)
#### 3.1 依赖引入
```
# go.mod 新增
github.com/expr-lang/expr v1.16.9 # 类型安全的 Go 表达式引擎
```
选择 `expr-lang/expr` 而非 `goja`(JS 引擎)的原因:
- 零依赖、类型安全
- 执行速度比 JS 解释器快 10x
- 不支持任意 Go 代码,天然沙箱
- 支持数学计算、字符串操作、数组操作
#### 3.2 表达式工具 `server/pkg/agent/tools/expr_eval.go`
```go
type ExprEvalTool struct{}
func (t *ExprEvalTool) Name() string { return "eval_expression" }
func (t *ExprEvalTool) Description() string {
return "安全执行数学表达式和数据转换,支持基本运算、字符串处理、数组操作"
}
func (t *ExprEvalTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{Name: "expression", Type: "string", Description: "表达式,如: age > 65 ? '老年' : '成年'", Required: true},
{Name: "variables", Type: "object", Description: "变量上下文,如: {age: 70, weight: 65}", Required: false},
}
}
func (t *ExprEvalTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
expression := params["expression"].(string)
variables, _ := params["variables"].(map[string]interface{})
// 使用 expr-lang 编译并执行
program, err := expr.Compile(expression)
result, err := expr.Run(program, variables)
return map[string]interface{}{"result": result, "expression": expression}, err
}
```
**医疗场景示例**:
```
expression: "weight / (height * height)" → BMI 计算
expression: "systolic >= 140 || diastolic >= 90" → 高血压判断
expression: "age >= 65 ? dose * 0.75 : dose" → 老年剂量调整
expression: "filter(medications, {.category == 'anticoagulant'})" → 抗凝药筛选
```
#### 3.3 完善工作流 code 节点
`workflow/engine.go` 的 `executeCodeNode()` 当前可能是 stub,用 `expr-lang` 补全:
```go
case NodeTypeCode:
output, err = e.executeCodeNode(ctx, node, execCtx)
// 读取 node.Config["expression"],注入 execCtx.Variables 执行
```
---
### Phase 4:知识库工具增强
#### 4.1 知识库写入工具 `server/pkg/agent/tools/knowledge_write.go`
```go
type KnowledgeWriteTool struct {
DB *gorm.DB
Retriever *rag.Retriever
}
func (t *KnowledgeWriteTool) Name() string { return "write_knowledge" }
func (t *KnowledgeWriteTool) Description() string {
return "向知识库写入或更新内容,Agent 可以保存诊断结论、治疗方案等结构化知识"
}
func (t *KnowledgeWriteTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{Name: "collection_id", Type: "string", Description: "知识库集合ID", Required: true},
{Name: "title", Type: "string", Description: "文档标题", Required: true},
{Name: "content", Type: "string", Description: "文档内容(Markdown)", Required: true},
{Name: "tags", Type: "array", Description: "标签列表", Required: false},
{Name: "doc_id", Type: "string", Description: "若指定则更新,不指定则创建", Required: false},
}
}
func (t *KnowledgeWriteTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
// 1. 写入/更新 knowledge_documents 和 knowledge_chunks
// 2. 异步触发向量索引生成(Retriever.IndexDocument)
// 3. 返回 document_id
}
```
#### 4.2 知识库集合查询工具 `server/pkg/agent/tools/knowledge_list.go`
```go
// 列出可用的知识库集合,Agent 可据此选择合适的 collection_id 搜索
type KnowledgeListTool struct{ DB *gorm.DB }
```
#### 4.3 RAG 增强:混合检索
在 `pkg/rag/retriever.go` 增加 BM25 分数与向量分数的加权融合:
```go
type HybridSearchRequest struct {
SearchRequest
VectorWeight float64 // 向量相似度权重,默认 0.7
KeywordWeight float64 // 关键词权重,默认 0.3
Rerank bool // 是否启用重排序
}
```
---
### Phase 5:通知工具落地
#### 5.1 通知工具实现 `server/pkg/agent/tools/notification.go`
```go
// SendNotificationTool 消息通知工具(当前空壳,此次落地)
type SendNotificationTool struct {
DB *gorm.DB
}
func (t *SendNotificationTool) Name() string { return "send_notification" }
func (t *SendNotificationTool) Description() string {
return "向指定用户发送系统通知,支持站内信、WebSocket 推送"
}
func (t *SendNotificationTool) Parameters() []agent.ToolParameter {
return []agent.ToolParameter{
{Name: "user_id", Type: "string", Description: "接收者用户ID", Required: true},
{Name: "title", Type: "string", Description: "通知标题", Required: true},
{Name: "content", Type: "string", Description: "通知内容", Required: true},
{Name: "type", Type: "string", Description: "通知类型", Required: false,
Enum: []string{"reminder", "alert", "info", "followup"}},
{Name: "priority", Type: "string", Description: "优先级: normal/high/urgent", Required: false},
}
}
func (t *SendNotificationTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
// 1. 写入 notifications 表(model.Notification)
// 2. 尝试 WebSocket 推送(如果用户在线)
// 3. 返回 notification_id
}
```
#### 5.2 随访计划生成工具落地
同样补全 `generate_follow_up_plan` 工具并注册。
---
### Phase 6:工具结果缓存
#### 6.1 缓存层封装 `server/pkg/agent/tool_cache.go`
```go
// ToolCache 工具结果缓存
type ToolCache struct {
redis *redis.Client
}
// Get 返回缓存命中的结果,第二个返回值为 true 表示命中
func (c *ToolCache) Get(toolName, argsHash string) (ToolResult, bool) { ... }
// Set 写入缓存(TTL 秒)
func (c *ToolCache) Set(toolName, argsHash string, result ToolResult, ttlSec int) { ... }
// argsHash 对工具参数计算 sha256 前 16 位,作为缓存 key 后缀
```
#### 6.2 Executor 集成缓存
```go
// executor.go Execute() 修改
func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) ToolResult {
// 0. 检查工具禁用
// 1. 检查缓存(TTL 从 AgentTool.CacheTTL 读取)
if cached, ok := e.cache.Get(name, hash(argsJSON)); ok {
return cached // 命中缓存,跳过实际执行
}
// 2. 执行工具
// 3. 写入缓存
}
```
#### 6.3 AgentTool 表新增字段
```go
// model/agent.go AgentTool 新增
CacheTTL int `gorm:"default:0"` // 缓存 TTL 秒,0=不缓存
Timeout int `gorm:"default:30"` // 执行超时秒数
MaxRetries int `gorm:"default:0"` // 失败重试次数
```
---
### Phase 7:管理端工具市场
#### 7.1 工具市场页面 `web/src/app/(main)/admin/tool-market/page.tsx`
**设计**:统一展示所有来源的工具(内置 + HTTP 自定义 + Agent 工具 + 工作流工具)
```
┌─────────────────────────────────────────────────────────────┐
│ 工具市场 [搜索框] [分类: 全部/内置/HTTP/Agent/工作流] │
├─────────────────────────────────────────────────────────────┤
│ 统计卡片:总工具数 | 已启用 | HTTP工具 | 今日调用次数 │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 🔧 query_drug │ │ 🌐 get_weather │ │
│ │ [pharmacy] [内置] │ │ [http] [自定义] │ │
│ │ 今日调用: 234 │ │ 今日调用: 12 │ │
│ │ [详情] [测试] │ │ [详情][测试][编辑]│ │
│ └──────────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 工具调用趋势图(按类别、按来源) │
└─────────────────────────────────────────────────────────────┘
```
**Tab 结构**:
- **工具卡片市场**:卡片视图,每张卡片显示名称/类别/来源/今日调用/启停
- **HTTP 工具管理**:HTTP 工具 CRUD(Phase 1 的管理页面,嵌入此处)
- **工具测试台**:选择工具 → 自动生成参数表单 → 执行 → 查看请求/响应
- **调用统计**:按工具名/时间的调用次数/成功率/P95耗时图表
#### 7.2 工具测试台组件 `ToolPlayground`
```tsx
// 核心交互:
// 1. 左侧:工具选择 + 参数表单(根据 Parameters 定义动态生成)
// 2. 右侧上:请求详情(会展示 HTTP 工具的实际 URL/Body)
// 2. 右侧下:响应结果(JSON 高亮展示)
// 3. 底部:调用耗时、缓存命中标识
```
---
### Phase 8:前端页面接入
#### 8.1 更新 admin layout,新增菜单项
```tsx
// admin/layout.tsx 工具平台子菜单新增
{ key: '/admin/http-tools', label: 'HTTP 工具配置', icon: <ApiOutlined /> }
{ key: '/admin/tool-market', label: '工具市场', icon: <AppstoreOutlined /> }
```
#### 8.2 agents 页面增强
在 Agent 编辑弹窗的"关联工具"多选框中,增加来源分组:
```tsx
// 工具来源分组
[
{ label: '内置工具', options: builtinTools },
{ label: 'HTTP 工具', options: httpTools },
{ label: 'Agent 工具', options: [{ value: 'call_agent', label: 'call_agent (调用子Agent)' }] },
{ label: '工作流工具', options: [{ value: 'trigger_workflow', label: 'trigger_workflow' }] },
]
```
---
## 五、文件变更清单
### 后端新增文件
```
server/internal/model/http_tool.go ← HTTP 工具定义模型
server/pkg/agent/tools/http_tool.go ← HTTP 工具执行器
server/pkg/agent/tools/agent_caller.go ← Agent 互调工具
server/pkg/agent/tools/workflow_trigger.go ← 触发工作流工具
server/pkg/agent/tools/workflow_query.go ← 查询工作流状态工具
server/pkg/agent/tools/human_review_tool.go ← 发起人工审核工具
server/pkg/agent/tools/expr_eval.go ← 表达式执行工具
server/pkg/agent/tools/knowledge_write.go ← 知识库写入工具
server/pkg/agent/tools/knowledge_list.go ← 知识库集合列表工具
server/pkg/agent/tools/notification.go ← 通知工具(落地实现)
server/pkg/agent/tool_cache.go ← 工具结果缓存层
server/internal/agent/http_loader.go ← HTTP 工具热加载器
server/internal/agent/http_tool_handler.go ← HTTP 工具 CRUD API
```
### 后端修改文件
```
server/internal/model/agent.go ← AgentTool 新增 CacheTTL/Timeout/MaxRetries
server/pkg/agent/registry.go ← 新增 Unregister/ListByCategory
server/pkg/agent/executor.go ← 集成缓存层、超时控制
server/pkg/agent/react_agent.go ← 注入 AgentCallerTool(防循环调用)
server/pkg/rag/retriever.go ← 混合检索(BM25 + 向量加权)
server/pkg/workflow/engine.go ← code 节点完整实现(expr-lang)
server/internal/agent/init.go ← 注册新工具、调用 LoadHTTPTools()
server/internal/agent/handler.go ← 挂载 HTTP 工具路由
server/cmd/api/main.go ← AutoMigrate 新增 HTTPToolDefinition
server/go.mod ← 新增 expr-lang/expr 依赖
```
### 前端新增文件
```
web/src/app/(main)/admin/http-tools/page.tsx ← HTTP 工具管理页面
web/src/app/(main)/admin/tool-market/page.tsx ← 工具市场总览
```
### 前端修改文件
```
web/src/api/agent.ts ← 新增 HTTP 工具 CRUD API
web/src/app/(main)/admin/layout.tsx ← 新增两个菜单项
web/src/app/(main)/admin/agents/page.tsx ← 工具选择框增加来源分组
web/src/app/(main)/admin/tools/page.tsx ← 新增缓存TTL/超时列
```
---
## 六、实施优先级与顺序
| 阶段 | 内容 | 依赖 | 价值 |
|------|------|------|------|
| Phase 1 | HTTP 动态工具平台 | 无 | ★★★★★ |
| Phase 5 | 通知工具落地 | 无 | ★★★★ |
| Phase 2 | Agent 互调 + 工作流工具 | Phase 1 基础架构 | ★★★★ |
| Phase 3 | 表达式执行工具 | go.mod 引入 expr-lang | ★★★ |
| Phase 4 | 知识库写入工具 | 无 | ★★★ |
| Phase 6 | 工具结果缓存 | Redis 已接入 | ★★★ |
| Phase 7 | 工具市场前端 | Phase 1-6 | ★★★ |
| Phase 8 | 前端页面完善 | Phase 7 | ★★ |
**推荐实施顺序**:1 → 5 → 2 → 3 → 4 → 6 → 7 → 8
---
## 七、关键技术决策
### 7.1 HTTP 工具模板语法:`{{param}}` vs Go text/template
选择 `strings.NewReplacer` 做简单替换,不引入 `text/template`:
- 避免模板注入风险(`text/template` 支持任意函数调用)
- 语法更直观,管理员易上手
- 如需 if/else 逻辑,使用 `expr_eval` 工具预处理后传入
### 7.2 Agent 互调防循环:调用深度限制
在 `context.Context` 中传递调用链 `agent_call_depth`,超过 3 层直接返回错误:
```go
const maxAgentCallDepth = 3
func (t *AgentCallerTool) Execute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
depth, _ := ctx.Value("agent_call_depth").(int)
if depth >= maxAgentCallDepth {
return nil, fmt.Errorf("agent call depth exceeded %d", maxAgentCallDepth)
}
ctx = context.WithValue(ctx, "agent_call_depth", depth+1)
// ...
}
```
### 7.3 工具缓存 key 设计
```
key: tool_cache:{tool_name}:{sha256(normalized_args)[0:16]}
TTL: 从 AgentTool.CacheTTL 读取(0 = 不缓存)
```
**哪些工具适合缓存**:
- `search_medical_knowledge`:知识检索(TTL=3600)
- `query_drug`:药品信息(TTL=86400)
- HTTP 天气/汇率等外部 API(TTL=300)
**不适合缓存**:
- `query_medical_record`:实时性要求高
- `send_notification`:副作用工具
### 7.4 expr-lang/expr 安全边界
允许:数学运算、字符串操作、数组 filter/map、条件表达式
禁止:文件IO、网络调用、反射
内置变量白名单:仅允许 `variables` 中传入的键值
---
## 八、数据库变更
```sql
-- 新表:HTTP 工具定义
CREATE TABLE http_tool_definitions (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(200),
description TEXT,
category VARCHAR(50) DEFAULT 'http',
method VARCHAR(10) DEFAULT 'GET',
url TEXT NOT NULL,
headers JSONB DEFAULT '{}',
body_template TEXT,
auth_type VARCHAR(20) DEFAULT 'none',
auth_config JSONB DEFAULT '{}',
parameters JSONB DEFAULT '[]',
timeout INT DEFAULT 10,
cache_ttl INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'active',
created_by VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 修改 agent_tools 表:新增缓存/超时字段
ALTER TABLE agent_tools ADD COLUMN cache_ttl INT DEFAULT 0;
ALTER TABLE agent_tools ADD COLUMN timeout INT DEFAULT 30;
ALTER TABLE agent_tools ADD COLUMN max_retries INT DEFAULT 0;
```
---
## 九、预期效果
完成后,工具体系从 8 个静态 Go 工具扩展为:
```
内置工具: 8 → 14(新增: send_notification, generate_follow_up_plan,
call_agent, trigger_workflow, workflow_query,
eval_expression, write_knowledge, knowledge_list)
HTTP 动态工具: 0 → N(管理员随时配置)
工具类别: 7 → 9(新增: agent-call, workflow)
```
**典型场景提升**:
| 场景 | v11 能力 | v12 能力 |
|------|----------|----------|
| 对接医保API | 需改代码 | UI 配置 HTTP 工具,5 分钟上线 |
| 诊断结论存档 | 手动写DB | Agent 调用 `write_knowledge` 自动归档 |
| 多 Agent 协作 | 只能串行工作流 | Agent 可直接 `call_agent` 调子 Agent |
| BMI/风险评分 | 后端硬编码 | `eval_expression` 动态配置公式 |
| 危急值自动通知 | 工作流触发 | Agent 直接调 `send_notification` |
| 工具性能优化 | 无缓存 | Redis 缓存,重复查询提速 10x |
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment