Commit 955eb364 authored by yuguo's avatar yuguo

fix

parent 59503a6c
...@@ -26,7 +26,11 @@ ...@@ -26,7 +26,11 @@
"Bash(go mod:*)", "Bash(go mod:*)",
"Bash(go vet:*)", "Bash(go vet:*)",
"Bash(cmd:*)", "Bash(cmd:*)",
"Bash(go get:*)" "Bash(go get:*)",
"Bash(find:*)",
"Bash(psql:*)",
"Bash(xargs grep:*)",
"Bash(xargs:*)"
] ]
} }
} }
...@@ -3,6 +3,7 @@ package main ...@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -39,10 +40,21 @@ func main() { ...@@ -39,10 +40,21 @@ func main() {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
// 自动迁移新表 // 自动迁移新表(每个 model 单独执行,避免单个失败影响其他表)
log.Println("开始执行数据库表迁移...") log.Println("开始执行数据库表迁移...")
db := database.GetDB() db := database.GetDB()
if err := db.AutoMigrate(
// 修复:给已有 consultations 记录补充 serial_number(迁移 NOT NULL 前必须)
if db.Migrator().HasTable("consultations") {
if db.Migrator().HasColumn(&model.Consultation{}, "serial_number") {
db.Exec("UPDATE consultations SET serial_number = 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') WHERE serial_number IS NULL OR serial_number = ''")
} else {
// 列不存在时先添加为可空,填值后再由 AutoMigrate 加约束
db.Exec("ALTER TABLE consultations ADD COLUMN IF NOT EXISTS serial_number VARCHAR(20)")
db.Exec("UPDATE consultations SET serial_number = 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') WHERE serial_number IS NULL OR serial_number = ''")
}
}
allModels := []interface{}{
// 用户相关 // 用户相关
&model.User{}, &model.User{},
&model.UserVerification{}, &model.UserVerification{},
...@@ -62,7 +74,7 @@ func main() { ...@@ -62,7 +74,7 @@ func main() {
&model.Medicine{}, &model.Medicine{},
&model.Prescription{}, &model.Prescription{},
&model.PrescriptionItem{}, &model.PrescriptionItem{},
// AI & 系统 // 健康档案
&model.LabReport{}, &model.LabReport{},
&model.FamilyMember{}, &model.FamilyMember{},
// 慢病管理 // 慢病管理
...@@ -70,6 +82,7 @@ func main() { ...@@ -70,6 +82,7 @@ func main() {
&model.RenewalRequest{}, &model.RenewalRequest{},
&model.MedicationReminder{}, &model.MedicationReminder{},
&model.HealthMetric{}, &model.HealthMetric{},
// AI & 系统
&model.AIConfig{}, &model.AIConfig{},
&model.AIUsageLog{}, &model.AIUsageLog{},
&model.PromptTemplate{}, &model.PromptTemplate{},
...@@ -97,10 +110,23 @@ func main() { ...@@ -97,10 +110,23 @@ func main() {
&model.SafetyFilterLog{}, &model.SafetyFilterLog{},
// HTTP 动态工具 // HTTP 动态工具
&model.HTTPToolDefinition{}, &model.HTTPToolDefinition{},
); err != nil { // v13: 快捷回复 + 转诊
log.Printf("Warning: AutoMigrate failed: %v", err) &model.QuickReplyTemplate{},
} else { &model.ConsultTransfer{},
// v14: Agent技能包
&model.AgentSkill{},
}
failCount := 0
for _, m := range allModels {
if err := db.AutoMigrate(m); err != nil {
log.Printf("Warning: AutoMigrate partial error: %v", err)
failCount++
}
}
if failCount == 0 {
log.Println("数据库表迁移成功") log.Println("数据库表迁移成功")
} else {
log.Printf("数据库表迁移完成(%d 个模型有警告,新表已创建)", failCount)
} }
log.Println("数据库表检查完成") log.Println("数据库表检查完成")
...@@ -230,8 +256,16 @@ func main() { ...@@ -230,8 +256,16 @@ func main() {
Timestamp: msg.CreatedAt, Timestamp: msg.CreatedAt,
}, nil }, nil
}) })
// v13: 注入已读回执处理器
wsHandler.SetReadReceiptHandler(func(messageID string) error {
now := time.Now()
return db.Model(&model.ConsultMessage{}).Where("id = ?", messageID).Update("read_at", now).Error
})
wsHandler.RegisterRoutes(api) wsHandler.RegisterRoutes(api)
// v13: 静态文件服务(上传的媒体文件)
api.Static("/uploads", "./uploads")
// 健康检查 // 健康检查
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"}) c.JSON(200, gin.H{"status": "ok"})
......
...@@ -7,94 +7,110 @@ import ( ...@@ -7,94 +7,110 @@ import (
) )
// defaultAgentDefinitions 返回内置Agent的默认数据库配置 // defaultAgentDefinitions 返回内置Agent的默认数据库配置
// 当数据库中不存在时,会自动写入并作为初始配置 // 三个角色专属通用智能体:患者、医生、管理员
func defaultAgentDefinitions() []model.AgentDefinition { func defaultAgentDefinitions() []model.AgentDefinition {
preConsultTools, _ := json.Marshal([]string{"query_symptom_knowledge", "recommend_department"}) // 患者通用智能体 — 合并 pre_consult + patient_assistant + follow_up 能力
diagnosisTools, _ := json.Marshal([]string{"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge"}) patientTools, _ := json.Marshal([]string{
prescriptionTools, _ := json.Marshal([]string{"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage"}) "query_symptom_knowledge", "recommend_department",
followUpTools, _ := json.Marshal([]string{"query_medical_record", "query_drug", "query_symptom_knowledge"}) "search_medical_knowledge", "query_drug",
"query_medical_record", "generate_follow_up_plan",
"send_notification",
})
// 医生通用智能体 — 合并 diagnosis + prescription + follow_up 能力
doctorTools, _ := json.Marshal([]string{
"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge",
"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage",
"generate_follow_up_plan", "send_notification",
})
// 管理员通用智能体 — 合并 admin_assistant + general 管理能力
adminTools, _ := json.Marshal([]string{
"eval_expression", "query_workflow_status", "call_agent",
"trigger_workflow", "request_human_review",
"list_knowledge_collections", "send_notification",
"query_drug", "search_medical_knowledge",
})
return []model.AgentDefinition{ return []model.AgentDefinition{
{ {
AgentID: "pre_consult_agent", AgentID: "patient_universal_agent",
Name: "预问诊智能助手", Name: "患者智能助手",
Description: "通过多轮对话收集患者症状,生成预问诊报告", Description: "患者端全能AI助手:预问诊、找医生、挂号、查处方、健康咨询、随访管理",
Category: "patient", Category: "patient",
SystemPrompt: `你是一位专业的AI预问诊助手。你的职责是: SystemPrompt: `你是互联网医院的患者专属AI智能助手,为患者提供全方位的医疗健康服务。
1. 通过友好的对话收集患者的症状信息
2. 询问症状的持续时间、严重程度、伴随症状等 你的核心能力:
3. 利用工具查询症状相关知识 1. **预问诊**:通过友好对话收集症状信息(持续时间、严重程度、伴随症状),利用知识库分析症状,推荐合适的就诊科室
4. 推荐合适的就诊科室 2. **找医生/挂号**:根据患者症状推荐科室和医生,帮助了解就医流程
5. 生成简洁的预问诊报告 3. **健康咨询**:搜索医学知识提供健康科普,查询药品信息和用药指导
4. **随访管理**:查询处方和用药情况,提醒按时用药,评估病情变化,生成随访计划
5. **药品查询**:查询药品信息、规格、用法和注意事项
请用中文与患者交流,语气温和专业。不要做出确定性诊断,只提供参考建议。`, 使用原则:
Tools: string(preConsultTools), - 用通俗易懂、温和专业的中文与患者交流
MaxIterations: 5, - 主动使用工具获取真实数据,不要凭空回答
- 不做确定性诊断,只提供参考建议
- 关注患者的用药依从性和健康状况变化
- 所有医疗建议仅供参考,请以专业医生判断为准`,
Tools: string(patientTools),
Config: "{}",
MaxIterations: 10,
Status: "active", Status: "active",
}, },
{ {
AgentID: "diagnosis_agent", AgentID: "doctor_universal_agent",
Name: "诊断辅助Agent", Name: "医生智能助手",
Description: "辅助医生进行诊断,提供鉴别诊断建议", Description: "医生端全能AI助手:辅助诊断、处方审核、用药建议、病历生成、随访计划",
Category: "doctor", Category: "doctor",
SystemPrompt: `你是一位经验丰富的诊断辅助AI,协助医生进行临床决策。 SystemPrompt: `你是互联网医院的医生专属AI智能助手,协助医生进行临床决策和日常工作。
你可以:
1. 查询患者病历记录(使用query_medical_record) 你的核心能力:
2. 检索医学知识库获取临床指南和疾病信息(使用search_medical_knowledge) 1. **辅助诊断**:查询患者病历,检索临床指南和疾病信息,分析症状和检验结果,提供鉴别诊断建议,推荐进一步检查项目
3. 分析症状和检验结果 2. **处方审核**:查询药品信息(规格、用法、禁忌),检查药物相互作用,检查患者禁忌症,验证剂量是否合理,综合评估处方安全性
4. 提供鉴别诊断建议 3. **用药方案**:根据患者情况推荐药物、用法用量和注意事项
5. 推荐进一步检查项目 4. **病历生成**:根据对话记录生成标准门诊病历(主诉、现病史、既往史、查体、辅助检查、初步诊断、处置意见)
5. **随访计划**:制定随访方案,包含复诊时间、复查项目、用药提醒、生活方式建议
6. **医嘱生成**:生成结构化医嘱(检查、治疗、护理、饮食、活动)
诊断流程: 诊断流程:首先查询患者病历了解病史 → 使用知识库检索诊断标准 → 综合分析后给出建议
- 首先查询患者病历了解病史 处方审核流程:检查药物相互作用 → 检查禁忌症 → 验证剂量 → 综合评估
- 使用知识库检索相关疾病的诊断标准和鉴别要点
- 综合分析后给出诊断建议
请基于循证医学原则提供建议,所有建议仅供医生参考。`, 使用原则:
Tools: string(diagnosisTools), - 基于循证医学原则提供建议
- 主动使用工具获取真实数据
- 对存在风险的处方要明确指出
- 所有建议仅供医生参考,请结合临床实际情况`,
Tools: string(doctorTools),
Config: "{}",
MaxIterations: 10, MaxIterations: 10,
Status: "active", Status: "active",
}, },
{ {
AgentID: "prescription_agent", AgentID: "admin_universal_agent",
Name: "处方审核Agent", Name: "管理员智能助手",
Description: "审核处方合理性,检查药物相互作用、禁忌症和剂量", Description: "管理端全能AI助手:运营数据查询、Agent状态监控、工作流管理、系统帮助",
Category: "pharmacy", Category: "admin",
SystemPrompt: `你是一位专业的临床药师AI,负责处方审核。 SystemPrompt: `你是互联网医院管理后台的专属AI智能助手,帮助管理员高效管理平台。
你的职责:
1. 查询药品信息(规格、用法、禁忌)
2. 检查药物相互作用(使用check_drug_interaction工具)
3. 检查患者禁忌症(使用check_contraindication工具)
4. 验证剂量是否合理(使用calculate_dosage工具)
5. 综合评估处方安全性,给出审核意见
审核流程: 你的核心能力:
- 首先使用check_drug_interaction检查所有药品的相互作用 1. **运营数据**:查询和计算运营指标,分析平台运行状况
- 然后对每个药品使用check_contraindication检查患者禁忌 2. **Agent监控**:调用其他Agent获取信息,监控Agent运行状态
- 使用calculate_dosage验证剂量是否在安全范围内 3. **工作流管理**:触发和查询工作流执行状态
- 最后综合所有检查结果给出审核意见 4. **知识库管理**:浏览知识库集合,了解知识库使用情况
5. **人工审核**:发起和管理人工审核任务
6. **通知管理**:发送系统通知
7. **药品/医学查询**:查询药品信息和医学知识辅助决策
请严格按照药品说明书和临床指南进行审核,对于存在风险的处方要明确指出。`, 使用原则:
Tools: string(prescriptionTools), - 以简洁专业的方式回答管理员的问题
- 主动使用工具获取真实数据
- 提供可操作的建议和方案
- 用中文回答`,
Tools: string(adminTools),
Config: "{}",
MaxIterations: 10, MaxIterations: 10,
Status: "active", Status: "active",
}, },
{
AgentID: "follow_up_agent",
Name: "随访管理Agent",
Description: "管理患者随访,提醒用药、复诊,收集健康数据",
Category: "patient",
SystemPrompt: `你是一位专业的随访管理AI助手。你的职责是:
1. 查询患者的处方和用药情况
2. 提醒患者按时用药
3. 收集患者的健康数据(血压、血糖等)
4. 评估病情变化,必要时建议复诊
5. 生成随访报告
请用温和关怀的语气与患者交流,关注患者的用药依从性和健康状况变化。`,
Tools: string(followUpTools),
MaxIterations: 8,
Status: "active",
},
} }
} }
...@@ -12,6 +12,12 @@ import ( ...@@ -12,6 +12,12 @@ import (
"internet-hospital/pkg/response" "internet-hospital/pkg/response"
) )
// sendSSEEvent 发送 SSE 事件
func sendSSEEvent(c *gin.Context, flusher interface{ Flush() }, event string, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flusher.Flush()
}
// Handler Agent HTTP处理器 // Handler Agent HTTP处理器
type Handler struct { type Handler struct {
svc *AgentService svc *AgentService
...@@ -24,6 +30,7 @@ func NewHandler() *Handler { ...@@ -24,6 +30,7 @@ func NewHandler() *Handler {
func (h *Handler) RegisterRoutes(r gin.IRouter) { func (h *Handler) RegisterRoutes(r gin.IRouter) {
g := r.Group("/agent") g := r.Group("/agent")
g.POST("/:agent_id/chat", h.Chat) g.POST("/:agent_id/chat", h.Chat)
g.POST("/:agent_id/chat/stream", h.ChatStream)
g.GET("/sessions", h.ListSessions) g.GET("/sessions", h.ListSessions)
g.DELETE("/session/:session_id", h.DeleteSession) g.DELETE("/session/:session_id", h.DeleteSession)
g.GET("/list", h.ListAgents) g.GET("/list", h.ListAgents)
...@@ -44,6 +51,12 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) { ...@@ -44,6 +51,12 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.PUT("/definitions/:agent_id", h.UpdateDefinition) g.PUT("/definitions/:agent_id", h.UpdateDefinition)
g.PUT("/definitions/:agent_id/reload", h.ReloadAgent) g.PUT("/definitions/:agent_id/reload", h.ReloadAgent)
g.PUT("/tools/:name/status", h.UpdateToolStatus) g.PUT("/tools/:name/status", h.UpdateToolStatus)
// 技能包管理
g.GET("/skills", h.ListSkills)
g.POST("/skills", h.CreateSkill)
g.PUT("/skills/:skill_id", h.UpdateSkill)
g.DELETE("/skills/:skill_id", h.DeleteSkill)
} }
func (h *Handler) Chat(c *gin.Context) { func (h *Handler) Chat(c *gin.Context) {
...@@ -73,6 +86,41 @@ func (h *Handler) Chat(c *gin.Context) { ...@@ -73,6 +86,41 @@ func (h *Handler) Chat(c *gin.Context) {
response.Success(c, output) response.Success(c, output)
} }
// ChatStream SSE 流式 Agent 对话
func (h *Handler) ChatStream(c *gin.Context) {
agentID := c.Param("agent_id")
var req struct {
SessionID string `json:"session_id"`
Message string `json:"message" binding:"required"`
Context map[string]interface{} `json:"context"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 设置 SSE 响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
c.JSON(500, gin.H{"error": "streaming not supported"})
return
}
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
emit := func(event, data string) {
sendSSEEvent(c, flusher, event, data)
}
h.svc.ChatStream(c.Request.Context(), agentID, uid, req.SessionID, req.Message, req.Context, emit)
}
func (h *Handler) ListAgents(c *gin.Context) { func (h *Handler) ListAgents(c *gin.Context) {
response.Success(c, h.svc.ListAgents()) response.Success(c, h.svc.ListAgents())
} }
...@@ -237,6 +285,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) { ...@@ -237,6 +285,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
Category string `json:"category"` Category string `json:"category"`
SystemPrompt string `json:"system_prompt"` SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"` Tools []string `json:"tools"`
Skills []string `json:"skills"`
MaxIterations int `json:"max_iterations"` MaxIterations int `json:"max_iterations"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
...@@ -244,6 +293,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) { ...@@ -244,6 +293,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
return return
} }
toolsJSON, _ := json.Marshal(req.Tools) toolsJSON, _ := json.Marshal(req.Tools)
skillsJSON, _ := json.Marshal(req.Skills)
if req.MaxIterations <= 0 { if req.MaxIterations <= 0 {
req.MaxIterations = 10 req.MaxIterations = 10
} }
...@@ -254,6 +304,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) { ...@@ -254,6 +304,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
Category: req.Category, Category: req.Category,
SystemPrompt: req.SystemPrompt, SystemPrompt: req.SystemPrompt,
Tools: string(toolsJSON), Tools: string(toolsJSON),
Skills: string(skillsJSON),
MaxIterations: req.MaxIterations, MaxIterations: req.MaxIterations,
Status: "active", Status: "active",
} }
...@@ -274,6 +325,7 @@ func (h *Handler) UpdateDefinition(c *gin.Context) { ...@@ -274,6 +325,7 @@ func (h *Handler) UpdateDefinition(c *gin.Context) {
Category string `json:"category"` Category string `json:"category"`
SystemPrompt string `json:"system_prompt"` SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"` Tools []string `json:"tools"`
Skills []string `json:"skills"`
MaxIterations int `json:"max_iterations"` MaxIterations int `json:"max_iterations"`
Status string `json:"status"` Status string `json:"status"`
} }
...@@ -306,6 +358,10 @@ func (h *Handler) UpdateDefinition(c *gin.Context) { ...@@ -306,6 +358,10 @@ func (h *Handler) UpdateDefinition(c *gin.Context) {
toolsJSON, _ := json.Marshal(req.Tools) toolsJSON, _ := json.Marshal(req.Tools)
updates["tools"] = string(toolsJSON) updates["tools"] = string(toolsJSON)
} }
if req.Skills != nil {
skillsJSON, _ := json.Marshal(req.Skills)
updates["skills"] = string(skillsJSON)
}
if req.MaxIterations > 0 { if req.MaxIterations > 0 {
updates["max_iterations"] = req.MaxIterations updates["max_iterations"] = req.MaxIterations
} }
......
...@@ -28,6 +28,7 @@ func GetService() *AgentService { ...@@ -28,6 +28,7 @@ func GetService() *AgentService {
globalAgentService = &AgentService{ globalAgentService = &AgentService{
agents: make(map[string]*agent.ReActAgent), agents: make(map[string]*agent.ReActAgent),
} }
ensureBuiltinSkills()
globalAgentService.loadFromDB() globalAgentService.loadFromDB()
globalAgentService.ensureBuiltinAgents() globalAgentService.ensureBuiltinAgents()
} }
...@@ -57,6 +58,40 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent { ...@@ -57,6 +58,40 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
if def.Tools != "" { if def.Tools != "" {
json.Unmarshal([]byte(def.Tools), &tools) json.Unmarshal([]byte(def.Tools), &tools)
} }
// 加载技能包中的工具和提示词
systemPrompt := def.SystemPrompt
var skillIDs []string
if def.Skills != "" {
json.Unmarshal([]byte(def.Skills), &skillIDs)
}
if len(skillIDs) > 0 {
db := database.GetDB()
if db != nil {
var skills []model.AgentSkill
db.Where("skill_id IN ? AND status = 'active'", skillIDs).Find(&skills)
toolSet := make(map[string]bool, len(tools))
for _, t := range tools {
toolSet[t] = true
}
for _, skill := range skills {
// 合并工具(去重)
var skillTools []string
json.Unmarshal([]byte(skill.Tools), &skillTools)
for _, st := range skillTools {
if !toolSet[st] {
tools = append(tools, st)
toolSet[st] = true
}
}
// 追加系统提示
if skill.SystemPromptAddon != "" {
systemPrompt += "\n\n[技能-" + skill.Name + "] " + skill.SystemPromptAddon
}
}
}
}
maxIter := def.MaxIterations maxIter := def.MaxIterations
if maxIter <= 0 { if maxIter <= 0 {
maxIter = 10 maxIter = 10
...@@ -65,7 +100,7 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent { ...@@ -65,7 +100,7 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
ID: def.AgentID, ID: def.AgentID,
Name: def.Name, Name: def.Name,
Description: def.Description, Description: def.Description,
SystemPrompt: def.SystemPrompt, SystemPrompt: systemPrompt,
Tools: tools, Tools: tools,
MaxIterations: maxIter, MaxIterations: maxIter,
}) })
...@@ -236,3 +271,109 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes ...@@ -236,3 +271,109 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
return output, nil return output, nil
} }
// ChatStream 流式执行Agent对话(SSE),完成后持久化
func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, sessionID, message string, contextData map[string]interface{}, emit func(event, data string)) {
a, ok := s.GetAgent(agentID)
if !ok {
errJSON, _ := json.Marshal(map[string]string{"error": "agent not found"})
emit("error", string(errJSON))
return
}
db := database.GetDB()
if sessionID == "" {
sessionID = uuid.New().String()
}
var session model.AgentSession
db.Where("session_id = ?", sessionID).First(&session)
// 发送 session 事件
sessionJSON, _ := json.Marshal(map[string]string{"session_id": sessionID})
emit("session", string(sessionJSON))
var history []ai.ChatMessage
if session.History != "" {
json.Unmarshal([]byte(session.History), &history)
}
input := agent.AgentInput{
SessionID: sessionID,
UserID: userID,
Message: message,
Context: contextData,
History: history,
}
start := time.Now()
// 将 StreamEvent 转发为 SSE 事件
onEvent := func(ev agent.StreamEvent) error {
data, _ := json.Marshal(ev.Data)
emit(string(ev.Type), string(data))
return ctx.Err()
}
output, err := a.RunStream(ctx, input, onEvent)
durationMs := int(time.Since(start).Milliseconds())
if err != nil {
errJSON, _ := json.Marshal(map[string]string{"error": err.Error()})
emit("error", string(errJSON))
return
}
// 持久化会话
history = append(history,
ai.ChatMessage{Role: "user", Content: message},
ai.ChatMessage{Role: "assistant", Content: output.Response},
)
historyJSON, _ := json.Marshal(history)
contextJSON, _ := json.Marshal(contextData)
if session.ID == 0 {
session = model.AgentSession{
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
History: string(historyJSON),
Context: string(contextJSON),
Status: "active",
}
db.Create(&session)
} else {
db.Model(&session).Updates(map[string]interface{}{
"history": string(historyJSON),
"updated_at": time.Now(),
})
}
// 记录执行日志
inputJSON, _ := json.Marshal(input)
outputJSON, _ := json.Marshal(output)
toolCallsJSON, _ := json.Marshal(output.ToolCalls)
db.Create(&model.AgentExecutionLog{
TraceID: output.TraceID,
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
Input: string(inputJSON),
Output: string(outputJSON),
ToolCalls: string(toolCallsJSON),
Iterations: output.Iterations,
TotalTokens: output.TotalTokens,
DurationMs: durationMs,
FinishReason: output.FinishReason,
Success: true,
})
// 发送 done 事件
doneData, _ := json.Marshal(map[string]interface{}{
"session_id": sessionID,
"iterations": output.Iterations,
"total_tokens": output.TotalTokens,
"finish_reason": output.FinishReason,
})
emit("done", string(doneData))
}
package internalagent
import (
"encoding/json"
"log"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// ListSkills 列出所有技能包
func (h *Handler) ListSkills(c *gin.Context) {
var skills []model.AgentSkill
database.GetDB().Order("id asc").Find(&skills)
response.Success(c, skills)
}
// CreateSkill 创建技能包
func (h *Handler) CreateSkill(c *gin.Context) {
var req struct {
SkillID string `json:"skill_id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Tools []string `json:"tools"`
SystemPromptAddon string `json:"system_prompt_addon"`
ContextSchema string `json:"context_schema"`
QuickReplies []string `json:"quick_replies"`
Icon string `json:"icon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
toolsJSON, _ := json.Marshal(req.Tools)
quickRepliesJSON, _ := json.Marshal(req.QuickReplies)
if req.ContextSchema == "" {
req.ContextSchema = "{}"
}
skill := model.AgentSkill{
SkillID: req.SkillID,
Name: req.Name,
Description: req.Description,
Category: req.Category,
Tools: string(toolsJSON),
SystemPromptAddon: req.SystemPromptAddon,
ContextSchema: req.ContextSchema,
QuickReplies: string(quickRepliesJSON),
Icon: req.Icon,
Status: "active",
}
if err := database.GetDB().Create(&skill).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, skill)
}
// UpdateSkill 更新技能包
func (h *Handler) UpdateSkill(c *gin.Context) {
skillID := c.Param("skill_id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
Tools []string `json:"tools"`
SystemPromptAddon string `json:"system_prompt_addon"`
ContextSchema string `json:"context_schema"`
QuickReplies []string `json:"quick_replies"`
Icon string `json:"icon"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
db := database.GetDB()
var skill model.AgentSkill
if err := db.Where("skill_id = ?", skillID).First(&skill).Error; err != nil {
response.Error(c, 404, "skill 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.Tools != nil {
toolsJSON, _ := json.Marshal(req.Tools)
updates["tools"] = string(toolsJSON)
}
if req.SystemPromptAddon != "" {
updates["system_prompt_addon"] = req.SystemPromptAddon
}
if req.ContextSchema != "" {
updates["context_schema"] = req.ContextSchema
}
if req.QuickReplies != nil {
qrJSON, _ := json.Marshal(req.QuickReplies)
updates["quick_replies"] = string(qrJSON)
}
if req.Icon != "" {
updates["icon"] = req.Icon
}
if req.Status != "" {
updates["status"] = req.Status
}
if err := db.Model(&skill).Updates(updates).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, skill)
}
// DeleteSkill 删除技能包
func (h *Handler) DeleteSkill(c *gin.Context) {
skillID := c.Param("skill_id")
if err := database.GetDB().Where("skill_id = ?", skillID).Delete(&model.AgentSkill{}).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
// ensureBuiltinSkills 初始化内置技能包
func ensureBuiltinSkills() {
db := database.GetDB()
if db == nil {
return
}
defaults := defaultSkillDefinitions()
for _, skill := range defaults {
var existing model.AgentSkill
if err := db.Where("skill_id = ?", skill.SkillID).First(&existing).Error; err != nil {
if err := db.Create(&skill).Error; err != nil {
log.Printf("[AgentService] 写入默认Skill失败: %v", err)
}
}
}
}
func defaultSkillDefinitions() []model.AgentSkill {
mustJSON := func(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}
return []model.AgentSkill{
{
SkillID: "symptom_analysis",
Name: "症状分析",
Description: "分析患者症状,推荐科室",
Category: "patient",
Tools: mustJSON([]string{"query_symptom_knowledge", "recommend_department"}),
SystemPromptAddon: "你具备症状分析能力,可以查询症状知识库并推荐合适的科室。",
QuickReplies: mustJSON([]string{"头痛", "发热", "咳嗽", "腹痛"}),
Icon: "heart",
Status: "active",
},
{
SkillID: "drug_lookup",
Name: "药品查询",
Description: "查询药品信息、药物相互作用",
Category: "general",
Tools: mustJSON([]string{"query_drug", "check_drug_interaction"}),
SystemPromptAddon: "你可以查询药品信息和检查药物之间的相互作用。",
QuickReplies: mustJSON([]string{"查询药品", "药物相互作用"}),
Icon: "medicine-box",
Status: "active",
},
{
SkillID: "prescription_review",
Name: "处方审核",
Description: "审核处方安全性:药物相互作用、禁忌症、剂量检查",
Category: "doctor",
Tools: mustJSON([]string{"check_drug_interaction", "check_contraindication", "calculate_dosage"}),
SystemPromptAddon: "你具备处方审核能力,能检查药物相互作用、禁忌症和剂量合理性。",
QuickReplies: mustJSON([]string{"审核处方", "检查禁忌症"}),
Icon: "safety",
Status: "active",
},
{
SkillID: "medical_record",
Name: "病历查询",
Description: "查询病历记录、搜索医学知识",
Category: "doctor",
Tools: mustJSON([]string{"query_medical_record", "search_medical_knowledge"}),
SystemPromptAddon: "你可以查询患者病历记录和检索医学知识库。",
QuickReplies: mustJSON([]string{"查看病历", "搜索指南"}),
Icon: "file-text",
Status: "active",
},
{
SkillID: "health_education",
Name: "健康教育",
Description: "提供健康知识科普和症状自查",
Category: "patient",
Tools: mustJSON([]string{"search_medical_knowledge", "query_symptom_knowledge"}),
SystemPromptAddon: "你具备健康教育能力,可以提供医学知识科普和症状自查参考。",
QuickReplies: mustJSON([]string{"健康知识", "症状自查", "预防建议"}),
Icon: "book",
Status: "active",
},
{
SkillID: "follow_up_mgmt",
Name: "随访管理",
Description: "管理随访计划、生成随访方案",
Category: "patient",
Tools: mustJSON([]string{"query_medical_record", "generate_follow_up_plan"}),
SystemPromptAddon: "你具备随访管理能力,可以查询病历并生成随访计划。",
QuickReplies: mustJSON([]string{"随访计划", "复诊提醒"}),
Icon: "calendar",
Status: "active",
},
}
}
...@@ -4,81 +4,82 @@ import "time" ...@@ -4,81 +4,82 @@ import "time"
// AgentTool 工具定义 // AgentTool 工具定义
type AgentTool struct { type AgentTool struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);uniqueIndex"` Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(200)"` DisplayName string `gorm:"type:varchar(200)" json:"display_name"`
Description string `gorm:"type:text"` Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)"` Category string `gorm:"type:varchar(50)" json:"category"`
Parameters string `gorm:"type:jsonb"` Parameters string `gorm:"type:jsonb" json:"parameters"`
Status string `gorm:"type:varchar(20);default:'active'"` Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CacheTTL int `gorm:"default:0"` // 缓存秒数,0=不缓存 CacheTTL int `gorm:"default:0" json:"cache_ttl"` // 缓存秒数,0=不缓存
Timeout int `gorm:"default:30"` // 执行超时秒数 Timeout int `gorm:"default:30" json:"timeout"` // 执行超时秒数
MaxRetries int `gorm:"default:0"` // 失败重试次数 MaxRetries int `gorm:"default:0" json:"max_retries"` // 失败重试次数
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
// AgentToolLog 工具调用日志 // AgentToolLog 工具调用日志
type AgentToolLog struct { type AgentToolLog struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
ToolName string `gorm:"type:varchar(100);index"` ToolName string `gorm:"type:varchar(100);index" json:"tool_name"`
AgentID string `gorm:"type:varchar(100);index"` AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
SessionID string `gorm:"type:varchar(100);index"` SessionID string `gorm:"type:varchar(100);index" json:"session_id"`
UserID string `gorm:"type:uuid;index"` UserID string `gorm:"type:uuid;index" json:"user_id"`
InputParams string `gorm:"type:jsonb"` InputParams string `gorm:"type:jsonb" json:"input_params"`
OutputResult string `gorm:"type:jsonb"` OutputResult string `gorm:"type:jsonb" json:"output_result"`
Success bool Success bool `json:"success"`
ErrorMessage string `gorm:"type:text"` ErrorMessage string `gorm:"type:text" json:"error_message"`
DurationMs int DurationMs int `json:"duration_ms"`
Iteration int // Agent第几轮迭代 Iteration int `json:"iteration"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
} }
// AgentDefinition Agent定义 // AgentDefinition Agent定义
type AgentDefinition struct { type AgentDefinition struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
AgentID string `gorm:"type:varchar(100);uniqueIndex"` AgentID string `gorm:"type:varchar(100);uniqueIndex" json:"agent_id"`
Name string `gorm:"type:varchar(200)"` Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text"` Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)"` Category string `gorm:"type:varchar(50)" json:"category"`
SystemPrompt string `gorm:"type:text"` SystemPrompt string `gorm:"type:text" json:"system_prompt"`
Tools string `gorm:"type:jsonb"` Tools string `gorm:"type:jsonb" json:"tools"`
Config string `gorm:"type:jsonb"` Config string `gorm:"type:jsonb" json:"config"`
MaxIterations int `gorm:"default:10"` MaxIterations int `gorm:"default:10" json:"max_iterations"`
Status string `gorm:"type:varchar(20);default:'active'"` Skills string `gorm:"type:jsonb;default:'[]'" json:"skills"`
CreatedAt time.Time Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
UpdatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// AgentSession Agent会话 // AgentSession Agent会话
type AgentSession struct { type AgentSession struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
SessionID string `gorm:"type:varchar(100);uniqueIndex"` SessionID string `gorm:"type:varchar(100);uniqueIndex" json:"session_id"`
AgentID string `gorm:"type:varchar(100);index"` AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
UserID string `gorm:"type:uuid;index"` UserID string `gorm:"type:uuid;index" json:"user_id"`
Context string `gorm:"type:jsonb"` Context string `gorm:"type:jsonb" json:"context"`
History string `gorm:"type:jsonb"` History string `gorm:"type:jsonb" json:"history"`
Status string `gorm:"type:varchar(20);default:'active'"` Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
// AgentExecutionLog Agent执行日志 // AgentExecutionLog Agent执行日志
type AgentExecutionLog struct { type AgentExecutionLog struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
SessionID string `gorm:"type:varchar(100);index"` SessionID string `gorm:"type:varchar(100);index" json:"session_id"`
AgentID string `gorm:"type:varchar(100);index"` AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
UserID string `gorm:"type:uuid;index"` UserID string `gorm:"type:uuid;index" json:"user_id"`
Input string `gorm:"type:jsonb"` Input string `gorm:"type:jsonb" json:"input"`
Output string `gorm:"type:jsonb"` Output string `gorm:"type:jsonb" json:"output"`
ToolCalls string `gorm:"type:jsonb"` ToolCalls string `gorm:"type:jsonb" json:"tool_calls"`
Iterations int Iterations int `json:"iterations"`
TotalTokens int TotalTokens int `json:"total_tokens"`
DurationMs int DurationMs int `json:"duration_ms"`
FinishReason string `gorm:"type:varchar(50)"` FinishReason string `gorm:"type:varchar(50)" json:"finish_reason"`
Success bool Success bool `json:"success"`
ErrorMessage string `gorm:"type:text"` ErrorMessage string `gorm:"type:text" json:"error_message"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
} }
package model
import "time"
// AgentSkill 技能包定义
type AgentSkill struct {
ID uint `gorm:"primaryKey" json:"id"`
SkillID string `gorm:"type:varchar(100);uniqueIndex" json:"skill_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"` // patient/doctor/admin/general
Tools string `gorm:"type:jsonb;default:'[]'" json:"tools"` // JSON array of tool names
SystemPromptAddon string `gorm:"type:text" json:"system_prompt_addon"` // 追加到agent system prompt
ContextSchema string `gorm:"type:jsonb;default:'{}'" json:"context_schema"` // 技能需要的上下文字段定义
QuickReplies string `gorm:"type:jsonb;default:'[]'" json:"quick_replies"` // 快捷回复模板
Icon string `gorm:"type:varchar(50)" json:"icon"` // antd icon名
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
type Consultation struct { type Consultation struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"` ID string `gorm:"type:uuid;primaryKey" json:"id"`
SerialNumber string `gorm:"type:varchar(20);uniqueIndex;not null" json:"serial_number"` // 就诊流水号,如 C20260303-0001
PatientID string `gorm:"type:uuid;index;not null" json:"patient_id"` PatientID string `gorm:"type:uuid;index;not null" json:"patient_id"`
DoctorID string `gorm:"type:uuid;index;not null" json:"doctor_id"` DoctorID string `gorm:"type:uuid;index;not null" json:"doctor_id"`
Type string `gorm:"type:varchar(20);not null" json:"type"` Type string `gorm:"type:varchar(20);not null" json:"type"`
...@@ -21,6 +22,12 @@ type Consultation struct { ...@@ -21,6 +22,12 @@ type Consultation struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// v13 新增字段
Diagnosis string `gorm:"type:text" json:"diagnosis"` // 诊断结论
Summary string `gorm:"type:text" json:"summary"` // 问诊小结
DurationMinutes int `gorm:"default:0" json:"duration_minutes"` // 问诊时长(分钟)
FollowUpDays int `gorm:"default:0" json:"follow_up_days"` // 随访天数
SatisfactionScore *int `gorm:"default:null" json:"satisfaction_score"` // 患者满意度(1-5)
} }
func (Consultation) TableName() string { func (Consultation) TableName() string {
...@@ -35,6 +42,11 @@ type ConsultMessage struct { ...@@ -35,6 +42,11 @@ type ConsultMessage struct {
Content string `gorm:"type:text;not null" json:"content"` Content string `gorm:"type:text;not null" json:"content"`
ContentType string `gorm:"type:varchar(20);default:'text'" json:"content_type"` ContentType string `gorm:"type:varchar(20);default:'text'" json:"content_type"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// v13 新增字段
ReadAt *time.Time `json:"read_at"` // 已读时间
MediaURL string `gorm:"type:varchar(500)" json:"media_url"` // 媒体文件URL
MediaType string `gorm:"type:varchar(20)" json:"media_type"` // image/audio/file
ReplyToID *string `gorm:"type:uuid" json:"reply_to_id"` // 引用回复的消息ID
} }
func (ConsultMessage) TableName() string { func (ConsultMessage) TableName() string {
......
...@@ -4,22 +4,22 @@ import "time" ...@@ -4,22 +4,22 @@ import "time"
// HTTPToolDefinition 动态 HTTP 工具定义(管理员从 UI 配置,无需改代码) // HTTPToolDefinition 动态 HTTP 工具定义(管理员从 UI 配置,无需改代码)
type HTTPToolDefinition struct { type HTTPToolDefinition struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);uniqueIndex"` // 工具名,如 get_weather Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(200)"` DisplayName string `gorm:"type:varchar(200)" json:"display_name"`
Description string `gorm:"type:text"` Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50);default:'http'"` Category string `gorm:"type:varchar(50);default:'http'" json:"category"`
Method string `gorm:"type:varchar(10);default:'GET'"` // GET/POST/PUT/DELETE Method string `gorm:"type:varchar(10);default:'GET'" json:"method"`
URL string `gorm:"type:text"` // 支持 {{param}} 模板变量 URL string `gorm:"type:text" json:"url"`
Headers string `gorm:"type:jsonb;default:'{}'"` // {"X-Key": "{{api_key}}"} Headers string `gorm:"type:jsonb;default:'{}'" json:"headers"`
BodyTemplate string `gorm:"type:text"` // JSON body 模板,支持 {{param}} BodyTemplate string `gorm:"type:text" json:"body_template"`
AuthType string `gorm:"type:varchar(20);default:'none'"` // none/bearer/basic/apikey AuthType string `gorm:"type:varchar(20);default:'none'" json:"auth_type"`
AuthConfig string `gorm:"type:jsonb;default:'{}'"` // 认证配置 AuthConfig string `gorm:"type:jsonb;default:'{}'" json:"auth_config"`
Parameters string `gorm:"type:jsonb;default:'[]'"` // ToolParameter 数组 JSON Parameters string `gorm:"type:jsonb;default:'[]'" json:"parameters"`
Timeout int `gorm:"default:10"` // 超时秒数 Timeout int `gorm:"default:10" json:"timeout"`
CacheTTL int `gorm:"default:0"` // 缓存 TTL 秒,0=不缓存 CacheTTL int `gorm:"default:0" json:"cache_ttl"`
Status string `gorm:"type:varchar(20);default:'active'"` Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedBy string `gorm:"type:varchar(100)"` CreatedBy string `gorm:"type:varchar(100)" json:"created_by"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
...@@ -4,40 +4,40 @@ import "time" ...@@ -4,40 +4,40 @@ import "time"
// KnowledgeCollection 知识库集合 // KnowledgeCollection 知识库集合
type KnowledgeCollection struct { type KnowledgeCollection struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
CollectionID string `gorm:"type:varchar(100);uniqueIndex"` CollectionID string `gorm:"type:varchar(100);uniqueIndex" json:"collection_id"`
Name string `gorm:"type:varchar(200)"` Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text"` Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)"` Category string `gorm:"type:varchar(50)" json:"category"`
DocumentCount int `gorm:"default:0"` DocumentCount int `gorm:"default:0" json:"document_count"`
Status string `gorm:"type:varchar(20);default:'active'"` Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
// KnowledgeDocument 知识文档 // KnowledgeDocument 知识文档
type KnowledgeDocument struct { type KnowledgeDocument struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
DocumentID string `gorm:"type:varchar(100);uniqueIndex"` DocumentID string `gorm:"type:varchar(100);uniqueIndex" json:"document_id"`
CollectionID string `gorm:"type:varchar(100);index"` CollectionID string `gorm:"type:varchar(100);index" json:"collection_id"`
Title string `gorm:"type:varchar(500)"` Title string `gorm:"type:varchar(500)" json:"title"`
Content string `gorm:"type:text"` Content string `gorm:"type:text" json:"content"`
Metadata string `gorm:"type:jsonb"` Metadata string `gorm:"type:jsonb" json:"metadata"`
FileType string `gorm:"type:varchar(50)"` FileType string `gorm:"type:varchar(50)" json:"file_type"`
ChunkCount int `gorm:"default:0"` ChunkCount int `gorm:"default:0" json:"chunk_count"`
Status string `gorm:"type:varchar(20);default:'ready'"` Status string `gorm:"type:varchar(20);default:'ready'" json:"status"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
// KnowledgeChunk 知识分块 // KnowledgeChunk 知识分块
type KnowledgeChunk struct { type KnowledgeChunk struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
ChunkID string `gorm:"type:varchar(100);uniqueIndex"` ChunkID string `gorm:"type:varchar(100);uniqueIndex" json:"chunk_id"`
DocumentID string `gorm:"type:varchar(100);index"` DocumentID string `gorm:"type:varchar(100);index" json:"document_id"`
CollectionID string `gorm:"type:varchar(100);index"` CollectionID string `gorm:"type:varchar(100);index" json:"collection_id"`
Content string `gorm:"type:text"` Content string `gorm:"type:text" json:"content"`
ChunkIndex int ChunkIndex int `json:"chunk_index"`
TokenCount int TokenCount int `json:"token_count"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
} }
package model
import "time"
// QuickReplyTemplate 快捷回复模板
type QuickReplyTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
DoctorID string `gorm:"type:uuid;index" json:"doctor_id"` // 空=系统级,非空=个人级
Category string `gorm:"type:varchar(50);index" json:"category"` // greeting/inquiry/diagnosis/prescription/closing
Title string `gorm:"type:varchar(100)" json:"title"`
Content string `gorm:"type:text" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
UseCount int `gorm:"default:0" json:"use_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (QuickReplyTemplate) TableName() string {
return "quick_reply_templates"
}
// ConsultTransfer 转诊记录(Phase 6 使用)
type ConsultTransfer struct {
ID uint `gorm:"primaryKey" json:"id"`
ConsultID string `gorm:"type:uuid;index" json:"consult_id"`
FromDoctorID string `gorm:"type:uuid;index" json:"from_doctor_id"`
ToDoctorID string `gorm:"type:uuid;index" json:"to_doctor_id"`
ToDepartmentID string `gorm:"type:uuid" json:"to_department_id"`
Reason string `gorm:"type:text" json:"reason"`
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // pending/accepted/rejected
TransferNote string `gorm:"type:text" json:"transfer_note"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ConsultTransfer) TableName() string {
return "consult_transfers"
}
...@@ -4,49 +4,49 @@ import "time" ...@@ -4,49 +4,49 @@ import "time"
// WorkflowDefinition 工作流定义 // WorkflowDefinition 工作流定义
type WorkflowDefinition struct { type WorkflowDefinition struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
WorkflowID string `gorm:"type:varchar(100);uniqueIndex"` WorkflowID string `gorm:"type:varchar(100);uniqueIndex" json:"workflow_id"`
Name string `gorm:"type:varchar(200)"` Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text"` Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)"` Category string `gorm:"type:varchar(50)" json:"category"`
Version int `gorm:"default:1"` Version int `gorm:"default:1" json:"version"`
Definition string `gorm:"type:jsonb"` Definition string `gorm:"type:jsonb" json:"definition"`
Status string `gorm:"type:varchar(20);default:'draft'"` Status string `gorm:"type:varchar(20);default:'draft'" json:"status"`
CreatedBy string `gorm:"type:uuid"` CreatedBy string `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
// WorkflowExecution 工作流执行实例 // WorkflowExecution 工作流执行实例
type WorkflowExecution struct { type WorkflowExecution struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
ExecutionID string `gorm:"type:varchar(100);uniqueIndex"` ExecutionID string `gorm:"type:varchar(100);uniqueIndex" json:"execution_id"`
WorkflowID string `gorm:"type:varchar(100);index"` WorkflowID string `gorm:"type:varchar(100);index" json:"workflow_id"`
Version int Version int `json:"version"`
TriggerType string `gorm:"type:varchar(50)"` TriggerType string `gorm:"type:varchar(50)" json:"trigger_type"`
TriggerBy string `gorm:"type:uuid"` TriggerBy string `gorm:"type:uuid" json:"trigger_by"`
Input string `gorm:"type:jsonb"` Input string `gorm:"type:jsonb" json:"input"`
Output string `gorm:"type:jsonb"` Output string `gorm:"type:jsonb" json:"output"`
Status string `gorm:"type:varchar(20)"` // pending, running, completed, failed Status string `gorm:"type:varchar(20)" json:"status"` // pending, running, completed, failed
CurrentNode string `gorm:"type:varchar(100)"` CurrentNode string `gorm:"type:varchar(100)" json:"current_node"`
StartedAt *time.Time StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time CompletedAt *time.Time `json:"completed_at"`
ErrorMessage string `gorm:"type:text"` ErrorMessage string `gorm:"type:text" json:"error_message"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
} }
// WorkflowHumanTask 人工审核任务 // WorkflowHumanTask 人工审核任务
type WorkflowHumanTask struct { type WorkflowHumanTask struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey" json:"id"`
TaskID string `gorm:"type:varchar(100);uniqueIndex"` TaskID string `gorm:"type:varchar(100);uniqueIndex" json:"task_id"`
ExecutionID string `gorm:"type:varchar(100);index"` ExecutionID string `gorm:"type:varchar(100);index" json:"execution_id"`
NodeID string `gorm:"type:varchar(100)"` NodeID string `gorm:"type:varchar(100)" json:"node_id"`
AssigneeRole string `gorm:"type:varchar(50)"` AssigneeRole string `gorm:"type:varchar(50)" json:"assignee_role"`
Title string `gorm:"type:varchar(200)"` Title string `gorm:"type:varchar(200)" json:"title"`
Description string `gorm:"type:text"` Description string `gorm:"type:text" json:"description"`
FormData string `gorm:"type:jsonb"` FormData string `gorm:"type:jsonb" json:"form_data"`
Result string `gorm:"type:jsonb"` Result string `gorm:"type:jsonb" json:"result"`
Status string `gorm:"type:varchar(20);default:'pending'"` Status string `gorm:"type:varchar(20);default:'pending'" json:"status"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time CompletedAt *time.Time `json:"completed_at"`
} }
...@@ -130,12 +130,12 @@ func (s *Service) GetAIRenewalAdvice(ctx context.Context, userID, renewalID stri ...@@ -130,12 +130,12 @@ func (s *Service) GetAIRenewalAdvice(ctx context.Context, userID, renewalID stri
msg := fmt.Sprintf("患者%s申请续药%d个月,当前用药:%s,原因:%s。请评估续药合理性并给出专业建议。", msg := fmt.Sprintf("患者%s申请续药%d个月,当前用药:%s,原因:%s。请评估续药合理性并给出专业建议。",
r.DiseaseName, durationMonths, r.Medicines, r.Reason) r.DiseaseName, durationMonths, r.Medicines, r.Reason)
output, err := internalagent.GetService().Chat(ctx, "follow_up_agent", userID, "", msg, agentCtx) output, err := internalagent.GetService().Chat(ctx, "patient_universal_agent", userID, "", msg, agentCtx)
if err != nil { if err != nil {
return "", fmt.Errorf("AI续药建议获取失败: %w", err) return "", fmt.Errorf("AI续药建议获取失败: %w", err)
} }
if output == nil { if output == nil {
return "", fmt.Errorf("follow_up_agent 未初始化") return "", fmt.Errorf("patient_universal_agent 未初始化")
} }
advice := output.Response advice := output.Response
......
...@@ -2,8 +2,12 @@ package consult ...@@ -2,8 +2,12 @@ package consult
import ( import (
"fmt" "fmt"
"io"
"os"
"path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"internet-hospital/pkg/middleware" "internet-hospital/pkg/middleware"
"internet-hospital/pkg/response" "internet-hospital/pkg/response"
...@@ -29,6 +33,14 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -29,6 +33,14 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.GET("/doctor/waiting", h.GetWaitingList) consult.GET("/doctor/waiting", h.GetWaitingList)
consult.GET("/doctor/patients", h.GetPatientList) consult.GET("/doctor/patients", h.GetPatientList)
consult.GET("/doctor/workbench-stats", h.GetDoctorWorkbenchStats) consult.GET("/doctor/workbench-stats", h.GetDoctorWorkbenchStats)
consult.GET("/doctor/patient-profile/:patient_id", h.GetPatientProfile)
// v13: 转诊
consult.POST("/:id/transfer", h.TransferConsult)
consult.GET("/doctor/transfer-inbox", h.GetTransferInbox)
consult.POST("/transfer/:transfer_id/accept", h.AcceptTransfer)
consult.POST("/transfer/:transfer_id/reject", h.RejectTransfer)
// v13: 处方状态追踪
consult.GET("/prescription/:id/status", h.GetPrescriptionStatus)
consult.POST("/:id/accept", h.AcceptConsult) consult.POST("/:id/accept", h.AcceptConsult)
consult.POST("/:id/reject", h.RejectConsult) consult.POST("/:id/reject", h.RejectConsult)
consult.GET("/:id", h.GetConsultDetail) consult.GET("/:id", h.GetConsultDetail)
...@@ -38,6 +50,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -38,6 +50,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.POST("/:id/end", h.EndConsult) consult.POST("/:id/end", h.EndConsult)
consult.POST("/:id/cancel", h.CancelConsult) consult.POST("/:id/cancel", h.CancelConsult)
consult.POST("/:id/ai-assist", h.AIAssist) consult.POST("/:id/ai-assist", h.AIAssist)
consult.POST("/:id/upload", h.UploadMedia)
// 患者端处方 // 患者端处方
consult.GET("/patient/prescriptions", h.GetPatientPrescriptions) consult.GET("/patient/prescriptions", h.GetPatientPrescriptions)
...@@ -77,7 +90,11 @@ func (h *Handler) GetConsultList(c *gin.Context) { ...@@ -77,7 +90,11 @@ func (h *Handler) GetConsultList(c *gin.Context) {
} }
func (h *Handler) GetConsultDetail(c *gin.Context) { func (h *Handler) GetConsultDetail(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
result, err := h.service.GetConsultByID(c.Request.Context(), id) result, err := h.service.GetConsultByID(c.Request.Context(), id)
if err != nil { if err != nil {
...@@ -113,7 +130,11 @@ func (h *Handler) GetDoctorWorkbenchStats(c *gin.Context) { ...@@ -113,7 +130,11 @@ func (h *Handler) GetDoctorWorkbenchStats(c *gin.Context) {
} }
func (h *Handler) GetConsultMessages(c *gin.Context) { func (h *Handler) GetConsultMessages(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
messages, err := h.service.GetConsultMessages(c.Request.Context(), id) messages, err := h.service.GetConsultMessages(c.Request.Context(), id)
if err != nil { if err != nil {
...@@ -130,7 +151,11 @@ type SendMessageRequest struct { ...@@ -130,7 +151,11 @@ type SendMessageRequest struct {
} }
func (h *Handler) SendMessage(c *gin.Context) { func (h *Handler) SendMessage(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
userRole, _ := c.Get("role") userRole, _ := c.Get("role")
...@@ -161,7 +186,11 @@ func (h *Handler) SendMessage(c *gin.Context) { ...@@ -161,7 +186,11 @@ func (h *Handler) SendMessage(c *gin.Context) {
} }
func (h *Handler) GetVideoRoomInfo(c *gin.Context) { func (h *Handler) GetVideoRoomInfo(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
roomInfo, err := h.service.GetVideoRoomInfo(c.Request.Context(), id) roomInfo, err := h.service.GetVideoRoomInfo(c.Request.Context(), id)
if err != nil { if err != nil {
...@@ -173,9 +202,17 @@ func (h *Handler) GetVideoRoomInfo(c *gin.Context) { ...@@ -173,9 +202,17 @@ func (h *Handler) GetVideoRoomInfo(c *gin.Context) {
} }
func (h *Handler) EndConsult(c *gin.Context) { func (h *Handler) EndConsult(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
if err := h.service.EndConsult(c.Request.Context(), id); err != nil { var req EndConsultRequest
// 允许空body(向后兼容简单结束)
c.ShouldBindJSON(&req)
if err := h.service.EndConsult(c.Request.Context(), id, &req); err != nil {
response.Error(c, 400, err.Error()) response.Error(c, 400, err.Error())
return return
} }
...@@ -188,7 +225,11 @@ type CancelRequest struct { ...@@ -188,7 +225,11 @@ type CancelRequest struct {
} }
func (h *Handler) CancelConsult(c *gin.Context) { func (h *Handler) CancelConsult(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
if err := h.service.CancelConsult(c.Request.Context(), id); err != nil { if err := h.service.CancelConsult(c.Request.Context(), id); err != nil {
response.Error(c, 500, "取消问诊失败") response.Error(c, 500, "取消问诊失败")
...@@ -200,7 +241,11 @@ func (h *Handler) CancelConsult(c *gin.Context) { ...@@ -200,7 +241,11 @@ func (h *Handler) CancelConsult(c *gin.Context) {
// AIAssist AI辅助诊断(SSE流式返回) // AIAssist AI辅助诊断(SSE流式返回)
func (h *Handler) AIAssist(c *gin.Context) { func (h *Handler) AIAssist(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct { var req struct {
Scene string `json:"scene" binding:"required"` // consult_diagnosis | consult_medication Scene string `json:"scene" binding:"required"` // consult_diagnosis | consult_medication
} }
...@@ -249,6 +294,177 @@ func (h *Handler) GetPrescriptionDetail(c *gin.Context) { ...@@ -249,6 +294,177 @@ func (h *Handler) GetPrescriptionDetail(c *gin.Context) {
response.Success(c, result) response.Success(c, result)
} }
// TransferConsult 发起转诊
func (h *Handler) TransferConsult(c *gin.Context) {
consultID, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
userID, _ := c.Get("user_id")
var req TransferRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
if err := h.service.TransferConsult(c.Request.Context(), consultID, userID.(string), &req); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// GetTransferInbox 获取转诊收件箱
func (h *Handler) GetTransferInbox(c *gin.Context) {
userID, _ := c.Get("user_id")
list, err := h.service.GetTransferInbox(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取转诊收件箱失败")
return
}
response.Success(c, list)
}
// AcceptTransfer 接受转诊
func (h *Handler) AcceptTransfer(c *gin.Context) {
transferID := c.Param("transfer_id")
userID, _ := c.Get("user_id")
var id uint
fmt.Sscanf(transferID, "%d", &id)
if err := h.service.AcceptTransfer(c.Request.Context(), id, userID.(string)); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// RejectTransfer 拒绝转诊
func (h *Handler) RejectTransfer(c *gin.Context) {
transferID := c.Param("transfer_id")
var id uint
fmt.Sscanf(transferID, "%d", &id)
var req struct {
Reason string `json:"reason"`
}
c.ShouldBindJSON(&req)
if err := h.service.RejectTransfer(c.Request.Context(), id, req.Reason); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// GetPrescriptionStatus 获取处方状态
func (h *Handler) GetPrescriptionStatus(c *gin.Context) {
id := c.Param("id")
result, err := h.service.GetPrescriptionStatus(c.Request.Context(), id)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, result)
}
// UploadMedia 上传多媒体文件(图片/PDF/音频)
func (h *Handler) UploadMedia(c *gin.Context) {
consultID, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "请上传文件")
return
}
defer file.Close()
// 检测文件类型
contentType := header.Header.Get("Content-Type")
var mediaType string
switch {
case contentType == "image/jpeg" || contentType == "image/png" || contentType == "image/gif":
mediaType = "image"
if header.Size > 5*1024*1024 {
response.BadRequest(c, "图片大小不能超过5MB")
return
}
case contentType == "application/pdf":
mediaType = "file"
if header.Size > 20*1024*1024 {
response.BadRequest(c, "PDF大小不能超过20MB")
return
}
case contentType == "audio/wav" || contentType == "audio/mpeg" || contentType == "audio/mp3":
mediaType = "audio"
if header.Size > 2*1024*1024 {
response.BadRequest(c, "音频大小不能超过2MB")
return
}
default:
response.BadRequest(c, "不支持的文件类型: "+contentType)
return
}
// 确保目录存在
uploadDir := fmt.Sprintf("uploads/consult/%s", consultID)
if err := os.MkdirAll(uploadDir, 0755); err != nil {
response.Error(c, 500, "创建上传目录失败")
return
}
// 生成文件名
ext := filepath.Ext(header.Filename)
if ext == "" {
switch mediaType {
case "image":
ext = ".jpg"
case "file":
ext = ".pdf"
case "audio":
ext = ".mp3"
}
}
fileName := fmt.Sprintf("%s%s", uuid.New().String()[:8], ext)
filePath := filepath.Join(uploadDir, fileName)
// 保存文件
out, err := os.Create(filePath)
if err != nil {
response.Error(c, 500, "保存文件失败")
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
response.Error(c, 500, "写入文件失败")
return
}
mediaURL := fmt.Sprintf("/api/v1/%s", filePath)
response.Success(c, map[string]interface{}{
"media_url": mediaURL,
"media_type": mediaType,
"file_name": header.Filename,
})
}
// GetPatientProfile 获取患者完整画像
func (h *Handler) GetPatientProfile(c *gin.Context) {
patientID := c.Param("patient_id")
if patientID == "" {
response.BadRequest(c, "patient_id 必填")
return
}
profile, err := h.service.GetPatientProfile(c.Request.Context(), patientID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, profile)
}
// ========== 医生端 API ========== // ========== 医生端 API ==========
// GetDoctorConsultList 医生端获取自己的问诊列表 // GetDoctorConsultList 医生端获取自己的问诊列表
...@@ -280,7 +496,11 @@ func (h *Handler) GetWaitingList(c *gin.Context) { ...@@ -280,7 +496,11 @@ func (h *Handler) GetWaitingList(c *gin.Context) {
// AcceptConsult 医生接诊 // AcceptConsult 医生接诊
func (h *Handler) AcceptConsult(c *gin.Context) { func (h *Handler) AcceptConsult(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
if err := h.service.AcceptConsult(c.Request.Context(), id, userID.(string)); err != nil { if err := h.service.AcceptConsult(c.Request.Context(), id, userID.(string)); err != nil {
...@@ -293,7 +513,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) { ...@@ -293,7 +513,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) {
// RejectConsult 医生拒诊 // RejectConsult 医生拒诊
func (h *Handler) RejectConsult(c *gin.Context) { func (h *Handler) RejectConsult(c *gin.Context) {
id := c.Param("id") id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct { var req struct {
Reason string `json:"reason"` Reason string `json:"reason"`
......
This diff is collapsed.
package consult
import (
"context"
"errors"
"fmt"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"github.com/google/uuid"
)
// TransferRequest 转诊请求
type TransferRequest struct {
ToDoctorID string `json:"to_doctor_id" binding:"required"`
ToDepartmentID string `json:"to_department_id"`
Reason string `json:"reason" binding:"required"`
TransferNote string `json:"transfer_note"`
}
// TransferConsult 发起转诊
func (s *Service) TransferConsult(ctx context.Context, consultID string, doctorUserID string, req *TransferRequest) error {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在")
}
if consult.Status != "in_progress" {
return fmt.Errorf("当前问诊状态为 %s,无法转诊", consult.Status)
}
// 获取当前医生 ID
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return errors.New("医生信息不存在")
}
transfer := &model.ConsultTransfer{
ConsultID: consultID,
FromDoctorID: doctor.ID,
ToDoctorID: req.ToDoctorID,
ToDepartmentID: req.ToDepartmentID,
Reason: req.Reason,
TransferNote: req.TransferNote,
Status: "pending",
}
if err := s.db.Create(transfer).Error; err != nil {
return fmt.Errorf("创建转诊记录失败: %w", err)
}
// 发送系统消息通知患者
var toDoctor model.Doctor
s.db.Where("id = ?", req.ToDoctorID).First(&toDoctor)
s.SendMessage(ctx, consultID, "", "system",
fmt.Sprintf("医生已为您发起转诊,目标医生:%s,原因:%s", toDoctor.Name, req.Reason), "text")
return nil
}
// TransferInboxItem 转诊收件箱项
type TransferInboxItem struct {
ID uint `json:"id"`
ConsultID string `json:"consult_id"`
FromDoctorName string `json:"from_doctor_name"`
PatientName string `json:"patient_name"`
ChiefComplaint string `json:"chief_complaint"`
Reason string `json:"reason"`
TransferNote string `json:"transfer_note"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// GetTransferInbox 获取转诊收件箱
func (s *Service) GetTransferInbox(ctx context.Context, doctorUserID string) ([]TransferInboxItem, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []TransferInboxItem{}, nil
}
var transfers []model.ConsultTransfer
if err := s.db.Where("to_doctor_id = ? AND status = ?", doctor.ID, "pending").
Order("created_at DESC").Find(&transfers).Error; err != nil {
return nil, err
}
var results []TransferInboxItem
for _, t := range transfers {
var fromDoc model.Doctor
s.db.Where("id = ?", t.FromDoctorID).First(&fromDoc)
var consult model.Consultation
s.db.Where("id = ?", t.ConsultID).First(&consult)
var patient model.User
s.db.Where("id = ?", consult.PatientID).First(&patient)
results = append(results, TransferInboxItem{
ID: t.ID,
ConsultID: t.ConsultID,
FromDoctorName: fromDoc.Name,
PatientName: patient.RealName,
ChiefComplaint: consult.ChiefComplaint,
Reason: t.Reason,
TransferNote: t.TransferNote,
Status: t.Status,
CreatedAt: t.CreatedAt,
})
}
return results, nil
}
// AcceptTransfer 接受转诊
func (s *Service) AcceptTransfer(ctx context.Context, transferID uint, doctorUserID string) error {
db := database.GetDB()
var transfer model.ConsultTransfer
if err := db.Where("id = ?", transferID).First(&transfer).Error; err != nil {
return errors.New("转诊记录不存在")
}
if transfer.Status != "pending" {
return errors.New("该转诊已处理")
}
// 更新转诊状态
db.Model(&transfer).Update("status", "accepted")
// 结束原问诊
now := time.Now()
db.Model(&model.Consultation{}).Where("id = ?", transfer.ConsultID).Updates(map[string]interface{}{
"status": "completed",
"ended_at": now,
})
// 获取原问诊信息
var origConsult model.Consultation
db.Where("id = ?", transfer.ConsultID).First(&origConsult)
// 创建新问诊
newConsult := &model.Consultation{
ID: uuid.New().String(),
PatientID: origConsult.PatientID,
DoctorID: transfer.ToDoctorID,
Type: origConsult.Type,
Status: "pending",
ChiefComplaint: origConsult.ChiefComplaint,
MedicalHistory: origConsult.MedicalHistory + "\n[转诊备注] " + transfer.TransferNote,
}
db.Create(newConsult)
// 发送系统消息
s.SendMessage(ctx, transfer.ConsultID, "", "system", "转诊已被接受,正在为您转接新医生", "text")
return nil
}
// RejectTransfer 拒绝转诊
func (s *Service) RejectTransfer(ctx context.Context, transferID uint, reason string) error {
db := database.GetDB()
var transfer model.ConsultTransfer
if err := db.Where("id = ?", transferID).First(&transfer).Error; err != nil {
return errors.New("转诊记录不存在")
}
if transfer.Status != "pending" {
return errors.New("该转诊已处理")
}
db.Model(&transfer).Update("status", "rejected")
s.SendMessage(ctx, transfer.ConsultID, "", "system",
fmt.Sprintf("转诊被拒绝,原因:%s", reason), "text")
return nil
}
// GetPrescriptionStatus 获取处方状态
func (s *Service) GetPrescriptionStatus(ctx context.Context, prescriptionID string) (map[string]interface{}, error) {
var prescription model.Prescription
if err := s.db.Where("id = ?", prescriptionID).First(&prescription).Error; err != nil {
return nil, errors.New("处方不存在")
}
timeline := []map[string]interface{}{
{"status": "signed", "label": "已签名", "time": prescription.SignedAt},
{"status": "approved", "label": "审核通过", "time": prescription.ReviewedAt},
}
return map[string]interface{}{
"id": prescription.ID,
"prescription_no": prescription.PrescriptionNo,
"status": prescription.Status,
"total_amount": prescription.TotalAmount,
"item_count": 0, // will be counted
"timeline": timeline,
}, nil
}
...@@ -2,14 +2,28 @@ package doctorportal ...@@ -2,14 +2,28 @@ package doctorportal
import ( import (
"fmt" "fmt"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response" "internet-hospital/pkg/response"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
// resolveConsultID 解析问诊标识:支持 UUID 或流水号(C开头)
func resolveConsultID(idOrSerial string) (string, error) {
if strings.HasPrefix(idOrSerial, "C") {
var consult model.Consultation
if err := database.GetDB().Where("serial_number = ?", idOrSerial).Select("id").First(&consult).Error; err != nil {
return "", fmt.Errorf("流水号 %s 对应的问诊不存在", idOrSerial)
}
return consult.ID, nil
}
return idOrSerial, nil
}
// Handler 医生端API处理器 // Handler 医生端API处理器
type Handler struct { type Handler struct {
service *Service service *Service
...@@ -63,6 +77,9 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -63,6 +77,9 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
dp.POST("/prescription/check", h.CheckPrescriptionSafety) dp.POST("/prescription/check", h.CheckPrescriptionSafety)
dp.GET("/prescriptions", h.GetDoctorPrescriptions) dp.GET("/prescriptions", h.GetDoctorPrescriptions)
dp.GET("/prescription/:id", h.GetPrescriptionDetail) dp.GET("/prescription/:id", h.GetPrescriptionDetail)
// v13: 快捷回复
h.RegisterQuickReplyRoutes(dp)
} }
} }
...@@ -134,7 +151,11 @@ func (h *Handler) GetWaitingQueue(c *gin.Context) { ...@@ -134,7 +151,11 @@ func (h *Handler) GetWaitingQueue(c *gin.Context) {
// AcceptConsult 接受问诊 // AcceptConsult 接受问诊
func (h *Handler) AcceptConsult(c *gin.Context) { func (h *Handler) AcceptConsult(c *gin.Context) {
id := c.Param("id") id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
result, err := h.service.AcceptConsult(c.Request.Context(), id) result, err := h.service.AcceptConsult(c.Request.Context(), id)
if err != nil { if err != nil {
response.Error(c, 500, "接诊失败") response.Error(c, 500, "接诊失败")
...@@ -145,7 +166,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) { ...@@ -145,7 +166,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) {
// GetConsultDetail 获取问诊详情 // GetConsultDetail 获取问诊详情
func (h *Handler) GetConsultDetail(c *gin.Context) { func (h *Handler) GetConsultDetail(c *gin.Context) {
id := c.Param("id") id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
detail, err := h.service.GetConsultDetail(c.Request.Context(), id) detail, err := h.service.GetConsultDetail(c.Request.Context(), id)
if err != nil { if err != nil {
response.Error(c, 404, "问诊不存在") response.Error(c, 404, "问诊不存在")
...@@ -156,7 +181,11 @@ func (h *Handler) GetConsultDetail(c *gin.Context) { ...@@ -156,7 +181,11 @@ func (h *Handler) GetConsultDetail(c *gin.Context) {
// GetConsultMessages 获取问诊消息 // GetConsultMessages 获取问诊消息
func (h *Handler) GetConsultMessages(c *gin.Context) { func (h *Handler) GetConsultMessages(c *gin.Context) {
id := c.Param("id") id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
messages, err := h.service.GetConsultMessages(c.Request.Context(), id) messages, err := h.service.GetConsultMessages(c.Request.Context(), id)
if err != nil { if err != nil {
response.Error(c, 500, "获取消息失败") response.Error(c, 500, "获取消息失败")
...@@ -167,7 +196,11 @@ func (h *Handler) GetConsultMessages(c *gin.Context) { ...@@ -167,7 +196,11 @@ func (h *Handler) GetConsultMessages(c *gin.Context) {
// SendMessage 发送消息 // SendMessage 发送消息
func (h *Handler) SendMessage(c *gin.Context) { func (h *Handler) SendMessage(c *gin.Context) {
id := c.Param("id") id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct { var req struct {
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
...@@ -186,7 +219,11 @@ func (h *Handler) SendMessage(c *gin.Context) { ...@@ -186,7 +219,11 @@ func (h *Handler) SendMessage(c *gin.Context) {
// EndConsult 结束问诊 // EndConsult 结束问诊
func (h *Handler) EndConsult(c *gin.Context) { func (h *Handler) EndConsult(c *gin.Context) {
id := c.Param("id") id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct { var req struct {
Summary string `json:"summary"` Summary string `json:"summary"`
} }
......
...@@ -17,6 +17,7 @@ import ( ...@@ -17,6 +17,7 @@ import (
type CreatePrescriptionReq struct { type CreatePrescriptionReq struct {
ConsultID string `json:"consult_id"` ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id" binding:"required"` PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name"` PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"` PatientGender string `json:"patient_gender"`
...@@ -55,6 +56,15 @@ type PrescriptionListResp struct { ...@@ -55,6 +56,15 @@ type PrescriptionListResp struct {
// ==================== 处方开具服务 ==================== // ==================== 处方开具服务 ====================
func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *CreatePrescriptionReq) (*model.Prescription, error) { func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *CreatePrescriptionReq) (*model.Prescription, error) {
// 解析 serial_number → consult_id
if req.ConsultID == "" && req.SerialNumber != "" {
resolved, err := resolveConsultID(req.SerialNumber)
if err != nil {
return nil, err
}
req.ConsultID = resolved
}
// 获取医生信息 // 获取医生信息
var doctor model.Doctor var doctor model.Doctor
if err := s.db.First(&doctor, "user_id = ?", doctorID).Error; err != nil { if err := s.db.First(&doctor, "user_id = ?", doctorID).Error; err != nil {
...@@ -200,7 +210,7 @@ func (s *Service) GetPrescriptionByID(ctx context.Context, id string) (*model.Pr ...@@ -200,7 +210,7 @@ func (s *Service) GetPrescriptionByID(ctx context.Context, id string) (*model.Pr
return &prescription, nil return &prescription, nil
} }
// CheckPrescriptionSafety 通过 prescription_agent 审核处方安全性 // CheckPrescriptionSafety 通过 doctor_universal_agent 审核处方安全性
func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID string, drugs []string) (map[string]interface{}, error) { func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID string, drugs []string) (map[string]interface{}, error) {
drugList := strings.Join(drugs, "、") drugList := strings.Join(drugs, "、")
agentCtx := map[string]interface{}{ agentCtx := map[string]interface{}{
...@@ -210,12 +220,12 @@ func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID ...@@ -210,12 +220,12 @@ func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID
message := fmt.Sprintf("请审核以下处方的安全性:%s,检查药物相互作用和禁忌症", drugList) message := fmt.Sprintf("请审核以下处方的安全性:%s,检查药物相互作用和禁忌症", drugList)
agentSvc := internalagent.GetService() agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(ctx, "prescription_agent", userID, "", message, agentCtx) output, err := agentSvc.Chat(ctx, "doctor_universal_agent", userID, "", message, agentCtx)
if err != nil { if err != nil {
return nil, fmt.Errorf("处方安全审核失败: %w", err) return nil, fmt.Errorf("处方安全审核失败: %w", err)
} }
if output == nil { if output == nil {
return nil, fmt.Errorf("prescription_agent 未初始化") return nil, fmt.Errorf("doctor_universal_agent 未初始化")
} }
// 判断是否有警告(简单关键词检测) // 判断是否有警告(简单关键词检测)
......
package doctorportal
import (
"fmt"
"github.com/gin-gonic/gin"
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// RegisterQuickReplyRoutes 注册快捷回复路由
func (h *Handler) RegisterQuickReplyRoutes(rg *gin.RouterGroup) {
qr := rg.Group("/quick-replies")
{
qr.GET("", h.ListQuickReplies)
qr.POST("", h.CreateQuickReply)
qr.PUT("/:id", h.UpdateQuickReply)
qr.DELETE("/:id", h.DeleteQuickReply)
qr.POST("/ai-suggest", h.AIGenerateReplies)
qr.POST("/:id/use", h.IncrementUseCount)
}
}
// ListQuickReplies 获取快捷回复列表(系统级 + 个人级)
func (h *Handler) ListQuickReplies(c *gin.Context) {
userID, _ := c.Get("user_id")
category := c.Query("category")
db := database.GetDB()
var templates []model.QuickReplyTemplate
query := db.Where("doctor_id = '' OR doctor_id = ?", userID.(string))
if category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("sort_order ASC, use_count DESC").Find(&templates).Error; err != nil {
response.Error(c, 500, "获取快捷回复失败")
return
}
response.Success(c, templates)
}
// CreateQuickReply 创建快捷回复模板
func (h *Handler) CreateQuickReply(c *gin.Context) {
userID, _ := c.Get("user_id")
var req struct {
Category string `json:"category" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
db := database.GetDB()
template := model.QuickReplyTemplate{
DoctorID: userID.(string),
Category: req.Category,
Title: req.Title,
Content: req.Content,
SortOrder: req.SortOrder,
}
if err := db.Create(&template).Error; err != nil {
response.Error(c, 500, "创建快捷回复失败")
return
}
response.Success(c, template)
}
// UpdateQuickReply 更新快捷回复模板
func (h *Handler) UpdateQuickReply(c *gin.Context) {
userID, _ := c.Get("user_id")
id := c.Param("id")
db := database.GetDB()
var template model.QuickReplyTemplate
if err := db.Where("id = ? AND doctor_id = ?", id, userID.(string)).First(&template).Error; err != nil {
response.Error(c, 404, "快捷回复不存在或无权修改")
return
}
var req struct {
Category *string `json:"category"`
Title *string `json:"title"`
Content *string `json:"content"`
SortOrder *int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
updates := map[string]interface{}{}
if req.Category != nil {
updates["category"] = *req.Category
}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Content != nil {
updates["content"] = *req.Content
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if err := db.Model(&template).Updates(updates).Error; err != nil {
response.Error(c, 500, "更新快捷回复失败")
return
}
response.Success(c, template)
}
// DeleteQuickReply 删除快捷回复模板
func (h *Handler) DeleteQuickReply(c *gin.Context) {
userID, _ := c.Get("user_id")
id := c.Param("id")
db := database.GetDB()
result := db.Where("id = ? AND doctor_id = ?", id, userID.(string)).Delete(&model.QuickReplyTemplate{})
if result.RowsAffected == 0 {
response.Error(c, 404, "快捷回复不存在或无权删除")
return
}
response.Success(c, nil)
}
// IncrementUseCount 增加使用次数
func (h *Handler) IncrementUseCount(c *gin.Context) {
id := c.Param("id")
db := database.GetDB()
db.Model(&model.QuickReplyTemplate{}).Where("id = ?", id).UpdateColumn("use_count", db.Raw("use_count + 1"))
response.Success(c, nil)
}
// AIGenerateReplies AI 推荐快捷回复
func (h *Handler) AIGenerateReplies(c *gin.Context) {
userID, _ := c.Get("user_id")
var req struct {
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "consult_id 或 serial_number 必填")
return
}
// 支持 serial_number 作为替代参数
consultID := req.ConsultID
if consultID == "" && req.SerialNumber != "" {
consultID = req.SerialNumber
}
if consultID == "" {
response.BadRequest(c, "consult_id 或 serial_number 必填")
return
}
resolved, err := resolveConsultID(consultID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
db := database.GetDB()
// 获取最近几条消息作为上下文
var messages []model.ConsultMessage
db.Where("consult_id = ?", resolved).Order("created_at DESC").Limit(10).Find(&messages)
chatContext := ""
for i := len(messages) - 1; i >= 0; i-- {
role := "患者"
if messages[i].SenderType == "doctor" {
role = "医生"
}
chatContext += fmt.Sprintf("%s: %s\n", role, messages[i].Content)
}
// 获取问诊主诉
var consult model.Consultation
db.Where("id = ?", resolved).First(&consult)
agentCtx := map[string]interface{}{
"chat_context": chatContext,
"chief_complaint": consult.ChiefComplaint,
}
agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(
c.Request.Context(),
"doctor_universal_agent",
userID.(string),
"",
"请根据以上对话上下文,生成3-5条医生可以使用的快捷回复建议。每条回复独立一行,不要编号,直接输出回复内容。回复应该专业、简洁、有针对性。",
agentCtx,
)
if err != nil {
response.Error(c, 500, "AI生成回复失败: "+err.Error())
return
}
if output == nil {
response.Error(c, 500, "AI agent不可用")
return
}
response.Success(c, map[string]interface{}{
"suggestions": output.Response,
})
}
...@@ -5,10 +5,27 @@ import ( ...@@ -5,10 +5,27 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"internet-hospital/pkg/ai" "internet-hospital/pkg/ai"
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
// StreamEventType SSE 事件类型
type StreamEventType string
const (
StreamEventThinking StreamEventType = "thinking"
StreamEventToolCall StreamEventType = "tool_call"
StreamEventToolResult StreamEventType = "tool_result"
StreamEventChunk StreamEventType = "chunk"
)
// StreamEvent 流式事件
type StreamEvent struct {
Type StreamEventType `json:"type"`
Data map[string]interface{} `json:"data"`
}
// AgentInput Agent输入 // AgentInput Agent输入
type AgentInput struct { type AgentInput struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
...@@ -183,26 +200,167 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e ...@@ -183,26 +200,167 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
}, nil }, nil
} }
// RunStream 流式执行Agent // RunStream 流式执行Agent,通过 onEvent 实时推送中间过程
func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onChunk func(AgentChunk) error) (*AgentOutput, error) { func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent func(StreamEvent) error) (*AgentOutput, error) {
// 简化:先用非流式,后续可升级为真正的流式 traceID := input.TraceID
output, err := a.Run(ctx, input) if traceID == "" {
traceID = uuid.New().String()
}
client := ai.GetClient()
if client == nil {
msg := "AI服务未配置,请在管理端配置API Key"
emitChunks(ctx, msg, onEvent)
return &AgentOutput{
Response: msg,
FinishReason: "no_client",
TraceID: traceID,
}, nil
}
maxIter := a.cfg.MaxIterations
if input.MaxIterations > 0 {
maxIter = input.MaxIterations
}
if maxIter <= 0 {
maxIter = 10
}
messages := []ai.ChatMessage{
{Role: "system", Content: a.buildSystemPrompt(input.Context)},
}
messages = append(messages, input.History...)
messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message})
tools := a.getToolSchemas()
var toolCallResults []ToolCallResult
totalTokens := 0
for i := 0; i < maxIter; i++ {
// 发射 thinking 事件
onEvent(StreamEvent{
Type: StreamEventThinking,
Data: map[string]interface{}{"iteration": i + 1, "status": "calling_llm"},
})
resp, err := client.ChatWithTools(ctx, messages, tools)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("AI调用失败: %w", err)
} }
// 模拟流式输出 totalTokens += resp.Usage.TotalTokens
for _, tc := range output.ToolCalls {
if err := onChunk(AgentChunk{Type: "tool_call", Content: tc}); err != nil { ai.SaveLog(ai.LogParams{
return output, err 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 {
emitChunks(ctx, choice.Message.Content, onEvent)
return &AgentOutput{
Response: choice.Message.Content,
ToolCalls: toolCallResults,
Iterations: i + 1,
FinishReason: "completed",
TotalTokens: totalTokens,
TraceID: traceID,
}, nil
}
// 执行工具调用
assistantMsg := ai.ChatMessage{
Role: "assistant",
Content: choice.Message.Content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
// 发射 tool_call 事件
onEvent(StreamEvent{
Type: StreamEventToolCall,
Data: map[string]interface{}{
"tool_name": tc.Function.Name,
"call_id": tc.ID,
"arguments": tc.Function.Arguments,
"iteration": i + 1,
},
})
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,
CallID: tc.ID,
Arguments: tc.Function.Arguments,
Result: result,
Success: result.Success,
})
// 发射 tool_result 事件
onEvent(StreamEvent{
Type: StreamEventToolResult,
Data: map[string]interface{}{
"tool_name": tc.Function.Name,
"call_id": tc.ID,
"success": result.Success,
},
})
messages = append(messages, ai.ChatMessage{
Role: "tool",
ToolCallID: tc.ID,
Content: string(resultJSON),
})
} }
} }
if err := onChunk(AgentChunk{Type: "text", Content: output.Response}); err != nil {
return output, err msg := "已达到最大迭代次数"
emitChunks(ctx, msg, onEvent)
return &AgentOutput{
Response: msg,
ToolCalls: toolCallResults,
Iterations: maxIter,
FinishReason: "max_iterations",
TotalTokens: totalTokens,
TraceID: traceID,
}, nil
}
// emitChunks 将文本按 rune 分块发射 chunk 事件(模拟打字效果)
func emitChunks(ctx context.Context, text string, onEvent func(StreamEvent) error) {
runes := []rune(text)
chunkSize := 3
for i := 0; i < len(runes); i += chunkSize {
select {
case <-ctx.Done():
return
default:
}
end := i + chunkSize
if end > len(runes) {
end = len(runes)
} }
if err := onChunk(AgentChunk{Type: "done", Content: output.FinishReason}); err != nil { onEvent(StreamEvent{
return output, err Type: StreamEventChunk,
Data: map[string]interface{}{"content": string(runes[i:end])},
})
time.Sleep(15 * time.Millisecond)
} }
return output, nil
} }
func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string { func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string {
......
...@@ -26,7 +26,7 @@ func (t *AgentCallerTool) Parameters() []agent.ToolParameter { ...@@ -26,7 +26,7 @@ func (t *AgentCallerTool) Parameters() []agent.ToolParameter {
{ {
Name: "agent_id", Name: "agent_id",
Type: "string", Type: "string",
Description: "目标 Agent ID,如 diagnosis_agent、prescription_agent、follow_up_agent", Description: "目标 Agent ID,如 patient_universal_agent、doctor_universal_agent、admin_universal_agent",
Required: true, Required: true,
}, },
{ {
......
...@@ -2,13 +2,18 @@ package websocket ...@@ -2,13 +2,18 @@ package websocket
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
...@@ -22,10 +27,14 @@ var upgrader = websocket.Upgrader{ ...@@ -22,10 +27,14 @@ var upgrader = websocket.Upgrader{
// MessageHandler 消息处理回调 // MessageHandler 消息处理回调
type MessageHandler func(consultID, senderID, senderType, content, contentType string) (*Message, error) type MessageHandler func(consultID, senderID, senderType, content, contentType string) (*Message, error)
// ReadReceiptHandler 已读回执处理回调
type ReadReceiptHandler func(messageID string) error
// Handler WebSocket处理器 // Handler WebSocket处理器
type Handler struct { type Handler struct {
hub *Hub hub *Hub
messageHandler MessageHandler messageHandler MessageHandler
readReceiptHandler ReadReceiptHandler
} }
// NewHandler 创建WebSocket处理器 // NewHandler 创建WebSocket处理器
...@@ -36,14 +45,57 @@ func NewHandler(msgHandler MessageHandler) *Handler { ...@@ -36,14 +45,57 @@ func NewHandler(msgHandler MessageHandler) *Handler {
} }
} }
// SetReadReceiptHandler 设置已读回执处理器
func (h *Handler) SetReadReceiptHandler(handler ReadReceiptHandler) {
h.readReceiptHandler = handler
}
// BroadcastConsultUpdate 广播问诊状态变更
func (h *Handler) BroadcastConsultUpdate(consultID string, status string, extra map[string]interface{}) {
msg := &Message{
Type: "consult_update",
ConsultID: consultID,
Content: status,
Timestamp: time.Now(),
Extra: extra,
}
h.hub.BroadcastToConsult(consultID, msg, "")
}
// BroadcastNewPatient 广播新患者排队通知
func (h *Handler) BroadcastNewPatient(doctorUserID string, consultID string, patientName string) {
h.hub.SendToUser(doctorUserID, &Message{
Type: "new_patient",
ConsultID: consultID,
Content: patientName + " 进入候诊队列",
Timestamp: time.Now(),
})
}
// RegisterRoutes 注册WebSocket路由 // RegisterRoutes 注册WebSocket路由
func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/ws/consult/:id", h.HandleConsultWS) r.GET("/ws/consult/:id", h.HandleConsultWS)
} }
// resolveConsultID 解析问诊标识:支持 UUID 或流水号(C开头)
func resolveConsultID(idOrSerial string) (string, error) {
if strings.HasPrefix(idOrSerial, "C") {
var consult model.Consultation
if err := database.GetDB().Where("serial_number = ?", idOrSerial).Select("id").First(&consult).Error; err != nil {
return "", fmt.Errorf("流水号 %s 对应的问诊不存在", idOrSerial)
}
return consult.ID, nil
}
return idOrSerial, nil
}
// HandleConsultWS 处理问诊WebSocket连接 // HandleConsultWS 处理问诊WebSocket连接
func (h *Handler) HandleConsultWS(c *gin.Context) { func (h *Handler) HandleConsultWS(c *gin.Context) {
consultID := c.Param("id") consultID, err := resolveConsultID(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
userID := c.Query("user_id") userID := c.Query("user_id")
userType := c.Query("user_type") // patient or doctor userType := c.Query("user_type") // patient or doctor
token := c.Query("token") token := c.Query("token")
...@@ -135,6 +187,12 @@ func (h *Handler) handleMessage(client *Client, data []byte) { ...@@ -135,6 +187,12 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
}, client.UserID) }, client.UserID)
case "read": case "read":
// 持久化已读状态
if h.readReceiptHandler != nil && msg.MessageID != "" {
if err := h.readReceiptHandler(msg.MessageID); err != nil {
log.Printf("已读回执更新失败: %v", err)
}
}
// 广播已读状态 // 广播已读状态
h.hub.BroadcastToConsult(client.ConsultID, &Message{ h.hub.BroadcastToConsult(client.ConsultID, &Message{
Type: "read", Type: "read",
...@@ -145,6 +203,9 @@ func (h *Handler) handleMessage(client *Client, data []byte) { ...@@ -145,6 +203,9 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
Timestamp: time.Now(), Timestamp: time.Now(),
}, client.UserID) }, client.UserID)
case "consult_update", "new_patient", "online_status":
// 这些类型由服务端主动推送,客户端发送时忽略
case "ping": case "ping":
// 心跳响应 // 心跳响应
client.SendMessage(&Message{ client.SendMessage(&Message{
......
This diff is collapsed.
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-rnd": "^10.5.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
...@@ -1659,6 +1660,15 @@ ...@@ -1659,6 +1660,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
...@@ -2248,6 +2258,12 @@ ...@@ -2248,6 +2258,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/json2mq": { "node_modules/json2mq": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz",
...@@ -2516,6 +2532,18 @@ ...@@ -2516,6 +2532,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
...@@ -3254,6 +3282,15 @@ ...@@ -3254,6 +3282,15 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-entities": { "node_modules/parse-entities": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz",
...@@ -3313,6 +3350,23 @@ ...@@ -3313,6 +3350,23 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/property-information": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz",
...@@ -3935,6 +3989,16 @@ ...@@ -3935,6 +3989,16 @@
"react-dom": ">=16.9.0" "react-dom": ">=16.9.0"
} }
}, },
"node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmmirror.com/re-resizable/-/re-resizable-6.11.2.tgz",
"integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
...@@ -3956,6 +4020,20 @@ ...@@ -3956,6 +4020,20 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
...@@ -3989,6 +4067,27 @@ ...@@ -3989,6 +4067,27 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/react-rnd": {
"version": "10.5.2",
"resolved": "https://registry.npmmirror.com/react-rnd/-/react-rnd-10.5.2.tgz",
"integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==",
"license": "MIT",
"dependencies": {
"re-resizable": "6.11.2",
"react-draggable": "4.4.6",
"tslib": "2.6.2"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-rnd/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/remark-parse": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz",
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-rnd": "^10.5.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
......
import { get, post, put, del } from './request'; import { get, post, put, del } from './request';
import { sseRequest } from './sse';
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
...@@ -70,6 +71,7 @@ export interface AgentDefinition { ...@@ -70,6 +71,7 @@ export interface AgentDefinition {
category: string; category: string;
system_prompt: string; system_prompt: string;
tools: string; // JSON array string tools: string; // JSON array string
skills: string; // JSON array string of skill_ids
config: string; config: string;
max_iterations: number; max_iterations: number;
status: string; status: string;
...@@ -84,10 +86,27 @@ export type AgentDefinitionParams = { ...@@ -84,10 +86,27 @@ export type AgentDefinitionParams = {
category?: string; category?: string;
system_prompt?: string; system_prompt?: string;
tools?: string; tools?: string;
skills?: string[];
max_iterations?: number; max_iterations?: number;
status?: string; status?: string;
}; };
export interface AgentSkill {
id: number;
skill_id: string;
name: string;
description: string;
category: string;
tools: string;
system_prompt_addon: string;
context_schema: string;
quick_replies: string;
icon: string;
status: string;
created_at: string;
updated_at: string;
}
export interface WorkflowCreateParams { export interface WorkflowCreateParams {
workflow_id: string; workflow_id: string;
name: string; name: string;
...@@ -108,6 +127,18 @@ export interface KnowledgeDocumentParams { ...@@ -108,6 +127,18 @@ export interface KnowledgeDocumentParams {
content: string; content: string;
} }
// ==================== Agent 流式回调类型 ====================
export interface AgentStreamCallbacks {
onSession?: (info: { session_id: string }) => void;
onThinking?: (info: { iteration: number; status: string }) => void;
onToolCall?: (info: { tool_name: string; arguments: string; call_id: string; iteration: number }) => void;
onToolResult?: (info: { tool_name: string; success: boolean; call_id: string }) => void;
onChunk?: (content: string) => void;
onDone?: (info: { session_id: string; iterations: number; total_tokens: number; finish_reason: string }) => void;
onError?: (error: string) => void;
}
// ==================== Agent API ==================== // ==================== Agent API ====================
// AI 推理调用专用超时(2分钟) // AI 推理调用专用超时(2分钟)
...@@ -120,6 +151,23 @@ export const agentApi = { ...@@ -120,6 +151,23 @@ export const agentApi = {
context?: Record<string, unknown>; context?: Record<string, unknown>;
}) => post<AgentOutput>(`/agent/${agentId}/chat`, params, { timeout: AI_TIMEOUT }), }) => post<AgentOutput>(`/agent/${agentId}/chat`, params, { timeout: AI_TIMEOUT }),
/** SSE 流式 Agent 对话 */
chatStream: (
agentId: string,
params: { session_id?: string; message: string; context?: Record<string, unknown> },
callbacks: AgentStreamCallbacks,
): AbortController => {
return sseRequest(`/agent/${agentId}/chat/stream`, params, {
session: (data) => callbacks.onSession?.(data as { session_id: string }),
thinking: (data) => callbacks.onThinking?.(data as { iteration: number; status: string }),
tool_call: (data) => callbacks.onToolCall?.(data as { tool_name: string; arguments: string; call_id: string; iteration: number }),
tool_result: (data) => callbacks.onToolResult?.(data as { tool_name: string; success: boolean; call_id: string }),
chunk: (data) => callbacks.onChunk?.((data.content as string) || ''),
done: (data) => callbacks.onDone?.(data as { session_id: string; iterations: number; total_tokens: number; finish_reason: string }),
error: (data) => callbacks.onError?.((data.error as string) || '未知错误'),
});
},
listAgents: () => listAgents: () =>
get<{ id: string; name: string; description: string }[]>('/agent/list'), get<{ id: string; name: string; description: string }[]>('/agent/list'),
...@@ -165,6 +213,20 @@ export const agentApi = { ...@@ -165,6 +213,20 @@ export const agentApi = {
put<null>(`/agent/tools/${name}/status`, { status }), put<null>(`/agent/tools/${name}/status`, { status }),
}; };
// ==================== Skill API ====================
export const skillApi = {
list: () => get<AgentSkill[]>('/agent/skills'),
create: (data: Partial<AgentSkill>) =>
post<AgentSkill>('/agent/skills', data),
update: (skillId: string, data: Partial<AgentSkill>) =>
put<AgentSkill>(`/agent/skills/${skillId}`, data),
delete: (skillId: string) => del<null>(`/agent/skills/${skillId}`),
};
// ==================== HTTP Tool API ==================== // ==================== HTTP Tool API ====================
export interface HTTPToolDefinition { export interface HTTPToolDefinition {
......
...@@ -26,8 +26,13 @@ export interface ConsultMessage { ...@@ -26,8 +26,13 @@ export interface ConsultMessage {
consult_id: string; consult_id: string;
sender_type: 'patient' | 'doctor' | 'system' | 'ai'; sender_type: 'patient' | 'doctor' | 'system' | 'ai';
content: string; content: string;
content_type: 'text' | 'image' | 'file' | 'prescription'; content_type: 'text' | 'image' | 'file' | 'prescription' | 'audio';
created_at: string; created_at: string;
// v13 新增
read_at?: string | null;
media_url?: string;
media_type?: string;
reply_to_id?: string | null;
} }
export interface CreateConsultParams { export interface CreateConsultParams {
...@@ -80,6 +85,7 @@ export interface VideoRoomInfo { ...@@ -80,6 +85,7 @@ export interface VideoRoomInfo {
export interface PatientListItem { export interface PatientListItem {
id: string; id: string;
consult_id: string; consult_id: string;
serial_number: string;
patient_id: string; patient_id: string;
patient_name: string; patient_name: string;
patient_gender: string; patient_gender: string;
...@@ -92,6 +98,111 @@ export interface PatientListItem { ...@@ -92,6 +98,111 @@ export interface PatientListItem {
created_at: string; created_at: string;
started_at: string | null; started_at: string | null;
ended_at: string | null; ended_at: string | null;
// v13 新增字段
allergy_history: string;
chronic_diseases: string[];
is_revisit: boolean;
visit_count: number;
risk_level: 'low' | 'medium' | 'high';
last_visit_date: string;
pre_consult_summary: string;
}
// ========== 转诊类型 (v13) ==========
export interface TransferInboxItem {
id: number;
consult_id: string;
from_doctor_name: string;
patient_name: string;
chief_complaint: string;
reason: string;
transfer_note: string;
status: string;
created_at: string;
}
export interface PrescriptionStatusResponse {
id: string;
prescription_no: string;
status: string;
total_amount: number;
item_count: number;
timeline: { status: string; label: string; time: string | null }[];
}
// ========== 患者画像类型 (v13) ==========
export interface PatientBasicInfo {
id: string;
name: string;
gender: string;
age: number;
phone: string;
allergy_history: string;
medical_history: string;
insurance_type: string;
insurance_no: string;
}
export interface ConsultSummary {
id: string;
doctor_name: string;
chief_complaint: string;
type: ConsultType;
status: ConsultStatus;
created_at: string;
ended_at: string | null;
}
export interface PrescriptionBrief {
id: string;
prescription_no: string;
diagnosis: string;
item_count: number;
total_amount: number;
status: string;
created_at: string;
}
export interface ChronicBrief {
id: string;
disease_name: string;
diagnosis_date: string | null;
control_status: string;
current_meds: string;
}
export interface LabReportBrief {
id: string;
title: string;
category: string;
report_date: string | null;
file_url: string;
}
export interface HealthMetricPoint {
metric_type: string;
value1: number;
value2: number;
unit: string;
recorded_at: string;
}
export interface FamilyMemberBrief {
name: string;
relation: string;
medical_history: string;
}
export interface PatientProfile {
basic_info: PatientBasicInfo;
consult_history: ConsultSummary[];
prescriptions: PrescriptionBrief[];
chronic_records: ChronicBrief[];
lab_reports: LabReportBrief[];
health_metrics: HealthMetricPoint[];
family_history: FamilyMemberBrief[];
} }
// 问诊 API // 问诊 API
...@@ -104,36 +215,62 @@ export const consultApi = { ...@@ -104,36 +215,62 @@ export const consultApi = {
getConsultList: (status?: ConsultStatus) => getConsultList: (status?: ConsultStatus) =>
get<Consultation[]>('/consult/list', { params: { status } }), get<Consultation[]>('/consult/list', { params: { status } }),
// 获取问诊详情 // 获取问诊详情(支持 consult_id 或 serial_number)
getConsultDetail: (id: string) => get<Consultation>(`/consult/${id}`), getConsultDetail: (idOrSerial: string) => get<Consultation>(`/consult/${idOrSerial}`),
// 获取问诊消息历史 // 获取问诊消息历史(支持 consult_id 或 serial_number)
getConsultMessages: (id: string) => get<ConsultMessage[]>(`/consult/${id}/messages`), getConsultMessages: (idOrSerial: string) => get<ConsultMessage[]>(`/consult/${idOrSerial}/messages`),
// 发送消息 // 发送消息(支持 consult_id 或 serial_number)
sendMessage: (consult_id: string, content: string, content_type: string = 'text') => sendMessage: (idOrSerial: string, content: string, content_type: string = 'text') =>
post<ConsultMessage>(`/consult/${consult_id}/message`, { content, content_type }), post<ConsultMessage>(`/consult/${idOrSerial}/message`, { content, content_type }),
// 获取视频房间信息 // 获取视频房间信息(支持 consult_id 或 serial_number)
getVideoRoomInfo: (consult_id: string) => getVideoRoomInfo: (idOrSerial: string) =>
get<VideoRoomInfo>(`/consult/${consult_id}/video-room`), get<VideoRoomInfo>(`/consult/${idOrSerial}/video-room`),
// 结束问诊 // 结束问诊(支持 consult_id 或 serial_number)
endConsult: (id: string) => post<null>(`/consult/${id}/end`), endConsult: (idOrSerial: string, data?: { diagnosis?: string; summary?: string; follow_up_days?: number; send_survey?: boolean }) =>
post<null>(`/consult/${idOrSerial}/end`, data || {}),
// AI辅助分析(鉴别诊断/用药建议)—— 通过 Agent,返回 response + tool_calls // AI辅助分析(支持 consult_id 或 serial_number)
aiAssist: (id: string, scene: string) => aiAssist: (idOrSerial: string, scene: string) =>
post<{ post<{
scene: string; scene: string;
response: string; response: string;
tool_calls?: import('./agent').ToolCall[]; tool_calls?: import('./agent').ToolCall[];
iterations?: number; iterations?: number;
total_tokens?: number; total_tokens?: number;
}>(`/consult/${id}/ai-assist`, { scene }, { timeout: 120000 }), }>(`/consult/${idOrSerial}/ai-assist`, { scene }, { timeout: 120000 }),
// 取消问诊(支持 consult_id 或 serial_number)
cancelConsult: (idOrSerial: string, reason?: string) =>
post<null>(`/consult/${idOrSerial}/cancel`, { reason }),
// 取消问诊 // v13: 上传多媒体文件(支持 consult_id 或 serial_number)
cancelConsult: (id: string, reason?: string) => uploadMedia: (idOrSerial: string, file: File) => {
post<null>(`/consult/${id}/cancel`, { reason }), const formData = new FormData();
formData.append('file', file);
return post<{ media_url: string; media_type: string; file_name: string }>(
`/consult/${idOrSerial}/upload`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
},
// v13: 转诊(支持 consult_id 或 serial_number)
transferConsult: (idOrSerial: string, data: { to_doctor_id: string; to_department_id?: string; reason: string; transfer_note?: string }) =>
post<null>(`/consult/${idOrSerial}/transfer`, data),
getTransferInbox: () => get<TransferInboxItem[]>('/consult/doctor/transfer-inbox'),
acceptTransfer: (transferId: number) => post<null>(`/consult/transfer/${transferId}/accept`),
rejectTransfer: (transferId: number, reason?: string) =>
post<null>(`/consult/transfer/${transferId}/reject`, { reason }),
// v13: 处方状态
getPrescriptionStatus: (prescriptionId: string) =>
get<PrescriptionStatusResponse>(`/consult/prescription/${prescriptionId}/status`),
// === 医生端 === // === 医生端 ===
// 获取医生待接诊队列 // 获取医生待接诊队列
...@@ -146,14 +283,17 @@ export const consultApi = { ...@@ -146,14 +283,17 @@ export const consultApi = {
// 获取统一患者列表(待接诊+进行中+已完成) // 获取统一患者列表(待接诊+进行中+已完成)
getPatientList: () => get<PatientListItem[]>('/consult/doctor/patients'), getPatientList: () => get<PatientListItem[]>('/consult/doctor/patients'),
// 获取患者完整画像
getPatientProfile: (patientId: string) => get<PatientProfile>(`/consult/doctor/patient-profile/${patientId}`),
// 获取医生工作台统计 // 获取医生工作台统计
getDoctorWorkbenchStats: () => get<DoctorWorkbenchStats>('/consult/doctor/workbench-stats'), getDoctorWorkbenchStats: () => get<DoctorWorkbenchStats>('/consult/doctor/workbench-stats'),
// 接诊 // 接诊(支持 consult_id 或 serial_number)
acceptConsult: (id: string) => post<null>(`/consult/${id}/accept`), acceptConsult: (idOrSerial: string) => post<null>(`/consult/${idOrSerial}/accept`),
// 拒诊 // 拒诊(支持 consult_id 或 serial_number)
rejectConsult: (id: string, reason?: string) => rejectConsult: (idOrSerial: string, reason?: string) =>
post<null>(`/consult/${id}/reject`, { reason }), post<null>(`/consult/${idOrSerial}/reject`, { reason }),
}; };
import { get, post, put } from './request'; import { get, post, put, del } from './request';
import type { Consultation, ConsultMessage } from './consult'; import type { Consultation, ConsultMessage } from './consult';
import type { ToolCall } from './agent'; import type { ToolCall } from './agent';
...@@ -14,6 +14,13 @@ export interface DoctorWorkbenchStats { ...@@ -14,6 +14,13 @@ export interface DoctorWorkbenchStats {
rating: number; rating: number;
income_today: number; income_today: number;
income_month: number; income_month: number;
// v13 效率指标
avg_wait_minutes: number;
avg_consult_minutes: number;
prescription_rate: number;
avg_satisfaction: number;
transfer_count: number;
ai_usage_rate: number;
} }
// 接诊队列患者 // 接诊队列患者
...@@ -130,24 +137,24 @@ export const doctorPortalApi = { ...@@ -130,24 +137,24 @@ export const doctorPortalApi = {
getWaitingQueue: () => getWaitingQueue: () =>
get<WaitingPatient[]>('/doctor-portal/queue/waiting'), get<WaitingPatient[]>('/doctor-portal/queue/waiting'),
acceptConsult: (consultId: string) => acceptConsult: (idOrSerial: string) =>
post<Consultation>(`/doctor-portal/consult/${consultId}/accept`), post<Consultation>(`/doctor-portal/consult/${idOrSerial}/accept`),
// === 问诊操作 === // === 问诊操作(支持 consult_id 或 serial_number) ===
getConsultDetail: (consultId: string) => getConsultDetail: (idOrSerial: string) =>
get<Consultation>(`/doctor-portal/consult/${consultId}`), get<Consultation>(`/doctor-portal/consult/${idOrSerial}`),
getConsultMessages: (consultId: string) => getConsultMessages: (idOrSerial: string) =>
get<ConsultMessage[]>(`/doctor-portal/consult/${consultId}/messages`), get<ConsultMessage[]>(`/doctor-portal/consult/${idOrSerial}/messages`),
sendMessage: (consultId: string, content: string, contentType: string = 'text') => sendMessage: (idOrSerial: string, content: string, contentType: string = 'text') =>
post<ConsultMessage>(`/doctor-portal/consult/${consultId}/message`, { post<ConsultMessage>(`/doctor-portal/consult/${idOrSerial}/message`, {
content, content,
content_type: contentType, content_type: contentType,
}), }),
endConsult: (consultId: string, summary?: string) => endConsult: (idOrSerial: string, summary?: string) =>
post<null>(`/doctor-portal/consult/${consultId}/end`, { summary }), post<null>(`/doctor-portal/consult/${idOrSerial}/end`, { summary }),
// === 历史问诊 === // === 历史问诊 ===
getConsultHistory: (params?: { page?: number; page_size?: number; status?: string }) => getConsultHistory: (params?: { page?: number; page_size?: number; status?: string }) =>
...@@ -192,4 +199,37 @@ export const doctorPortalApi = { ...@@ -192,4 +199,37 @@ export const doctorPortalApi = {
has_warning: boolean; has_warning: boolean;
has_contraindication: boolean; has_contraindication: boolean;
}>('/doctor-portal/prescription/check', params, { timeout: 120000 }), }>('/doctor-portal/prescription/check', params, { timeout: 120000 }),
// === v13: 快捷回复 ===
listQuickReplies: (category?: string) =>
get<QuickReplyTemplate[]>('/doctor-portal/quick-replies', { params: { category } }),
createQuickReply: (data: { category: string; title: string; content: string; sort_order?: number }) =>
post<QuickReplyTemplate>('/doctor-portal/quick-replies', data),
updateQuickReply: (id: number, data: Partial<{ category: string; title: string; content: string; sort_order: number }>) =>
put<QuickReplyTemplate>(`/doctor-portal/quick-replies/${id}`, data),
deleteQuickReply: (id: number) =>
del<null>(`/doctor-portal/quick-replies/${id}`),
incrementQuickReplyUse: (id: number) =>
post<null>(`/doctor-portal/quick-replies/${id}/use`),
aiSuggestReplies: (idOrSerial: string) =>
post<{ suggestions: string }>('/doctor-portal/quick-replies/ai-suggest',
{ serial_number: idOrSerial }, { timeout: 60000 }),
}; };
// 快捷回复模板
export interface QuickReplyTemplate {
id: number;
doctor_id: string;
category: string;
title: string;
content: string;
sort_order: number;
use_count: number;
created_at: string;
updated_at: string;
}
import { get } from './request'; import { get } from './request';
import { sseRequest } from './sse';
// ====== 对话消息统一格式 ====== // ====== 对话消息统一格式 ======
export interface ChatMessage { export interface ChatMessage {
...@@ -65,19 +66,6 @@ export interface ChatDoneInfo { ...@@ -65,19 +66,6 @@ export interface ChatDoneInfo {
} }
// ====== SSE 流式对话工具函数 ====== // ====== SSE 流式对话工具函数 ======
const getApiBaseURL = () => {
const envUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (envUrl && envUrl.includes('/api/v1')) return envUrl;
return (envUrl || 'http://localhost:8080') + '/api/v1';
};
/**
* 发起SSE流式对话请求
* @param url 相对API路径
* @param body POST请求体
* @param callbacks SSE事件回调
* @returns AbortController 用于取消请求
*/
export function sseChat( export function sseChat(
url: string, url: string,
body: object, body: object,
...@@ -88,91 +76,12 @@ export function sseChat( ...@@ -88,91 +76,12 @@ export function sseChat(
onError?: (error: string) => void; onError?: (error: string) => void;
} }
): AbortController { ): AbortController {
const controller = new AbortController(); return sseRequest(url, body, {
const token = localStorage.getItem('access_token'); session: (data) => callbacks.onSession?.(data as unknown as ChatSessionInfo),
chunk: (data) => callbacks.onChunk?.((data.content as string) || ''),
fetch(`${getApiBaseURL()}${url}`, { done: (data) => callbacks.onDone?.(data as unknown as ChatDoneInfo),
method: 'POST', error: (data) => callbacks.onError?.((data.error as string) || '未知错误'),
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const text = await response.text();
callbacks.onError?.(`请求失败 (HTTP ${response.status}): ${text}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError?.('无法读取响应流');
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按双换行分割SSE事件
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
const lines = part.split('\n');
let eventType = '';
let eventData = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
}
}
if (!eventType || !eventData) continue;
try {
const parsed = JSON.parse(eventData);
switch (eventType) {
case 'session':
callbacks.onSession?.(parsed);
break;
case 'chunk':
callbacks.onChunk?.(parsed.content || '');
break;
case 'done':
callbacks.onDone?.(parsed);
break;
case 'error':
callbacks.onError?.(parsed.error || '未知错误');
break;
}
} catch {
// JSON解析失败,忽略
}
}
}
})
.catch((err) => {
if (err.name !== 'AbortError') {
callbacks.onError?.(err.message || '网络请求失败');
}
}); });
return controller;
} }
// 预问诊 API // 预问诊 API
......
...@@ -93,6 +93,7 @@ export interface CreatePrescriptionItemReq { ...@@ -93,6 +93,7 @@ export interface CreatePrescriptionItemReq {
export interface CreatePrescriptionReq { export interface CreatePrescriptionReq {
consult_id?: string; consult_id?: string;
serial_number?: string;
patient_id: string; patient_id: string;
patient_name: string; patient_name: string;
patient_gender?: string; patient_gender?: string;
......
/**
* 通用 SSE 流式请求工具函数
* 支持任意事件类型的回调映射
*/
const getApiBaseURL = () => {
const envUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (envUrl && envUrl.includes('/api/v1')) return envUrl;
return (envUrl || 'http://localhost:8080') + '/api/v1';
};
/**
* 发起 SSE 流式请求
* @param url 相对 API 路径(如 /agent/xxx/chat/stream)
* @param body POST 请求体
* @param handlers 事件类型 → 回调函数 映射
* @returns AbortController 用于取消请求
*/
export function sseRequest(
url: string,
body: object,
handlers: Record<string, (data: Record<string, unknown>) => void>,
): AbortController {
const controller = new AbortController();
const token = localStorage.getItem('access_token');
fetch(`${getApiBaseURL()}${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const text = await response.text();
handlers.error?.({ error: `请求失败 (HTTP ${response.status}): ${text}` });
return;
}
const reader = response.body?.getReader();
if (!reader) {
handlers.error?.({ error: '无法读取响应流' });
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按双换行分割 SSE 事件
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
const lines = part.split('\n');
let eventType = '';
let eventData = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
}
}
if (!eventType || !eventData) continue;
try {
const parsed = JSON.parse(eventData);
handlers[eventType]?.(parsed);
} catch {
// JSON 解析失败,忽略
}
}
}
})
.catch((err) => {
if (err.name !== 'AbortError') {
handlers.error?.({ error: err.message || '网络请求失败' });
}
});
return controller;
}
...@@ -11,26 +11,46 @@ import { ...@@ -11,26 +11,46 @@ import {
ReloadOutlined, PlusOutlined, ReloadOutlined, PlusOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api/agent'; import { agentApi, skillApi } from '@/api/agent';
import type { ToolCall, AgentExecutionLog, AgentDefinition } from '@/api/agent'; import type { ToolCall, AgentExecutionLog, AgentDefinition } from '@/api/agent';
const { Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const categoryColor: Record<string, string> = { const categoryColor: Record<string, string> = {
pre_consult: 'blue', diagnosis: 'purple', prescription: 'orange', follow_up: 'green', patient: 'green', doctor: 'blue', pharmacy: 'orange', admin: 'purple', general: 'cyan',
}; };
const categoryLabel: Record<string, string> = { const categoryLabel: Record<string, string> = {
pre_consult: '预问诊', diagnosis: '诊断辅助', prescription: '处方审核', follow_up: '随访管理', patient: '患者服务', doctor: '医生辅助', pharmacy: '处方审核', admin: '管理辅助', general: '通用',
}; };
const CATEGORY_OPTIONS = [ const CATEGORY_OPTIONS = [
{ value: 'pre_consult', label: '预问诊' }, { value: 'patient', label: '患者服务' },
{ value: 'diagnosis', label: '诊断辅助' }, { value: 'doctor', label: '医生辅助' },
{ value: 'prescription', label: '处方审核' }, { value: 'pharmacy', label: '处方审核' },
{ value: 'follow_up', label: '随访管理' }, { value: 'admin', label: '管理辅助' },
{ value: 'general', label: '通用' },
]; ];
// 技能包选择器组件
function SkillSelect(props: { value?: string[]; onChange?: (v: string[]) => void }) {
const { data: skills = [] } = useQuery({
queryKey: ['agent-skills'],
queryFn: () => skillApi.list(),
select: r => (r.data ?? []).map(s => ({ value: s.skill_id, label: `${s.name} - ${s.description}` })),
});
return (
<Select
mode="multiple"
options={skills}
placeholder="选择技能包(可选,技能中的工具会自动合并)"
allowClear
value={props.value}
onChange={props.onChange}
/>
);
}
interface AgentResponse { interface AgentResponse {
response: string; response: string;
tool_calls?: ToolCall[]; tool_calls?: ToolCall[];
...@@ -53,7 +73,7 @@ export default function AgentsPage() { ...@@ -53,7 +73,7 @@ export default function AgentsPage() {
const [editModal, setEditModal] = useState<{ open: boolean; agent: AgentDefinition | null; isNew: boolean }>({ open: false, agent: null, isNew: false }); const [editModal, setEditModal] = useState<{ open: boolean; agent: AgentDefinition | null; isNew: boolean }>({ open: false, agent: null, isNew: false });
// 可用工具列表 // 可用工具列表
const [availableTools, setAvailableTools] = useState<string[]>([]); const [availableTools, setAvailableTools] = useState<{ name: string; description: string; category: string }[]>([]);
// 执行日志 // 执行日志
const [logs, setLogs] = useState<AgentExecutionLog[]>([]); const [logs, setLogs] = useState<AgentExecutionLog[]>([]);
...@@ -73,7 +93,7 @@ export default function AgentsPage() { ...@@ -73,7 +93,7 @@ export default function AgentsPage() {
useEffect(() => { useEffect(() => {
agentApi.listTools().then(res => { agentApi.listTools().then(res => {
if (res.data?.length > 0) { if (res.data?.length > 0) {
setAvailableTools(res.data.map((t: { name: string }) => t.name)); setAvailableTools(res.data.map(t => ({ name: t.name, description: t.description, category: t.category })));
} }
}).catch(() => {}); }).catch(() => {});
fetchLogs(); fetchLogs();
...@@ -92,6 +112,7 @@ export default function AgentsPage() { ...@@ -92,6 +112,7 @@ export default function AgentsPage() {
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => { mutationFn: (values: Record<string, unknown>) => {
const toolsArr = values.tools_array as string[] || []; const toolsArr = values.tools_array as string[] || [];
const skillsArr = values.skills_array as string[] || [];
const params = { const params = {
agent_id: values.agent_id as string, agent_id: values.agent_id as string,
name: values.name as string, name: values.name as string,
...@@ -99,6 +120,7 @@ export default function AgentsPage() { ...@@ -99,6 +120,7 @@ export default function AgentsPage() {
category: values.category as string, category: values.category as string,
system_prompt: values.system_prompt as string, system_prompt: values.system_prompt as string,
tools: JSON.stringify(toolsArr), tools: JSON.stringify(toolsArr),
skills: skillsArr,
max_iterations: values.max_iterations as number, max_iterations: values.max_iterations as number,
status: values.status as string, status: values.status as string,
}; };
...@@ -136,7 +158,9 @@ export default function AgentsPage() { ...@@ -136,7 +158,9 @@ export default function AgentsPage() {
const openEdit = (agent?: AgentDefinition) => { const openEdit = (agent?: AgentDefinition) => {
if (agent) { if (agent) {
let toolsArr: string[] = []; let toolsArr: string[] = [];
let skillsArr: string[] = [];
try { toolsArr = JSON.parse(agent.tools || '[]'); } catch {} try { toolsArr = JSON.parse(agent.tools || '[]'); } catch {}
try { skillsArr = JSON.parse(agent.skills || '[]'); } catch {}
form.setFieldsValue({ form.setFieldsValue({
agent_id: agent.agent_id, agent_id: agent.agent_id,
name: agent.name, name: agent.name,
...@@ -144,6 +168,7 @@ export default function AgentsPage() { ...@@ -144,6 +168,7 @@ export default function AgentsPage() {
category: agent.category, category: agent.category,
system_prompt: agent.system_prompt, system_prompt: agent.system_prompt,
tools_array: toolsArr, tools_array: toolsArr,
skills_array: skillsArr,
max_iterations: agent.max_iterations, max_iterations: agent.max_iterations,
status: agent.status === 'active', status: agent.status === 'active',
}); });
...@@ -237,7 +262,7 @@ export default function AgentsPage() { ...@@ -237,7 +262,7 @@ export default function AgentsPage() {
render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '运行中' : '停用'} />, render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '运行中' : '停用'} />,
}, },
{ {
title: '操作', key: 'action', width: 200, title: '操作', key: 'action', width: 260, fixed: 'right' as const,
render: (_: unknown, r: AgentDefinition) => ( render: (_: unknown, r: AgentDefinition) => (
<Space> <Space>
<Button size="small" icon={<PlayCircleOutlined />} type="link" onClick={() => openTest(r.agent_id, r.name)}>测试</Button> <Button size="small" icon={<PlayCircleOutlined />} type="link" onClick={() => openTest(r.agent_id, r.name)}>测试</Button>
...@@ -283,7 +308,23 @@ export default function AgentsPage() { ...@@ -283,7 +308,23 @@ export default function AgentsPage() {
}, },
]; ];
const toolOptions = availableTools.map(t => ({ value: t, label: t })); // 按分类分组工具选项
const toolCategoryLabels: Record<string, string> = {
knowledge: '知识库', recommendation: '智能推荐', medical: '病历管理',
pharmacy: '药品管理', safety: '安全检查', follow_up: '随访管理',
notification: '消息通知', agent: 'Agent调用', workflow: '工作流',
expression: '表达式', other: '其他',
};
const toolsByCategory = availableTools.reduce((acc, t) => {
const cat = t.category || 'other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(t);
return acc;
}, {} as Record<string, typeof availableTools>);
const toolOptions = Object.entries(toolsByCategory).map(([cat, tools]) => ({
label: toolCategoryLabels[cat] || cat,
options: tools.map(t => ({ value: t.name, label: t.name, title: t.description })),
}));
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
...@@ -309,6 +350,7 @@ export default function AgentsPage() { ...@@ -309,6 +350,7 @@ export default function AgentsPage() {
loading={agentsLoading} loading={agentsLoading}
pagination={false} pagination={false}
size="small" size="small"
scroll={{ x: 1100 }}
/> />
), ),
}, },
...@@ -379,7 +421,7 @@ export default function AgentsPage() { ...@@ -379,7 +421,7 @@ export default function AgentsPage() {
onFinish={(values) => saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' })} onFinish={(values) => saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' })}
> >
<Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}> <Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}>
<Input placeholder="如: diagnosis_agent" disabled={!editModal.isNew} /> <Input placeholder="如: doctor_universal_agent" disabled={!editModal.isNew} />
</Form.Item> </Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}> <Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如: 诊断辅助 Agent" /> <Input placeholder="如: 诊断辅助 Agent" />
...@@ -401,6 +443,9 @@ export default function AgentsPage() { ...@@ -401,6 +443,9 @@ export default function AgentsPage() {
allowClear allowClear
/> />
</Form.Item> </Form.Item>
<Form.Item name="skills_array" label="技能包">
<SkillSelect />
</Form.Item>
<Form.Item name="max_iterations" label="最大迭代次数"> <Form.Item name="max_iterations" label="最大迭代次数">
<InputNumber min={1} max={50} style={{ width: 120 }} /> <InputNumber min={1} max={50} style={{ width: 120 }} />
</Form.Item> </Form.Item>
......
...@@ -208,10 +208,9 @@ export default function AICenterPage() { ...@@ -208,10 +208,9 @@ export default function AICenterPage() {
value={agentFilter || undefined} value={agentFilter || undefined}
onChange={(v) => { setAgentFilter(v ?? ''); setPage(1); }} onChange={(v) => { setAgentFilter(v ?? ''); setPage(1); }}
options={[ options={[
{ value: 'pre_consult_agent', label: '预问诊 Agent' }, { value: 'patient_universal_agent', label: '患者智能助手' },
{ value: 'diagnosis_agent', label: '诊断辅助 Agent' }, { value: 'doctor_universal_agent', label: '医生智能助手' },
{ value: 'prescription_agent', label: '处方审核 Agent' }, { value: 'admin_universal_agent', label: '管理员智能助手' },
{ value: 'follow_up_agent', label: '随访管理 Agent' },
]} ]}
/> />
<Input.Search <Input.Search
......
'use client'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd'; import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag } from 'antd';
import { import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined, DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined, SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined, FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined, ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, CloudOutlined, SafetyOutlined, FundOutlined, AppstoreOutlined, CloudOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useUserStore } from '@/store/userStore'; import { useUserStore } from '@/store/userStore';
const { Content } = Layout; const { Sider, Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
const menuItems = [ const menuItems = [
...@@ -35,9 +36,7 @@ const menuItems = [ ...@@ -35,9 +36,7 @@ const menuItems = [
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台', key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [ children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: 'Agent管理' }, { key: '/admin/agents', icon: <RobotOutlined />, label: 'Agent管理' },
{ key: '/admin/tool-market', icon: <AppstoreOutlined />, label: '工具市场' }, { key: '/admin/tools', 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/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' }, { key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' }, { key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
...@@ -51,6 +50,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ...@@ -51,6 +50,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useUserStore(); const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const currentPath = pathname || ''; const currentPath = pathname || '';
const userMenuItems = [ const userMenuItems = [
...@@ -70,50 +70,98 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ...@@ -70,50 +70,98 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (key === 'logout') { logout(); router.push('/login'); } if (key === 'logout') { logout(); router.push('/login'); }
}; };
// 选中的菜单项(支持子路径匹配)
const getSelectedKeys = () => { const getSelectedKeys = () => {
const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins', const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
'/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy', '/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
'/admin/ai-config', '/admin/compliance', '/admin/agents', '/admin/tool-market', '/admin/ai-config', '/admin/compliance', '/admin/agents',
'/admin/tools', '/admin/http-tools', '/admin/workflows', '/admin/tools', '/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center']; '/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center'];
const match = allKeys.find(k => currentPath.startsWith(k)); const match = allKeys.find(k => currentPath.startsWith(k));
return match ? [match] : []; return match ? [match] : [];
}; };
const getOpenKeys = () => {
if (collapsed) return [];
const keys: string[] = [];
if (['/admin/patients', '/admin/doctors', '/admin/admins'].some(k => currentPath.startsWith(k))) {
keys.push('user-mgmt');
}
if (['/admin/agents', '/admin/tools',
'/admin/workflows', '/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center']
.some(k => currentPath.startsWith(k))) {
keys.push('ai-platform');
}
return keys;
};
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<header <Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
collapsedWidth={64}
trigger={null}
style={{ style={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000, position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
height: 56, display: 'flex', alignItems: 'center', padding: '0 24px', background: '#fff',
background: '#ffffff', borderRight: '1px solid #f0f0f0',
borderBottom: '1px solid #e8f0fc', overflow: 'auto',
boxShadow: '0 1px 4px rgba(0, 82, 204, 0.06)',
}} }}
> >
{/* Logo */}
<div <div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 20, flexShrink: 0 }} style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/admin/dashboard')} onClick={() => router.push('/admin/dashboard')}
> >
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} /> <MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div> </div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129' }}>互联网医院</span> {!collapsed && (
<Tag color="purple" style={{ marginLeft: 2, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理后台</Tag> <>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="purple" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理</Tag>
</>
)}
</div> </div>
{/* Menu */}
<Menu <Menu
mode="horizontal" mode="inline"
selectedKeys={getSelectedKeys()} selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={handleMenuClick}
theme="light" style={{ border: 'none', padding: '8px 0' }}
className="patient-nav-menu"
style={{ flex: 1, minWidth: 0, borderBottom: 'none' }}
/> />
</Sider>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}> <Layout style={{ marginLeft: collapsed ? 64 : 220, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Badge count={2} size="small"> <Badge count={2} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} /> <BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge> </Badge>
...@@ -124,15 +172,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ...@@ -124,15 +172,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Text style={{ color: '#1d2129', fontSize: 13 }}>{user.real_name || '管理员'}</Text> <Text style={{ color: '#1d2129', fontSize: 13 }}>{user.real_name || '管理员'}</Text>
</Space> </Space>
</Dropdown> </Dropdown>
) : ( ) : null}
<Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)}
</div> </div>
</header> </header>
<Content style={{ marginTop: 56, minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}> <Content style={{ minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}>
{children} {children}
</Content> </Content>
</Layout> </Layout>
</Layout>
); );
} }
...@@ -398,7 +398,7 @@ export default function SafetyPage() { ...@@ -398,7 +398,7 @@ export default function SafetyPage() {
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="agent_id" label="限定 Agent(空=全局)"> <Form.Item name="agent_id" label="限定 Agent(空=全局)">
<Input placeholder="如: prescription_agent" /> <Input placeholder="如: doctor_universal_agent" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
......
This diff is collapsed.
'use client';
import React, { useState } from 'react';
import {
Card, Row, Col, Tag, Switch, Badge, Empty, Tooltip, Typography, message,
} from 'antd';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi, httpToolApi } from '@/api/agent';
import type { CATEGORY_CONFIG_TYPE, SOURCE_CONFIG_TYPE } from './page';
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;
}
interface Props {
search: string;
categoryFilter: string;
CATEGORY_CONFIG: CATEGORY_CONFIG_TYPE;
SOURCE_CONFIG: SOURCE_CONFIG_TYPE;
}
export default function AllToolsTab({ search, categoryFilter, CATEGORY_CONFIG, SOURCE_CONFIG }: Props) {
const qc = useQueryClient();
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;
}[],
});
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 filtered = allTools.filter(t => {
const matchSearch = !search || t.name.includes(search) || t.description.includes(search);
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchCategory;
});
const grouped: Record<string, UnifiedTool[]> = {};
for (const t of filtered) {
if (!grouped[t.category]) grouped[t.category] = [];
grouped[t.category].push(t);
}
if (Object.keys(grouped).length === 0) {
return <Empty description="暂无匹配工具" style={{ marginTop: 60 }} />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{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',
}}
styles={{ body: { 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'} />
<Text code style={{ fontSize: 12 }}>{tool.name}</Text>
</div>
<div style={{ marginTop: 4 }}>
<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 => { setTogglingName(tool.id); toggleMut.mutate({ 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>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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