Commit 94e48089 authored by yuguo's avatar yuguo

fix

parent 147e328b
......@@ -43,7 +43,8 @@
"Bash(/tmp/check_perms.sql:*)",
"Bash(/tmp/check_perms2.sql:*)",
"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() {
agent.InitToolMonitor(db)
initToolSelector(r)
log.Println("[InitTools] ToolMonitor & ToolSelector 初始化完成")
// v16: 初始化智能工具推荐器(使用pgvector)
agent.InitToolRecommender(db, embedder)
log.Println("[InitTools] ToolRecommender 初始化完成")
}
// WireCallbacks 注入跨包回调(在 InitTools 和 GetService 初始化完成后调用)
......
......@@ -273,20 +273,43 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, userRole, sess
historyJSON, _ := json.Marshal(history)
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 {
session = model.AgentSession{
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
History: string(historyJSON),
Context: string(contextJSON),
Status: "active",
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
History: string(historyJSON),
Context: string(contextJSON),
Status: "active",
UserRole: userRole,
CurrentPage: currentPage,
PageContext: pageContextJSON,
MessageCount: 2,
TotalTokens: output.TotalTokens,
}
db.Create(&session)
} else {
db.Model(&session).Updates(map[string]interface{}{
"history": string(historyJSON),
"updated_at": time.Now(),
"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(),
})
}
......@@ -426,20 +449,43 @@ func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, userRole
historyJSON, _ := json.Marshal(history)
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 {
session = model.AgentSession{
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
History: string(historyJSON),
Context: string(contextJSON),
Status: "active",
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
History: string(historyJSON),
Context: string(contextJSON),
Status: "active",
UserRole: userRole,
CurrentPage: currentPageStream,
PageContext: pageContextJSONStream,
MessageCount: 2,
TotalTokens: output.TotalTokens,
}
db.Create(&session)
} else {
db.Model(&session).Updates(map[string]interface{}{
"history": string(historyJSON),
"updated_at": time.Now(),
"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(),
})
}
......
......@@ -22,6 +22,10 @@ type AgentTool struct {
QualityScore float64 `gorm:"default:0" json:"quality_score"` // 质量评分 0-100
LastUsedAt *time.Time `json:"last_used_at"` // 最后使用时间
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"`
UpdatedAt time.Time `json:"updated_at"`
}
......@@ -40,7 +44,12 @@ type AgentToolLog struct {
ErrorMessage string `gorm:"type:text" json:"error_message"`
DurationMs int `json:"duration_ms"`
Iteration int `json:"iteration"`
CreatedAt time.Time `json:"created_at"`
// 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"`
}
// AgentDefinition Agent定义
......@@ -69,8 +78,17 @@ type AgentSession struct {
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"`
// 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"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentExecutionLog Agent执行日志
......
......@@ -144,6 +144,7 @@ type ConsultListParams struct {
// AdminConsultItem 管理后台问诊列表项
type AdminConsultItem struct {
ID string `json:"id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
DoctorID string `json:"doctor_id"`
......
......@@ -265,6 +265,7 @@ type DoctorManageItem struct {
DepartmentName string `json:"department_name"`
Rating float64 `json:"rating"`
ConsultCount int `json:"consult_count"`
Price int `json:"price"`
SubmittedAt *time.Time `json:"submitted_at"`
ReviewID string `json:"review_id"`
LicenseImage string `json:"license_image"`
......@@ -315,6 +316,7 @@ func (s *Service) GetDoctorManageList(ctx context.Context, params *DoctorManageP
item.Hospital = doctor.Hospital
item.Rating = doctor.Rating
item.ConsultCount = doctor.ConsultCount
item.Price = doctor.Price
item.Status = doctor.Status
// 获取科室信息
......
......@@ -130,9 +130,8 @@ func (s *Service) UpdateDoctor(ctx context.Context, doctorID string, req *Update
if req.Introduction != "" {
doctorUpdates["introduction"] = req.Introduction
}
if req.Price > 0 {
doctorUpdates["price"] = req.Price
}
// Price 允许设置为 0,所以不判断 > 0
doctorUpdates["price"] = req.Price
if len(doctorUpdates) > 0 {
return s.db.Model(&model.Doctor{}).Where("user_id = ?", doctorID).Updates(doctorUpdates).Error
}
......
......@@ -30,6 +30,14 @@ func (h *Handler) CreateWorkflow(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
// 获取当前用户ID
userID, _ := c.Get("user_id")
createdBy := ""
if uid, ok := userID.(string); ok {
createdBy = uid
}
wf := model.WorkflowDefinition{
WorkflowID: req.WorkflowID,
Name: req.Name,
......@@ -38,8 +46,12 @@ func (h *Handler) CreateWorkflow(c *gin.Context) {
Definition: req.Definition,
Status: "draft",
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)
}
......
-- 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 (
"encoding/json"
"fmt"
"log"
"strings"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
)
// getToolConfig 从数据库获取工具配置(CacheTTL/Timeout/MaxRetries/Status)
// getToolConfig 从数据库获取工具配置(CacheTTL/Timeout/MaxRetries/Status/AllowedRoles
func getToolConfig(name string) model.AgentTool {
db := database.GetDB()
if db == nil {
return model.AgentTool{Status: "active", Timeout: 30}
return model.AgentTool{Status: "active", Timeout: 30, AllowedRoles: "*"}
}
var tool model.AgentTool
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
}
// 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 工具执行器
type Executor struct {
registry *ToolRegistry
......@@ -36,6 +70,11 @@ func NewExecutor(r *ToolRegistry) *Executor {
// Execute 执行工具调用(不记日志,集成缓存)
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)
// 检查工具是否被禁用
......@@ -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)}
}
// 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 cached, ok := e.cache.Get(name, argsJSON); ok {
......@@ -96,10 +143,12 @@ func (e *Executor) Execute(ctx context.Context, name string, argsJSON string) To
// ExecuteWithLog 执行工具调用并写入 AgentToolLog
func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID, agentID, sessionID, userID string, iteration int) ToolResult {
start := time.Now()
result := e.Execute(ctx, name, argsJSON)
userRole, _ := ctx.Value(ContextKeyUserRole).(string)
result := e.ExecuteWithRole(ctx, name, argsJSON, userRole)
durationMs := int(time.Since(start).Milliseconds())
cfg := getToolConfig(name)
// 异步写日志 + 更新工具质量指标(v15)
// 异步写日志 + 更新工具质量指标(v15)+ 审计增强(v16)
go func() {
db := database.GetDB()
if db == nil {
......@@ -122,6 +171,9 @@ func (e *Executor) ExecuteWithLog(ctx context.Context, name, argsJSON, traceID,
ErrorMessage: errMsg,
DurationMs: durationMs,
Iteration: iteration,
UserRole: userRole,
AuditLevel: cfg.AuditLevel,
IsSensitive: cfg.IsSensitive,
CreatedAt: time.Now(),
}
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
}
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, ai.ChatMessage{Role: "user", Content: input.Message})
......@@ -271,7 +271,7 @@ func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent fu
}
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, ai.ChatMessage{Role: "user", Content: input.Message})
......@@ -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 提示词模板
prompt := ai.GetActivePromptByAgent(a.cfg.ID)
// 2. 回退到 AgentDefinition 的 SystemPrompt(即代码配置值)
......@@ -450,7 +450,20 @@ func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string {
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
return prompt
}
......
This diff is collapsed.
......@@ -17,6 +17,7 @@ type menuEntry struct {
Path string // 路由路径 e.g. "/admin/doctors"
Name string // 页面名称 e.g. "医生管理"
Permission string // 权限码 e.g. "admin:doctors:list"
RoleScope string // 角色范围: admin/doctor/patient(从路径前缀推断)
}
// routeCache 路由缓存(从数据库 menus 表动态加载)
......@@ -24,7 +25,7 @@ var (
menuCache map[string]menuEntry // key = page_code e.g. "admin_doctors"
menuCacheMu sync.RWMutex
menuCacheTime time.Time
menuCacheTTL = 5 * time.Minute
menuCacheTTL = agent.DefaultMenuCacheTTL // v16: 使用统一常量
)
// loadMenuCache 从数据库 menus 表加载路由数据(带缓存)
......@@ -62,10 +63,13 @@ func loadMenuCache() map[string]menuEntry {
pageCode = strings.ReplaceAll(pageCode, "/", "_")
pageCode = strings.ReplaceAll(pageCode, "-", "_")
if pageCode != "" {
// 从路径推断角色范围
roleScope := inferRoleScope(m.Path)
cache[pageCode] = menuEntry{
Path: m.Path,
Name: m.Name,
Permission: m.Permission,
RoleScope: roleScope,
}
}
}
......@@ -78,22 +82,61 @@ func loadMenuCache() map[string]menuEntry {
// NavigatePageTool 页面导航工具 — 从 menus 表动态加载路由,无需额外维护
type NavigatePageTool struct{}
func (t *NavigatePageTool) Name() string { return "navigate_page" }
func (t *NavigatePageTool) Description() string { return "导航到互联网医院系统页面(路由从数据库菜单表实时获取)" }
func (t *NavigatePageTool) Name() string { return "navigate_page" }
func (t *NavigatePageTool) Description() string {
return "导航到互联网医院系统页面。【重要】必须根据当前用户角色选择对应端的页面:admin用户选admin_*页面,doctor用户选doctor_*页面,patient用户选patient_*页面,否则会被拒绝访问。"
}
func (t *NavigatePageTool) Parameters() []agent.ToolParameter {
cache := loadMenuCache()
// 构建 page_code 枚举和说明
// 构建 page_code 枚举和说明(按角色分组)
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)
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
desc.WriteString("目标页面代码。可选值:")
for code, entry := range cache {
desc.WriteString(fmt.Sprintf("\n- %s (%s)", code, entry.Name))
desc.WriteString("目标页面代码。【权限说明】只能导航到当前用户角色对应的页面,否则会被拒绝。")
if len(adminPages) > 0 {
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{
......@@ -189,6 +232,20 @@ func (t *NavigatePageTool) Execute(ctx context.Context, params map[string]interf
}, 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 的页面导航权限校验
// 规则:
// - admin → 可访问所有页面(admin_*、patient_*、doctor_*)
......@@ -213,12 +270,12 @@ func checkPagePermission(userRole, pageCode string, entry menuEntry) (bool, stri
if userRole == "patient" {
return true, ""
}
return false, fmt.Sprintf("该页面仅限患者访问", )
return false, fmt.Sprintf("您没有访问患者端「%s」页面的权限", entry.Name)
case strings.HasPrefix(pageCode, "doctor_"):
if userRole == "doctor" {
return true, ""
}
return false, fmt.Sprintf("该页面仅限医生访问")
return false, fmt.Sprintf("您没有访问医生端「%s」页面的权限", entry.Name)
default:
// 无明确前缀的页面(如果有),放行
return true, ""
......
......@@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
ignoreBuildErrors: true,
},
skipTrailingSlashRedirect: true,
transpilePackages: ['@xyflow/react', '@xyflow/system'],
async rewrites() {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8080';
return [
......
......@@ -207,6 +207,7 @@ export interface AILogListParams {
// 问诊管理
export interface AdminConsultItem {
id: string;
serial_number: string;
patient_id: string;
patient_name: string;
doctor_id: string;
......
......@@ -341,55 +341,6 @@ export default function AILogsPage() {
</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',
label: <Space><FundOutlined />统计分析</Space>,
......
'use client';
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 { workflowApi } from '@/api/agent';
import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor';
......@@ -17,6 +17,13 @@ interface Workflow {
definition?: string;
}
interface CreateWorkflowFormValues {
workflow_id: string;
name: string;
description?: string;
category?: string;
}
const statusColor: Record<string, 'success' | 'warning' | 'default'> = {
active: 'success', draft: 'warning', archived: 'default',
};
......@@ -24,15 +31,27 @@ const statusLabel: Record<string, string> = {
active: '已启用', draft: '草稿', archived: '已归档',
};
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() {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [createModal, setCreateModal] = useState(false);
const [createDrawer, setCreateDrawer] = useState(false);
const [editorModal, setEditorModal] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [form] = Form.useForm();
const [form] = Form.useForm<CreateWorkflowFormValues>();
const [loading, setLoading] = useState(false);
const [tableLoading, setTableLoading] = useState(false);
......@@ -48,7 +67,7 @@ export default function WorkflowsPage() {
useEffect(() => { fetchWorkflows(); }, []);
const handleCreate = async (values: Record<string, string>) => {
const handleCreate = async (values: CreateWorkflowFormValues) => {
setLoading(true);
try {
const definition = {
......@@ -67,16 +86,34 @@ export default function WorkflowsPage() {
definition: JSON.stringify(definition),
});
message.success('创建成功');
setCreateModal(false);
setCreateDrawer(false);
form.resetFields();
fetchWorkflows();
} catch {
// 延迟刷新,确保后端数据已保存
setTimeout(() => {
fetchWorkflows();
}, 300);
} catch (err) {
console.error('创建失败:', err);
message.error('创建失败');
} finally {
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
const handleSaveWorkflow = useCallback(async (nodes: any[], edges: any[]) => {
if (!editingWorkflow) return;
......@@ -105,7 +142,40 @@ export default function WorkflowsPage() {
const getEditorInitialData = (): { nodes?: unknown[]; edges?: unknown[] } | 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 = [
......@@ -151,7 +221,7 @@ export default function WorkflowsPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DeploymentUnitOutlined style={{ color: '#8c8c8c' }} />
<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>
</div>
......@@ -164,10 +234,22 @@ export default function WorkflowsPage() {
/>
</Card>
{/* 新建工作流 Modal */}
<Modal title="新建工作流" open={createModal} onCancel={() => { setCreateModal(false); form.resetFields(); }}
onOk={() => form.submit()} confirmLoading={loading} okText="创建">
<Form form={form} layout="vertical" onFinish={handleCreate} style={{ marginTop: 16 }}>
{/* 新建工作流 Drawer */}
<Drawer
title="新建工作流"
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' }]}>
<Input placeholder="如: smart_pre_consult" />
</Form.Item>
......@@ -177,16 +259,11 @@ export default function WorkflowsPage() {
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="请输入描述(选填)" />
</Form.Item>
<Form.Item name="category" label="类别">
<Select placeholder="选择类别" options={[
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访' },
]} />
<Form.Item name="category" label="类别" rules={[{ required: true, message: '请选择类别' }]}>
<Select placeholder="选择类别" options={Object.entries(categoryLabel).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Modal>
</Drawer>
{/* 可视化编辑器 Modal */}
<Modal
......
......@@ -21,6 +21,8 @@ import MarkdownRenderer from '../MarkdownRenderer';
import ToolCallCard from './ToolCallCard';
import ToolResultCard from './ToolResultCard';
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 { ROLE_AGENT_ID, ROLE_AGENT_NAME, ROLE_THEME, QUICK_ITEMS } from './types';
......@@ -132,15 +134,23 @@ 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
));
// 自动处理导航工具结果
// 自动处理导航工具结果(v16: 带权限校验)
if (result?.data && typeof result.data === 'object') {
const d = result.data as Record<string, unknown>;
if (d.action === 'navigate') {
const route = (d.route as string) || (d.page as string) || '';
if (route) {
const navPath = route.startsWith('/') ? route : '/' + route;
window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } }));
// v16: 前端二次权限校验
if (validateNavigationPermission(navPath, role)) {
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) || '您没有访问该页面的权限');
}
}
},
......@@ -152,16 +162,24 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
setMessages(prev => prev.map(m =>
m._id === msgId ? { ...m, streaming: false, meta: { iterations, tokens: total_tokens, agent_id: agentId } } : m
));
// 处理标准化导航指令数组
// 处理标准化导航指令数组(v16: 带权限校验)
if (Array.isArray(navigation_actions)) {
for (const nav of navigation_actions as Array<Record<string, unknown>>) {
if (nav.action === 'navigate') {
const route = (nav.route as string) || (nav.page as string) || '';
if (route) {
const navPath = route.startsWith('/') ? route : '/' + route;
window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } }));
break;
// v16: 前端二次权限校验
if (validateNavigationPermission(navPath, role)) {
window.dispatchEvent(new CustomEvent('ai-action', { detail: { action: 'navigate', page: navPath } }));
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 = () => {
alignItems: 'center', justifyContent: 'center',
background: '#f5f6fa', zIndex: 1,
}}>
<Spin tip="页面加载中...">
<Spin tip="">
<div style={{ height: 100 }} />
</Spin>
</div>
......
......@@ -18,7 +18,7 @@ import {
Position,
} from '@xyflow/react';
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 {
SaveOutlined,
PlayCircleOutlined,
......@@ -133,6 +133,7 @@ export default function VisualWorkflowEditor({
onSave,
onExecute,
}: VisualWorkflowEditorProps) {
const { message: messageApi } = App.useApp();
const defaultNodes: Node<NodeData>[] = initialNodes || [
{ 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' } },
......@@ -179,9 +180,9 @@ export default function VisualWorkflowEditor({
};
setNodes((nds) => nds.concat(newNode));
message.success(`已添加 ${config?.label} 节点`);
messageApi.success(`已添加 ${config?.label} 节点`);
},
[setNodes]
[setNodes, messageApi]
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
......@@ -196,15 +197,15 @@ export default function VisualWorkflowEditor({
const handleDeleteNode = useCallback(() => {
if (!selectedNode) return;
if (selectedNode.data.nodeType === 'start') {
message.warning('不能删除开始节点');
messageApi.warning('不能删除开始节点');
return;
}
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id));
setSelectedNode(null);
setDrawerOpen(false);
message.success('节点已删除');
}, [selectedNode, setNodes, setEdges]);
messageApi.success('节点已删除');
}, [selectedNode, setNodes, setEdges, messageApi]);
const handleUpdateNode = useCallback((values: Record<string, unknown>) => {
if (!selectedNode) return;
......@@ -216,14 +217,14 @@ export default function VisualWorkflowEditor({
: n
)
);
message.success('节点配置已更新');
messageApi.success('节点配置已更新');
setDrawerOpen(false);
}, [selectedNode, setNodes]);
}, [selectedNode, setNodes, messageApi]);
const handleSave = useCallback(() => {
onSave?.(nodes, edges);
message.success('工作流已保存');
}, [nodes, edges, onSave]);
messageApi.success('工作流已保存');
}, [nodes, edges, onSave, messageApi]);
const handleExecute = useCallback(() => {
onExecute?.(nodes, edges);
......
/**
* 前端导航事件标准化 (v15)
* 前端导航事件标准化 (v16)
* 统一封装前端事件为结构化 NavigationEvent,供 AI Agent 消费
* v16: 增加前端导航权限二次校验
*/
import { resolveRoute, getRouteByCode } from '@/config/routes';
import { resolveRoute, getRouteByCode, getRoutesByRole } from '@/config/routes';
import { message } from 'antd';
/** 前端 → Agent 的导航事件 */
export interface NavigationEvent {
......@@ -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.message) {
message.warning(action.message);
}
return false;
}
......@@ -68,6 +100,14 @@ export function executeNavigationAction(action: NavigationAction): boolean {
if (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', {
detail: { action: 'navigate', page: route },
}));
......@@ -77,3 +117,11 @@ export function executeNavigationAction(action: NavigationAction): boolean {
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 = () => {
const [templateModalType, setTemplateModalType] = useState<'add' | 'edit'>('add');
const [editingTemplate, setEditingTemplate] = useState<PromptTemplateData | null>(null);
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(() => {
const fetchConfig = async () => {
......@@ -173,6 +181,11 @@ const AdminAIConfigPage: React.FC = () => {
fetchTemplates();
}, []);
const fetchAILogs = async () => {
// TODO: 实现 AI 日志获取逻辑
console.log('获取 AI 日志', { logSceneFilter, logSuccessFilter, logDateRange });
};
const handleAddTemplate = () => {
setTemplateModalType('add');
setEditingTemplate(null);
......
......@@ -18,11 +18,11 @@ const AdminConsultationsPage: React.FC = () => {
const columns: ProColumns<AdminConsultItem>[] = [
{
title: '问诊ID',
dataIndex: 'id',
title: '就诊流水号',
dataIndex: 'serial_number',
search: false,
width: 100,
render: (_, record) => record.id ? record.id.substring(0, 8) + '...' : '-',
width: 160,
render: (_, record) => record.serial_number || '-',
},
{
title: '关键词',
......
......@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { Typography, Space, Button, Modal, Tag, App } from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, MedicineBoxOutlined,
PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
ProTable, DrawerForm, ProFormText, ProFormDigit,
......@@ -61,31 +61,7 @@ const AdminDepartmentsPage: React.FC = () => {
title: '科室',
dataIndex: 'name',
search: false,
render: (_, record) => (
<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>
),
render: (_, record) => <Text strong>{record.name}</Text>,
},
{
title: '排序',
......@@ -94,18 +70,6 @@ const AdminDepartmentsPage: React.FC = () => {
width: 80,
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: '操作',
valueType: 'option',
......@@ -179,15 +143,7 @@ const AdminDepartmentsPage: React.FC = () => {
request={async (params) => {
const res = await adminApi.getDepartmentList();
const list = res.data || [];
// Flatten tree for display
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 : []);
const rows: Department[] = Array.isArray(list) ? list : [];
// Client-side keyword filter
const keyword = params.keyword?.trim()?.toLowerCase();
const filtered = keyword
......
......@@ -229,7 +229,7 @@ const AdminDoctorsPage: React.FC = () => {
width: 90,
render: (_, record) => {
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 = () => {
/>
<ProFormDigit
name="price"
label="问诊价格()"
placeholder="例如 5000 = ¥50"
label="问诊价格()"
placeholder=""
min={0}
fieldProps={{ precision: 0 }}
rules={[{ required: true, message: '请输入问诊价格' }]}
......@@ -506,8 +506,8 @@ const AdminDoctorsPage: React.FC = () => {
/>
<ProFormDigit
name="price"
label="问诊价格()"
placeholder="例如 5000 = ¥50"
label="问诊价格()"
placeholder=""
min={0}
fieldProps={{ precision: 0 }}
colProps={{ span: 12 }}
......@@ -532,7 +532,7 @@ const AdminDoctorsPage: React.FC = () => {
<Descriptions.Item label="医院" span={2}>{currentDoctor.hospital || '-'}</Descriptions.Item>
<Descriptions.Item label="执业证号">{currentDoctor.license_no || '-'}</Descriptions.Item>
<Descriptions.Item label="问诊价格">
{currentDoctor.price ? `¥${(currentDoctor.price / 100).toFixed(0)}` : '未设置'}
{currentDoctor.price ? `¥${currentDoctor.price}` : '未设置'}
</Descriptions.Item>
<Descriptions.Item label="认证状态">{getStatusTag(currentDoctor.review_status)}</Descriptions.Item>
<Descriptions.Item label="账号状态">
......
This diff is collapsed.
......@@ -242,7 +242,7 @@ const DoctorCard: React.FC<{
{/* 价格 */}
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#0D9488', lineHeight: 1 }}>
¥{((doctor.price || 0) / 100).toFixed(0)}
¥{doctor.price || 0}
</div>
<div style={{ fontSize: 11, color: '#bfbfbf', marginTop: 2 }}>元 / 次</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