Commit 94e48089 authored by yuguo's avatar yuguo

fix

parent 147e328b
...@@ -43,7 +43,8 @@ ...@@ -43,7 +43,8 @@
"Bash(/tmp/check_perms.sql:*)", "Bash(/tmp/check_perms.sql:*)",
"Bash(/tmp/check_perms2.sql:*)", "Bash(/tmp/check_perms2.sql:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(cd:*)" "Bash(cd:*)",
"Bash(PGPASSWORD=postgres psql:*)"
] ]
} }
} }
# AI智能助手升级改造方案
## 一、现状分析
### 1.1 系统架构概述
互联网医院AI智能助手采用 **ReAct Agent** 架构,支持三种角色端:
- **患者端** (`patient_universal_agent`):预问诊、找医生、健康咨询
- **医生端** (`doctor_universal_agent`):辅助诊断、处方审核、病历生成
- **管理端** (`admin_universal_agent`):运营分析、工作流、平台管理
### 1.2 核心组件
| 组件 | 位置 | 功能 |
|------|------|------|
| ReActAgent | `server/pkg/agent/react_agent.go` | Agent执行引擎,支持流式/非流式 |
| ToolRegistry | `server/pkg/agent/registry.go` | 工具注册中心 |
| NavigatePageTool | `server/pkg/agent/tools/navigate_page.go` | 页面导航工具 |
| ChatPanel | `web/src/components/GlobalAIFloat/ChatPanel.tsx` | 前端聊天面板 |
| AgentService | `server/internal/agent/service.go` | Agent服务层 |
### 1.3 已注册工具清单(21个)
| 类别 | 工具名 | 功能 |
|------|--------|------|
| **基础查询** | query_drug | 药品信息查询 |
| | query_medical_record | 病历记录查询 |
| | query_symptom_knowledge | 症状知识查询 |
| | recommend_department | 科室推荐 |
| **处方安全** | check_drug_interaction | 药物相互作用检查 |
| | check_contraindication | 禁忌症检查 |
| | calculate_dosage | 剂量计算 |
| **知识库** | search_medical_knowledge | 医学知识检索 |
| | write_knowledge | 知识写入 |
| | list_knowledge_collections | 知识库列表 |
| **Agent/工作流** | call_agent | Agent互调 |
| | trigger_workflow | 触发工作流 |
| | query_workflow_status | 查询工作流状态 |
| | request_human_review | 人工审核请求 |
| **导航** | navigate_page | 页面导航 |
| **通知** | send_notification | 发送通知 |
| | generate_follow_up_plan | 随访计划生成 |
| **表达式** | eval_expression | 表达式计算 |
| **代码生成** | generate_tool | 动态工具生成 |
| **动态工具** | DynamicSQLTool | 动态SQL查询 |
| | DynamicHTTPTool | 动态HTTP调用 |
---
## 二、已修复问题
### 2.1 导航权限控制缺失(已修复)
**问题描述**
在管理端智能助手中输入"找医生",AI会返回患者端的找医生页面(`/patient/doctors`),没有进行角色权限限制。
**根本原因**
1. `Parameters()` 方法返回所有菜单给LLM,不区分用户角色
2. LLM看到所有页面选项后,可能选择任何页面
3. 虽然 `Execute()` 有权限校验,但用户体验差(先选错再被拒绝)
**修复方案**
#### 2.1.1 增强工具描述(navigate_page.go)
```go
func (t *NavigatePageTool) Description() string {
return "导航到互联网医院系统页面。【重要】必须根据当前用户角色选择对应端的页面:admin用户选admin_*页面,doctor用户选doctor_*页面,patient用户选patient_*页面,否则会被拒绝访问。"
}
```
#### 2.1.2 按角色分组显示菜单
```go
// Parameters() 方法中按角色分组
desc.WriteString("【管理端页面 - 仅admin可访问】")
desc.WriteString("【医生端页面 - 仅doctor可访问】")
desc.WriteString("【患者端页面 - 仅patient可访问】")
```
#### 2.1.3 系统提示注入用户角色
```go
func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}, userRole string) string {
// ...
prompt += fmt.Sprintf("\n\n【当前用户角色】%s\n使用navigate_page工具时,必须选择当前角色有权限访问的页面,否则会被拒绝。", desc)
}
```
#### 2.1.4 保持执行时权限校验(最后防线)
```go
func checkPagePermission(userRole, pageCode string, entry menuEntry) (bool, string) {
// admin → 可访问所有页面
// patient → 仅可访问 patient_* 页面
// doctor → 仅可访问 doctor_* 页面
}
```
---
## 三、待完善功能建议
### 3.1 高优先级
#### 3.1.1 工具权限精细化控制
**现状**:所有工具对所有角色可见
**建议**
-`AgentTool` 表增加 `allowed_roles` 字段
-`getFilteredTools()` 中根据用户角色过滤工具
- 敏感工具(如 `generate_tool``write_knowledge`)仅对特定角色开放
```go
// 建议的数据结构
type AgentTool struct {
// ...
AllowedRoles string `json:"allowed_roles"` // "admin,doctor" 或 "*"
}
```
#### 3.1.2 导航结果前端二次校验
**现状**:前端直接执行后端返回的导航指令
**建议**
- 前端在执行导航前校验路由是否在当前角色允许范围内
- 对于 `permission_denied` 结果,显示友好提示而非静默失败
```typescript
// 建议的前端校验
function validateNavigation(route: string, userRole: string): boolean {
if (userRole === 'admin') return true;
if (userRole === 'doctor') return route.startsWith('/doctor');
if (userRole === 'patient') return route.startsWith('/patient');
return false;
}
```
#### 3.1.3 会话上下文持久化增强
**现状**:会话历史仅存储消息内容
**建议**
- 持久化用户角色、当前页面上下文
- 支持跨会话的上下文继承
- 增加会话摘要功能,减少长对话的token消耗
### 3.2 中优先级
#### 3.2.1 工具调用审计日志
**现状**:有 `AgentExecutionLog` 但缺少工具级别的审计
**建议**
- 记录每次工具调用的参数、结果、耗时
- 对敏感操作(导航、写入、通知)增加审计标记
- 支持按工具、用户、时间范围查询
#### 3.2.2 智能工具推荐
**现状**`ToolSelector` 基于关键词匹配
**建议**
- 引入语义相似度匹配(使用Embedding)
- 基于用户历史行为推荐常用工具
- 支持工具组合推荐(如"开处方"自动关联药品查询+禁忌检查+剂量计算)
#### 3.2.3 多轮对话优化
**现状**:每轮对话独立处理
**建议**
- 实现对话意图追踪
- 支持指代消解("刚才那个医生"→具体医生ID)
- 增加确认机制(敏感操作前确认)
### 3.3 低优先级
#### 3.3.1 Agent配置热更新
**现状**:需要调用 `ReloadAgent` API
**建议**
- 支持配置文件监听自动重载
- 增加配置版本管理
- 支持A/B测试不同配置
#### 3.3.2 多模态支持
**建议**
- 支持图片输入(如检查报告图片)
- 支持语音输入/输出
- 支持图表生成(统计数据可视化)
#### 3.3.3 国际化支持
**建议**
- 工具描述多语言
- 系统提示模板多语言
- 错误消息多语言
---
## 四、技术债务清理
### 4.1 代码层面
| 问题 | 位置 | 建议 |
|------|------|------|
| 硬编码角色列表 | 多处 | 抽取为常量或配置 |
| 菜单缓存TTL固定 | navigate_page.go | 支持配置化 |
| 工具分类硬编码 | init.go | 迁移到数据库或配置文件 |
### 4.2 架构层面
| 问题 | 建议 |
|------|------|
| 工具接口缺少上下文感知 | 考虑增加 `ContextAwareTool` 接口 |
| 前后端路由定义重复 | 统一路由注册表,前端从后端获取 |
| Agent配置分散 | 统一配置中心管理 |
---
## 五、实施计划
### 第一阶段:安全加固(1周)✅ 已完成
- [x] 修复导航权限控制问题
- [x] 增加工具权限精细化控制
- [x] 前端导航二次校验
- [x] 工具调用审计日志
### 第二阶段:体验优化(2周)✅ 已完成
- [x] 会话上下文持久化增强
- [x] 智能工具推荐(使用pgvector)
- [x] 多轮对话优化(意图追踪、实体消解)
- [x] 错误提示友好化
### 第三阶段:能力扩展(3周)✅ 已完成
- [x] Agent配置热更新和版本管理
- [x] 多模态支持基础框架
- [x] 技术债务清理(角色常量、配置化缓存TTL)
---
## 六、修改文件清单
### 6.1 初始修复文件
| 文件 | 修改内容 |
|------|----------|
| `server/pkg/agent/tools/navigate_page.go` | 增加 `RoleScope` 字段、`inferRoleScope()` 函数、按角色分组显示菜单、增强工具描述 |
| `server/pkg/agent/react_agent.go` | 修改 `buildSystemPrompt()` 签名,注入用户角色信息 |
### 6.2 v16升级新增文件
| 文件 | 功能 |
|------|------|
| `server/pkg/agent/constants.go` | 统一角色常量、缓存配置、审计级别等 |
| `server/pkg/agent/tool_recommender.go` | 智能工具推荐器(pgvector语义匹配) |
| `server/pkg/agent/conversation_context.go` | 对话上下文管理(意图追踪、实体消解) |
| `server/pkg/agent/config_manager.go` | Agent配置版本管理器 |
| `server/pkg/agent/multimodal.go` | 多模态处理器(图片、音频、文档) |
| `server/migrations/004_ai_assistant_upgrade.sql` | 数据库迁移脚本 |
| `web/src/lib/navigation-event.ts` | 前端导航权限校验增强 |
| `web/src/components/GlobalAIFloat/ChatPanel.tsx` | 前端导航二次校验集成 |
### 6.3 v16升级修改文件
| 文件 | 修改内容 |
|------|----------|
| `server/internal/model/agent.go` | AgentTool增加权限字段、AgentToolLog增加审计字段、AgentSession增加上下文字段 |
| `server/pkg/agent/executor.go` | 增加角色权限检查、审计日志增强 |
| `server/internal/agent/service.go` | 会话上下文持久化增强 |
| `server/internal/agent/init.go` | 初始化工具推荐器 |
---
## 七、测试验证
### 7.1 功能测试用例
| 场景 | 输入 | 预期结果 |
|------|------|----------|
| 管理员找医生 | "找医生" | 导航到 `/admin/doctors` |
| 患者找医生 | "找医生" | 导航到 `/patient/doctors` |
| 医生访问管理页面 | "打开患者管理" | 返回权限拒绝提示 |
| 患者访问医生页面 | "打开工作台" | 返回权限拒绝提示 |
### 7.2 回归测试
- 验证所有现有导航功能正常
- 验证工具调用日志正常记录
- 验证会话持久化正常
---
*文档版本:v1.0*
*更新日期:2026-03-05*
*作者:AI Assistant*
...@@ -73,6 +73,10 @@ func InitTools() { ...@@ -73,6 +73,10 @@ func InitTools() {
agent.InitToolMonitor(db) agent.InitToolMonitor(db)
initToolSelector(r) initToolSelector(r)
log.Println("[InitTools] ToolMonitor & ToolSelector 初始化完成") log.Println("[InitTools] ToolMonitor & ToolSelector 初始化完成")
// v16: 初始化智能工具推荐器(使用pgvector)
agent.InitToolRecommender(db, embedder)
log.Println("[InitTools] ToolRecommender 初始化完成")
} }
// WireCallbacks 注入跨包回调(在 InitTools 和 GetService 初始化完成后调用) // WireCallbacks 注入跨包回调(在 InitTools 和 GetService 初始化完成后调用)
......
...@@ -273,6 +273,19 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, userRole, sess ...@@ -273,6 +273,19 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, userRole, sess
historyJSON, _ := json.Marshal(history) historyJSON, _ := json.Marshal(history)
contextJSON, _ := json.Marshal(contextData) contextJSON, _ := json.Marshal(contextData)
// v16: 提取页面上下文
currentPage := ""
pageContextJSON := ""
if contextData != nil {
if page, ok := contextData["page"].(map[string]interface{}); ok {
if pathname, ok := page["pathname"].(string); ok {
currentPage = pathname
}
pageCtxBytes, _ := json.Marshal(page)
pageContextJSON = string(pageCtxBytes)
}
}
if session.ID == 0 { if session.ID == 0 {
session = model.AgentSession{ session = model.AgentSession{
SessionID: sessionID, SessionID: sessionID,
...@@ -281,11 +294,21 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, userRole, sess ...@@ -281,11 +294,21 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, userRole, sess
History: string(historyJSON), History: string(historyJSON),
Context: string(contextJSON), Context: string(contextJSON),
Status: "active", Status: "active",
UserRole: userRole,
CurrentPage: currentPage,
PageContext: pageContextJSON,
MessageCount: 2,
TotalTokens: output.TotalTokens,
} }
db.Create(&session) db.Create(&session)
} else { } else {
db.Model(&session).Updates(map[string]interface{}{ db.Model(&session).Updates(map[string]interface{}{
"history": string(historyJSON), "history": string(historyJSON),
"user_role": userRole,
"current_page": currentPage,
"page_context": pageContextJSON,
"message_count": session.MessageCount + 2,
"total_tokens": session.TotalTokens + output.TotalTokens,
"updated_at": time.Now(), "updated_at": time.Now(),
}) })
} }
...@@ -426,6 +449,19 @@ func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, userRole ...@@ -426,6 +449,19 @@ func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, userRole
historyJSON, _ := json.Marshal(history) historyJSON, _ := json.Marshal(history)
contextJSON, _ := json.Marshal(contextData) contextJSON, _ := json.Marshal(contextData)
// v16: 提取页面上下文(ChatStream)
currentPageStream := ""
pageContextJSONStream := ""
if contextData != nil {
if page, ok := contextData["page"].(map[string]interface{}); ok {
if pathname, ok := page["pathname"].(string); ok {
currentPageStream = pathname
}
pageCtxBytes, _ := json.Marshal(page)
pageContextJSONStream = string(pageCtxBytes)
}
}
if session.ID == 0 { if session.ID == 0 {
session = model.AgentSession{ session = model.AgentSession{
SessionID: sessionID, SessionID: sessionID,
...@@ -434,11 +470,21 @@ func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, userRole ...@@ -434,11 +470,21 @@ func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, userRole
History: string(historyJSON), History: string(historyJSON),
Context: string(contextJSON), Context: string(contextJSON),
Status: "active", Status: "active",
UserRole: userRole,
CurrentPage: currentPageStream,
PageContext: pageContextJSONStream,
MessageCount: 2,
TotalTokens: output.TotalTokens,
} }
db.Create(&session) db.Create(&session)
} else { } else {
db.Model(&session).Updates(map[string]interface{}{ db.Model(&session).Updates(map[string]interface{}{
"history": string(historyJSON), "history": string(historyJSON),
"user_role": userRole,
"current_page": currentPageStream,
"page_context": pageContextJSONStream,
"message_count": session.MessageCount + 2,
"total_tokens": session.TotalTokens + output.TotalTokens,
"updated_at": time.Now(), "updated_at": time.Now(),
}) })
} }
......
...@@ -22,6 +22,10 @@ type AgentTool struct { ...@@ -22,6 +22,10 @@ type AgentTool struct {
QualityScore float64 `gorm:"default:0" json:"quality_score"` // 质量评分 0-100 QualityScore float64 `gorm:"default:0" json:"quality_score"` // 质量评分 0-100
LastUsedAt *time.Time `json:"last_used_at"` // 最后使用时间 LastUsedAt *time.Time `json:"last_used_at"` // 最后使用时间
AutoDisabled bool `gorm:"default:false" json:"auto_disabled"` // 自动禁用标记 AutoDisabled bool `gorm:"default:false" json:"auto_disabled"` // 自动禁用标记
// v16: 工具权限精细化控制
AllowedRoles string `gorm:"type:varchar(100);default:'*'" json:"allowed_roles"` // 允许的角色,逗号分隔,*表示所有角色
IsSensitive bool `gorm:"default:false" json:"is_sensitive"` // 是否敏感操作(需审计)
AuditLevel string `gorm:"type:varchar(20);default:'none'" json:"audit_level"` // 审计级别: none/basic/full
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
...@@ -40,6 +44,11 @@ type AgentToolLog struct { ...@@ -40,6 +44,11 @@ type AgentToolLog struct {
ErrorMessage string `gorm:"type:text" json:"error_message"` ErrorMessage string `gorm:"type:text" json:"error_message"`
DurationMs int `json:"duration_ms"` DurationMs int `json:"duration_ms"`
Iteration int `json:"iteration"` Iteration int `json:"iteration"`
// v16: 审计增强字段
UserRole string `gorm:"type:varchar(50)" json:"user_role"`
AuditLevel string `gorm:"type:varchar(20);default:'none'" json:"audit_level"`
IsSensitive bool `gorm:"default:false" json:"is_sensitive"`
PageContext string `gorm:"type:varchar(200)" json:"page_context"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
...@@ -69,6 +78,15 @@ type AgentSession struct { ...@@ -69,6 +78,15 @@ type AgentSession struct {
Context string `gorm:"type:jsonb" json:"context"` Context string `gorm:"type:jsonb" json:"context"`
History string `gorm:"type:jsonb" json:"history"` History string `gorm:"type:jsonb" json:"history"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"` Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
// v16: 会话上下文增强
UserRole string `gorm:"type:varchar(50)" json:"user_role"` // 用户角色
CurrentPage string `gorm:"type:varchar(200)" json:"current_page"` // 当前页面路径
PageContext string `gorm:"type:jsonb" json:"page_context"` // 页面上下文数据
Summary string `gorm:"type:text" json:"summary"` // 会话摘要(减少token消耗)
MessageCount int `gorm:"default:0" json:"message_count"` // 消息数量
TotalTokens int `gorm:"default:0" json:"total_tokens"` // 累计token消耗
LastIntent string `gorm:"type:varchar(100)" json:"last_intent"` // 最后识别的意图
EntityContext string `gorm:"type:jsonb" json:"entity_context"` // 实体上下文(指代消解)
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
......
...@@ -144,6 +144,7 @@ type ConsultListParams struct { ...@@ -144,6 +144,7 @@ type ConsultListParams struct {
// AdminConsultItem 管理后台问诊列表项 // AdminConsultItem 管理后台问诊列表项
type AdminConsultItem struct { type AdminConsultItem struct {
ID string `json:"id"` ID string `json:"id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id"` PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"` PatientName string `json:"patient_name"`
DoctorID string `json:"doctor_id"` DoctorID string `json:"doctor_id"`
......
...@@ -265,6 +265,7 @@ type DoctorManageItem struct { ...@@ -265,6 +265,7 @@ type DoctorManageItem struct {
DepartmentName string `json:"department_name"` DepartmentName string `json:"department_name"`
Rating float64 `json:"rating"` Rating float64 `json:"rating"`
ConsultCount int `json:"consult_count"` ConsultCount int `json:"consult_count"`
Price int `json:"price"`
SubmittedAt *time.Time `json:"submitted_at"` SubmittedAt *time.Time `json:"submitted_at"`
ReviewID string `json:"review_id"` ReviewID string `json:"review_id"`
LicenseImage string `json:"license_image"` LicenseImage string `json:"license_image"`
...@@ -315,6 +316,7 @@ func (s *Service) GetDoctorManageList(ctx context.Context, params *DoctorManageP ...@@ -315,6 +316,7 @@ func (s *Service) GetDoctorManageList(ctx context.Context, params *DoctorManageP
item.Hospital = doctor.Hospital item.Hospital = doctor.Hospital
item.Rating = doctor.Rating item.Rating = doctor.Rating
item.ConsultCount = doctor.ConsultCount item.ConsultCount = doctor.ConsultCount
item.Price = doctor.Price
item.Status = doctor.Status item.Status = doctor.Status
// 获取科室信息 // 获取科室信息
......
...@@ -130,9 +130,8 @@ func (s *Service) UpdateDoctor(ctx context.Context, doctorID string, req *Update ...@@ -130,9 +130,8 @@ func (s *Service) UpdateDoctor(ctx context.Context, doctorID string, req *Update
if req.Introduction != "" { if req.Introduction != "" {
doctorUpdates["introduction"] = req.Introduction doctorUpdates["introduction"] = req.Introduction
} }
if req.Price > 0 { // Price 允许设置为 0,所以不判断 > 0
doctorUpdates["price"] = req.Price doctorUpdates["price"] = req.Price
}
if len(doctorUpdates) > 0 { if len(doctorUpdates) > 0 {
return s.db.Model(&model.Doctor{}).Where("user_id = ?", doctorID).Updates(doctorUpdates).Error return s.db.Model(&model.Doctor{}).Where("user_id = ?", doctorID).Updates(doctorUpdates).Error
} }
......
...@@ -30,6 +30,14 @@ func (h *Handler) CreateWorkflow(c *gin.Context) { ...@@ -30,6 +30,14 @@ func (h *Handler) CreateWorkflow(c *gin.Context) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
// 获取当前用户ID
userID, _ := c.Get("user_id")
createdBy := ""
if uid, ok := userID.(string); ok {
createdBy = uid
}
wf := model.WorkflowDefinition{ wf := model.WorkflowDefinition{
WorkflowID: req.WorkflowID, WorkflowID: req.WorkflowID,
Name: req.Name, Name: req.Name,
...@@ -38,8 +46,12 @@ func (h *Handler) CreateWorkflow(c *gin.Context) { ...@@ -38,8 +46,12 @@ func (h *Handler) CreateWorkflow(c *gin.Context) {
Definition: req.Definition, Definition: req.Definition,
Status: "draft", Status: "draft",
Version: 1, Version: 1,
CreatedBy: createdBy,
}
if err := database.GetDB().Create(&wf).Error; err != nil {
response.Error(c, 500, "创建失败: "+err.Error())
return
} }
database.GetDB().Create(&wf)
response.Success(c, wf) response.Success(c, wf)
} }
......
-- AI智能助手升级迁移脚本 v16
-- 包含:工具权限控制、会话上下文增强、智能工具推荐、审计日志等
-- ============================================
-- 1. 工具权限精细化控制
-- ============================================
ALTER TABLE agent_tools ADD COLUMN IF NOT EXISTS allowed_roles VARCHAR(100) DEFAULT '*';
ALTER TABLE agent_tools ADD COLUMN IF NOT EXISTS is_sensitive BOOLEAN DEFAULT FALSE;
ALTER TABLE agent_tools ADD COLUMN IF NOT EXISTS audit_level VARCHAR(20) DEFAULT 'none';
-- 设置敏感工具的权限和审计级别
UPDATE agent_tools SET allowed_roles = 'admin', is_sensitive = true, audit_level = 'full'
WHERE name IN ('generate_tool', 'write_knowledge', 'trigger_workflow');
UPDATE agent_tools SET is_sensitive = true, audit_level = 'basic'
WHERE name IN ('navigate_page', 'send_notification', 'request_human_review');
UPDATE agent_tools SET allowed_roles = 'admin,doctor'
WHERE name IN ('query_medical_record', 'check_drug_interaction', 'check_contraindication', 'calculate_dosage');
-- ============================================
-- 2. 会话上下文增强
-- ============================================
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS user_role VARCHAR(50);
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS current_page VARCHAR(200);
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS page_context JSONB;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS message_count INT DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS total_tokens INT DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS last_intent VARCHAR(100);
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_context JSONB;
-- ============================================
-- 3. 工具调用审计日志增强
-- ============================================
ALTER TABLE agent_tool_logs ADD COLUMN IF NOT EXISTS user_role VARCHAR(50);
ALTER TABLE agent_tool_logs ADD COLUMN IF NOT EXISTS audit_level VARCHAR(20) DEFAULT 'none';
ALTER TABLE agent_tool_logs ADD COLUMN IF NOT EXISTS is_sensitive BOOLEAN DEFAULT FALSE;
ALTER TABLE agent_tool_logs ADD COLUMN IF NOT EXISTS page_context VARCHAR(200);
CREATE INDEX IF NOT EXISTS idx_agent_tool_logs_audit ON agent_tool_logs(audit_level) WHERE audit_level != 'none';
CREATE INDEX IF NOT EXISTS idx_agent_tool_logs_sensitive ON agent_tool_logs(is_sensitive) WHERE is_sensitive = true;
-- ============================================
-- 4. 智能工具推荐 - 工具向量索引表
-- ============================================
CREATE TABLE IF NOT EXISTS tool_embeddings (
id SERIAL PRIMARY KEY,
tool_name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
keywords TEXT,
embedding vector(1024),
usage_count INT DEFAULT 0,
success_rate DECIMAL(5,2) DEFAULT 0,
avg_duration_ms INT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tool_embeddings_name ON tool_embeddings(tool_name);
-- 向量索引(需要pgvector扩展)
-- CREATE INDEX IF NOT EXISTS idx_tool_embeddings_vector ON tool_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 20);
-- ============================================
-- 5. 用户工具使用历史(用于个性化推荐)
-- ============================================
CREATE TABLE IF NOT EXISTS user_tool_preferences (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
user_role VARCHAR(50),
tool_name VARCHAR(100) NOT NULL,
usage_count INT DEFAULT 1,
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
success_count INT DEFAULT 0,
avg_satisfaction DECIMAL(3,2) DEFAULT 0,
UNIQUE(user_id, tool_name)
);
CREATE INDEX IF NOT EXISTS idx_user_tool_prefs_user ON user_tool_preferences(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tool_prefs_role ON user_tool_preferences(user_role);
-- ============================================
-- 6. 意图识别缓存表
-- ============================================
CREATE TABLE IF NOT EXISTS intent_cache (
id SERIAL PRIMARY KEY,
query_hash VARCHAR(64) UNIQUE NOT NULL,
query_text TEXT,
intent VARCHAR(100),
entities JSONB,
confidence DECIMAL(5,4),
tool_suggestions JSONB,
hit_count INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_intent_cache_hash ON intent_cache(query_hash);
CREATE INDEX IF NOT EXISTS idx_intent_cache_intent ON intent_cache(intent);
-- ============================================
-- 7. Agent配置版本管理
-- ============================================
CREATE TABLE IF NOT EXISTS agent_config_versions (
id SERIAL PRIMARY KEY,
agent_id VARCHAR(100) NOT NULL,
version INT NOT NULL,
config JSONB NOT NULL,
system_prompt TEXT,
tools JSONB,
is_active BOOLEAN DEFAULT FALSE,
created_by VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(agent_id, version)
);
CREATE INDEX IF NOT EXISTS idx_agent_config_versions_agent ON agent_config_versions(agent_id);
CREATE INDEX IF NOT EXISTS idx_agent_config_versions_active ON agent_config_versions(agent_id, is_active) WHERE is_active = true;
-- ============================================
-- 8. 多模态支持 - 附件表
-- ============================================
CREATE TABLE IF NOT EXISTS agent_attachments (
id SERIAL PRIMARY KEY,
attachment_id VARCHAR(100) UNIQUE NOT NULL,
session_id VARCHAR(100),
message_index INT,
file_type VARCHAR(50), -- image, audio, document
mime_type VARCHAR(100),
file_name VARCHAR(200),
file_size INT,
storage_path VARCHAR(500),
thumbnail_path VARCHAR(500),
ocr_text TEXT, -- OCR识别文本
analysis_result JSONB, -- AI分析结果
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_agent_attachments_session ON agent_attachments(session_id);
-- ============================================
-- 完成
-- ============================================
# AI智能助手v16升级迁移指南
## 执行步骤
### 1. 连接到远程数据库
```bash
# 使用psql连接到远程数据库
psql -h 10.10.0.102 -p 5432 -U postgres -d xxxx
```
### 2. 执行迁移脚本
连接成功后,在psql命令行中执行:
```sql
-- 执行迁移脚本
\i server/migrations/004_ai_assistant_upgrade.sql
```
或者直接在命令行执行:
```bash
psql -h 10.10.0.102 -p 5432 -U postgres -d xxxx -f server/migrations/004_ai_assistant_upgrade.sql
```
### 3. 验证迁移结果
执行以下SQL验证表结构是否正确更新:
```sql
-- 检查agent_tools表新增字段
\d agent_tools
-- 检查agent_sessions表新增字段
\d agent_sessions
-- 检查agent_tool_logs表新增字段
\d agent_tool_logs
-- 验证新增的表
\dt tool_embeddings
\dt user_tool_preferences
\dt intent_cache
\dt agent_config_versions
\dt agent_attachments
```
### 4. 启用pgvector扩展(如果未启用)
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### 5. 验证数据更新
```sql
-- 检查工具权限设置
SELECT name, allowed_roles, is_sensitive, audit_level FROM agent_tools WHERE is_sensitive = true;
-- 检查索引是否创建成功
SELECT indexname FROM pg_indexes WHERE tablename IN ('agent_tool_logs', 'tool_embeddings');
```
## 注意事项
1. **备份数据**:执行迁移前请先备份重要数据
2. **权限确认**:确保postgres用户有足够的权限执行ALTER和CREATE操作
3. **事务处理**:迁移脚本包含多个操作,如果中途失败需要检查并手动处理
4. **pgvector依赖**:智能工具推荐功能需要pgvector扩展支持
## 迁移内容摘要
- ✅ 工具权限控制字段(allowed_roles, is_sensitive, audit_level)
- ✅ 会话上下文字段(user_role, current_page, page_context等)
- ✅ 审计日志增强字段(user_role, audit_level, is_sensitive)
- ✅ 智能工具推荐相关表(tool_embeddings, user_tool_preferences)
- ✅ 意图缓存表(intent_cache)
- ✅ 配置版本管理表(agent_config_versions)
- ✅ 多模态附件表(agent_attachments)
## 回滚方案
如果需要回滚,请保存以下SQL:
```sql
-- 删除新增的表(注意会丢失数据!)
DROP TABLE IF EXISTS agent_attachments;
DROP TABLE IF EXISTS agent_config_versions;
DROP TABLE IF EXISTS intent_cache;
DROP TABLE IF EXISTS user_tool_preferences;
DROP TABLE IF EXISTS tool_embeddings;
-- 删除新增的列(注意会丢失数据!)
ALTER TABLE agent_tools DROP COLUMN IF EXISTS allowed_roles;
ALTER TABLE agent_tools DROP COLUMN IF EXISTS is_sensitive;
ALTER TABLE agent_tools DROP COLUMN IF EXISTS audit_level;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS user_role;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS current_page;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS page_context;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS summary;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS message_count;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS total_tokens;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS last_intent;
ALTER TABLE agent_sessions DROP COLUMN IF EXISTS entity_context;
ALTER TABLE agent_tool_logs DROP COLUMN IF EXISTS user_role;
ALTER TABLE agent_tool_logs DROP COLUMN IF EXISTS audit_level;
ALTER TABLE agent_tool_logs DROP COLUMN IF EXISTS is_sensitive;
ALTER TABLE agent_tool_logs DROP COLUMN IF EXISTS page_context;
```
This diff is collapsed.
package agent
import "time"
// 角色常量(v16: 技术债务清理 - 统一角色定义)
const (
RoleAdmin = "admin"
RoleDoctor = "doctor"
RolePatient = "patient"
RolePublic = "public"
)
// AllRoles 所有角色列表
var AllRoles = []string{RoleAdmin, RoleDoctor, RolePatient}
// RoleDescriptions 角色描述
var RoleDescriptions = map[string]string{
RoleAdmin: "管理员(可访问所有admin_*页面)",
RoleDoctor: "医生(仅可访问doctor_*页面)",
RolePatient: "患者(仅可访问patient_*页面)",
}
// 缓存配置(v16: 技术债务清理 - 配置化缓存TTL)
const (
DefaultMenuCacheTTL = 5 * time.Minute // 菜单缓存TTL
DefaultToolCacheTTL = 30 * time.Second // 工具结果缓存TTL
DefaultIntentCacheTTL = 24 * time.Hour // 意图缓存TTL
DefaultSessionTimeout = 30 * time.Minute // 会话超时时间
DefaultAttachmentTTL = 7 * 24 * time.Hour // 附件保留时间
)
// Agent配置默认值
const (
DefaultMaxIterations = 10
DefaultToolTimeout = 30 // 秒
DefaultMaxRetries = 0
)
// 审计级别
const (
AuditLevelNone = "none"
AuditLevelBasic = "basic"
AuditLevelFull = "full"
)
// 工具分类
const (
ToolCategoryQuery = "query" // 查询类
ToolCategoryAction = "action" // 操作类
ToolCategoryAnalysis = "analysis" // 分析类
ToolCategoryNavigation = "navigation" // 导航类
ToolCategoryWorkflow = "workflow" // 工作流类
)
// 敏感工具列表(需要审计)
var SensitiveTools = []string{
"generate_tool",
"write_knowledge",
"trigger_workflow",
"navigate_page",
"send_notification",
"request_human_review",
}
// 角色工具权限映射
var RoleToolPermissions = map[string][]string{
RoleAdmin: {"*"}, // admin可以使用所有工具
RoleDoctor: {
"query_drug",
"query_medical_record",
"check_drug_interaction",
"check_contraindication",
"calculate_dosage",
"search_medical_knowledge",
"navigate_page",
"send_notification",
"generate_follow_up_plan",
},
RolePatient: {
"query_drug",
"recommend_department",
"search_medical_knowledge",
"navigate_page",
},
}
// IsValidRole 检查角色是否有效
func IsValidRole(role string) bool {
for _, r := range AllRoles {
if r == role {
return true
}
}
return false
}
// GetRoleDescription 获取角色描述
func GetRoleDescription(role string) string {
if desc, ok := RoleDescriptions[role]; ok {
return desc
}
return role
}
// IsSensitiveTool 检查是否为敏感工具
func IsSensitiveTool(toolName string) bool {
for _, t := range SensitiveTools {
if t == toolName {
return true
}
}
return false
}
This diff is collapsed.
...@@ -5,25 +5,59 @@ import ( ...@@ -5,25 +5,59 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
) )
// getToolConfig 从数据库获取工具配置(CacheTTL/Timeout/MaxRetries/Status) // getToolConfig 从数据库获取工具配置(CacheTTL/Timeout/MaxRetries/Status/AllowedRoles
func getToolConfig(name string) model.AgentTool { func getToolConfig(name string) model.AgentTool {
db := database.GetDB() db := database.GetDB()
if db == nil { if db == nil {
return model.AgentTool{Status: "active", Timeout: 30} return model.AgentTool{Status: "active", Timeout: 30, AllowedRoles: "*"}
} }
var tool model.AgentTool var tool model.AgentTool
if err := db.Where("name = ?", name).First(&tool).Error; err != nil { if err := db.Where("name = ?", name).First(&tool).Error; err != nil {
return model.AgentTool{Status: "active", Timeout: 30} return model.AgentTool{Status: "active", Timeout: 30, AllowedRoles: "*"}
} }
return tool return tool
} }
// checkToolPermission 检查用户角色是否有权限使用该工具
func checkToolPermission(cfg model.AgentTool, userRole string) bool {
if cfg.AllowedRoles == "" || cfg.AllowedRoles == "*" {
return true
}
if userRole == "" {
return false
}
// admin 拥有所有工具权限
if userRole == "admin" {
return true
}
// 检查角色是否在允许列表中
for _, role := range splitRoles(cfg.AllowedRoles) {
if role == userRole || role == "*" {
return true
}
}
return false
}
// splitRoles 分割角色字符串
func splitRoles(roles string) []string {
var result []string
for _, r := range strings.Split(roles, ",") {
r = strings.TrimSpace(r)
if r != "" {
result = append(result, r)
}
}
return result
}
// Executor 工具执行器 // Executor 工具执行器
type Executor struct { type Executor struct {
registry *ToolRegistry registry *ToolRegistry
...@@ -36,6 +70,11 @@ func NewExecutor(r *ToolRegistry) *Executor { ...@@ -36,6 +70,11 @@ func NewExecutor(r *ToolRegistry) *Executor {
// Execute 执行工具调用(不记日志,集成缓存) // Execute 执行工具调用(不记日志,集成缓存)
func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) ToolResult { func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) ToolResult {
return e.ExecuteWithRole(ctx, name, argsJSON, "")
}
// ExecuteWithRole 执行工具调用(带角色权限检查)
func (e *Executor) ExecuteWithRole(ctx context.Context, name string, argsJSON string, userRole string) ToolResult {
cfg := getToolConfig(name) cfg := getToolConfig(name)
// 检查工具是否被禁用 // 检查工具是否被禁用
...@@ -43,6 +82,14 @@ func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) To ...@@ -43,6 +82,14 @@ func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) To
return ToolResult{Success: false, Error: fmt.Sprintf("工具 %s 已被管理员禁用", name)} return ToolResult{Success: false, Error: fmt.Sprintf("工具 %s 已被管理员禁用", name)}
} }
// v16: 检查用户角色权限
if userRole == "" {
userRole, _ = ctx.Value(ContextKeyUserRole).(string)
}
if !checkToolPermission(cfg, userRole) {
return ToolResult{Success: false, Error: fmt.Sprintf("您的角色(%s)无权使用工具 %s", userRole, name)}
}
// 缓存命中检查 // 缓存命中检查
if cfg.CacheTTL > 0 { if cfg.CacheTTL > 0 {
if cached, ok := e.cache.Get(name, argsJSON); ok { if cached, ok := e.cache.Get(name, argsJSON); ok {
...@@ -96,10 +143,12 @@ func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) To ...@@ -96,10 +143,12 @@ func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) To
// ExecuteWithLog 执行工具调用并写入 AgentToolLog // ExecuteWithLog 执行工具调用并写入 AgentToolLog
func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID, agentID, sessionID, userID string, iteration int) ToolResult { func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID, agentID, sessionID, userID string, iteration int) ToolResult {
start := time.Now() start := time.Now()
result := e.Execute(ctx, name, argsJSON) userRole, _ := ctx.Value(ContextKeyUserRole).(string)
result := e.ExecuteWithRole(ctx, name, argsJSON, userRole)
durationMs := int(time.Since(start).Milliseconds()) durationMs := int(time.Since(start).Milliseconds())
cfg := getToolConfig(name)
// 异步写日志 + 更新工具质量指标(v15) // 异步写日志 + 更新工具质量指标(v15)+ 审计增强(v16)
go func() { go func() {
db := database.GetDB() db := database.GetDB()
if db == nil { if db == nil {
...@@ -122,6 +171,9 @@ func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID, ...@@ -122,6 +171,9 @@ func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID,
ErrorMessage: errMsg, ErrorMessage: errMsg,
DurationMs: durationMs, DurationMs: durationMs,
Iteration: iteration, Iteration: iteration,
UserRole: userRole,
AuditLevel: cfg.AuditLevel,
IsSensitive: cfg.IsSensitive,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := db.Create(entry).Error; err != nil { if err := db.Create(entry).Error; err != nil {
......
This diff is collapsed.
...@@ -156,7 +156,7 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e ...@@ -156,7 +156,7 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
} }
messages := []ai.ChatMessage{ messages := []ai.ChatMessage{
{Role: "system", Content: a.buildSystemPrompt(input.Context)}, {Role: "system", Content: a.buildSystemPrompt(input.Context, input.UserRole)},
} }
messages = append(messages, input.History...) messages = append(messages, input.History...)
messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message}) messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message})
...@@ -271,7 +271,7 @@ func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent fu ...@@ -271,7 +271,7 @@ func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent fu
} }
messages := []ai.ChatMessage{ messages := []ai.ChatMessage{
{Role: "system", Content: a.buildSystemPrompt(input.Context)}, {Role: "system", Content: a.buildSystemPrompt(input.Context, input.UserRole)},
} }
messages = append(messages, input.History...) messages = append(messages, input.History...)
messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message}) messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message})
...@@ -437,7 +437,7 @@ const actionsPromptSuffix = ` ...@@ -437,7 +437,7 @@ const actionsPromptSuffix = `
- 导航路径必须是系统中存在的页面 - 导航路径必须是系统中存在的页面
` `
func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string { func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}, userRole string) string {
// 1. 优先从数据库加载该Agent关联的 active 提示词模板 // 1. 优先从数据库加载该Agent关联的 active 提示词模板
prompt := ai.GetActivePromptByAgent(a.cfg.ID) prompt := ai.GetActivePromptByAgent(a.cfg.ID)
// 2. 回退到 AgentDefinition 的 SystemPrompt(即代码配置值) // 2. 回退到 AgentDefinition 的 SystemPrompt(即代码配置值)
...@@ -450,7 +450,20 @@ func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string { ...@@ -450,7 +450,20 @@ func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string {
prompt += fmt.Sprintf("\n\n当前患者ID: %s", patientID) prompt += fmt.Sprintf("\n\n当前患者ID: %s", patientID)
} }
} }
// 4. 追加 ACTIONS 按钮格式说明(v12) // 4. 注入当前用户角色信息(用于导航权限控制)
if userRole != "" {
roleDesc := map[string]string{
"admin": "管理员(可访问所有admin_*页面)",
"doctor": "医生(仅可访问doctor_*页面)",
"patient": "患者(仅可访问patient_*页面)",
}
desc := roleDesc[userRole]
if desc == "" {
desc = userRole
}
prompt += fmt.Sprintf("\n\n【当前用户角色】%s\n使用navigate_page工具时,必须选择当前角色有权限访问的页面,否则会被拒绝。", desc)
}
// 5. 追加 ACTIONS 按钮格式说明(v12)
prompt += actionsPromptSuffix prompt += actionsPromptSuffix
return prompt return prompt
} }
......
This diff is collapsed.
...@@ -17,6 +17,7 @@ type menuEntry struct { ...@@ -17,6 +17,7 @@ type menuEntry struct {
Path string // 路由路径 e.g. "/admin/doctors" Path string // 路由路径 e.g. "/admin/doctors"
Name string // 页面名称 e.g. "医生管理" Name string // 页面名称 e.g. "医生管理"
Permission string // 权限码 e.g. "admin:doctors:list" Permission string // 权限码 e.g. "admin:doctors:list"
RoleScope string // 角色范围: admin/doctor/patient(从路径前缀推断)
} }
// routeCache 路由缓存(从数据库 menus 表动态加载) // routeCache 路由缓存(从数据库 menus 表动态加载)
...@@ -24,7 +25,7 @@ var ( ...@@ -24,7 +25,7 @@ var (
menuCache map[string]menuEntry // key = page_code e.g. "admin_doctors" menuCache map[string]menuEntry // key = page_code e.g. "admin_doctors"
menuCacheMu sync.RWMutex menuCacheMu sync.RWMutex
menuCacheTime time.Time menuCacheTime time.Time
menuCacheTTL = 5 * time.Minute menuCacheTTL = agent.DefaultMenuCacheTTL // v16: 使用统一常量
) )
// loadMenuCache 从数据库 menus 表加载路由数据(带缓存) // loadMenuCache 从数据库 menus 表加载路由数据(带缓存)
...@@ -62,10 +63,13 @@ func loadMenuCache() map[string]menuEntry { ...@@ -62,10 +63,13 @@ func loadMenuCache() map[string]menuEntry {
pageCode = strings.ReplaceAll(pageCode, "/", "_") pageCode = strings.ReplaceAll(pageCode, "/", "_")
pageCode = strings.ReplaceAll(pageCode, "-", "_") pageCode = strings.ReplaceAll(pageCode, "-", "_")
if pageCode != "" { if pageCode != "" {
// 从路径推断角色范围
roleScope := inferRoleScope(m.Path)
cache[pageCode] = menuEntry{ cache[pageCode] = menuEntry{
Path: m.Path, Path: m.Path,
Name: m.Name, Name: m.Name,
Permission: m.Permission, Permission: m.Permission,
RoleScope: roleScope,
} }
} }
} }
...@@ -79,21 +83,60 @@ func loadMenuCache() map[string]menuEntry { ...@@ -79,21 +83,60 @@ func loadMenuCache() map[string]menuEntry {
type NavigatePageTool struct{} type NavigatePageTool struct{}
func (t *NavigatePageTool) Name() string { return "navigate_page" } func (t *NavigatePageTool) Name() string { return "navigate_page" }
func (t *NavigatePageTool) Description() string { return "导航到互联网医院系统页面(路由从数据库菜单表实时获取)" } func (t *NavigatePageTool) Description() string {
return "导航到互联网医院系统页面。【重要】必须根据当前用户角色选择对应端的页面:admin用户选admin_*页面,doctor用户选doctor_*页面,patient用户选patient_*页面,否则会被拒绝访问。"
}
func (t *NavigatePageTool) Parameters() []agent.ToolParameter { func (t *NavigatePageTool) Parameters() []agent.ToolParameter {
cache := loadMenuCache() cache := loadMenuCache()
// 构建 page_code 枚举和说明 // 构建 page_code 枚举和说明(按角色分组)
codes := make([]string, 0, len(cache)) codes := make([]string, 0, len(cache))
for code := range cache { adminPages := make([]string, 0)
doctorPages := make([]string, 0)
patientPages := make([]string, 0)
publicPages := make([]string, 0)
for code, entry := range cache {
codes = append(codes, code) codes = append(codes, code)
pageInfo := fmt.Sprintf("%s (%s)", code, entry.Name)
switch entry.RoleScope {
case "admin":
adminPages = append(adminPages, pageInfo)
case "doctor":
doctorPages = append(doctorPages, pageInfo)
case "patient":
patientPages = append(patientPages, pageInfo)
default:
publicPages = append(publicPages, pageInfo)
}
} }
// 构建可选页面说明 // 构建按角色分组的说明
var desc strings.Builder var desc strings.Builder
desc.WriteString("目标页面代码。可选值:") desc.WriteString("目标页面代码。【权限说明】只能导航到当前用户角色对应的页面,否则会被拒绝。")
for code, entry := range cache { if len(adminPages) > 0 {
desc.WriteString(fmt.Sprintf("\n- %s (%s)", code, entry.Name)) desc.WriteString("\n\n【管理端页面 - 仅admin可访问】")
for _, p := range adminPages {
desc.WriteString("\n- " + p)
}
}
if len(doctorPages) > 0 {
desc.WriteString("\n\n【医生端页面 - 仅doctor可访问】")
for _, p := range doctorPages {
desc.WriteString("\n- " + p)
}
}
if len(patientPages) > 0 {
desc.WriteString("\n\n【患者端页面 - 仅patient可访问】")
for _, p := range patientPages {
desc.WriteString("\n- " + p)
}
}
if len(publicPages) > 0 {
desc.WriteString("\n\n【公共页面】")
for _, p := range publicPages {
desc.WriteString("\n- " + p)
}
} }
return []agent.ToolParameter{ return []agent.ToolParameter{
...@@ -189,6 +232,20 @@ func (t *NavigatePageTool) Execute(ctx context.Context, params map[string]interf ...@@ -189,6 +232,20 @@ func (t *NavigatePageTool) Execute(ctx context.Context, params map[string]interf
}, nil }, nil
} }
// inferRoleScope 从路径推断页面所属角色范围
func inferRoleScope(path string) string {
switch {
case strings.HasPrefix(path, "/admin"):
return "admin"
case strings.HasPrefix(path, "/doctor"):
return "doctor"
case strings.HasPrefix(path, "/patient"):
return "patient"
default:
return "public"
}
}
// checkPagePermission 基于 User.Role 的页面导航权限校验 // checkPagePermission 基于 User.Role 的页面导航权限校验
// 规则: // 规则:
// - admin → 可访问所有页面(admin_*、patient_*、doctor_*) // - admin → 可访问所有页面(admin_*、patient_*、doctor_*)
...@@ -213,12 +270,12 @@ func checkPagePermission(userRole, pageCode string, entry menuEntry) (bool, stri ...@@ -213,12 +270,12 @@ func checkPagePermission(userRole, pageCode string, entry menuEntry) (bool, stri
if userRole == "patient" { if userRole == "patient" {
return true, "" return true, ""
} }
return false, fmt.Sprintf("该页面仅限患者访问", ) return false, fmt.Sprintf("您没有访问患者端「%s」页面的权限", entry.Name)
case strings.HasPrefix(pageCode, "doctor_"): case strings.HasPrefix(pageCode, "doctor_"):
if userRole == "doctor" { if userRole == "doctor" {
return true, "" return true, ""
} }
return false, fmt.Sprintf("该页面仅限医生访问") return false, fmt.Sprintf("您没有访问医生端「%s」页面的权限", entry.Name)
default: default:
// 无明确前缀的页面(如果有),放行 // 无明确前缀的页面(如果有),放行
return true, "" return true, ""
......
...@@ -6,6 +6,7 @@ const nextConfig: NextConfig = { ...@@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
skipTrailingSlashRedirect: true, skipTrailingSlashRedirect: true,
transpilePackages: ['@xyflow/react', '@xyflow/system'],
async rewrites() { async rewrites() {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8080'; const backendUrl = process.env.BACKEND_URL || 'http://localhost:8080';
return [ return [
......
...@@ -207,6 +207,7 @@ export interface AILogListParams { ...@@ -207,6 +207,7 @@ export interface AILogListParams {
// 问诊管理 // 问诊管理
export interface AdminConsultItem { export interface AdminConsultItem {
id: string; id: string;
serial_number: string;
patient_id: string; patient_id: string;
patient_name: string; patient_name: string;
doctor_id: string; doctor_id: string;
......
...@@ -341,55 +341,6 @@ export default function AILogsPage() { ...@@ -341,55 +341,6 @@ export default function AILogsPage() {
</div> </div>
), ),
}, },
{
key: 'ai-calls',
label: <Space><FundOutlined />AI 调用日志</Space>,
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Space style={{ flexWrap: 'wrap' }}>
<Select
placeholder="按 Agent 过滤"
allowClear
style={{ width: 200 }}
value={aiAgentFilter || undefined}
onChange={(v) => { setAiAgentFilter(v ?? ''); setAiPage(1); }}
options={agents.map(a => ({ value: a.agent_id, label: a.name }))}
/>
<RangePicker
defaultValue={[dayjs(), dayjs()]}
onChange={(dates) => {
const start = dates?.[0]?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD');
const end = dates?.[1]?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD');
setAiDateRange([start, end]);
setAiPage(1);
}}
/>
<Input.Search
placeholder="按 TraceID 追踪"
value={traceSearch}
onChange={(e) => setTraceSearch(e.target.value)}
onSearch={(v) => v && openTrace(v)}
style={{ width: 280 }}
prefix={<SearchOutlined />}
/>
</Space>
<Table
columns={aiLogColumns}
dataSource={aiStats?.recent_logs ?? []}
rowKey="id"
loading={aiLoading}
size="small"
pagination={{
current: aiPage,
total: aiStats?.logs_total ?? 0,
pageSize: 20,
onChange: setAiPage,
showTotal: (t) => `共 ${t} 条`,
}}
/>
</div>
),
},
{ {
key: 'agent-stats', key: 'agent-stats',
label: <Space><FundOutlined />统计分析</Space>, label: <Space><FundOutlined />统计分析</Space>,
......
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, Badge } from 'antd'; import { Card, Table, Tag, Button, Modal, Drawer, Form, Input, Select, message, Space, Badge } from 'antd';
import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined } from '@ant-design/icons'; import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined } from '@ant-design/icons';
import { workflowApi } from '@/api/agent'; import { workflowApi } from '@/api/agent';
import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor'; import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor';
...@@ -17,6 +17,13 @@ interface Workflow { ...@@ -17,6 +17,13 @@ interface Workflow {
definition?: string; definition?: string;
} }
interface CreateWorkflowFormValues {
workflow_id: string;
name: string;
description?: string;
category?: string;
}
const statusColor: Record<string, 'success' | 'warning' | 'default'> = { const statusColor: Record<string, 'success' | 'warning' | 'default'> = {
active: 'success', draft: 'warning', archived: 'default', active: 'success', draft: 'warning', archived: 'default',
}; };
...@@ -24,15 +31,27 @@ const statusLabel: Record<string, string> = { ...@@ -24,15 +31,27 @@ const statusLabel: Record<string, string> = {
active: '已启用', draft: '草稿', archived: '已归档', active: '已启用', draft: '草稿', archived: '已归档',
}; };
const categoryLabel: Record<string, string> = { const categoryLabel: Record<string, string> = {
pre_consult: '预问诊', diagnosis: '诊断', prescription: '处方审核', follow_up: '随访', pre_consult: '预问诊',
consult_created: '问诊创建',
consult_ended: '问诊结束',
follow_up: '随访',
prescription_created: '处方创建',
prescription_approved: '处方审核通过',
payment_completed: '支付完成',
renewal_requested: '续方申请',
health_alert: '健康预警',
doctor_review: '医生审核',
chronic_management: '慢病管理',
medication_reminder: '用药提醒',
lab_report_review: '检验报告审核',
}; };
export default function WorkflowsPage() { export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState<Workflow[]>([]); const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [createModal, setCreateModal] = useState(false); const [createDrawer, setCreateDrawer] = useState(false);
const [editorModal, setEditorModal] = useState(false); const [editorModal, setEditorModal] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null); const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm<CreateWorkflowFormValues>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tableLoading, setTableLoading] = useState(false); const [tableLoading, setTableLoading] = useState(false);
...@@ -48,7 +67,7 @@ export default function WorkflowsPage() { ...@@ -48,7 +67,7 @@ export default function WorkflowsPage() {
useEffect(() => { fetchWorkflows(); }, []); useEffect(() => { fetchWorkflows(); }, []);
const handleCreate = async (values: Record<string, string>) => { const handleCreate = async (values: CreateWorkflowFormValues) => {
setLoading(true); setLoading(true);
try { try {
const definition = { const definition = {
...@@ -67,16 +86,34 @@ export default function WorkflowsPage() { ...@@ -67,16 +86,34 @@ export default function WorkflowsPage() {
definition: JSON.stringify(definition), definition: JSON.stringify(definition),
}); });
message.success('创建成功'); message.success('创建成功');
setCreateModal(false); setCreateDrawer(false);
form.resetFields(); form.resetFields();
// 延迟刷新,确保后端数据已保存
setTimeout(() => {
fetchWorkflows(); fetchWorkflows();
} catch { }, 300);
} catch (err) {
console.error('创建失败:', err);
message.error('创建失败'); message.error('创建失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const closeCreateDrawer = () => {
setCreateDrawer(false);
form.resetFields();
};
const handleSubmitCreate = async () => {
try {
const values = await form.validateFields();
await handleCreate(values);
} catch {
// Form validation errors are shown inline by Ant Design.
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSaveWorkflow = useCallback(async (nodes: any[], edges: any[]) => { const handleSaveWorkflow = useCallback(async (nodes: any[], edges: any[]) => {
if (!editingWorkflow) return; if (!editingWorkflow) return;
...@@ -105,7 +142,40 @@ export default function WorkflowsPage() { ...@@ -105,7 +142,40 @@ export default function WorkflowsPage() {
const getEditorInitialData = (): { nodes?: unknown[]; edges?: unknown[] } | undefined => { const getEditorInitialData = (): { nodes?: unknown[]; edges?: unknown[] } | undefined => {
if (!editingWorkflow?.definition) return undefined; if (!editingWorkflow?.definition) return undefined;
try { return JSON.parse(editingWorkflow.definition); } catch { return undefined; } try {
const def = JSON.parse(editingWorkflow.definition);
// 如果 nodes 是对象格式,转换为数组
let nodesArray: unknown[] = [];
if (def.nodes && !Array.isArray(def.nodes)) {
const entries = Object.values(def.nodes) as Array<{ id: string; type: string; name: string; config?: Record<string, unknown> }>;
nodesArray = entries.map((n, i) => ({
id: n.id,
type: 'custom',
position: { x: 250, y: 50 + i * 120 },
data: { label: n.name, nodeType: n.type, config: n.config },
}));
} else if (Array.isArray(def.nodes)) {
nodesArray = def.nodes;
}
// 处理 edges
let edgesArray: unknown[] = [];
if (Array.isArray(def.edges)) {
edgesArray = def.edges.map((e: { id: string; source_node?: string; source?: string; target_node?: string; target?: string }) => ({
id: e.id,
source: e.source_node || e.source,
target: e.target_node || e.target,
animated: true,
style: { stroke: '#1890ff' },
}));
}
return { nodes: nodesArray, edges: edgesArray };
} catch (err) {
console.error('解析工作流定义失败:', err);
return undefined;
}
}; };
const columns = [ const columns = [
...@@ -151,7 +221,7 @@ export default function WorkflowsPage() { ...@@ -151,7 +221,7 @@ export default function WorkflowsPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DeploymentUnitOutlined style={{ color: '#8c8c8c' }} /> <DeploymentUnitOutlined style={{ color: '#8c8c8c' }} />
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {workflows.length} 个工作流</span> <span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {workflows.length} 个工作流</span>
<Button type="primary" icon={<PlusOutlined />} style={{ marginLeft: 'auto' }} onClick={() => setCreateModal(true)}> <Button type="primary" icon={<PlusOutlined />} style={{ marginLeft: 'auto' }} onClick={() => setCreateDrawer(true)}>
新建工作流 新建工作流
</Button> </Button>
</div> </div>
...@@ -164,10 +234,22 @@ export default function WorkflowsPage() { ...@@ -164,10 +234,22 @@ export default function WorkflowsPage() {
/> />
</Card> </Card>
{/* 新建工作流 Modal */} {/* 新建工作流 Drawer */}
<Modal title="新建工作流" open={createModal} onCancel={() => { setCreateModal(false); form.resetFields(); }} <Drawer
onOk={() => form.submit()} confirmLoading={loading} okText="创建"> title="新建工作流"
<Form form={form} layout="vertical" onFinish={handleCreate} style={{ marginTop: 16 }}> open={createDrawer}
placement="right"
width={520}
destroyOnClose
onClose={closeCreateDrawer}
footer={(
<Space style={{ float: 'right' }}>
<Button onClick={closeCreateDrawer} disabled={loading}>取消</Button>
<Button type="primary" loading={loading} onClick={handleSubmitCreate}>创建</Button>
</Space>
)}
>
<Form form={form} layout="vertical" onFinish={handleCreate} style={{ marginTop: 8 }}>
<Form.Item name="workflow_id" label="工作流 ID" rules={[{ required: true, message: '请输入工作流ID' }]}> <Form.Item name="workflow_id" label="工作流 ID" rules={[{ required: true, message: '请输入工作流ID' }]}>
<Input placeholder="如: smart_pre_consult" /> <Input placeholder="如: smart_pre_consult" />
</Form.Item> </Form.Item>
...@@ -177,16 +259,11 @@ export default function WorkflowsPage() { ...@@ -177,16 +259,11 @@ export default function WorkflowsPage() {
<Form.Item name="description" label="描述"> <Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="请输入描述(选填)" /> <Input.TextArea rows={2} placeholder="请输入描述(选填)" />
</Form.Item> </Form.Item>
<Form.Item name="category" label="类别"> <Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
<Select placeholder="选择类别" options={[ <Select placeholder="选择类别" options={Object.entries(categoryLabel).map(([value, label]) => ({ value, label }))} />
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访' },
]} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Drawer>
{/* 可视化编辑器 Modal */} {/* 可视化编辑器 Modal */}
<Modal <Modal
......
...@@ -21,6 +21,8 @@ import MarkdownRenderer from '../MarkdownRenderer'; ...@@ -21,6 +21,8 @@ import MarkdownRenderer from '../MarkdownRenderer';
import ToolCallCard from './ToolCallCard'; import ToolCallCard from './ToolCallCard';
import ToolResultCard from './ToolResultCard'; import ToolResultCard from './ToolResultCard';
import SuggestedActions, { parseActions } from './SuggestedActions'; import SuggestedActions, { parseActions } from './SuggestedActions';
import { validateNavigationPermission } from '../../lib/navigation-event';
import { message as antMessage } from 'antd';
import type { ChatMessage, ToolCall, WidgetRole } from './types'; import type { ChatMessage, ToolCall, WidgetRole } from './types';
import { ROLE_AGENT_ID, ROLE_AGENT_NAME, ROLE_THEME, QUICK_ITEMS } from './types'; import { ROLE_AGENT_ID, ROLE_AGENT_NAME, ROLE_THEME, QUICK_ITEMS } from './types';
...@@ -132,16 +134,24 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => { ...@@ -132,16 +134,24 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
? { ...m, toolCalls: (m.toolCalls || []).map(tc => tc.call_id === call_id ? { ...tc, success, result: result || { success } } : tc) } ? { ...m, toolCalls: (m.toolCalls || []).map(tc => tc.call_id === call_id ? { ...tc, success, result: result || { success } } : tc) }
: m : m
)); ));
// 自动处理导航工具结果 // 自动处理导航工具结果(v16: 带权限校验)
if (result?.data && typeof result.data === 'object') { if (result?.data && typeof result.data === 'object') {
const d = result.data as Record<string, unknown>; const d = result.data as Record<string, unknown>;
if (d.action === 'navigate') { if (d.action === 'navigate') {
const route = (d.route as string) || (d.page as string) || ''; const route = (d.route as string) || (d.page as string) || '';
if (route) { if (route) {
const navPath = route.startsWith('/') ? route : '/' + route; const navPath = route.startsWith('/') ? route : '/' + route;
// v16: 前端二次权限校验
if (validateNavigationPermission(navPath, role)) {
window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } })); window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } }));
} else {
antMessage.warning('您没有访问该页面的权限');
console.warn(`[Navigation] 权限拒绝: 角色 ${role} 无法访问 ${navPath}`);
} }
} }
} else if (d.action === 'permission_denied') {
antMessage.warning((d.message as string) || '您没有访问该页面的权限');
}
} }
}, },
onChunk: (content: string) => { onChunk: (content: string) => {
...@@ -152,16 +162,24 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => { ...@@ -152,16 +162,24 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
setMessages(prev => prev.map(m => setMessages(prev => prev.map(m =>
m._id === msgId ? { ...m, streaming: false, meta: { iterations, tokens: total_tokens, agent_id: agentId } } : m m._id === msgId ? { ...m, streaming: false, meta: { iterations, tokens: total_tokens, agent_id: agentId } } : m
)); ));
// 处理标准化导航指令数组 // 处理标准化导航指令数组(v16: 带权限校验)
if (Array.isArray(navigation_actions)) { if (Array.isArray(navigation_actions)) {
for (const nav of navigation_actions as Array<Record<string, unknown>>) { for (const nav of navigation_actions as Array<Record<string, unknown>>) {
if (nav.action === 'navigate') { if (nav.action === 'navigate') {
const route = (nav.route as string) || (nav.page as string) || ''; const route = (nav.route as string) || (nav.page as string) || '';
if (route) { if (route) {
const navPath = route.startsWith('/') ? route : '/' + route; const navPath = route.startsWith('/') ? route : '/' + route;
// v16: 前端二次权限校验
if (validateNavigationPermission(navPath, role)) {
window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } })); window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } }));
break; break;
} else {
antMessage.warning('您没有访问该页面的权限');
console.warn(`[Navigation] 权限拒绝: 角色 ${role} 无法访问 ${navPath}`);
}
} }
} else if (nav.action === 'permission_denied') {
antMessage.warning((nav.message as string) || '您没有访问该页面的权限');
} }
} }
} }
......
...@@ -286,7 +286,7 @@ const FloatContainer: React.FC = () => { ...@@ -286,7 +286,7 @@ const FloatContainer: React.FC = () => {
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
background: '#f5f6fa', zIndex: 1, background: '#f5f6fa', zIndex: 1,
}}> }}>
<Spin tip="页面加载中..."> <Spin tip="">
<div style={{ height: 100 }} /> <div style={{ height: 100 }} />
</Spin> </Spin>
</div> </div>
......
...@@ -18,7 +18,7 @@ import { ...@@ -18,7 +18,7 @@ import {
Position, Position,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { Button, Drawer, Form, Input, Select, message, Space, Card, Tooltip } from 'antd'; import { Button, Drawer, Form, Input, Select, Space, Card, Tooltip, App } from 'antd';
import { import {
SaveOutlined, SaveOutlined,
PlayCircleOutlined, PlayCircleOutlined,
...@@ -133,6 +133,7 @@ export default function VisualWorkflowEditor({ ...@@ -133,6 +133,7 @@ export default function VisualWorkflowEditor({
onSave, onSave,
onExecute, onExecute,
}: VisualWorkflowEditorProps) { }: VisualWorkflowEditorProps) {
const { message: messageApi } = App.useApp();
const defaultNodes: Node<NodeData>[] = initialNodes || [ const defaultNodes: Node<NodeData>[] = initialNodes || [
{ id: 'start', type: 'custom', position: { x: 250, y: 50 }, data: { label: '开始', nodeType: 'start' } }, { id: 'start', type: 'custom', position: { x: 250, y: 50 }, data: { label: '开始', nodeType: 'start' } },
{ id: 'end', type: 'custom', position: { x: 250, y: 300 }, data: { label: '结束', nodeType: 'end' } }, { id: 'end', type: 'custom', position: { x: 250, y: 300 }, data: { label: '结束', nodeType: 'end' } },
...@@ -179,9 +180,9 @@ export default function VisualWorkflowEditor({ ...@@ -179,9 +180,9 @@ export default function VisualWorkflowEditor({
}; };
setNodes((nds) => nds.concat(newNode)); setNodes((nds) => nds.concat(newNode));
message.success(`已添加 ${config?.label} 节点`); messageApi.success(`已添加 ${config?.label} 节点`);
}, },
[setNodes] [setNodes, messageApi]
); );
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
...@@ -196,15 +197,15 @@ export default function VisualWorkflowEditor({ ...@@ -196,15 +197,15 @@ export default function VisualWorkflowEditor({
const handleDeleteNode = useCallback(() => { const handleDeleteNode = useCallback(() => {
if (!selectedNode) return; if (!selectedNode) return;
if (selectedNode.data.nodeType === 'start') { if (selectedNode.data.nodeType === 'start') {
message.warning('不能删除开始节点'); messageApi.warning('不能删除开始节点');
return; return;
} }
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id)); setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id));
setSelectedNode(null); setSelectedNode(null);
setDrawerOpen(false); setDrawerOpen(false);
message.success('节点已删除'); messageApi.success('节点已删除');
}, [selectedNode, setNodes, setEdges]); }, [selectedNode, setNodes, setEdges, messageApi]);
const handleUpdateNode = useCallback((values: Record<string, unknown>) => { const handleUpdateNode = useCallback((values: Record<string, unknown>) => {
if (!selectedNode) return; if (!selectedNode) return;
...@@ -216,14 +217,14 @@ export default function VisualWorkflowEditor({ ...@@ -216,14 +217,14 @@ export default function VisualWorkflowEditor({
: n : n
) )
); );
message.success('节点配置已更新'); messageApi.success('节点配置已更新');
setDrawerOpen(false); setDrawerOpen(false);
}, [selectedNode, setNodes]); }, [selectedNode, setNodes, messageApi]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
onSave?.(nodes, edges); onSave?.(nodes, edges);
message.success('工作流已保存'); messageApi.success('工作流已保存');
}, [nodes, edges, onSave]); }, [nodes, edges, onSave, messageApi]);
const handleExecute = useCallback(() => { const handleExecute = useCallback(() => {
onExecute?.(nodes, edges); onExecute?.(nodes, edges);
......
/** /**
* 前端导航事件标准化 (v15) * 前端导航事件标准化 (v16)
* 统一封装前端事件为结构化 NavigationEvent,供 AI Agent 消费 * 统一封装前端事件为结构化 NavigationEvent,供 AI Agent 消费
* v16: 增加前端导航权限二次校验
*/ */
import { resolveRoute, getRouteByCode } from '@/config/routes'; import { resolveRoute, getRouteByCode, getRoutesByRole } from '@/config/routes';
import { message } from 'antd';
/** 前端 → Agent 的导航事件 */ /** 前端 → Agent 的导航事件 */
export interface NavigationEvent { export interface NavigationEvent {
...@@ -44,11 +46,41 @@ export function emitNavigationEvent(pageCode: string, intent: string, params: Re ...@@ -44,11 +46,41 @@ export function emitNavigationEvent(pageCode: string, intent: string, params: Re
} }
/** /**
* 执行 Agent 输出的导航指令 * 校验导航路由是否在当前用户角色允许范围内
* @param route 目标路由
* @param userRole 用户角色
* @returns 是否允许导航
*/ */
export function executeNavigationAction(action: NavigationAction): boolean { export function validateNavigationPermission(route: string, userRole: string): boolean {
if (!route || !userRole) return false;
// admin 可以访问所有页面
if (userRole === 'admin') return true;
// 检查路由前缀是否匹配用户角色
const normalizedRoute = route.startsWith('/') ? route : '/' + route;
if (userRole === 'doctor') {
return normalizedRoute.startsWith('/doctor') || normalizedRoute.startsWith('/public');
}
if (userRole === 'patient') {
return normalizedRoute.startsWith('/patient') || normalizedRoute.startsWith('/public');
}
return false;
}
/**
* 执行 Agent 输出的导航指令(带权限二次校验)
* @param action 导航指令
* @param userRole 当前用户角色(可选,用于前端二次校验)
*/
export function executeNavigationAction(action: NavigationAction, userRole?: string): boolean {
if (action.action === 'permission_denied') { if (action.action === 'permission_denied') {
// 可由调用方处理提示 // 显示权限拒绝提示
if (action.message) {
message.warning(action.message);
}
return false; return false;
} }
...@@ -68,6 +100,14 @@ export function executeNavigationAction(action: NavigationAction): boolean { ...@@ -68,6 +100,14 @@ export function executeNavigationAction(action: NavigationAction): boolean {
if (route) { if (route) {
if (!route.startsWith('/')) route = '/' + route; if (!route.startsWith('/')) route = '/' + route;
// v16: 前端二次权限校验
if (userRole && !validateNavigationPermission(route, userRole)) {
message.warning(`您没有访问该页面的权限`);
console.warn(`[Navigation] 权限拒绝: 角色 ${userRole} 无法访问 ${route}`);
return false;
}
window.dispatchEvent(new CustomEvent('ai-action', { window.dispatchEvent(new CustomEvent('ai-action', {
detail: { action: 'navigate', page: route }, detail: { action: 'navigate', page: route },
})); }));
...@@ -77,3 +117,11 @@ export function executeNavigationAction(action: NavigationAction): boolean { ...@@ -77,3 +117,11 @@ export function executeNavigationAction(action: NavigationAction): boolean {
return false; return false;
} }
/**
* 获取当前用户角色可访问的所有路由
*/
export function getAccessibleRoutes(userRole: string): string[] {
const routes = getRoutesByRole(userRole);
return routes.map(r => r.path);
}
...@@ -86,6 +86,14 @@ const AdminAIConfigPage: React.FC = () => { ...@@ -86,6 +86,14 @@ const AdminAIConfigPage: React.FC = () => {
const [templateModalType, setTemplateModalType] = useState<'add' | 'edit'>('add'); const [templateModalType, setTemplateModalType] = useState<'add' | 'edit'>('add');
const [editingTemplate, setEditingTemplate] = useState<PromptTemplateData | null>(null); const [editingTemplate, setEditingTemplate] = useState<PromptTemplateData | null>(null);
const [templateSaving, setTemplateSaving] = useState(false); const [templateSaving, setTemplateSaving] = useState(false);
const [logSceneFilter, setLogSceneFilter] = useState<string | undefined>(undefined);
const [logSuccessFilter, setLogSuccessFilter] = useState<string | undefined>(undefined);
const [logDateRange, setLogDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [logData, setLogData] = useState<any[]>([]);
const [logLoading, setLogLoading] = useState(false);
const [logTotal, setLogTotal] = useState(0);
const [logPage, setLogPage] = useState(1);
const [logPageSize, setLogPageSize] = useState(20);
useEffect(() => { useEffect(() => {
const fetchConfig = async () => { const fetchConfig = async () => {
...@@ -173,6 +181,11 @@ const AdminAIConfigPage: React.FC = () => { ...@@ -173,6 +181,11 @@ const AdminAIConfigPage: React.FC = () => {
fetchTemplates(); fetchTemplates();
}, []); }, []);
const fetchAILogs = async () => {
// TODO: 实现 AI 日志获取逻辑
console.log('获取 AI 日志', { logSceneFilter, logSuccessFilter, logDateRange });
};
const handleAddTemplate = () => { const handleAddTemplate = () => {
setTemplateModalType('add'); setTemplateModalType('add');
setEditingTemplate(null); setEditingTemplate(null);
......
...@@ -18,11 +18,11 @@ const AdminConsultationsPage: React.FC = () => { ...@@ -18,11 +18,11 @@ const AdminConsultationsPage: React.FC = () => {
const columns: ProColumns<AdminConsultItem>[] = [ const columns: ProColumns<AdminConsultItem>[] = [
{ {
title: '问诊ID', title: '就诊流水号',
dataIndex: 'id', dataIndex: 'serial_number',
search: false, search: false,
width: 100, width: 160,
render: (_, record) => record.id ? record.id.substring(0, 8) + '...' : '-', render: (_, record) => record.serial_number || '-',
}, },
{ {
title: '关键词', title: '关键词',
......
...@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from 'react'; ...@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Typography, Space, Button, Modal, Tag, App } from 'antd'; import { Typography, Space, Button, Modal, Tag, App } from 'antd';
import { import {
PlusOutlined, EditOutlined, DeleteOutlined, MedicineBoxOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
ProTable, DrawerForm, ProFormText, ProFormDigit, ProTable, DrawerForm, ProFormText, ProFormDigit,
...@@ -61,31 +61,7 @@ const AdminDepartmentsPage: React.FC = () => { ...@@ -61,31 +61,7 @@ const AdminDepartmentsPage: React.FC = () => {
title: '科室', title: '科室',
dataIndex: 'name', dataIndex: 'name',
search: false, search: false,
render: (_, record) => ( render: (_, record) => <Text strong>{record.name}</Text>,
<Space>
<div style={{
width: 40,
height: 40,
borderRadius: 8,
background: 'linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
}}>
{record.icon || <MedicineBoxOutlined style={{ color: '#1890ff' }} />}
</div>
<div>
<Text strong>{record.name}</Text>
{record.parent_id && (
<>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>子科室</Text>
</>
)}
</div>
</Space>
),
}, },
{ {
title: '排序', title: '排序',
...@@ -94,18 +70,6 @@ const AdminDepartmentsPage: React.FC = () => { ...@@ -94,18 +70,6 @@ const AdminDepartmentsPage: React.FC = () => {
width: 80, width: 80,
render: (v) => <Tag>{v as number}</Tag>, render: (v) => <Tag>{v as number}</Tag>,
}, },
{
title: '子科室',
dataIndex: 'children',
width: 100,
search: false,
render: (_, record) => {
const count = record.children?.length || 0;
return count > 0
? <Tag color="blue">{count}</Tag>
: <Text type="secondary">-</Text>;
},
},
{ {
title: '操作', title: '操作',
valueType: 'option', valueType: 'option',
...@@ -179,15 +143,7 @@ const AdminDepartmentsPage: React.FC = () => { ...@@ -179,15 +143,7 @@ const AdminDepartmentsPage: React.FC = () => {
request={async (params) => { request={async (params) => {
const res = await adminApi.getDepartmentList(); const res = await adminApi.getDepartmentList();
const list = res.data || []; const list = res.data || [];
// Flatten tree for display const rows: Department[] = Array.isArray(list) ? list : [];
const rows: Department[] = [];
const flatten = (items: Department[]) => {
for (const item of items) {
rows.push(item);
if (item.children?.length) flatten(item.children);
}
};
flatten(Array.isArray(list) ? list : []);
// Client-side keyword filter // Client-side keyword filter
const keyword = params.keyword?.trim()?.toLowerCase(); const keyword = params.keyword?.trim()?.toLowerCase();
const filtered = keyword const filtered = keyword
......
...@@ -229,7 +229,7 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -229,7 +229,7 @@ const AdminDoctorsPage: React.FC = () => {
width: 90, width: 90,
render: (_, record) => { render: (_, record) => {
const p = record.price; const p = record.price;
return p ? <Text style={{ color: '#1890ff', fontWeight: 600 }}>¥{(p / 100).toFixed(0)}</Text> : <Text type="secondary">未设置</Text>; return p ? <Text style={{ color: '#1890ff', fontWeight: 600 }}>¥{p}</Text> : <Text type="secondary">未设置</Text>;
}, },
}, },
{ {
...@@ -411,8 +411,8 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -411,8 +411,8 @@ const AdminDoctorsPage: React.FC = () => {
/> />
<ProFormDigit <ProFormDigit
name="price" name="price"
label="问诊价格()" label="问诊价格()"
placeholder="例如 5000 = ¥50" placeholder=""
min={0} min={0}
fieldProps={{ precision: 0 }} fieldProps={{ precision: 0 }}
rules={[{ required: true, message: '请输入问诊价格' }]} rules={[{ required: true, message: '请输入问诊价格' }]}
...@@ -506,8 +506,8 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -506,8 +506,8 @@ const AdminDoctorsPage: React.FC = () => {
/> />
<ProFormDigit <ProFormDigit
name="price" name="price"
label="问诊价格()" label="问诊价格()"
placeholder="例如 5000 = ¥50" placeholder=""
min={0} min={0}
fieldProps={{ precision: 0 }} fieldProps={{ precision: 0 }}
colProps={{ span: 12 }} colProps={{ span: 12 }}
...@@ -532,7 +532,7 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -532,7 +532,7 @@ const AdminDoctorsPage: React.FC = () => {
<Descriptions.Item label="医院" span={2}>{currentDoctor.hospital || '-'}</Descriptions.Item> <Descriptions.Item label="医院" span={2}>{currentDoctor.hospital || '-'}</Descriptions.Item>
<Descriptions.Item label="执业证号">{currentDoctor.license_no || '-'}</Descriptions.Item> <Descriptions.Item label="执业证号">{currentDoctor.license_no || '-'}</Descriptions.Item>
<Descriptions.Item label="问诊价格"> <Descriptions.Item label="问诊价格">
{currentDoctor.price ? `¥${(currentDoctor.price / 100).toFixed(0)}` : '未设置'} {currentDoctor.price ? `¥${currentDoctor.price}` : '未设置'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="认证状态">{getStatusTag(currentDoctor.review_status)}</Descriptions.Item> <Descriptions.Item label="认证状态">{getStatusTag(currentDoctor.review_status)}</Descriptions.Item>
<Descriptions.Item label="账号状态"> <Descriptions.Item label="账号状态">
......
This diff is collapsed.
...@@ -242,7 +242,7 @@ const DoctorCard: React.FC<{ ...@@ -242,7 +242,7 @@ const DoctorCard: React.FC<{
{/* 价格 */} {/* 价格 */}
<div style={{ textAlign: 'right', flexShrink: 0 }}> <div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#0D9488', lineHeight: 1 }}> <div style={{ fontSize: 20, fontWeight: 700, color: '#0D9488', lineHeight: 1 }}>
¥{((doctor.price || 0) / 100).toFixed(0)} ¥{doctor.price || 0}
</div> </div>
<div style={{ fontSize: 11, color: '#bfbfbf', marginTop: 2 }}>元 / 次</div> <div style={{ fontSize: 11, color: '#bfbfbf', marginTop: 2 }}>元 / 次</div>
</div> </div>
......
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