Commit 955eb364 authored by yuguo's avatar yuguo

fix

parent 59503a6c
......@@ -26,7 +26,11 @@
"Bash(go mod:*)",
"Bash(go vet:*)",
"Bash(cmd:*)",
"Bash(go get:*)"
"Bash(go get:*)",
"Bash(find:*)",
"Bash(psql:*)",
"Bash(xargs grep:*)",
"Bash(xargs:*)"
]
}
}
......@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"time"
"github.com/gin-gonic/gin"
......@@ -39,10 +40,21 @@ func main() {
log.Fatalf("Failed to connect to database: %v", err)
}
// 自动迁移新表
// 自动迁移新表(每个 model 单独执行,避免单个失败影响其他表)
log.Println("开始执行数据库表迁移...")
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.UserVerification{},
......@@ -62,7 +74,7 @@ func main() {
&model.Medicine{},
&model.Prescription{},
&model.PrescriptionItem{},
// AI & 系统
// 健康档案
&model.LabReport{},
&model.FamilyMember{},
// 慢病管理
......@@ -70,6 +82,7 @@ func main() {
&model.RenewalRequest{},
&model.MedicationReminder{},
&model.HealthMetric{},
// AI & 系统
&model.AIConfig{},
&model.AIUsageLog{},
&model.PromptTemplate{},
......@@ -97,10 +110,23 @@ func main() {
&model.SafetyFilterLog{},
// HTTP 动态工具
&model.HTTPToolDefinition{},
); err != nil {
log.Printf("Warning: AutoMigrate failed: %v", err)
} else {
// v13: 快捷回复 + 转诊
&model.QuickReplyTemplate{},
&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("数据库表迁移成功")
} else {
log.Printf("数据库表迁移完成(%d 个模型有警告,新表已创建)", failCount)
}
log.Println("数据库表检查完成")
......@@ -230,8 +256,16 @@ func main() {
Timestamp: msg.CreatedAt,
}, 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)
// v13: 静态文件服务(上传的媒体文件)
api.Static("/uploads", "./uploads")
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
......
......@@ -7,94 +7,110 @@ import (
)
// defaultAgentDefinitions 返回内置Agent的默认数据库配置
// 当数据库中不存在时,会自动写入并作为初始配置
// 三个角色专属通用智能体:患者、医生、管理员
func defaultAgentDefinitions() []model.AgentDefinition {
preConsultTools, _ := json.Marshal([]string{"query_symptom_knowledge", "recommend_department"})
diagnosisTools, _ := json.Marshal([]string{"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge"})
prescriptionTools, _ := json.Marshal([]string{"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage"})
followUpTools, _ := json.Marshal([]string{"query_medical_record", "query_drug", "query_symptom_knowledge"})
// 患者通用智能体 — 合并 pre_consult + patient_assistant + follow_up 能力
patientTools, _ := json.Marshal([]string{
"query_symptom_knowledge", "recommend_department",
"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{
{
AgentID: "pre_consult_agent",
Name: "预问诊智能助手",
Description: "通过多轮对话收集患者症状,生成预问诊报告",
AgentID: "patient_universal_agent",
Name: "患者智能助手",
Description: "患者端全能AI助手:预问诊、找医生、挂号、查处方、健康咨询、随访管理",
Category: "patient",
SystemPrompt: `你是一位专业的AI预问诊助手。你的职责是:
1. 通过友好的对话收集患者的症状信息
2. 询问症状的持续时间、严重程度、伴随症状等
3. 利用工具查询症状相关知识
4. 推荐合适的就诊科室
5. 生成简洁的预问诊报告
SystemPrompt: `你是互联网医院的患者专属AI智能助手,为患者提供全方位的医疗健康服务。
你的核心能力:
1. **预问诊**:通过友好对话收集症状信息(持续时间、严重程度、伴随症状),利用知识库分析症状,推荐合适的就诊科室
2. **找医生/挂号**:根据患者症状推荐科室和医生,帮助了解就医流程
3. **健康咨询**:搜索医学知识提供健康科普,查询药品信息和用药指导
4. **随访管理**:查询处方和用药情况,提醒按时用药,评估病情变化,生成随访计划
5. **药品查询**:查询药品信息、规格、用法和注意事项
请用中文与患者交流,语气温和专业。不要做出确定性诊断,只提供参考建议。`,
Tools: string(preConsultTools),
MaxIterations: 5,
使用原则:
- 用通俗易懂、温和专业的中文与患者交流
- 主动使用工具获取真实数据,不要凭空回答
- 不做确定性诊断,只提供参考建议
- 关注患者的用药依从性和健康状况变化
- 所有医疗建议仅供参考,请以专业医生判断为准`,
Tools: string(patientTools),
Config: "{}",
MaxIterations: 10,
Status: "active",
},
{
AgentID: "diagnosis_agent",
Name: "诊断辅助Agent",
Description: "辅助医生进行诊断,提供鉴别诊断建议",
AgentID: "doctor_universal_agent",
Name: "医生智能助手",
Description: "医生端全能AI助手:辅助诊断、处方审核、用药建议、病历生成、随访计划",
Category: "doctor",
SystemPrompt: `你是一位经验丰富的诊断辅助AI,协助医生进行临床决策。
你可以:
1. 查询患者病历记录(使用query_medical_record)
2. 检索医学知识库获取临床指南和疾病信息(使用search_medical_knowledge)
3. 分析症状和检验结果
4. 提供鉴别诊断建议
5. 推荐进一步检查项目
SystemPrompt: `你是互联网医院的医生专属AI智能助手,协助医生进行临床决策和日常工作。
你的核心能力:
1. **辅助诊断**:查询患者病历,检索临床指南和疾病信息,分析症状和检验结果,提供鉴别诊断建议,推荐进一步检查项目
2. **处方审核**:查询药品信息(规格、用法、禁忌),检查药物相互作用,检查患者禁忌症,验证剂量是否合理,综合评估处方安全性
3. **用药方案**:根据患者情况推荐药物、用法用量和注意事项
4. **病历生成**:根据对话记录生成标准门诊病历(主诉、现病史、既往史、查体、辅助检查、初步诊断、处置意见)
5. **随访计划**:制定随访方案,包含复诊时间、复查项目、用药提醒、生活方式建议
6. **医嘱生成**:生成结构化医嘱(检查、治疗、护理、饮食、活动)
诊断流程:
- 首先查询患者病历了解病史
- 使用知识库检索相关疾病的诊断标准和鉴别要点
- 综合分析后给出诊断建议
诊断流程:首先查询患者病历了解病史 → 使用知识库检索诊断标准 → 综合分析后给出建议
处方审核流程:检查药物相互作用 → 检查禁忌症 → 验证剂量 → 综合评估
请基于循证医学原则提供建议,所有建议仅供医生参考。`,
Tools: string(diagnosisTools),
使用原则:
- 基于循证医学原则提供建议
- 主动使用工具获取真实数据
- 对存在风险的处方要明确指出
- 所有建议仅供医生参考,请结合临床实际情况`,
Tools: string(doctorTools),
Config: "{}",
MaxIterations: 10,
Status: "active",
},
{
AgentID: "prescription_agent",
Name: "处方审核Agent",
Description: "审核处方合理性,检查药物相互作用、禁忌症和剂量",
Category: "pharmacy",
SystemPrompt: `你是一位专业的临床药师AI,负责处方审核。
你的职责:
1. 查询药品信息(规格、用法、禁忌)
2. 检查药物相互作用(使用check_drug_interaction工具)
3. 检查患者禁忌症(使用check_contraindication工具)
4. 验证剂量是否合理(使用calculate_dosage工具)
5. 综合评估处方安全性,给出审核意见
AgentID: "admin_universal_agent",
Name: "管理员智能助手",
Description: "管理端全能AI助手:运营数据查询、Agent状态监控、工作流管理、系统帮助",
Category: "admin",
SystemPrompt: `你是互联网医院管理后台的专属AI智能助手,帮助管理员高效管理平台。
审核流程:
- 首先使用check_drug_interaction检查所有药品的相互作用
- 然后对每个药品使用check_contraindication检查患者禁忌
- 使用calculate_dosage验证剂量是否在安全范围内
- 最后综合所有检查结果给出审核意见
你的核心能力:
1. **运营数据**:查询和计算运营指标,分析平台运行状况
2. **Agent监控**:调用其他Agent获取信息,监控Agent运行状态
3. **工作流管理**:触发和查询工作流执行状态
4. **知识库管理**:浏览知识库集合,了解知识库使用情况
5. **人工审核**:发起和管理人工审核任务
6. **通知管理**:发送系统通知
7. **药品/医学查询**:查询药品信息和医学知识辅助决策
请严格按照药品说明书和临床指南进行审核,对于存在风险的处方要明确指出。`,
Tools: string(prescriptionTools),
使用原则:
- 以简洁专业的方式回答管理员的问题
- 主动使用工具获取真实数据
- 提供可操作的建议和方案
- 用中文回答`,
Tools: string(adminTools),
Config: "{}",
MaxIterations: 10,
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 (
"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处理器
type Handler struct {
svc *AgentService
......@@ -24,6 +30,7 @@ func NewHandler() *Handler {
func (h *Handler) RegisterRoutes(r gin.IRouter) {
g := r.Group("/agent")
g.POST("/:agent_id/chat", h.Chat)
g.POST("/:agent_id/chat/stream", h.ChatStream)
g.GET("/sessions", h.ListSessions)
g.DELETE("/session/:session_id", h.DeleteSession)
g.GET("/list", h.ListAgents)
......@@ -44,6 +51,12 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.PUT("/definitions/:agent_id", h.UpdateDefinition)
g.PUT("/definitions/:agent_id/reload", h.ReloadAgent)
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) {
......@@ -73,6 +86,41 @@ func (h *Handler) Chat(c *gin.Context) {
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) {
response.Success(c, h.svc.ListAgents())
}
......@@ -237,6 +285,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
Category string `json:"category"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"`
Skills []string `json:"skills"`
MaxIterations int `json:"max_iterations"`
}
if err := c.ShouldBindJSON(&req); err != nil {
......@@ -244,6 +293,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
return
}
toolsJSON, _ := json.Marshal(req.Tools)
skillsJSON, _ := json.Marshal(req.Skills)
if req.MaxIterations <= 0 {
req.MaxIterations = 10
}
......@@ -254,6 +304,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
Category: req.Category,
SystemPrompt: req.SystemPrompt,
Tools: string(toolsJSON),
Skills: string(skillsJSON),
MaxIterations: req.MaxIterations,
Status: "active",
}
......@@ -274,6 +325,7 @@ func (h *Handler) UpdateDefinition(c *gin.Context) {
Category string `json:"category"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"`
Skills []string `json:"skills"`
MaxIterations int `json:"max_iterations"`
Status string `json:"status"`
}
......@@ -306,6 +358,10 @@ func (h *Handler) UpdateDefinition(c *gin.Context) {
toolsJSON, _ := json.Marshal(req.Tools)
updates["tools"] = string(toolsJSON)
}
if req.Skills != nil {
skillsJSON, _ := json.Marshal(req.Skills)
updates["skills"] = string(skillsJSON)
}
if req.MaxIterations > 0 {
updates["max_iterations"] = req.MaxIterations
}
......
......@@ -28,6 +28,7 @@ func GetService() *AgentService {
globalAgentService = &AgentService{
agents: make(map[string]*agent.ReActAgent),
}
ensureBuiltinSkills()
globalAgentService.loadFromDB()
globalAgentService.ensureBuiltinAgents()
}
......@@ -57,6 +58,40 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
if def.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
if maxIter <= 0 {
maxIter = 10
......@@ -65,7 +100,7 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
ID: def.AgentID,
Name: def.Name,
Description: def.Description,
SystemPrompt: def.SystemPrompt,
SystemPrompt: systemPrompt,
Tools: tools,
MaxIterations: maxIter,
})
......@@ -236,3 +271,109 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
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"
// AgentTool 工具定义
type AgentTool struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);uniqueIndex"`
DisplayName string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
Parameters string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'active'"`
CacheTTL int `gorm:"default:0"` // 缓存秒数,0=不缓存
Timeout int `gorm:"default:30"` // 执行超时秒数
MaxRetries int `gorm:"default:0"` // 失败重试次数
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(200)" json:"display_name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
Parameters string `gorm:"type:jsonb" json:"parameters"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CacheTTL int `gorm:"default:0" json:"cache_ttl"` // 缓存秒数,0=不缓存
Timeout int `gorm:"default:30" json:"timeout"` // 执行超时秒数
MaxRetries int `gorm:"default:0" json:"max_retries"` // 失败重试次数
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentToolLog 工具调用日志
type AgentToolLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID
ToolName string `gorm:"type:varchar(100);index"`
AgentID string `gorm:"type:varchar(100);index"`
SessionID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
InputParams string `gorm:"type:jsonb"`
OutputResult string `gorm:"type:jsonb"`
Success bool
ErrorMessage string `gorm:"type:text"`
DurationMs int
Iteration int // Agent第几轮迭代
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
ToolName string `gorm:"type:varchar(100);index" json:"tool_name"`
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
SessionID string `gorm:"type:varchar(100);index" json:"session_id"`
UserID string `gorm:"type:uuid;index" json:"user_id"`
InputParams string `gorm:"type:jsonb" json:"input_params"`
OutputResult string `gorm:"type:jsonb" json:"output_result"`
Success bool `json:"success"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
DurationMs int `json:"duration_ms"`
Iteration int `json:"iteration"`
CreatedAt time.Time `json:"created_at"`
}
// AgentDefinition Agent定义
type AgentDefinition struct {
ID uint `gorm:"primaryKey"`
AgentID string `gorm:"type:varchar(100);uniqueIndex"`
Name string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
SystemPrompt string `gorm:"type:text"`
Tools string `gorm:"type:jsonb"`
Config string `gorm:"type:jsonb"`
MaxIterations int `gorm:"default:10"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
AgentID string `gorm:"type:varchar(100);uniqueIndex" json:"agent_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
SystemPrompt string `gorm:"type:text" json:"system_prompt"`
Tools string `gorm:"type:jsonb" json:"tools"`
Config string `gorm:"type:jsonb" json:"config"`
MaxIterations int `gorm:"default:10" json:"max_iterations"`
Skills string `gorm:"type:jsonb;default:'[]'" json:"skills"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentSession Agent会话
type AgentSession struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"type:varchar(100);uniqueIndex"`
AgentID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
Context string `gorm:"type:jsonb"`
History string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
SessionID string `gorm:"type:varchar(100);uniqueIndex" json:"session_id"`
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
UserID string `gorm:"type:uuid;index" json:"user_id"`
Context string `gorm:"type:jsonb" json:"context"`
History string `gorm:"type:jsonb" json:"history"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentExecutionLog Agent执行日志
type AgentExecutionLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID
SessionID string `gorm:"type:varchar(100);index"`
AgentID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
Input string `gorm:"type:jsonb"`
Output string `gorm:"type:jsonb"`
ToolCalls string `gorm:"type:jsonb"`
Iterations int
TotalTokens int
DurationMs int
FinishReason string `gorm:"type:varchar(50)"`
Success bool
ErrorMessage string `gorm:"type:text"`
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
SessionID string `gorm:"type:varchar(100);index" json:"session_id"`
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
UserID string `gorm:"type:uuid;index" json:"user_id"`
Input string `gorm:"type:jsonb" json:"input"`
Output string `gorm:"type:jsonb" json:"output"`
ToolCalls string `gorm:"type:jsonb" json:"tool_calls"`
Iterations int `json:"iterations"`
TotalTokens int `json:"total_tokens"`
DurationMs int `json:"duration_ms"`
FinishReason string `gorm:"type:varchar(50)" json:"finish_reason"`
Success bool `json:"success"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
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 (
type Consultation struct {
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"`
DoctorID string `gorm:"type:uuid;index;not null" json:"doctor_id"`
Type string `gorm:"type:varchar(20);not null" json:"type"`
......@@ -21,6 +22,12 @@ type Consultation struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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 {
......@@ -35,6 +42,11 @@ type ConsultMessage struct {
Content string `gorm:"type:text;not null" json:"content"`
ContentType string `gorm:"type:varchar(20);default:'text'" json:"content_type"`
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 {
......
......@@ -4,22 +4,22 @@ import "time"
// HTTPToolDefinition 动态 HTTP 工具定义(管理员从 UI 配置,无需改代码)
type HTTPToolDefinition struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);uniqueIndex"` // 工具名,如 get_weather
DisplayName string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50);default:'http'"`
Method string `gorm:"type:varchar(10);default:'GET'"` // GET/POST/PUT/DELETE
URL string `gorm:"type:text"` // 支持 {{param}} 模板变量
Headers string `gorm:"type:jsonb;default:'{}'"` // {"X-Key": "{{api_key}}"}
BodyTemplate string `gorm:"type:text"` // JSON body 模板,支持 {{param}}
AuthType string `gorm:"type:varchar(20);default:'none'"` // none/bearer/basic/apikey
AuthConfig string `gorm:"type:jsonb;default:'{}'"` // 认证配置
Parameters string `gorm:"type:jsonb;default:'[]'"` // ToolParameter 数组 JSON
Timeout int `gorm:"default:10"` // 超时秒数
CacheTTL int `gorm:"default:0"` // 缓存 TTL 秒,0=不缓存
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedBy string `gorm:"type:varchar(100)"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(200)" json:"display_name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50);default:'http'" json:"category"`
Method string `gorm:"type:varchar(10);default:'GET'" json:"method"`
URL string `gorm:"type:text" json:"url"`
Headers string `gorm:"type:jsonb;default:'{}'" json:"headers"`
BodyTemplate string `gorm:"type:text" json:"body_template"`
AuthType string `gorm:"type:varchar(20);default:'none'" json:"auth_type"`
AuthConfig string `gorm:"type:jsonb;default:'{}'" json:"auth_config"`
Parameters string `gorm:"type:jsonb;default:'[]'" json:"parameters"`
Timeout int `gorm:"default:10" json:"timeout"`
CacheTTL int `gorm:"default:0" json:"cache_ttl"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedBy string `gorm:"type:varchar(100)" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
......@@ -4,40 +4,40 @@ import "time"
// KnowledgeCollection 知识库集合
type KnowledgeCollection struct {
ID uint `gorm:"primaryKey"`
CollectionID string `gorm:"type:varchar(100);uniqueIndex"`
Name string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
DocumentCount int `gorm:"default:0"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
CollectionID string `gorm:"type:varchar(100);uniqueIndex" json:"collection_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
DocumentCount int `gorm:"default:0" json:"document_count"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// KnowledgeDocument 知识文档
type KnowledgeDocument struct {
ID uint `gorm:"primaryKey"`
DocumentID string `gorm:"type:varchar(100);uniqueIndex"`
CollectionID string `gorm:"type:varchar(100);index"`
Title string `gorm:"type:varchar(500)"`
Content string `gorm:"type:text"`
Metadata string `gorm:"type:jsonb"`
FileType string `gorm:"type:varchar(50)"`
ChunkCount int `gorm:"default:0"`
Status string `gorm:"type:varchar(20);default:'ready'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
DocumentID string `gorm:"type:varchar(100);uniqueIndex" json:"document_id"`
CollectionID string `gorm:"type:varchar(100);index" json:"collection_id"`
Title string `gorm:"type:varchar(500)" json:"title"`
Content string `gorm:"type:text" json:"content"`
Metadata string `gorm:"type:jsonb" json:"metadata"`
FileType string `gorm:"type:varchar(50)" json:"file_type"`
ChunkCount int `gorm:"default:0" json:"chunk_count"`
Status string `gorm:"type:varchar(20);default:'ready'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// KnowledgeChunk 知识分块
type KnowledgeChunk struct {
ID uint `gorm:"primaryKey"`
ChunkID string `gorm:"type:varchar(100);uniqueIndex"`
DocumentID string `gorm:"type:varchar(100);index"`
CollectionID string `gorm:"type:varchar(100);index"`
Content string `gorm:"type:text"`
ChunkIndex int
TokenCount int
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
ChunkID string `gorm:"type:varchar(100);uniqueIndex" json:"chunk_id"`
DocumentID string `gorm:"type:varchar(100);index" json:"document_id"`
CollectionID string `gorm:"type:varchar(100);index" json:"collection_id"`
Content string `gorm:"type:text" json:"content"`
ChunkIndex int `json:"chunk_index"`
TokenCount int `json:"token_count"`
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"
// WorkflowDefinition 工作流定义
type WorkflowDefinition struct {
ID uint `gorm:"primaryKey"`
WorkflowID string `gorm:"type:varchar(100);uniqueIndex"`
Name string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
Version int `gorm:"default:1"`
Definition string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'draft'"`
CreatedBy string `gorm:"type:uuid"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
WorkflowID string `gorm:"type:varchar(100);uniqueIndex" json:"workflow_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
Version int `gorm:"default:1" json:"version"`
Definition string `gorm:"type:jsonb" json:"definition"`
Status string `gorm:"type:varchar(20);default:'draft'" json:"status"`
CreatedBy string `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WorkflowExecution 工作流执行实例
type WorkflowExecution struct {
ID uint `gorm:"primaryKey"`
ExecutionID string `gorm:"type:varchar(100);uniqueIndex"`
WorkflowID string `gorm:"type:varchar(100);index"`
Version int
TriggerType string `gorm:"type:varchar(50)"`
TriggerBy string `gorm:"type:uuid"`
Input string `gorm:"type:jsonb"`
Output string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20)"` // pending, running, completed, failed
CurrentNode string `gorm:"type:varchar(100)"`
StartedAt *time.Time
CompletedAt *time.Time
ErrorMessage string `gorm:"type:text"`
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
ExecutionID string `gorm:"type:varchar(100);uniqueIndex" json:"execution_id"`
WorkflowID string `gorm:"type:varchar(100);index" json:"workflow_id"`
Version int `json:"version"`
TriggerType string `gorm:"type:varchar(50)" json:"trigger_type"`
TriggerBy string `gorm:"type:uuid" json:"trigger_by"`
Input string `gorm:"type:jsonb" json:"input"`
Output string `gorm:"type:jsonb" json:"output"`
Status string `gorm:"type:varchar(20)" json:"status"` // pending, running, completed, failed
CurrentNode string `gorm:"type:varchar(100)" json:"current_node"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
CreatedAt time.Time `json:"created_at"`
}
// WorkflowHumanTask 人工审核任务
type WorkflowHumanTask struct {
ID uint `gorm:"primaryKey"`
TaskID string `gorm:"type:varchar(100);uniqueIndex"`
ExecutionID string `gorm:"type:varchar(100);index"`
NodeID string `gorm:"type:varchar(100)"`
AssigneeRole string `gorm:"type:varchar(50)"`
Title string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
FormData string `gorm:"type:jsonb"`
Result string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'pending'"`
CreatedAt time.Time
CompletedAt *time.Time
ID uint `gorm:"primaryKey" json:"id"`
TaskID string `gorm:"type:varchar(100);uniqueIndex" json:"task_id"`
ExecutionID string `gorm:"type:varchar(100);index" json:"execution_id"`
NodeID string `gorm:"type:varchar(100)" json:"node_id"`
AssigneeRole string `gorm:"type:varchar(50)" json:"assignee_role"`
Title string `gorm:"type:varchar(200)" json:"title"`
Description string `gorm:"type:text" json:"description"`
FormData string `gorm:"type:jsonb" json:"form_data"`
Result string `gorm:"type:jsonb" json:"result"`
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
}
......@@ -130,12 +130,12 @@ func (s *Service) GetAIRenewalAdvice(ctx context.Context, userID, renewalID stri
msg := fmt.Sprintf("患者%s申请续药%d个月,当前用药:%s,原因:%s。请评估续药合理性并给出专业建议。",
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 {
return "", fmt.Errorf("AI续药建议获取失败: %w", err)
}
if output == nil {
return "", fmt.Errorf("follow_up_agent 未初始化")
return "", fmt.Errorf("patient_universal_agent 未初始化")
}
advice := output.Response
......
......@@ -2,8 +2,12 @@ package consult
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"internet-hospital/pkg/middleware"
"internet-hospital/pkg/response"
......@@ -29,6 +33,14 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.GET("/doctor/waiting", h.GetWaitingList)
consult.GET("/doctor/patients", h.GetPatientList)
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/reject", h.RejectConsult)
consult.GET("/:id", h.GetConsultDetail)
......@@ -38,6 +50,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.POST("/:id/end", h.EndConsult)
consult.POST("/:id/cancel", h.CancelConsult)
consult.POST("/:id/ai-assist", h.AIAssist)
consult.POST("/:id/upload", h.UploadMedia)
// 患者端处方
consult.GET("/patient/prescriptions", h.GetPatientPrescriptions)
......@@ -77,7 +90,11 @@ func (h *Handler) GetConsultList(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)
if err != nil {
......@@ -113,7 +130,11 @@ func (h *Handler) GetDoctorWorkbenchStats(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)
if err != nil {
......@@ -130,7 +151,11 @@ type SendMessageRequest struct {
}
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")
userRole, _ := c.Get("role")
......@@ -161,7 +186,11 @@ func (h *Handler) SendMessage(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)
if err != nil {
......@@ -173,9 +202,17 @@ func (h *Handler) GetVideoRoomInfo(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())
return
}
......@@ -188,7 +225,11 @@ type CancelRequest struct {
}
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 {
response.Error(c, 500, "取消问诊失败")
......@@ -200,7 +241,11 @@ func (h *Handler) CancelConsult(c *gin.Context) {
// AIAssist AI辅助诊断(SSE流式返回)
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 {
Scene string `json:"scene" binding:"required"` // consult_diagnosis | consult_medication
}
......@@ -249,6 +294,177 @@ func (h *Handler) GetPrescriptionDetail(c *gin.Context) {
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 ==========
// GetDoctorConsultList 医生端获取自己的问诊列表
......@@ -280,7 +496,11 @@ func (h *Handler) GetWaitingList(c *gin.Context) {
// AcceptConsult 医生接诊
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")
if err := h.service.AcceptConsult(c.Request.Context(), id, userID.(string)); err != nil {
......@@ -293,7 +513,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) {
// RejectConsult 医生拒诊
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 {
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
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
"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处理器
type Handler struct {
service *Service
......@@ -63,6 +77,9 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
dp.POST("/prescription/check", h.CheckPrescriptionSafety)
dp.GET("/prescriptions", h.GetDoctorPrescriptions)
dp.GET("/prescription/:id", h.GetPrescriptionDetail)
// v13: 快捷回复
h.RegisterQuickReplyRoutes(dp)
}
}
......@@ -134,7 +151,11 @@ func (h *Handler) GetWaitingQueue(c *gin.Context) {
// AcceptConsult 接受问诊
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)
if err != nil {
response.Error(c, 500, "接诊失败")
......@@ -145,7 +166,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) {
// GetConsultDetail 获取问诊详情
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)
if err != nil {
response.Error(c, 404, "问诊不存在")
......@@ -156,7 +181,11 @@ func (h *Handler) GetConsultDetail(c *gin.Context) {
// GetConsultMessages 获取问诊消息
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)
if err != nil {
response.Error(c, 500, "获取消息失败")
......@@ -167,7 +196,11 @@ func (h *Handler) GetConsultMessages(c *gin.Context) {
// SendMessage 发送消息
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 {
Content string `json:"content" binding:"required"`
ContentType string `json:"content_type"`
......@@ -186,7 +219,11 @@ func (h *Handler) SendMessage(c *gin.Context) {
// EndConsult 结束问诊
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 {
Summary string `json:"summary"`
}
......
......@@ -17,6 +17,7 @@ import (
type CreatePrescriptionReq struct {
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
......@@ -55,6 +56,15 @@ type PrescriptionListResp struct {
// ==================== 处方开具服务 ====================
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
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
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) {
drugList := strings.Join(drugs, "、")
agentCtx := map[string]interface{}{
......@@ -210,12 +220,12 @@ func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID
message := fmt.Sprintf("请审核以下处方的安全性:%s,检查药物相互作用和禁忌症", drugList)
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 {
return nil, fmt.Errorf("处方安全审核失败: %w", err)
}
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 (
"encoding/json"
"fmt"
"internet-hospital/pkg/ai"
"time"
"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输入
type AgentInput struct {
SessionID string `json:"session_id"`
......@@ -183,26 +200,167 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
}, nil
}
// RunStream 流式执行Agent
func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onChunk func(AgentChunk) error) (*AgentOutput, error) {
// 简化:先用非流式,后续可升级为真正的流式
output, err := a.Run(ctx, input)
// RunStream 流式执行Agent,通过 onEvent 实时推送中间过程
func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent func(StreamEvent) error) (*AgentOutput, error) {
traceID := input.TraceID
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 {
return nil, err
return nil, fmt.Errorf("AI调用失败: %w", err)
}
// 模拟流式输出
for _, tc := range output.ToolCalls {
if err := onChunk(AgentChunk{Type: "tool_call", Content: tc}); err != nil {
return output, err
totalTokens += resp.Usage.TotalTokens
ai.SaveLog(ai.LogParams{
Scene: a.cfg.ID,
UserID: input.UserID,
RequestContent: input.Message,
ResponseContent: resp.Choices[0].Message.Content,
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
TotalTokens: resp.Usage.TotalTokens,
Success: true,
TraceID: traceID,
AgentID: a.cfg.ID,
SessionID: input.SessionID,
Iteration: i + 1,
})
choice := resp.Choices[0]
// 最终文本响应 — 流式推送
if choice.FinishReason == "stop" || len(choice.Message.ToolCalls) == 0 {
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 {
return output, err
onEvent(StreamEvent{
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 {
......
......@@ -26,7 +26,7 @@ func (t *AgentCallerTool) Parameters() []agent.ToolParameter {
{
Name: "agent_id",
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,
},
{
......
......@@ -2,13 +2,18 @@ package websocket
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
)
var upgrader = websocket.Upgrader{
......@@ -22,10 +27,14 @@ var upgrader = websocket.Upgrader{
// MessageHandler 消息处理回调
type MessageHandler func(consultID, senderID, senderType, content, contentType string) (*Message, error)
// ReadReceiptHandler 已读回执处理回调
type ReadReceiptHandler func(messageID string) error
// Handler WebSocket处理器
type Handler struct {
hub *Hub
messageHandler MessageHandler
readReceiptHandler ReadReceiptHandler
}
// NewHandler 创建WebSocket处理器
......@@ -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路由
func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
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连接
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")
userType := c.Query("user_type") // patient or doctor
token := c.Query("token")
......@@ -135,6 +187,12 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
}, client.UserID)
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{
Type: "read",
......@@ -145,6 +203,9 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
Timestamp: time.Now(),
}, client.UserID)
case "consult_update", "new_patient", "online_status":
// 这些类型由服务端主动推送,客户端发送时忽略
case "ping":
// 心跳响应
client.SendMessage(&Message{
......
This diff is collapsed.
......@@ -20,6 +20,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-rnd": "^10.5.2",
"tailwindcss": "^4",
"zustand": "^5.0.5"
},
......@@ -1659,6 +1660,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
......@@ -2248,6 +2258,12 @@
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz",
......@@ -2516,6 +2532,18 @@
"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": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
......@@ -3254,6 +3282,15 @@
"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": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz",
......@@ -3313,6 +3350,23 @@
"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": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz",
......@@ -3935,6 +3989,16 @@
"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": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
......@@ -3956,6 +4020,20 @@
"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": {
"version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
......@@ -3989,6 +4067,27 @@
"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": {
"version": "11.0.0",
"resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz",
......
......@@ -21,6 +21,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-rnd": "^10.5.2",
"tailwindcss": "^4",
"zustand": "^5.0.5"
},
......
import { get, post, put, del } from './request';
import { sseRequest } from './sse';
// ==================== 类型定义 ====================
......@@ -70,6 +71,7 @@ export interface AgentDefinition {
category: string;
system_prompt: string;
tools: string; // JSON array string
skills: string; // JSON array string of skill_ids
config: string;
max_iterations: number;
status: string;
......@@ -84,10 +86,27 @@ export type AgentDefinitionParams = {
category?: string;
system_prompt?: string;
tools?: string;
skills?: string[];
max_iterations?: number;
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 {
workflow_id: string;
name: string;
......@@ -108,6 +127,18 @@ export interface KnowledgeDocumentParams {
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 ====================
// AI 推理调用专用超时(2分钟)
......@@ -120,6 +151,23 @@ export const agentApi = {
context?: Record<string, unknown>;
}) => 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: () =>
get<{ id: string; name: string; description: string }[]>('/agent/list'),
......@@ -165,6 +213,20 @@ export const agentApi = {
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 ====================
export interface HTTPToolDefinition {
......
......@@ -26,8 +26,13 @@ export interface ConsultMessage {
consult_id: string;
sender_type: 'patient' | 'doctor' | 'system' | 'ai';
content: string;
content_type: 'text' | 'image' | 'file' | 'prescription';
content_type: 'text' | 'image' | 'file' | 'prescription' | 'audio';
created_at: string;
// v13 新增
read_at?: string | null;
media_url?: string;
media_type?: string;
reply_to_id?: string | null;
}
export interface CreateConsultParams {
......@@ -80,6 +85,7 @@ export interface VideoRoomInfo {
export interface PatientListItem {
id: string;
consult_id: string;
serial_number: string;
patient_id: string;
patient_name: string;
patient_gender: string;
......@@ -92,6 +98,111 @@ export interface PatientListItem {
created_at: string;
started_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
......@@ -104,36 +215,62 @@ export const consultApi = {
getConsultList: (status?: ConsultStatus) =>
get<Consultation[]>('/consult/list', { params: { status } }),
// 获取问诊详情
getConsultDetail: (id: string) => get<Consultation>(`/consult/${id}`),
// 获取问诊详情(支持 consult_id 或 serial_number)
getConsultDetail: (idOrSerial: string) => get<Consultation>(`/consult/${idOrSerial}`),
// 获取问诊消息历史
getConsultMessages: (id: string) => get<ConsultMessage[]>(`/consult/${id}/messages`),
// 获取问诊消息历史(支持 consult_id 或 serial_number)
getConsultMessages: (idOrSerial: string) => get<ConsultMessage[]>(`/consult/${idOrSerial}/messages`),
// 发送消息
sendMessage: (consult_id: string, content: string, content_type: string = 'text') =>
post<ConsultMessage>(`/consult/${consult_id}/message`, { content, content_type }),
// 发送消息(支持 consult_id 或 serial_number)
sendMessage: (idOrSerial: string, content: string, content_type: string = 'text') =>
post<ConsultMessage>(`/consult/${idOrSerial}/message`, { content, content_type }),
// 获取视频房间信息
getVideoRoomInfo: (consult_id: string) =>
get<VideoRoomInfo>(`/consult/${consult_id}/video-room`),
// 获取视频房间信息(支持 consult_id 或 serial_number)
getVideoRoomInfo: (idOrSerial: string) =>
get<VideoRoomInfo>(`/consult/${idOrSerial}/video-room`),
// 结束问诊
endConsult: (id: string) => post<null>(`/consult/${id}/end`),
// 结束问诊(支持 consult_id 或 serial_number)
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
aiAssist: (id: string, scene: string) =>
// AI辅助分析(支持 consult_id 或 serial_number)
aiAssist: (idOrSerial: string, scene: string) =>
post<{
scene: string;
response: string;
tool_calls?: import('./agent').ToolCall[];
iterations?: 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 }),
// 取消问诊
cancelConsult: (id: string, reason?: string) =>
post<null>(`/consult/${id}/cancel`, { reason }),
// v13: 上传多媒体文件(支持 consult_id 或 serial_number)
uploadMedia: (idOrSerial: string, file: File) => {
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 = {
// 获取统一患者列表(待接诊+进行中+已完成)
getPatientList: () => get<PatientListItem[]>('/consult/doctor/patients'),
// 获取患者完整画像
getPatientProfile: (patientId: string) => get<PatientProfile>(`/consult/doctor/patient-profile/${patientId}`),
// 获取医生工作台统计
getDoctorWorkbenchStats: () => get<DoctorWorkbenchStats>('/consult/doctor/workbench-stats'),
// 接诊
acceptConsult: (id: string) => post<null>(`/consult/${id}/accept`),
// 接诊(支持 consult_id 或 serial_number)
acceptConsult: (idOrSerial: string) => post<null>(`/consult/${idOrSerial}/accept`),
// 拒诊
rejectConsult: (id: string, reason?: string) =>
post<null>(`/consult/${id}/reject`, { reason }),
// 拒诊(支持 consult_id 或 serial_number)
rejectConsult: (idOrSerial: string, reason?: string) =>
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 { ToolCall } from './agent';
......@@ -14,6 +14,13 @@ export interface DoctorWorkbenchStats {
rating: number;
income_today: 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 = {
getWaitingQueue: () =>
get<WaitingPatient[]>('/doctor-portal/queue/waiting'),
acceptConsult: (consultId: string) =>
post<Consultation>(`/doctor-portal/consult/${consultId}/accept`),
acceptConsult: (idOrSerial: string) =>
post<Consultation>(`/doctor-portal/consult/${idOrSerial}/accept`),
// === 问诊操作 ===
getConsultDetail: (consultId: string) =>
get<Consultation>(`/doctor-portal/consult/${consultId}`),
// === 问诊操作(支持 consult_id 或 serial_number) ===
getConsultDetail: (idOrSerial: string) =>
get<Consultation>(`/doctor-portal/consult/${idOrSerial}`),
getConsultMessages: (consultId: string) =>
get<ConsultMessage[]>(`/doctor-portal/consult/${consultId}/messages`),
getConsultMessages: (idOrSerial: string) =>
get<ConsultMessage[]>(`/doctor-portal/consult/${idOrSerial}/messages`),
sendMessage: (consultId: string, content: string, contentType: string = 'text') =>
post<ConsultMessage>(`/doctor-portal/consult/${consultId}/message`, {
sendMessage: (idOrSerial: string, content: string, contentType: string = 'text') =>
post<ConsultMessage>(`/doctor-portal/consult/${idOrSerial}/message`, {
content,
content_type: contentType,
}),
endConsult: (consultId: string, summary?: string) =>
post<null>(`/doctor-portal/consult/${consultId}/end`, { summary }),
endConsult: (idOrSerial: string, summary?: string) =>
post<null>(`/doctor-portal/consult/${idOrSerial}/end`, { summary }),
// === 历史问诊 ===
getConsultHistory: (params?: { page?: number; page_size?: number; status?: string }) =>
......@@ -192,4 +199,37 @@ export const doctorPortalApi = {
has_warning: boolean;
has_contraindication: boolean;
}>('/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 { sseRequest } from './sse';
// ====== 对话消息统一格式 ======
export interface ChatMessage {
......@@ -65,19 +66,6 @@ export interface ChatDoneInfo {
}
// ====== 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(
url: string,
body: object,
......@@ -88,91 +76,12 @@ export function sseChat(
onError?: (error: string) => 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();
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 sseRequest(url, body, {
session: (data) => callbacks.onSession?.(data as unknown as ChatSessionInfo),
chunk: (data) => callbacks.onChunk?.((data.content as string) || ''),
done: (data) => callbacks.onDone?.(data as unknown as ChatDoneInfo),
error: (data) => callbacks.onError?.((data.error as string) || '未知错误'),
});
return controller;
}
// 预问诊 API
......
......@@ -93,6 +93,7 @@ export interface CreatePrescriptionItemReq {
export interface CreatePrescriptionReq {
consult_id?: string;
serial_number?: string;
patient_id: string;
patient_name: 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 {
ReloadOutlined, PlusOutlined,
} from '@ant-design/icons';
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';
const { Text } = Typography;
const { RangePicker } = DatePicker;
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> = {
pre_consult: '预问诊', diagnosis: '诊断辅助', prescription: '处方审核', follow_up: '随访管理',
patient: '患者服务', doctor: '医生辅助', pharmacy: '处方审核', admin: '管理辅助', general: '通用',
};
const CATEGORY_OPTIONS = [
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断辅助' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访管理' },
{ value: 'patient', label: '患者服务' },
{ value: 'doctor', label: '医生辅助' },
{ value: 'pharmacy', 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 {
response: string;
tool_calls?: ToolCall[];
......@@ -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 [availableTools, setAvailableTools] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<{ name: string; description: string; category: string }[]>([]);
// 执行日志
const [logs, setLogs] = useState<AgentExecutionLog[]>([]);
......@@ -73,7 +93,7 @@ export default function AgentsPage() {
useEffect(() => {
agentApi.listTools().then(res => {
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(() => {});
fetchLogs();
......@@ -92,6 +112,7 @@ export default function AgentsPage() {
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const toolsArr = values.tools_array as string[] || [];
const skillsArr = values.skills_array as string[] || [];
const params = {
agent_id: values.agent_id as string,
name: values.name as string,
......@@ -99,6 +120,7 @@ export default function AgentsPage() {
category: values.category as string,
system_prompt: values.system_prompt as string,
tools: JSON.stringify(toolsArr),
skills: skillsArr,
max_iterations: values.max_iterations as number,
status: values.status as string,
};
......@@ -136,7 +158,9 @@ export default function AgentsPage() {
const openEdit = (agent?: AgentDefinition) => {
if (agent) {
let toolsArr: string[] = [];
let skillsArr: string[] = [];
try { toolsArr = JSON.parse(agent.tools || '[]'); } catch {}
try { skillsArr = JSON.parse(agent.skills || '[]'); } catch {}
form.setFieldsValue({
agent_id: agent.agent_id,
name: agent.name,
......@@ -144,6 +168,7 @@ export default function AgentsPage() {
category: agent.category,
system_prompt: agent.system_prompt,
tools_array: toolsArr,
skills_array: skillsArr,
max_iterations: agent.max_iterations,
status: agent.status === 'active',
});
......@@ -237,7 +262,7 @@ export default function AgentsPage() {
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) => (
<Space>
<Button size="small" icon={<PlayCircleOutlined />} type="link" onClick={() => openTest(r.agent_id, r.name)}>测试</Button>
......@@ -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 (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
......@@ -309,6 +350,7 @@ export default function AgentsPage() {
loading={agentsLoading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
/>
),
},
......@@ -379,7 +421,7 @@ export default function AgentsPage() {
onFinish={(values) => saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' })}
>
<Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}>
<Input placeholder="如: diagnosis_agent" disabled={!editModal.isNew} />
<Input placeholder="如: doctor_universal_agent" disabled={!editModal.isNew} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如: 诊断辅助 Agent" />
......@@ -401,6 +443,9 @@ export default function AgentsPage() {
allowClear
/>
</Form.Item>
<Form.Item name="skills_array" label="技能包">
<SkillSelect />
</Form.Item>
<Form.Item name="max_iterations" label="最大迭代次数">
<InputNumber min={1} max={50} style={{ width: 120 }} />
</Form.Item>
......
......@@ -208,10 +208,9 @@ export default function AICenterPage() {
value={agentFilter || undefined}
onChange={(v) => { setAgentFilter(v ?? ''); setPage(1); }}
options={[
{ value: 'pre_consult_agent', label: '预问诊 Agent' },
{ value: 'diagnosis_agent', label: '诊断辅助 Agent' },
{ value: 'prescription_agent', label: '处方审核 Agent' },
{ value: 'follow_up_agent', label: '随访管理 Agent' },
{ value: 'patient_universal_agent', label: '患者智能助手' },
{ value: 'doctor_universal_agent', label: '医生智能助手' },
{ value: 'admin_universal_agent', label: '管理员智能助手' },
]}
/>
<Input.Search
......
'use client';
import React from 'react';
import React, { useState } from 'react';
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 {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, CloudOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
const { Content } = Layout;
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
......@@ -35,9 +36,7 @@ const menuItems = [
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: 'Agent管理' },
{ key: '/admin/tool-market', icon: <AppstoreOutlined />, label: '工具市场' },
{ key: '/admin/tools', icon: <ApiOutlined />, label: '内置工具' },
{ key: '/admin/http-tools', icon: <CloudOutlined />, label: 'HTTP工具' },
{ key: '/admin/tools', icon: <AppstoreOutlined />, label: '工具中心' },
{ key: '/admin/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
......@@ -51,6 +50,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const pathname = usePathname();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const currentPath = pathname || '';
const userMenuItems = [
......@@ -70,50 +70,98 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (key === 'logout') { logout(); router.push('/login'); }
};
// 选中的菜单项(支持子路径匹配)
const getSelectedKeys = () => {
const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
'/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
'/admin/ai-config', '/admin/compliance', '/admin/agents', '/admin/tool-market',
'/admin/tools', '/admin/http-tools', '/admin/workflows',
'/admin/ai-config', '/admin/compliance', '/admin/agents',
'/admin/tools', '/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center'];
const match = allKeys.find(k => currentPath.startsWith(k));
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 (
<Layout style={{ minHeight: '100vh' }}>
<header
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
collapsedWidth={64}
trigger={null}
style={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
height: 56, display: 'flex', alignItems: 'center', padding: '0 24px',
background: '#ffffff',
borderBottom: '1px solid #e8f0fc',
boxShadow: '0 1px 4px rgba(0, 82, 204, 0.06)',
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
}}
>
{/* Logo */}
<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')}
>
<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' }} />
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129' }}>互联网医院</span>
<Tag color="purple" style={{ marginLeft: 2, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理后台</Tag>
{!collapsed && (
<>
<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>
{/* Menu */}
<Menu
mode="horizontal"
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
theme="light"
className="patient-nav-menu"
style={{ flex: 1, minWidth: 0, borderBottom: 'none' }}
style={{ border: 'none', padding: '8px 0' }}
/>
</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">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
......@@ -124,15 +172,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Text style={{ color: '#1d2129', fontSize: 13 }}>{user.real_name || '管理员'}</Text>
</Space>
</Dropdown>
) : (
<Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)}
) : null}
</div>
</header>
<Content style={{ marginTop: 56, minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
......@@ -398,7 +398,7 @@ export default function SafetyPage() {
</Col>
<Col span={12}>
<Form.Item name="agent_id" label="限定 Agent(空=全局)">
<Input placeholder="如: prescription_agent" />
<Input placeholder="如: doctor_universal_agent" />
</Form.Item>
</Col>
</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