Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
I
Internet-hospital
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Labels
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Jobs
Commits
Open sidebar
yuguo
Internet-hospital
Commits
94e48089
Commit
94e48089
authored
Mar 05, 2026
by
yuguo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
147e328b
Changes
33
Show whitespace changes
Inline
Side-by-side
Showing
33 changed files
with
2606 additions
and
453 deletions
+2606
-453
.claude/settings.local.json
.claude/settings.local.json
+2
-1
docs/AI智能助手升级改造方案.md
docs/AI智能助手升级改造方案.md
+280
-0
server/internal/agent/init.go
server/internal/agent/init.go
+4
-0
server/internal/agent/service.go
server/internal/agent/service.go
+62
-16
server/internal/model/agent.go
server/internal/model/agent.go
+21
-3
server/internal/service/admin/consult_log.go
server/internal/service/admin/consult_log.go
+1
-0
server/internal/service/admin/service.go
server/internal/service/admin/service.go
+2
-0
server/internal/service/admin/user_crud_service.go
server/internal/service/admin/user_crud_service.go
+2
-3
server/internal/service/admin/workflow_handler.go
server/internal/service/admin/workflow_handler.go
+13
-1
server/migrations/004_ai_assistant_upgrade.sql
server/migrations/004_ai_assistant_upgrade.sql
+142
-0
server/migrations/README_v16_migration.md
server/migrations/README_v16_migration.md
+104
-0
server/pkg/agent/config_manager.go
server/pkg/agent/config_manager.go
+353
-0
server/pkg/agent/constants.go
server/pkg/agent/constants.go
+113
-0
server/pkg/agent/conversation_context.go
server/pkg/agent/conversation_context.go
+417
-0
server/pkg/agent/executor.go
server/pkg/agent/executor.go
+57
-5
server/pkg/agent/multimodal.go
server/pkg/agent/multimodal.go
+380
-0
server/pkg/agent/react_agent.go
server/pkg/agent/react_agent.go
+17
-4
server/pkg/agent/tool_recommender.go
server/pkg/agent/tool_recommender.go
+352
-0
server/pkg/agent/tools/navigate_page.go
server/pkg/agent/tools/navigate_page.go
+68
-11
web/next.config.ts
web/next.config.ts
+1
-0
web/src/api/admin.ts
web/src/api/admin.ts
+1
-0
web/src/app/(main)/admin/ai-logs/page.tsx
web/src/app/(main)/admin/ai-logs/page.tsx
+0
-49
web/src/app/(main)/admin/workflows/page.tsx
web/src/app/(main)/admin/workflows/page.tsx
+99
-22
web/src/components/GlobalAIFloat/ChatPanel.tsx
web/src/components/GlobalAIFloat/ChatPanel.tsx
+23
-5
web/src/components/GlobalAIFloat/FloatContainer.tsx
web/src/components/GlobalAIFloat/FloatContainer.tsx
+1
-1
web/src/components/workflow/VisualWorkflowEditor.tsx
web/src/components/workflow/VisualWorkflowEditor.tsx
+11
-10
web/src/lib/navigation-event.ts
web/src/lib/navigation-event.ts
+53
-5
web/src/pages/admin/AIConfig/index.tsx
web/src/pages/admin/AIConfig/index.tsx
+13
-0
web/src/pages/admin/Consultations/index.tsx
web/src/pages/admin/Consultations/index.tsx
+4
-4
web/src/pages/admin/Departments/index.tsx
web/src/pages/admin/Departments/index.tsx
+3
-47
web/src/pages/admin/Doctors/index.tsx
web/src/pages/admin/Doctors/index.tsx
+6
-6
web/src/pages/admin/Workflows/index.tsx
web/src/pages/admin/Workflows/index.tsx
+0
-259
web/src/pages/patient/Doctors/index.tsx
web/src/pages/patient/Doctors/index.tsx
+1
-1
No files found.
.claude/settings.local.json
View file @
94e48089
...
@@ -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:*)"
]
]
}
}
}
}
docs/AI智能助手升级改造方案.md
0 → 100644
View file @
94e48089
# 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*
server/internal/agent/init.go
View file @
94e48089
...
@@ -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 初始化完成后调用)
...
...
server/internal/agent/service.go
View file @
94e48089
...
@@ -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
(),
})
})
}
}
...
...
server/internal/model/agent.go
View file @
94e48089
...
@@ -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"`
}
}
...
...
server/internal/service/admin/consult_log.go
View file @
94e48089
...
@@ -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"`
...
...
server/internal/service/admin/service.go
View file @
94e48089
...
@@ -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
// 获取科室信息
// 获取科室信息
...
...
server/internal/service/admin/user_crud_service.go
View file @
94e48089
...
@@ -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
}
}
...
...
server/internal/service/admin/workflow_handler.go
View file @
94e48089
...
@@ -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
)
}
}
...
...
server/migrations/004_ai_assistant_upgrade.sql
0 → 100644
View file @
94e48089
-- 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
);
-- ============================================
-- 完成
-- ============================================
server/migrations/README_v16_migration.md
0 → 100644
View file @
94e48089
# 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
;
```
server/pkg/agent/config_manager.go
0 → 100644
View file @
94e48089
package
agent
import
(
"encoding/json"
"fmt"
"log"
"sync"
"time"
"gorm.io/gorm"
)
// ConfigManager Agent配置版本管理器(v16)
// 支持配置版本管理、热更新、A/B测试
type
ConfigManager
struct
{
db
*
gorm
.
DB
mu
sync
.
RWMutex
activeConfig
map
[
string
]
*
AgentConfigVersion
// agentID -> active config
watchers
[]
func
(
agentID
string
)
// 配置变更监听器
}
var
globalConfigManager
*
ConfigManager
// AgentConfigVersion Agent配置版本
type
AgentConfigVersion
struct
{
AgentID
string
`json:"agent_id"`
Version
int
`json:"version"`
SystemPrompt
string
`json:"system_prompt"`
Tools
[]
string
`json:"tools"`
Config
map
[
string
]
interface
{}
`json:"config"`
IsActive
bool
`json:"is_active"`
CreatedBy
string
`json:"created_by"`
CreatedAt
time
.
Time
`json:"created_at"`
}
// GetConfigManager 获取全局配置管理器
func
GetConfigManager
()
*
ConfigManager
{
return
globalConfigManager
}
// InitConfigManager 初始化配置管理器
func
InitConfigManager
(
db
*
gorm
.
DB
)
*
ConfigManager
{
globalConfigManager
=
&
ConfigManager
{
db
:
db
,
activeConfig
:
make
(
map
[
string
]
*
AgentConfigVersion
),
watchers
:
make
([]
func
(
agentID
string
),
0
),
}
globalConfigManager
.
loadActiveConfigs
()
return
globalConfigManager
}
// loadActiveConfigs 加载所有活跃配置
func
(
m
*
ConfigManager
)
loadActiveConfigs
()
{
if
m
.
db
==
nil
{
return
}
var
configs
[]
struct
{
AgentID
string
`gorm:"column:agent_id"`
Version
int
`gorm:"column:version"`
SystemPrompt
string
`gorm:"column:system_prompt"`
Tools
string
`gorm:"column:tools"`
Config
string
`gorm:"column:config"`
CreatedBy
string
`gorm:"column:created_by"`
CreatedAt
time
.
Time
`gorm:"column:created_at"`
}
m
.
db
.
Raw
(
`
SELECT agent_id, version, system_prompt, tools, config, created_by, created_at
FROM agent_config_versions
WHERE is_active = true
`
)
.
Scan
(
&
configs
)
m
.
mu
.
Lock
()
defer
m
.
mu
.
Unlock
()
for
_
,
cfg
:=
range
configs
{
var
tools
[]
string
var
configMap
map
[
string
]
interface
{}
json
.
Unmarshal
([]
byte
(
cfg
.
Tools
),
&
tools
)
json
.
Unmarshal
([]
byte
(
cfg
.
Config
),
&
configMap
)
m
.
activeConfig
[
cfg
.
AgentID
]
=
&
AgentConfigVersion
{
AgentID
:
cfg
.
AgentID
,
Version
:
cfg
.
Version
,
SystemPrompt
:
cfg
.
SystemPrompt
,
Tools
:
tools
,
Config
:
configMap
,
IsActive
:
true
,
CreatedBy
:
cfg
.
CreatedBy
,
CreatedAt
:
cfg
.
CreatedAt
,
}
}
log
.
Printf
(
"[ConfigManager] 加载了 %d 个活跃配置"
,
len
(
configs
))
}
// GetActiveConfig 获取Agent的活跃配置
func
(
m
*
ConfigManager
)
GetActiveConfig
(
agentID
string
)
*
AgentConfigVersion
{
m
.
mu
.
RLock
()
defer
m
.
mu
.
RUnlock
()
return
m
.
activeConfig
[
agentID
]
}
// CreateVersion 创建新配置版本
func
(
m
*
ConfigManager
)
CreateVersion
(
agentID
string
,
systemPrompt
string
,
tools
[]
string
,
config
map
[
string
]
interface
{},
createdBy
string
)
(
*
AgentConfigVersion
,
error
)
{
if
m
.
db
==
nil
{
return
nil
,
fmt
.
Errorf
(
"database not initialized"
)
}
// 获取当前最大版本号
var
maxVersion
int
m
.
db
.
Raw
(
`SELECT COALESCE(MAX(version), 0) FROM agent_config_versions WHERE agent_id = $1`
,
agentID
)
.
Scan
(
&
maxVersion
)
newVersion
:=
maxVersion
+
1
toolsJSON
,
_
:=
json
.
Marshal
(
tools
)
configJSON
,
_
:=
json
.
Marshal
(
config
)
err
:=
m
.
db
.
Exec
(
`
INSERT INTO agent_config_versions (agent_id, version, system_prompt, tools, config, is_active, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, false, $6, NOW())
`
,
agentID
,
newVersion
,
systemPrompt
,
string
(
toolsJSON
),
string
(
configJSON
),
createdBy
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
return
&
AgentConfigVersion
{
AgentID
:
agentID
,
Version
:
newVersion
,
SystemPrompt
:
systemPrompt
,
Tools
:
tools
,
Config
:
config
,
IsActive
:
false
,
CreatedBy
:
createdBy
,
CreatedAt
:
time
.
Now
(),
},
nil
}
// ActivateVersion 激活指定版本
func
(
m
*
ConfigManager
)
ActivateVersion
(
agentID
string
,
version
int
)
error
{
if
m
.
db
==
nil
{
return
fmt
.
Errorf
(
"database not initialized"
)
}
// 开始事务
tx
:=
m
.
db
.
Begin
()
// 停用当前活跃版本
if
err
:=
tx
.
Exec
(
`
UPDATE agent_config_versions SET is_active = false WHERE agent_id = $1 AND is_active = true
`
,
agentID
)
.
Error
;
err
!=
nil
{
tx
.
Rollback
()
return
err
}
// 激活指定版本
if
err
:=
tx
.
Exec
(
`
UPDATE agent_config_versions SET is_active = true WHERE agent_id = $1 AND version = $2
`
,
agentID
,
version
)
.
Error
;
err
!=
nil
{
tx
.
Rollback
()
return
err
}
// 同步更新 AgentDefinition 表
var
cfg
struct
{
SystemPrompt
string
`gorm:"column:system_prompt"`
Tools
string
`gorm:"column:tools"`
Config
string
`gorm:"column:config"`
}
tx
.
Raw
(
`SELECT system_prompt, tools, config FROM agent_config_versions WHERE agent_id = $1 AND version = $2`
,
agentID
,
version
)
.
Scan
(
&
cfg
)
if
err
:=
tx
.
Exec
(
`
UPDATE agent_definitions SET system_prompt = $1, tools = $2, config = $3, updated_at = NOW() WHERE agent_id = $4
`
,
cfg
.
SystemPrompt
,
cfg
.
Tools
,
cfg
.
Config
,
agentID
)
.
Error
;
err
!=
nil
{
tx
.
Rollback
()
return
err
}
if
err
:=
tx
.
Commit
()
.
Error
;
err
!=
nil
{
return
err
}
// 更新内存缓存
m
.
reloadConfig
(
agentID
)
// 通知监听器
m
.
notifyWatchers
(
agentID
)
log
.
Printf
(
"[ConfigManager] Agent %s 已激活版本 %d"
,
agentID
,
version
)
return
nil
}
// RollbackVersion 回滚到上一个版本
func
(
m
*
ConfigManager
)
RollbackVersion
(
agentID
string
)
error
{
// 获取当前活跃版本
var
currentVersion
int
m
.
db
.
Raw
(
`SELECT version FROM agent_config_versions WHERE agent_id = $1 AND is_active = true`
,
agentID
)
.
Scan
(
&
currentVersion
)
if
currentVersion
<=
1
{
return
fmt
.
Errorf
(
"no previous version to rollback"
)
}
return
m
.
ActivateVersion
(
agentID
,
currentVersion
-
1
)
}
// ListVersions 列出Agent的所有配置版本
func
(
m
*
ConfigManager
)
ListVersions
(
agentID
string
)
([]
AgentConfigVersion
,
error
)
{
if
m
.
db
==
nil
{
return
nil
,
fmt
.
Errorf
(
"database not initialized"
)
}
var
versions
[]
struct
{
Version
int
`gorm:"column:version"`
SystemPrompt
string
`gorm:"column:system_prompt"`
Tools
string
`gorm:"column:tools"`
Config
string
`gorm:"column:config"`
IsActive
bool
`gorm:"column:is_active"`
CreatedBy
string
`gorm:"column:created_by"`
CreatedAt
time
.
Time
`gorm:"column:created_at"`
}
err
:=
m
.
db
.
Raw
(
`
SELECT version, system_prompt, tools, config, is_active, created_by, created_at
FROM agent_config_versions
WHERE agent_id = $1
ORDER BY version DESC
`
,
agentID
)
.
Scan
(
&
versions
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
AgentConfigVersion
,
len
(
versions
))
for
i
,
v
:=
range
versions
{
var
tools
[]
string
var
configMap
map
[
string
]
interface
{}
json
.
Unmarshal
([]
byte
(
v
.
Tools
),
&
tools
)
json
.
Unmarshal
([]
byte
(
v
.
Config
),
&
configMap
)
result
[
i
]
=
AgentConfigVersion
{
AgentID
:
agentID
,
Version
:
v
.
Version
,
SystemPrompt
:
v
.
SystemPrompt
,
Tools
:
tools
,
Config
:
configMap
,
IsActive
:
v
.
IsActive
,
CreatedBy
:
v
.
CreatedBy
,
CreatedAt
:
v
.
CreatedAt
,
}
}
return
result
,
nil
}
// reloadConfig 重新加载指定Agent的配置
func
(
m
*
ConfigManager
)
reloadConfig
(
agentID
string
)
{
var
cfg
struct
{
Version
int
`gorm:"column:version"`
SystemPrompt
string
`gorm:"column:system_prompt"`
Tools
string
`gorm:"column:tools"`
Config
string
`gorm:"column:config"`
CreatedBy
string
`gorm:"column:created_by"`
CreatedAt
time
.
Time
`gorm:"column:created_at"`
}
err
:=
m
.
db
.
Raw
(
`
SELECT version, system_prompt, tools, config, created_by, created_at
FROM agent_config_versions
WHERE agent_id = $1 AND is_active = true
`
,
agentID
)
.
Scan
(
&
cfg
)
.
Error
if
err
!=
nil
{
return
}
var
tools
[]
string
var
configMap
map
[
string
]
interface
{}
json
.
Unmarshal
([]
byte
(
cfg
.
Tools
),
&
tools
)
json
.
Unmarshal
([]
byte
(
cfg
.
Config
),
&
configMap
)
m
.
mu
.
Lock
()
m
.
activeConfig
[
agentID
]
=
&
AgentConfigVersion
{
AgentID
:
agentID
,
Version
:
cfg
.
Version
,
SystemPrompt
:
cfg
.
SystemPrompt
,
Tools
:
tools
,
Config
:
configMap
,
IsActive
:
true
,
CreatedBy
:
cfg
.
CreatedBy
,
CreatedAt
:
cfg
.
CreatedAt
,
}
m
.
mu
.
Unlock
()
}
// RegisterWatcher 注册配置变更监听器
func
(
m
*
ConfigManager
)
RegisterWatcher
(
watcher
func
(
agentID
string
))
{
m
.
mu
.
Lock
()
defer
m
.
mu
.
Unlock
()
m
.
watchers
=
append
(
m
.
watchers
,
watcher
)
}
// notifyWatchers 通知所有监听器
func
(
m
*
ConfigManager
)
notifyWatchers
(
agentID
string
)
{
m
.
mu
.
RLock
()
watchers
:=
make
([]
func
(
agentID
string
),
len
(
m
.
watchers
))
copy
(
watchers
,
m
.
watchers
)
m
.
mu
.
RUnlock
()
for
_
,
watcher
:=
range
watchers
{
go
watcher
(
agentID
)
}
}
// HotReload 热重载Agent配置(从数据库同步到内存)
func
(
m
*
ConfigManager
)
HotReload
(
agentID
string
)
error
{
m
.
reloadConfig
(
agentID
)
m
.
notifyWatchers
(
agentID
)
log
.
Printf
(
"[ConfigManager] Agent %s 配置已热重载"
,
agentID
)
return
nil
}
// CompareVersions 比较两个版本的差异
func
(
m
*
ConfigManager
)
CompareVersions
(
agentID
string
,
v1
,
v2
int
)
(
map
[
string
]
interface
{},
error
)
{
versions
,
err
:=
m
.
ListVersions
(
agentID
)
if
err
!=
nil
{
return
nil
,
err
}
var
cfg1
,
cfg2
*
AgentConfigVersion
for
i
:=
range
versions
{
if
versions
[
i
]
.
Version
==
v1
{
cfg1
=
&
versions
[
i
]
}
if
versions
[
i
]
.
Version
==
v2
{
cfg2
=
&
versions
[
i
]
}
}
if
cfg1
==
nil
||
cfg2
==
nil
{
return
nil
,
fmt
.
Errorf
(
"version not found"
)
}
diff
:=
map
[
string
]
interface
{}{
"prompt_changed"
:
cfg1
.
SystemPrompt
!=
cfg2
.
SystemPrompt
,
"tools_v1"
:
cfg1
.
Tools
,
"tools_v2"
:
cfg2
.
Tools
,
"config_v1"
:
cfg1
.
Config
,
"config_v2"
:
cfg2
.
Config
,
}
return
diff
,
nil
}
server/pkg/agent/constants.go
0 → 100644
View file @
94e48089
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
}
server/pkg/agent/conversation_context.go
0 → 100644
View file @
94e48089
package
agent
import
(
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"time"
"internet-hospital/internal/model"
"gorm.io/gorm"
)
// ConversationContext 对话上下文管理器(v16)
// 支持意图追踪、实体消解、会话摘要
type
ConversationContext
struct
{
db
*
gorm
.
DB
}
var
globalConversationContext
*
ConversationContext
// GetConversationContext 获取全局对话上下文管理器
func
GetConversationContext
()
*
ConversationContext
{
return
globalConversationContext
}
// InitConversationContext 初始化对话上下文管理器
func
InitConversationContext
(
db
*
gorm
.
DB
)
*
ConversationContext
{
globalConversationContext
=
&
ConversationContext
{
db
:
db
}
return
globalConversationContext
}
// Intent 意图结构
type
Intent
struct
{
Name
string
`json:"name"`
// 意图名称
Confidence
float64
`json:"confidence"`
// 置信度
Entities
map
[
string
]
string
`json:"entities"`
// 提取的实体
Slots
map
[
string
]
string
`json:"slots"`
// 槽位填充
}
// EntityReference 实体引用(用于指代消解)
type
EntityReference
struct
{
Type
string
`json:"type"`
// 实体类型: doctor, patient, medicine, department
ID
string
`json:"id"`
// 实体ID
Name
string
`json:"name"`
// 实体名称
Mention
string
`json:"mention"`
// 原始提及文本
Timestamp
time
.
Time
`json:"timestamp"`
// 提及时间
}
// ConversationState 对话状态
type
ConversationState
struct
{
SessionID
string
`json:"session_id"`
CurrentIntent
*
Intent
`json:"current_intent"`
IntentHistory
[]
Intent
`json:"intent_history"`
EntityContext
map
[
string
]
EntityReference
`json:"entity_context"`
// 最近提及的实体
ConfirmPending
bool
`json:"confirm_pending"`
// 是否等待确认
PendingAction
string
`json:"pending_action"`
// 待确认的操作
TurnCount
int
`json:"turn_count"`
}
// ExtractIntent 从用户消息中提取意图
func
(
c
*
ConversationContext
)
ExtractIntent
(
ctx
context
.
Context
,
message
string
,
history
[]
string
)
(
*
Intent
,
error
)
{
// 先检查缓存
queryHash
:=
hashQuery
(
message
)
if
cached
:=
c
.
getIntentCache
(
queryHash
);
cached
!=
nil
{
return
cached
,
nil
}
intent
:=
&
Intent
{
Entities
:
make
(
map
[
string
]
string
),
Slots
:
make
(
map
[
string
]
string
),
}
// 基于规则的意图识别
intent
.
Name
,
intent
.
Confidence
=
c
.
classifyIntent
(
message
)
// 实体提取
intent
.
Entities
=
c
.
extractEntities
(
message
)
// 缓存结果
c
.
saveIntentCache
(
queryHash
,
message
,
intent
)
return
intent
,
nil
}
// classifyIntent 基于规则的意图分类
func
(
c
*
ConversationContext
)
classifyIntent
(
message
string
)
(
string
,
float64
)
{
msg
:=
strings
.
ToLower
(
message
)
// 导航意图
navPatterns
:=
[]
string
{
"打开"
,
"跳转"
,
"进入"
,
"去"
,
"查看"
,
"找"
}
for
_
,
p
:=
range
navPatterns
{
if
strings
.
Contains
(
msg
,
p
)
{
return
"navigate"
,
0.8
}
}
// 查询意图
queryPatterns
:=
[]
string
{
"查询"
,
"搜索"
,
"查找"
,
"有哪些"
,
"列表"
,
"显示"
}
for
_
,
p
:=
range
queryPatterns
{
if
strings
.
Contains
(
msg
,
p
)
{
return
"query"
,
0.8
}
}
// 操作意图
actionPatterns
:=
[]
string
{
"添加"
,
"新增"
,
"创建"
,
"删除"
,
"修改"
,
"编辑"
,
"更新"
}
for
_
,
p
:=
range
actionPatterns
{
if
strings
.
Contains
(
msg
,
p
)
{
return
"action"
,
0.8
}
}
// 咨询意图
consultPatterns
:=
[]
string
{
"怎么"
,
"如何"
,
"为什么"
,
"什么是"
,
"能不能"
,
"可以吗"
}
for
_
,
p
:=
range
consultPatterns
{
if
strings
.
Contains
(
msg
,
p
)
{
return
"consult"
,
0.7
}
}
// 确认意图
confirmPatterns
:=
[]
string
{
"是的"
,
"对"
,
"确认"
,
"好的"
,
"可以"
,
"没问题"
}
for
_
,
p
:=
range
confirmPatterns
{
if
strings
.
Contains
(
msg
,
p
)
{
return
"confirm"
,
0.9
}
}
// 否定意图
denyPatterns
:=
[]
string
{
"不是"
,
"不对"
,
"取消"
,
"不要"
,
"算了"
}
for
_
,
p
:=
range
denyPatterns
{
if
strings
.
Contains
(
msg
,
p
)
{
return
"deny"
,
0.9
}
}
return
"unknown"
,
0.5
}
// extractEntities 从消息中提取实体
func
(
c
*
ConversationContext
)
extractEntities
(
message
string
)
map
[
string
]
string
{
entities
:=
make
(
map
[
string
]
string
)
// 医生名称模式
doctorPattern
:=
regexp
.
MustCompile
(
`([\p{Han}]{2,4})(医生|大夫|主任|教授)`
)
if
matches
:=
doctorPattern
.
FindStringSubmatch
(
message
);
len
(
matches
)
>
1
{
entities
[
"doctor_name"
]
=
matches
[
1
]
}
// 科室模式
deptPattern
:=
regexp
.
MustCompile
(
`([\p{Han}]{2,6})(科|门诊|中心)`
)
if
matches
:=
deptPattern
.
FindStringSubmatch
(
message
);
len
(
matches
)
>
1
{
entities
[
"department"
]
=
matches
[
0
]
}
// 药品模式
medicinePattern
:=
regexp
.
MustCompile
(
`([\p{Han}]{2,10})(片|胶囊|颗粒|注射液|口服液)`
)
if
matches
:=
medicinePattern
.
FindStringSubmatch
(
message
);
len
(
matches
)
>
1
{
entities
[
"medicine"
]
=
matches
[
0
]
}
// 指代词检测
pronouns
:=
map
[
string
]
string
{
"这个医生"
:
"doctor"
,
"那个医生"
:
"doctor"
,
"他"
:
"doctor"
,
"她"
:
"doctor"
,
"这个患者"
:
"patient"
,
"那个患者"
:
"patient"
,
"这个药"
:
"medicine"
,
"那个药"
:
"medicine"
,
"刚才那个"
:
"last_entity"
,
}
for
pronoun
,
entityType
:=
range
pronouns
{
if
strings
.
Contains
(
message
,
pronoun
)
{
entities
[
"pronoun_ref"
]
=
entityType
}
}
return
entities
}
// ResolveReferences 解析指代引用
func
(
c
*
ConversationContext
)
ResolveReferences
(
message
string
,
entityContext
map
[
string
]
EntityReference
)
string
{
resolved
:=
message
// 检查是否有指代词需要解析
pronounMappings
:=
map
[
string
]
string
{
"这个医生"
:
"doctor"
,
"那个医生"
:
"doctor"
,
"他"
:
"doctor"
,
"她"
:
"doctor"
,
"这个患者"
:
"patient"
,
"那个患者"
:
"patient"
,
"这个药"
:
"medicine"
,
"那个药"
:
"medicine"
,
}
for
pronoun
,
entityType
:=
range
pronounMappings
{
if
strings
.
Contains
(
message
,
pronoun
)
{
if
ref
,
ok
:=
entityContext
[
entityType
];
ok
{
// 替换指代词为实际实体名称
resolved
=
strings
.
Replace
(
resolved
,
pronoun
,
ref
.
Name
,
1
)
log
.
Printf
(
"[ConversationContext] 指代消解: %s -> %s"
,
pronoun
,
ref
.
Name
)
}
}
}
return
resolved
}
// UpdateEntityContext 更新实体上下文
func
(
c
*
ConversationContext
)
UpdateEntityContext
(
sessionID
string
,
entities
map
[
string
]
string
,
entityContext
map
[
string
]
EntityReference
)
map
[
string
]
EntityReference
{
if
entityContext
==
nil
{
entityContext
=
make
(
map
[
string
]
EntityReference
)
}
now
:=
time
.
Now
()
// 根据提取的实体更新上下文
if
doctorName
,
ok
:=
entities
[
"doctor_name"
];
ok
{
entityContext
[
"doctor"
]
=
EntityReference
{
Type
:
"doctor"
,
Name
:
doctorName
,
Mention
:
doctorName
,
Timestamp
:
now
,
}
}
if
dept
,
ok
:=
entities
[
"department"
];
ok
{
entityContext
[
"department"
]
=
EntityReference
{
Type
:
"department"
,
Name
:
dept
,
Mention
:
dept
,
Timestamp
:
now
,
}
}
if
medicine
,
ok
:=
entities
[
"medicine"
];
ok
{
entityContext
[
"medicine"
]
=
EntityReference
{
Type
:
"medicine"
,
Name
:
medicine
,
Mention
:
medicine
,
Timestamp
:
now
,
}
}
return
entityContext
}
// GenerateSummary 生成会话摘要
func
(
c
*
ConversationContext
)
GenerateSummary
(
history
[]
string
,
maxLength
int
)
string
{
if
len
(
history
)
==
0
{
return
""
}
// 简单的摘要策略:保留最近的关键对话
var
summary
strings
.
Builder
summary
.
WriteString
(
"对话摘要:"
)
// 提取关键信息
keyInfo
:=
make
([]
string
,
0
)
for
_
,
msg
:=
range
history
{
// 提取包含实体的消息
if
strings
.
Contains
(
msg
,
"医生"
)
||
strings
.
Contains
(
msg
,
"患者"
)
||
strings
.
Contains
(
msg
,
"药"
)
||
strings
.
Contains
(
msg
,
"科室"
)
{
if
len
(
msg
)
>
50
{
keyInfo
=
append
(
keyInfo
,
msg
[
:
50
]
+
"..."
)
}
else
{
keyInfo
=
append
(
keyInfo
,
msg
)
}
}
}
// 限制摘要长度
for
i
,
info
:=
range
keyInfo
{
if
summary
.
Len
()
+
len
(
info
)
>
maxLength
{
break
}
if
i
>
0
{
summary
.
WriteString
(
"; "
)
}
summary
.
WriteString
(
info
)
}
return
summary
.
String
()
}
// NeedConfirmation 判断是否需要确认
func
(
c
*
ConversationContext
)
NeedConfirmation
(
intent
*
Intent
,
action
string
)
bool
{
// 敏感操作需要确认
sensitiveActions
:=
[]
string
{
"delete"
,
"remove"
,
"cancel"
,
"send_notification"
,
"trigger_workflow"
}
for
_
,
sa
:=
range
sensitiveActions
{
if
strings
.
Contains
(
strings
.
ToLower
(
action
),
sa
)
{
return
true
}
}
// 低置信度意图需要确认
if
intent
!=
nil
&&
intent
.
Confidence
<
0.7
{
return
true
}
return
false
}
// getIntentCache 从缓存获取意图
func
(
c
*
ConversationContext
)
getIntentCache
(
queryHash
string
)
*
Intent
{
if
c
.
db
==
nil
{
return
nil
}
var
cache
struct
{
Intent
string
`gorm:"column:intent"`
Entities
string
`gorm:"column:entities"`
Confidence
float64
`gorm:"column:confidence"`
}
err
:=
c
.
db
.
Raw
(
`
SELECT intent, entities, confidence FROM intent_cache
WHERE query_hash = $1 AND updated_at > NOW() - INTERVAL '24 hours'
`
,
queryHash
)
.
Scan
(
&
cache
)
.
Error
if
err
!=
nil
||
cache
.
Intent
==
""
{
return
nil
}
// 更新命中计数
c
.
db
.
Exec
(
`UPDATE intent_cache SET hit_count = hit_count + 1 WHERE query_hash = $1`
,
queryHash
)
intent
:=
&
Intent
{
Name
:
cache
.
Intent
,
Confidence
:
cache
.
Confidence
,
Entities
:
make
(
map
[
string
]
string
),
}
json
.
Unmarshal
([]
byte
(
cache
.
Entities
),
&
intent
.
Entities
)
return
intent
}
// saveIntentCache 保存意图到缓存
func
(
c
*
ConversationContext
)
saveIntentCache
(
queryHash
,
queryText
string
,
intent
*
Intent
)
{
if
c
.
db
==
nil
||
intent
==
nil
{
return
}
entitiesJSON
,
_
:=
json
.
Marshal
(
intent
.
Entities
)
c
.
db
.
Exec
(
`
INSERT INTO intent_cache (query_hash, query_text, intent, entities, confidence, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (query_hash) DO UPDATE SET
hit_count = intent_cache.hit_count + 1,
updated_at = NOW()
`
,
queryHash
,
queryText
,
intent
.
Name
,
string
(
entitiesJSON
),
intent
.
Confidence
)
}
// hashQuery 计算查询哈希
func
hashQuery
(
query
string
)
string
{
// 标准化查询
normalized
:=
strings
.
TrimSpace
(
strings
.
ToLower
(
query
))
hash
:=
sha256
.
Sum256
([]
byte
(
normalized
))
return
hex
.
EncodeToString
(
hash
[
:
])
}
// SaveSessionState 保存会话状态
func
(
c
*
ConversationContext
)
SaveSessionState
(
sessionID
string
,
state
*
ConversationState
)
error
{
if
c
.
db
==
nil
{
return
fmt
.
Errorf
(
"database not initialized"
)
}
entityContextJSON
,
_
:=
json
.
Marshal
(
state
.
EntityContext
)
lastIntent
:=
""
if
state
.
CurrentIntent
!=
nil
{
lastIntent
=
state
.
CurrentIntent
.
Name
}
return
c
.
db
.
Model
(
&
model
.
AgentSession
{})
.
Where
(
"session_id = ?"
,
sessionID
)
.
Updates
(
map
[
string
]
interface
{}{
"last_intent"
:
lastIntent
,
"entity_context"
:
string
(
entityContextJSON
),
"updated_at"
:
time
.
Now
(),
})
.
Error
}
// LoadSessionState 加载会话状态
func
(
c
*
ConversationContext
)
LoadSessionState
(
sessionID
string
)
(
*
ConversationState
,
error
)
{
if
c
.
db
==
nil
{
return
nil
,
fmt
.
Errorf
(
"database not initialized"
)
}
var
session
model
.
AgentSession
if
err
:=
c
.
db
.
Where
(
"session_id = ?"
,
sessionID
)
.
First
(
&
session
)
.
Error
;
err
!=
nil
{
return
nil
,
err
}
state
:=
&
ConversationState
{
SessionID
:
sessionID
,
EntityContext
:
make
(
map
[
string
]
EntityReference
),
TurnCount
:
session
.
MessageCount
/
2
,
}
if
session
.
EntityContext
!=
""
{
json
.
Unmarshal
([]
byte
(
session
.
EntityContext
),
&
state
.
EntityContext
)
}
if
session
.
LastIntent
!=
""
{
state
.
CurrentIntent
=
&
Intent
{
Name
:
session
.
LastIntent
}
}
return
state
,
nil
}
server/pkg/agent/executor.go
View file @
94e48089
...
@@ -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
{
...
...
server/pkg/agent/multimodal.go
0 → 100644
View file @
94e48089
package
agent
import
(
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// MultimodalHandler 多模态处理器(v16)
// 支持图片、音频、文档等多种输入类型
type
MultimodalHandler
struct
{
db
*
gorm
.
DB
storagePath
string
}
var
globalMultimodalHandler
*
MultimodalHandler
// AttachmentType 附件类型
type
AttachmentType
string
const
(
AttachmentTypeImage
AttachmentType
=
"image"
AttachmentTypeAudio
AttachmentType
=
"audio"
AttachmentTypeDocument
AttachmentType
=
"document"
)
// Attachment 附件信息
type
Attachment
struct
{
ID
string
`json:"id"`
SessionID
string
`json:"session_id"`
MessageIndex
int
`json:"message_index"`
FileType
AttachmentType
`json:"file_type"`
MimeType
string
`json:"mime_type"`
FileName
string
`json:"file_name"`
FileSize
int64
`json:"file_size"`
StoragePath
string
`json:"storage_path"`
ThumbnailPath
string
`json:"thumbnail_path,omitempty"`
OCRText
string
`json:"ocr_text,omitempty"`
AnalysisResult
map
[
string
]
interface
{}
`json:"analysis_result,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
}
// MultimodalInput 多模态输入
type
MultimodalInput
struct
{
Text
string
`json:"text"`
Attachments
[]
Attachment
`json:"attachments,omitempty"`
}
// GetMultimodalHandler 获取全局多模态处理器
func
GetMultimodalHandler
()
*
MultimodalHandler
{
return
globalMultimodalHandler
}
// InitMultimodalHandler 初始化多模态处理器
func
InitMultimodalHandler
(
db
*
gorm
.
DB
,
storagePath
string
)
*
MultimodalHandler
{
if
storagePath
==
""
{
storagePath
=
"./uploads/agent_attachments"
}
// 确保存储目录存在
os
.
MkdirAll
(
storagePath
,
0755
)
globalMultimodalHandler
=
&
MultimodalHandler
{
db
:
db
,
storagePath
:
storagePath
,
}
return
globalMultimodalHandler
}
// SaveAttachment 保存附件
func
(
h
*
MultimodalHandler
)
SaveAttachment
(
ctx
context
.
Context
,
sessionID
string
,
messageIndex
int
,
fileName
string
,
mimeType
string
,
data
io
.
Reader
)
(
*
Attachment
,
error
)
{
// 生成附件ID
attachmentID
:=
uuid
.
New
()
.
String
()
// 确定文件类型
fileType
:=
h
.
detectFileType
(
mimeType
)
// 构建存储路径
ext
:=
filepath
.
Ext
(
fileName
)
if
ext
==
""
{
ext
=
h
.
getExtensionFromMime
(
mimeType
)
}
storageName
:=
fmt
.
Sprintf
(
"%s%s"
,
attachmentID
,
ext
)
storagePath
:=
filepath
.
Join
(
h
.
storagePath
,
sessionID
,
storageName
)
// 确保目录存在
os
.
MkdirAll
(
filepath
.
Dir
(
storagePath
),
0755
)
// 保存文件
file
,
err
:=
os
.
Create
(
storagePath
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"创建文件失败: %w"
,
err
)
}
defer
file
.
Close
()
fileSize
,
err
:=
io
.
Copy
(
file
,
data
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"写入文件失败: %w"
,
err
)
}
attachment
:=
&
Attachment
{
ID
:
attachmentID
,
SessionID
:
sessionID
,
MessageIndex
:
messageIndex
,
FileType
:
fileType
,
MimeType
:
mimeType
,
FileName
:
fileName
,
FileSize
:
fileSize
,
StoragePath
:
storagePath
,
CreatedAt
:
time
.
Now
(),
}
// 保存到数据库
if
h
.
db
!=
nil
{
h
.
db
.
Exec
(
`
INSERT INTO agent_attachments (attachment_id, session_id, message_index, file_type, mime_type, file_name, file_size, storage_path, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
,
attachment
.
ID
,
attachment
.
SessionID
,
attachment
.
MessageIndex
,
attachment
.
FileType
,
attachment
.
MimeType
,
attachment
.
FileName
,
attachment
.
FileSize
,
attachment
.
StoragePath
,
attachment
.
CreatedAt
)
}
log
.
Printf
(
"[Multimodal] 保存附件: %s (%s, %d bytes)"
,
fileName
,
fileType
,
fileSize
)
return
attachment
,
nil
}
// SaveBase64Attachment 保存Base64编码的附件
func
(
h
*
MultimodalHandler
)
SaveBase64Attachment
(
ctx
context
.
Context
,
sessionID
string
,
messageIndex
int
,
fileName
string
,
mimeType
string
,
base64Data
string
)
(
*
Attachment
,
error
)
{
// 移除可能的data URI前缀
if
idx
:=
strings
.
Index
(
base64Data
,
","
);
idx
!=
-
1
{
base64Data
=
base64Data
[
idx
+
1
:
]
}
// 解码Base64
data
,
err
:=
base64
.
StdEncoding
.
DecodeString
(
base64Data
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"Base64解码失败: %w"
,
err
)
}
return
h
.
SaveAttachment
(
ctx
,
sessionID
,
messageIndex
,
fileName
,
mimeType
,
strings
.
NewReader
(
string
(
data
)))
}
// GetAttachment 获取附件信息
func
(
h
*
MultimodalHandler
)
GetAttachment
(
attachmentID
string
)
(
*
Attachment
,
error
)
{
if
h
.
db
==
nil
{
return
nil
,
fmt
.
Errorf
(
"database not initialized"
)
}
var
att
struct
{
ID
string
`gorm:"column:attachment_id"`
SessionID
string
`gorm:"column:session_id"`
MessageIndex
int
`gorm:"column:message_index"`
FileType
string
`gorm:"column:file_type"`
MimeType
string
`gorm:"column:mime_type"`
FileName
string
`gorm:"column:file_name"`
FileSize
int64
`gorm:"column:file_size"`
StoragePath
string
`gorm:"column:storage_path"`
ThumbnailPath
string
`gorm:"column:thumbnail_path"`
OCRText
string
`gorm:"column:ocr_text"`
CreatedAt
time
.
Time
`gorm:"column:created_at"`
}
err
:=
h
.
db
.
Raw
(
`
SELECT attachment_id, session_id, message_index, file_type, mime_type, file_name, file_size, storage_path, thumbnail_path, ocr_text, created_at
FROM agent_attachments
WHERE attachment_id = $1
`
,
attachmentID
)
.
Scan
(
&
att
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
return
&
Attachment
{
ID
:
att
.
ID
,
SessionID
:
att
.
SessionID
,
MessageIndex
:
att
.
MessageIndex
,
FileType
:
AttachmentType
(
att
.
FileType
),
MimeType
:
att
.
MimeType
,
FileName
:
att
.
FileName
,
FileSize
:
att
.
FileSize
,
StoragePath
:
att
.
StoragePath
,
ThumbnailPath
:
att
.
ThumbnailPath
,
OCRText
:
att
.
OCRText
,
CreatedAt
:
att
.
CreatedAt
,
},
nil
}
// GetSessionAttachments 获取会话的所有附件
func
(
h
*
MultimodalHandler
)
GetSessionAttachments
(
sessionID
string
)
([]
Attachment
,
error
)
{
if
h
.
db
==
nil
{
return
nil
,
fmt
.
Errorf
(
"database not initialized"
)
}
var
attachments
[]
struct
{
ID
string
`gorm:"column:attachment_id"`
SessionID
string
`gorm:"column:session_id"`
MessageIndex
int
`gorm:"column:message_index"`
FileType
string
`gorm:"column:file_type"`
MimeType
string
`gorm:"column:mime_type"`
FileName
string
`gorm:"column:file_name"`
FileSize
int64
`gorm:"column:file_size"`
StoragePath
string
`gorm:"column:storage_path"`
ThumbnailPath
string
`gorm:"column:thumbnail_path"`
OCRText
string
`gorm:"column:ocr_text"`
CreatedAt
time
.
Time
`gorm:"column:created_at"`
}
err
:=
h
.
db
.
Raw
(
`
SELECT attachment_id, session_id, message_index, file_type, mime_type, file_name, file_size, storage_path, thumbnail_path, ocr_text, created_at
FROM agent_attachments
WHERE session_id = $1
ORDER BY message_index, created_at
`
,
sessionID
)
.
Scan
(
&
attachments
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
result
:=
make
([]
Attachment
,
len
(
attachments
))
for
i
,
att
:=
range
attachments
{
result
[
i
]
=
Attachment
{
ID
:
att
.
ID
,
SessionID
:
att
.
SessionID
,
MessageIndex
:
att
.
MessageIndex
,
FileType
:
AttachmentType
(
att
.
FileType
),
MimeType
:
att
.
MimeType
,
FileName
:
att
.
FileName
,
FileSize
:
att
.
FileSize
,
StoragePath
:
att
.
StoragePath
,
ThumbnailPath
:
att
.
ThumbnailPath
,
OCRText
:
att
.
OCRText
,
CreatedAt
:
att
.
CreatedAt
,
}
}
return
result
,
nil
}
// UpdateOCRText 更新附件的OCR文本
func
(
h
*
MultimodalHandler
)
UpdateOCRText
(
attachmentID
string
,
ocrText
string
)
error
{
if
h
.
db
==
nil
{
return
fmt
.
Errorf
(
"database not initialized"
)
}
return
h
.
db
.
Exec
(
`
UPDATE agent_attachments SET ocr_text = $1 WHERE attachment_id = $2
`
,
ocrText
,
attachmentID
)
.
Error
}
// UpdateAnalysisResult 更新附件的分析结果
func
(
h
*
MultimodalHandler
)
UpdateAnalysisResult
(
attachmentID
string
,
result
map
[
string
]
interface
{})
error
{
if
h
.
db
==
nil
{
return
fmt
.
Errorf
(
"database not initialized"
)
}
resultJSON
,
_
:=
json
.
Marshal
(
result
)
return
h
.
db
.
Exec
(
`
UPDATE agent_attachments SET analysis_result = $1 WHERE attachment_id = $2
`
,
string
(
resultJSON
),
attachmentID
)
.
Error
}
// detectFileType 检测文件类型
func
(
h
*
MultimodalHandler
)
detectFileType
(
mimeType
string
)
AttachmentType
{
mimeType
=
strings
.
ToLower
(
mimeType
)
if
strings
.
HasPrefix
(
mimeType
,
"image/"
)
{
return
AttachmentTypeImage
}
if
strings
.
HasPrefix
(
mimeType
,
"audio/"
)
{
return
AttachmentTypeAudio
}
return
AttachmentTypeDocument
}
// getExtensionFromMime 从MIME类型获取文件扩展名
func
(
h
*
MultimodalHandler
)
getExtensionFromMime
(
mimeType
string
)
string
{
mimeToExt
:=
map
[
string
]
string
{
"image/jpeg"
:
".jpg"
,
"image/png"
:
".png"
,
"image/gif"
:
".gif"
,
"image/webp"
:
".webp"
,
"audio/mpeg"
:
".mp3"
,
"audio/wav"
:
".wav"
,
"audio/ogg"
:
".ogg"
,
"application/pdf"
:
".pdf"
,
"text/plain"
:
".txt"
,
"application/json"
:
".json"
,
}
if
ext
,
ok
:=
mimeToExt
[
mimeType
];
ok
{
return
ext
}
return
".bin"
}
// BuildMultimodalPrompt 构建包含附件信息的提示词
func
(
h
*
MultimodalHandler
)
BuildMultimodalPrompt
(
text
string
,
attachments
[]
Attachment
)
string
{
if
len
(
attachments
)
==
0
{
return
text
}
var
sb
strings
.
Builder
sb
.
WriteString
(
text
)
sb
.
WriteString
(
"
\n\n
[附件信息]"
)
for
i
,
att
:=
range
attachments
{
sb
.
WriteString
(
fmt
.
Sprintf
(
"
\n
%d. %s (%s, %d bytes)"
,
i
+
1
,
att
.
FileName
,
att
.
FileType
,
att
.
FileSize
))
if
att
.
OCRText
!=
""
{
sb
.
WriteString
(
fmt
.
Sprintf
(
"
\n
OCR识别文本: %s"
,
att
.
OCRText
))
}
}
return
sb
.
String
()
}
// DeleteAttachment 删除附件
func
(
h
*
MultimodalHandler
)
DeleteAttachment
(
attachmentID
string
)
error
{
att
,
err
:=
h
.
GetAttachment
(
attachmentID
)
if
err
!=
nil
{
return
err
}
// 删除文件
if
att
.
StoragePath
!=
""
{
os
.
Remove
(
att
.
StoragePath
)
}
if
att
.
ThumbnailPath
!=
""
{
os
.
Remove
(
att
.
ThumbnailPath
)
}
// 删除数据库记录
if
h
.
db
!=
nil
{
h
.
db
.
Exec
(
`DELETE FROM agent_attachments WHERE attachment_id = $1`
,
attachmentID
)
}
return
nil
}
// CleanupOldAttachments 清理过期附件
func
(
h
*
MultimodalHandler
)
CleanupOldAttachments
(
olderThan
time
.
Duration
)
(
int
,
error
)
{
if
h
.
db
==
nil
{
return
0
,
fmt
.
Errorf
(
"database not initialized"
)
}
cutoff
:=
time
.
Now
()
.
Add
(
-
olderThan
)
var
attachments
[]
struct
{
ID
string
`gorm:"column:attachment_id"`
StoragePath
string
`gorm:"column:storage_path"`
Thumbnail
string
`gorm:"column:thumbnail_path"`
}
h
.
db
.
Raw
(
`
SELECT attachment_id, storage_path, thumbnail_path
FROM agent_attachments
WHERE created_at < $1
`
,
cutoff
)
.
Scan
(
&
attachments
)
count
:=
0
for
_
,
att
:=
range
attachments
{
if
att
.
StoragePath
!=
""
{
os
.
Remove
(
att
.
StoragePath
)
}
if
att
.
Thumbnail
!=
""
{
os
.
Remove
(
att
.
Thumbnail
)
}
h
.
db
.
Exec
(
`DELETE FROM agent_attachments WHERE attachment_id = $1`
,
att
.
ID
)
count
++
}
log
.
Printf
(
"[Multimodal] 清理了 %d 个过期附件"
,
count
)
return
count
,
nil
}
server/pkg/agent/react_agent.go
View file @
94e48089
...
@@ -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
}
}
...
...
server/pkg/agent/tool_recommender.go
0 → 100644
View file @
94e48089
package
agent
import
(
"context"
"fmt"
"log"
"strings"
"sync"
"internet-hospital/internal/model"
"internet-hospital/pkg/rag"
"gorm.io/gorm"
)
// ToolRecommender 智能工具推荐器(v16)
// 使用pgvector进行语义相似度匹配,结合用户历史行为推荐工具
type
ToolRecommender
struct
{
db
*
gorm
.
DB
embedder
*
rag
.
Embedder
mu
sync
.
RWMutex
}
var
globalToolRecommender
*
ToolRecommender
// GetToolRecommender 获取全局工具推荐器实例
func
GetToolRecommender
()
*
ToolRecommender
{
return
globalToolRecommender
}
// InitToolRecommender 初始化工具推荐器
func
InitToolRecommender
(
db
*
gorm
.
DB
,
embedder
*
rag
.
Embedder
)
*
ToolRecommender
{
globalToolRecommender
=
&
ToolRecommender
{
db
:
db
,
embedder
:
embedder
,
}
return
globalToolRecommender
}
// ToolRecommendation 工具推荐结果
type
ToolRecommendation
struct
{
ToolName
string
`json:"tool_name"`
DisplayName
string
`json:"display_name"`
Description
string
`json:"description"`
Score
float64
`json:"score"`
Reason
string
`json:"reason"`
// 推荐原因: semantic/history/popular
}
// RecommendTools 根据用户消息推荐工具
// 结合语义相似度、用户历史、工具热度进行综合推荐
func
(
r
*
ToolRecommender
)
RecommendTools
(
ctx
context
.
Context
,
userMessage
string
,
userID
string
,
userRole
string
,
topK
int
)
([]
ToolRecommendation
,
error
)
{
if
r
==
nil
||
r
.
db
==
nil
{
return
nil
,
fmt
.
Errorf
(
"recommender not initialized"
)
}
if
topK
<=
0
{
topK
=
5
}
var
recommendations
[]
ToolRecommendation
// 1. 语义相似度推荐(使用pgvector)
if
r
.
embedder
!=
nil
{
semanticRecs
,
err
:=
r
.
semanticRecommend
(
ctx
,
userMessage
,
userRole
,
topK
)
if
err
!=
nil
{
log
.
Printf
(
"[ToolRecommender] 语义推荐失败: %v"
,
err
)
}
else
{
recommendations
=
append
(
recommendations
,
semanticRecs
...
)
}
}
// 2. 用户历史行为推荐
if
userID
!=
""
{
historyRecs
,
err
:=
r
.
historyRecommend
(
ctx
,
userID
,
userRole
,
topK
)
if
err
!=
nil
{
log
.
Printf
(
"[ToolRecommender] 历史推荐失败: %v"
,
err
)
}
else
{
recommendations
=
mergeRecommendations
(
recommendations
,
historyRecs
)
}
}
// 3. 热门工具推荐(兜底)
if
len
(
recommendations
)
<
topK
{
popularRecs
,
err
:=
r
.
popularRecommend
(
ctx
,
userRole
,
topK
-
len
(
recommendations
))
if
err
!=
nil
{
log
.
Printf
(
"[ToolRecommender] 热门推荐失败: %v"
,
err
)
}
else
{
recommendations
=
mergeRecommendations
(
recommendations
,
popularRecs
)
}
}
// 去重并限制数量
recommendations
=
deduplicateRecommendations
(
recommendations
,
topK
)
return
recommendations
,
nil
}
// semanticRecommend 基于语义相似度推荐工具
func
(
r
*
ToolRecommender
)
semanticRecommend
(
ctx
context
.
Context
,
query
string
,
userRole
string
,
topK
int
)
([]
ToolRecommendation
,
error
)
{
// 生成查询向量
queryVector
,
err
:=
r
.
embedder
.
EmbedSingle
(
ctx
,
query
)
if
err
!=
nil
{
return
nil
,
err
}
// 构建向量字符串
vectorStr
:=
floatsToVectorString
(
queryVector
)
// 构建SQL查询
sql
:=
`
SELECT te.tool_name, te.description,
1 - (te.embedding <=> $1::vector) as score
FROM tool_embeddings te
JOIN agent_tools at ON at.name = te.tool_name
WHERE te.embedding IS NOT NULL
AND at.status = 'active'
`
args
:=
[]
interface
{}{
vectorStr
}
// 根据用户角色过滤
if
userRole
!=
""
&&
userRole
!=
"admin"
{
sql
+=
` AND (at.allowed_roles = '*' OR at.allowed_roles LIKE $2)`
args
=
append
(
args
,
"%"
+
userRole
+
"%"
)
}
sql
+=
fmt
.
Sprintf
(
` ORDER BY te.embedding <=> $1::vector LIMIT $%d`
,
len
(
args
)
+
1
)
args
=
append
(
args
,
topK
)
var
results
[]
struct
{
ToolName
string
`gorm:"column:tool_name"`
Description
string
`gorm:"column:description"`
Score
float64
`gorm:"column:score"`
}
if
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Raw
(
sql
,
args
...
)
.
Scan
(
&
results
)
.
Error
;
err
!=
nil
{
return
nil
,
err
}
recommendations
:=
make
([]
ToolRecommendation
,
len
(
results
))
for
i
,
res
:=
range
results
{
recommendations
[
i
]
=
ToolRecommendation
{
ToolName
:
res
.
ToolName
,
Description
:
res
.
Description
,
Score
:
res
.
Score
,
Reason
:
"semantic"
,
}
}
return
recommendations
,
nil
}
// historyRecommend 基于用户历史行为推荐工具
func
(
r
*
ToolRecommender
)
historyRecommend
(
ctx
context
.
Context
,
userID
string
,
userRole
string
,
topK
int
)
([]
ToolRecommendation
,
error
)
{
sql
:=
`
SELECT utp.tool_name, at.description,
(utp.usage_count * 0.5 + utp.success_count * 0.3 + utp.avg_satisfaction * 20) as score
FROM user_tool_preferences utp
JOIN agent_tools at ON at.name = utp.tool_name
WHERE utp.user_id = $1
AND at.status = 'active'
`
args
:=
[]
interface
{}{
userID
}
if
userRole
!=
""
&&
userRole
!=
"admin"
{
sql
+=
` AND (at.allowed_roles = '*' OR at.allowed_roles LIKE $2)`
args
=
append
(
args
,
"%"
+
userRole
+
"%"
)
}
sql
+=
fmt
.
Sprintf
(
` ORDER BY score DESC LIMIT $%d`
,
len
(
args
)
+
1
)
args
=
append
(
args
,
topK
)
var
results
[]
struct
{
ToolName
string
`gorm:"column:tool_name"`
Description
string
`gorm:"column:description"`
Score
float64
`gorm:"column:score"`
}
if
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Raw
(
sql
,
args
...
)
.
Scan
(
&
results
)
.
Error
;
err
!=
nil
{
return
nil
,
err
}
recommendations
:=
make
([]
ToolRecommendation
,
len
(
results
))
for
i
,
res
:=
range
results
{
recommendations
[
i
]
=
ToolRecommendation
{
ToolName
:
res
.
ToolName
,
Description
:
res
.
Description
,
Score
:
res
.
Score
*
0.8
,
// 历史推荐权重稍低
Reason
:
"history"
,
}
}
return
recommendations
,
nil
}
// popularRecommend 基于热门度推荐工具
func
(
r
*
ToolRecommender
)
popularRecommend
(
ctx
context
.
Context
,
userRole
string
,
topK
int
)
([]
ToolRecommendation
,
error
)
{
sql
:=
`
SELECT name as tool_name, description,
(usage_count * 0.4 + success_count * 0.4 + quality_score * 0.2) as score
FROM agent_tools
WHERE status = 'active'
`
args
:=
[]
interface
{}{}
if
userRole
!=
""
&&
userRole
!=
"admin"
{
sql
+=
` AND (allowed_roles = '*' OR allowed_roles LIKE $1)`
args
=
append
(
args
,
"%"
+
userRole
+
"%"
)
}
sql
+=
fmt
.
Sprintf
(
` ORDER BY score DESC LIMIT $%d`
,
len
(
args
)
+
1
)
args
=
append
(
args
,
topK
)
var
results
[]
struct
{
ToolName
string
`gorm:"column:tool_name"`
Description
string
`gorm:"column:description"`
Score
float64
`gorm:"column:score"`
}
if
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Raw
(
sql
,
args
...
)
.
Scan
(
&
results
)
.
Error
;
err
!=
nil
{
return
nil
,
err
}
recommendations
:=
make
([]
ToolRecommendation
,
len
(
results
))
for
i
,
res
:=
range
results
{
recommendations
[
i
]
=
ToolRecommendation
{
ToolName
:
res
.
ToolName
,
Description
:
res
.
Description
,
Score
:
res
.
Score
*
0.6
,
// 热门推荐权重最低
Reason
:
"popular"
,
}
}
return
recommendations
,
nil
}
// IndexToolEmbeddings 为所有工具生成向量索引
func
(
r
*
ToolRecommender
)
IndexToolEmbeddings
(
ctx
context
.
Context
)
error
{
if
r
.
embedder
==
nil
{
return
fmt
.
Errorf
(
"embedder not configured"
)
}
// 获取所有工具
var
tools
[]
model
.
AgentTool
if
err
:=
r
.
db
.
Where
(
"status = 'active'"
)
.
Find
(
&
tools
)
.
Error
;
err
!=
nil
{
return
err
}
for
_
,
tool
:=
range
tools
{
// 构建工具描述文本
text
:=
fmt
.
Sprintf
(
"%s: %s. 关键词: %s"
,
tool
.
Name
,
tool
.
Description
,
tool
.
Keywords
)
// 生成向量
embedding
,
err
:=
r
.
embedder
.
EmbedSingle
(
ctx
,
text
)
if
err
!=
nil
{
log
.
Printf
(
"[ToolRecommender] 生成工具 %s 向量失败: %v"
,
tool
.
Name
,
err
)
continue
}
vectorStr
:=
floatsToVectorString
(
embedding
)
// 更新或插入向量
r
.
db
.
Exec
(
`
INSERT INTO tool_embeddings (tool_name, description, keywords, embedding, updated_at)
VALUES ($1, $2, $3, $4::vector, NOW())
ON CONFLICT (tool_name) DO UPDATE SET
description = EXCLUDED.description,
keywords = EXCLUDED.keywords,
embedding = EXCLUDED.embedding,
updated_at = NOW()
`
,
tool
.
Name
,
tool
.
Description
,
tool
.
Keywords
,
vectorStr
)
}
log
.
Printf
(
"[ToolRecommender] 已为 %d 个工具生成向量索引"
,
len
(
tools
))
return
nil
}
// UpdateUserToolPreference 更新用户工具使用偏好
func
(
r
*
ToolRecommender
)
UpdateUserToolPreference
(
userID
,
userRole
,
toolName
string
,
success
bool
)
{
if
r
.
db
==
nil
||
userID
==
""
||
toolName
==
""
{
return
}
successIncr
:=
0
if
success
{
successIncr
=
1
}
r
.
db
.
Exec
(
`
INSERT INTO user_tool_preferences (user_id, user_role, tool_name, usage_count, success_count, last_used_at)
VALUES ($1, $2, $3, 1, $4, NOW())
ON CONFLICT (user_id, tool_name) DO UPDATE SET
usage_count = user_tool_preferences.usage_count + 1,
success_count = user_tool_preferences.success_count + $4,
last_used_at = NOW()
`
,
userID
,
userRole
,
toolName
,
successIncr
)
}
// mergeRecommendations 合并推荐结果
func
mergeRecommendations
(
existing
,
new
[]
ToolRecommendation
)
[]
ToolRecommendation
{
toolMap
:=
make
(
map
[
string
]
ToolRecommendation
)
for
_
,
rec
:=
range
existing
{
toolMap
[
rec
.
ToolName
]
=
rec
}
for
_
,
rec
:=
range
new
{
if
existingRec
,
ok
:=
toolMap
[
rec
.
ToolName
];
ok
{
// 合并分数
existingRec
.
Score
=
(
existingRec
.
Score
+
rec
.
Score
)
/
2
existingRec
.
Reason
=
existingRec
.
Reason
+
"+"
+
rec
.
Reason
toolMap
[
rec
.
ToolName
]
=
existingRec
}
else
{
toolMap
[
rec
.
ToolName
]
=
rec
}
}
result
:=
make
([]
ToolRecommendation
,
0
,
len
(
toolMap
))
for
_
,
rec
:=
range
toolMap
{
result
=
append
(
result
,
rec
)
}
return
result
}
// deduplicateRecommendations 去重并按分数排序
func
deduplicateRecommendations
(
recs
[]
ToolRecommendation
,
topK
int
)
[]
ToolRecommendation
{
// 按分数排序
for
i
:=
0
;
i
<
len
(
recs
)
-
1
;
i
++
{
for
j
:=
i
+
1
;
j
<
len
(
recs
);
j
++
{
if
recs
[
j
]
.
Score
>
recs
[
i
]
.
Score
{
recs
[
i
],
recs
[
j
]
=
recs
[
j
],
recs
[
i
]
}
}
}
if
len
(
recs
)
>
topK
{
return
recs
[
:
topK
]
}
return
recs
}
// floatsToVectorString 将浮点数数组转换为pgvector格式字符串
func
floatsToVectorString
(
floats
[]
float32
)
string
{
if
len
(
floats
)
==
0
{
return
"[]"
}
var
sb
strings
.
Builder
sb
.
WriteString
(
"["
)
for
i
,
f
:=
range
floats
{
if
i
>
0
{
sb
.
WriteString
(
","
)
}
sb
.
WriteString
(
fmt
.
Sprintf
(
"%f"
,
f
))
}
sb
.
WriteString
(
"]"
)
return
sb
.
String
()
}
server/pkg/agent/tools/navigate_page.go
View file @
94e48089
...
@@ -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
,
""
...
...
web/next.config.ts
View file @
94e48089
...
@@ -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
[
...
...
web/src/api/admin.ts
View file @
94e48089
...
@@ -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
;
...
...
web/src/app/(main)/admin/ai-logs/page.tsx
View file @
94e48089
...
@@ -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
>,
...
...
web/src/app/(main)/admin/workflows/page.tsx
View file @
94e48089
'
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
[
create
Modal
,
setCreateModal
]
=
useState
(
false
);
const
[
create
Drawer
,
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
(
'
创建成功
'
);
setCreate
Modal
(
false
);
setCreate
Drawer
(
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={() => setCreate
Modal
(true)}>
<Button type="primary" icon={<PlusOutlined />} style={{ marginLeft: 'auto' }} onClick={() => setCreate
Drawer
(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
...
...
web/src/components/GlobalAIFloat/ChatPanel.tsx
View file @
94e48089
...
@@ -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
)
||
'
您没有访问该页面的权限
'
);
}
}
}
}
}
}
...
...
web/src/components/GlobalAIFloat/FloatContainer.tsx
View file @
94e48089
...
@@ -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
>
...
...
web/src/components/workflow/VisualWorkflowEditor.tsx
View file @
94e48089
...
@@ -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
,
Toolti
p
}
from
'
antd
'
;
import
{
Button
,
Drawer
,
Form
,
Input
,
Select
,
Space
,
Card
,
Tooltip
,
Ap
p
}
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
}
节点
`);
message
Api
.
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('不能删除开始节点');
message
Api
.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('节点已删除');
message
Api
.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('节点配置已更新');
message
Api
.success('节点配置已更新');
setDrawerOpen(false);
setDrawerOpen(false);
}, [selectedNode, setNodes]);
}, [selectedNode, setNodes
, messageApi
]);
const handleSave = useCallback(() => {
const handleSave = useCallback(() => {
onSave?.(nodes, edges);
onSave?.(nodes, edges);
message.success('工作流已保存');
message
Api
.success('工作流已保存');
}, [nodes, edges, onSave]);
}, [nodes, edges, onSave
, messageApi
]);
const handleExecute = useCallback(() => {
const handleExecute = useCallback(() => {
onExecute?.(nodes, edges);
onExecute?.(nodes, edges);
...
...
web/src/lib/navigation-event.ts
View file @
94e48089
/**
/**
* 前端导航事件标准化 (v1
5
)
* 前端导航事件标准化 (v1
6
)
* 统一封装前端事件为结构化 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
);
}
web/src/pages/admin/AIConfig/index.tsx
View file @
94e48089
...
@@ -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
);
...
...
web/src/pages/admin/Consultations/index.tsx
View file @
94e48089
...
@@ -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
:
1
0
0
,
width
:
1
6
0
,
render
:
(
_
,
record
)
=>
record
.
id
?
record
.
id
.
substring
(
0
,
8
)
+
'
...
'
:
'
-
'
,
render
:
(
_
,
record
)
=>
record
.
serial_number
||
'
-
'
,
},
},
{
{
title
:
'
关键词
'
,
title
:
'
关键词
'
,
...
...
web/src/pages/admin/Departments/index.tsx
View file @
94e48089
...
@@ -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
...
...
web/src/pages/admin/Doctors/index.tsx
View file @
94e48089
...
@@ -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=
"账号状态"
>
...
...
web/src/pages/admin/Workflows/index.tsx
deleted
100644 → 0
View file @
147e328b
'
use client
'
;
import
React
,
{
useState
,
useRef
}
from
'
react
'
;
import
dynamic
from
'
next/dynamic
'
;
import
{
Button
,
Space
,
Tag
,
Badge
,
Drawer
,
Popconfirm
,
App
}
from
'
antd
'
;
import
{
PlayCircleOutlined
,
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
ProTable
,
DrawerForm
,
ProFormText
,
ProFormTextArea
,
ProFormSelect
,
}
from
'
@ant-design/pro-components
'
;
import
type
{
ActionType
,
ProColumns
}
from
'
@ant-design/pro-components
'
;
import
{
workflowApi
}
from
'
@/api/agent
'
;
const
VisualWorkflowEditor
=
dynamic
(
()
=>
import
(
'
@/components/workflow/VisualWorkflowEditor
'
),
{
ssr
:
false
},
);
interface
Workflow
{
id
:
number
;
workflow_id
:
string
;
name
:
string
;
description
:
string
;
category
:
string
;
status
:
string
;
version
:
number
;
definition
?:
string
;
}
const
statusColor
:
Record
<
string
,
'
success
'
|
'
warning
'
|
'
default
'
>
=
{
active
:
'
success
'
,
draft
:
'
warning
'
,
archived
:
'
default
'
,
};
const
statusLabel
:
Record
<
string
,
string
>
=
{
active
:
'
已启用
'
,
draft
:
'
草稿
'
,
archived
:
'
已归档
'
,
};
const
categoryLabel
:
Record
<
string
,
string
>
=
{
pre_consult
:
'
预问诊
'
,
consult_created
:
'
问诊创建
'
,
consult_ended
:
'
问诊结束
'
,
follow_up
:
'
随访
'
,
prescription_created
:
'
处方创建
'
,
prescription_approved
:
'
处方审核通过
'
,
payment_completed
:
'
支付完成
'
,
renewal_requested
:
'
续方申请
'
,
health_alert
:
'
健康预警
'
,
doctor_review
:
'
医生审核
'
,
};
// 后端 definition 格式转 ReactFlow 格式
function
convertDefinitionToReactFlow
(
definition
:
string
|
undefined
)
{
if
(
!
definition
)
return
undefined
;
try
{
const
def
=
JSON
.
parse
(
definition
);
let
nodeArray
:
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
>
}
>
;
nodeArray
=
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
))
{
nodeArray
=
def
.
nodes
;
}
let
edgeArray
:
unknown
[]
=
[];
if
(
Array
.
isArray
(
def
.
edges
))
{
edgeArray
=
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
:
nodeArray
,
edges
:
edgeArray
};
}
catch
{
return
undefined
;
}
}
const
AdminWorkflowsPage
:
React
.
FC
=
()
=>
{
const
{
message
}
=
App
.
useApp
();
const
actionRef
=
useRef
<
ActionType
>
(
null
);
const
[
addModalVisible
,
setAddModalVisible
]
=
useState
(
false
);
const
[
editorDrawer
,
setEditorDrawer
]
=
useState
(
false
);
const
[
editingWorkflow
,
setEditingWorkflow
]
=
useState
<
Workflow
|
null
>
(
null
);
const
handleExecute
=
async
(
workflowId
:
string
)
=>
{
try
{
const
result
=
await
workflowApi
.
execute
(
workflowId
);
message
.
success
(
`执行已启动:
${
result
.
data
?.
execution_id
}
`);
} catch { message.error('执行失败'); }
};
const columns: ProColumns<Workflow>[] = [
{
title: '工作流', dataIndex: 'name',
render: (_, r) => (
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.workflow_id}</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', search: false, ellipsis: true },
{
title: '类别', dataIndex: 'category', width: 110,
valueEnum: Object.fromEntries(Object.entries(categoryLabel).map(([k, v]) => [k, { text: v }])),
render: (_, r) => <Tag color="blue">{categoryLabel[r.category] || r.category}</Tag>,
},
{ title: '版本', dataIndex: 'version', search: false, width: 70, render: (v) => `
v$
{
v
as
number
}
` },
{
title: '状态', dataIndex: 'status', width: 90,
valueEnum: { active: { text: '已启用', status: 'Success' }, draft: { text: '草稿', status: 'Warning' }, archived: { text: '已归档', status: 'Default' } },
render: (_, r) => <Badge status={statusColor[r.status] || 'default'} text={statusLabel[r.status] || r.status} />,
},
{
title: '操作', valueType: 'option', width: 260,
render: (_, r) => (
<Space size={0}>
<a onClick={() => { setEditingWorkflow(r); setEditorDrawer(true); }}><EditOutlined /> 编辑</a>
{r.status === 'active' && <a onClick={() => handleExecute(r.workflow_id)}><PlayCircleOutlined /> 执行</a>}
{r.status === 'draft' && (
<a onClick={async () => {
await workflowApi.publish(r.id);
message.success('已激活');
actionRef.current?.reload();
}}>激活</a>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.workflow_id);
message.success('删除成功');
actionRef.current?.reload();
}}>
<a style={{ color: '#ff4d4f' }}><DeleteOutlined /> 删除</a>
</Popconfirm>
</Space>
),
},
];
const editorData = editorDrawer && editingWorkflow ? convertDefinitionToReactFlow(editingWorkflow.definition) : undefined;
return (
<div style={{ padding: '20px 24px' }}>
<ProTable<Workflow>
headerTitle="工作流管理"
tooltip="设计和管理 AI 工作流,实现复杂业务流程自动化"
actionRef={actionRef}
rowKey="id"
columns={columns}
cardBordered
request={async () => {
const res = await workflowApi.list();
return {
data: (res.data as Workflow[]) || [],
total: (res.data as Workflow[])?.length || 0,
success: true,
};
}}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (t) => `
共
$
{
t
}
条
` }}
search={{ labelWidth: 'auto' }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
新建工作流
</Button>,
]}
/>
{/* 新建工作流 DrawerForm */}
<DrawerForm
title="新建工作流"
open={addModalVisible}
onOpenChange={setAddModalVisible}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
submitter={{
searchConfig: { submitText: '创建', resetText: '取消' },
resetButtonProps: { onClick: () => setAddModalVisible(false) },
}}
onFinish={async (values) => {
try {
const definition = {
id: values.workflow_id, name: values.name,
nodes: {
start: { id: 'start', type: 'start', name: '开始', config: {}, next_nodes: ['end'] },
end: { id: 'end', type: 'end', name: '结束', config: {}, next_nodes: [] },
},
edges: [{ id: 'e1', source_node: 'start', target_node: 'end' }],
};
await workflowApi.create({
workflow_id: values.workflow_id,
name: values.name,
description: values.description,
category: values.category,
definition: JSON.stringify(definition),
});
message.success('创建成功');
actionRef.current?.reload();
return true;
} catch {
message.error('创建失败');
return false;
}
}}
>
<ProFormText name="workflow_id" label="工作流 ID" placeholder="如: smart_pre_consult"
rules={[{ required: true, message: '请输入工作流ID' }]} />
<ProFormText name="name" label="名称" placeholder="请输入工作流名称"
rules={[{ required: true, message: '请输入名称' }]} />
<ProFormTextArea name="description" label="描述" placeholder="请输入描述(选填)"
fieldProps={{ rows: 2 }} />
<ProFormSelect name="category" label="类别" placeholder="选择类别"
options={Object.entries(categoryLabel).map(([value, label]) => ({ value, label }))} />
</DrawerForm>
{/* 可视化编辑器 Drawer */}
<Drawer
title={`
编辑工作流
·
$
{
editingWorkflow
?.
name
||
''
}
`}
open={editorDrawer}
onClose={() => { setEditorDrawer(false); setEditingWorkflow(null); }}
placement="right"
destroyOnClose
width={960}
>
{editorData && (
<div style={{ height: 650 }}>
<VisualWorkflowEditor
workflowName={editingWorkflow?.name || '编辑工作流'}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialNodes={editorData.nodes as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEdges={editorData.edges as any}
onSave={async (nodes, edges) => {
if (!editingWorkflow) return;
try {
await workflowApi.update(editingWorkflow.id, { definition: JSON.stringify({ nodes, edges }) });
message.success('工作流已保存');
actionRef.current?.reload();
} catch { message.error('保存失败'); }
}}
onExecute={async (nodes, edges) => {
if (!editingWorkflow) return;
try {
const result = await workflowApi.execute(editingWorkflow.workflow_id, { workflow_data: { nodes, edges } });
message.success(`
执行已启动
:
$
{
result
.
data
?.
execution_id
}
`);
} catch { message.error('执行失败'); }
}}
/>
</div>
)}
</Drawer>
</div>
);
};
export default AdminWorkflowsPage;
web/src/pages/patient/Doctors/index.tsx
View file @
94e48089
...
@@ -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
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment