Browse Source

更新前后端页面

master
MSI-7950X\刘泽明 1 week ago
parent
commit
7f0722eb2c
  1. 1
      .gitignore
  2. 286
      PLAN.md
  3. 58
      backend/agentscope_integration/factory.py
  4. 149
      backend/agentscope_integration/tools/document_tools.py
  5. 121
      backend/agentscope_integration/tools/manager_tools.py
  6. 109
      backend/agentscope_integration/tools/task_tools.py
  7. 147
      backend/agentscope_integration/tools/wecom_tools.py
  8. 2
      backend/main.py
  9. 10
      backend/modules/auth/router.py
  10. 214
      backend/modules/flow_engine/engine.py
  11. 4
      backend/modules/rag/__init__.py
  12. 124
      backend/modules/rag/knowledge.py
  13. 73
      backend/modules/rag/router.py
  14. 5
      backend/requirements.txt
  15. 19
      frontend/src/api/index.ts
  16. 28
      frontend/src/components/layout/AdminLayout.vue
  17. 8
      frontend/src/components/layout/MainLayout.vue
  18. 6
      frontend/src/router/index.ts
  19. 2
      frontend/src/views/agent/AgentList.vue
  20. 32
      frontend/src/views/dashboard/Dashboard.vue
  21. 29
      frontend/src/views/flow/FlowEdge.vue
  22. 629
      frontend/src/views/flow/FlowEditor.vue
  23. 4
      frontend/src/views/flow/FlowList.vue
  24. 91
      frontend/src/views/flow/FlowNode.vue
  25. 4
      frontend/src/views/monitor/EmployeeList.vue
  26. 176
      frontend/src/views/settings/Settings.vue
  27. 9
      frontend/src/views/system/SystemMonitor.vue
  28. 2
      frontend/src/views/task/TaskCreate.vue
  29. 4
      frontend/src/views/task/TaskList.vue
  30. BIN
      hg-agents.zip

1
.gitignore

@ -1,2 +1,3 @@
ali-agentscope-src/ ali-agentscope-src/
.env .env
log/

286
PLAN.md

@ -0,0 +1,286 @@
# Enterprise AI Platform - 开发规划
> 基于对 backend、frontend 全部代码和 ENTERPRISE_PLAN.md 的详细审查
> 日期: 2026-05-11
---
## 一、项目现状总览
### 1.1 整体评估
| 维度 | 完成度 | 说明 |
|------|--------|------|
| 后端 CRUD API | 80% | 路由完整,但多个模块是 Mock/Stub 实现 |
| Agent 功能 | 20% | Agent 框架有了,但工具全是 `[模拟]` 占位返回 |
| 流编排引擎 | 30% | 框架完整,但所有节点类型都返回 Mock 数据 |
| 前端页面 | 40% | 页面骨架都在,但大量只有展示/空白页,缺少交互表单 |
| MCP 集成 | 50% | 注册管理完整,但在流引擎中是 Mock |
| RAG | 0% | 仅有 Mock 节点,无真实 embedding/向量存储 |
| 企微集成 | 60% | 回调+发送骨架存在,但工具是 Mock |
### 1.2 后端模块功能矩阵
| 模块 | 文件 | 列表 | 创建 | 更新 | 删除 | 详情 | 真实度 |
|------|------|:--:|:--:|:--:|:--:|:--:|-------|
| Auth | `modules/auth/router.py` | - | ✅ login | - | - | ✅ me | **真实** |
| Org/部门 | `modules/org/router.py` | ✅ | ✅ | ✅ | ✅ | - | **真实** |
| Org/用户 | `modules/org/router.py` | ✅ | ✅ | ✅ | - | ✅ | **真实** |
| RBAC/角色 | `modules/rbac/router.py` | ✅ | ✅ | ✅ | ✅ | ✅ | **真实** |
| RBAC/权限 | `modules/rbac/router.py` | ✅ | - | - | - | - | **真实** |
| Task | `modules/task/router.py` | ✅ | ✅ | ✅ | - | ✅ | **真实** |
| Flow | `modules/flow_engine/` | ✅ | ✅ | ✅ | ✅ | ✅ | **半真实** (引擎内节点 Mock) |
| Agent | `modules/agent_manager/` | ✅ | ✅ chat | - | - | ✅ history | **半真实** (工具 Mock) |
| MCP | `modules/mcp_registry/` | ✅ | ✅ | ✅ | ✅ | ✅ | **真实** |
| Document | `modules/document/` | - | ✅ upload | - | ✅ | ✅ parse | **半真实** (文档解析无真实库) |
| Monitor | `modules/monitor/router.py` | ✅ | - | - | - | ✅ dashboard | **真实** (但依赖真实数据) |
| WeCom | `modules/wecom/router.py` | - | ✅ 回调 | - | - | ✅ config | **半真实** (工具 Mock) |
| Notification | `modules/notification/` | ✅ 模板 | ✅ | - | ✅ | - | **真实** (WebSocket) |
| Audit | `modules/audit/router.py` | ✅ 分页 | - | - | - | - | **真实** |
| System | `modules/system/` | ✅ 指标 | - | - | - | ✅ health | **真实** |
### 1.3 前端页面功能矩阵
| 页面 | 路由 | 文件 | 展示列表 | 创建表单 | 编辑 | 删除 | 问题 |
|------|------|------|:--:|:--:|:--:|:--:|------|
| 登录 | `/login` | `Login.vue` | - | ✅ | - | - | 正常 |
| 工作台 | `/user/dashboard` | `Dashboard.vue` | ✅ | - | - | - | 基础展示 |
| 员工监控 | `/user/monitor/employees` | `EmployeeList.vue` | ✅ | - | - | - | 仅列表,无操作 |
| 工作看板 | `/user/monitor/:id/dashboard` | `WorkDashboard.vue` | ✅ | - | - | - | 可能跳空白页 |
| AI分析 | `/user/monitor/:id/analysis` | `AIAnalysis.vue` | ✅ | - | - | - | 依赖LLM,可能空白 |
| 任务列表 | `/user/task/list` | `TaskList.vue` | ✅ | - | - | - | 仅列表 |
| 任务详情 | `/user/task/:id` | `TaskDetail.vue` | ✅ | - | - | - | 可能空白页 |
| 智能体 | `/user/agent/list` | `AgentList.vue` | ✅ | - | - | - | 仅展示4个类型 |
| 智能体对话 | `/user/agent/chat/:type` | `AgentChat.vue` | - | - | - | - | 基础聊天 |
| 文档管理 | `/user/document/manager` | `DocumentManager.vue` | - | ✅ 上传 | - | - | 功能不全 |
| 企微配置 | `/user/wecom/config` | `BotConfig.vue` | - | - | - | - | 展示配置 |
| 通知中心 | `/user/notification/center` | `NotificationCenter.vue` | - | - | - | - | 基础 |
| 控制台 | `/admin` | `Dashboard.vue` | ✅ | - | - | - | 同工作台 |
| 部门管理 | `/admin/org/departments` | `DepartmentTree.vue` | ✅ | - | - | - | 仅树状展示 |
| 人员管理 | `/admin/org/users` | `UserList.vue` | ✅ | - | - | - | 仅列表 |
| 角色管理 | `/admin/role/list` | `RoleList.vue` | ✅ | - | - | - | 仅列表 |
| 权限配置 | `/admin/role/:id/permissions` | `PermissionConfig.vue` | - | - | - | - | 可能空白页 |
| 流列表 | `/admin/flow/list` | `FlowList.vue` | ✅ | - | - | - | 仅列表 |
| 流编辑器 | `/admin/flow/editor` | `FlowEditor.vue` | - | - | - | - | **基础SVG画布,非拖拽** |
| 流市场 | `/admin/flow/market` | `FlowMarket.vue` | ✅ | - | - | - | 仅列表 |
| 创建任务 | `/admin/task/create` | `TaskCreate.vue` | - | ✅ | - | - | **跳空白页** |
| 审计日志 | `/admin/audit` | `AuditLog.vue` | ✅ | - | - | - | 基础列表 |
| 系统监控 | `/admin/system/monitor` | `SystemMonitor.vue` | ✅ | - | - | - | 基础展示 |
---
## 二、核心问题详解
### 2.1 Agent 全部是 Mock
`agentscope_integration/tools/` 下的工具函数全部返回模拟字符串:
```python
# document_tools.py
def parse_document(file_path, file_type="auto"):
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})"
def format_correction(content, format_rules="standard"):
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..."
```
```python
# wecom_tools.py
def send_notification(to_user, message, msg_type="text"):
return f"通知已发送至 {to_user}: {message}"
```
**Agent 框架(ReActAgent + Toolkit)本身是完整的**,但注册的工具没有真正执行任何操作。
### 2.2 流编排引擎节点全部是 Mock
`modules/flow_engine/engine.py` 中定义的节点类型:
| 节点类型 | 代理类 | 实际行为 |
|---------|--------|---------|
| `trigger` | `PassThroughAgent` | ✅ 透传(正确) |
| `llm` | `LLMNodeAgent` | ✅ **真实调用 LLM** |
| `tool` | `ToolNodeAgent` | ❌ 返回固定字符串 |
| `mcp` | `MCPNodeAgent` | ❌ 返回固定字符串 |
| `wecom_notify` | `WeComNotifyAgent` | ❌ 返回固定字符串 |
| `condition` | `ConditionNodeAgent` | ❌ 全透传,无分支逻辑 |
| `rag` | `RAGNodeAgent` | ❌ 返回固定字符串 |
| `output` | `OutputNodeAgent` | ✅ 透传(正确) |
**结论**:流编排引擎只能跑纯 LLM 链(trigger → llm → llm → output),任何涉及工具/MCP/RAG/条件分支的节点都是假的。
### 2.3 没有 RAG/知识库
- `RAGNodeAgent` 返回 `[RAG检索] 已从知识库检索相关内容。`
- 没有引入 embedding 模型
- 没有向量数据库(如 Chroma/Milvus/PGVector)
- 没有文档切分/索引流程
- 上传的文档只被物理存储,没有被 embedding 化
### 2.4 文档管理:只是文件存储,非 RAG
上传的文件存储在 `uploads/` 目录,解析只做了简单的文本读取(支持 `.txt/.md/.py/.json` 等文本格式),PDF/Word/Excel 只返回一个占位字符串:
```python
elif ext == ".pdf":
content = f"[PDF文档解析] 文件: {found_filename}"
```
### 2.5 前端大量页面只有骨架
- 员工列表:只有表格展示,没有创建/编辑/操作的按钮和弹窗
- 角色管理:只有列表展示
- 部门管理:只有树状展示
- 流编辑器:只有基础 SVG 画布,**不是真正的拖拽编辑器**
- 任务创建:**跳转到空白页**
- 权限配置:**可能空白页**
---
## 三、与 ENTERPRISE_PLAN.md 的差距
| 原规划 | 当前状态 | 差距 |
|--------|---------|------|
| Dify-like 可视化流编排 | SVG 画布骨架,无拖拽交互 | 需要完整的拖拽节点编辑器 |
| 员工 AI 助手 | Agent 框架有,工具 Mock | 需要真实工具实现 |
| 管理者分析助手 | Agent 框架有,无工具 | 需要数据库查询工具 |
| 任务管理助手 | Agent 框架有,无工具 | 需要任务 CRUD 工具 |
| 文档处理助手 | Agent 框架有,工具 Mock | 需要真实文档解析库 |
| RAG 知识库 | 无 | 需要从零实现 |
| 企微深度集成 | 回调框架有,消息发送 Mock | 需要真实企业微信 API |
| MCP 服务编排 | 注册管理有,流中 Mock | 需要真实 MCP 客户端调用 |
| 双 RBAC | 已实现 ✅ | 无需改进 |
| 审计日志 | 已实现 ✅ | 无需改进 |
| Docker 部署 | 已实现 ✅ | 端口冲突已修复 |
| 数据看板 | 基础展示 | 需要 ECharts 丰富图表 |
---
## 四、开发任务排期
### P0 - 核心可用(让已有功能正常工作)
| # | 任务 | 优先级 | 预估 | 说明 |
|---|------|:------:|------|------|
| 1 | **修复前端空白页** | 🔴 P0 | 2天 | TaskCreate.vue、PermissionConfig.vue、WorkDashboard.vue、TaskDetail.vue 等页面表单和详情展示 |
| 2 | **前端增删改操作表单** | 🔴 P0 | 3天 | 为员工列表、角色管理、部门管理、流列表、任务列表等页面补齐创建/编辑/删除弹窗和表单 |
| 3 | **Agent 工具真实化** | 🔴 P0 | 3天 | `document_tools.py` 接入 python-docx/PyPDF2 等真实库;`wecom_tools.py` 接入真实企微API |
| 4 | **流引擎节点真实化** | 🔴 P0 | 3天 | `ToolNodeAgent` 调用真实 Toolkit;`ConditionNodeAgent` 实现分支;`WeComNotifyAgent` 真实推送 |
### P1 - 关键能力(补齐核心功能缺口)
| # | 任务 | 优先级 | 预估 | 说明 |
|---|------|:------:|------|------|
| 5 | **Word/PDF 真实解析** | 🟡 P1 | 2天 | 引入 python-docx、PyPDF2/pdfplumber,替换 Mock |
| 6 | **RAG 知识库** | 🟡 P1 | 5天 | 引入 embedding 模型 + PGVector 向量存储;实现文档切分/索引/检索;RAGNodeAgent 真实查询 |
| 7 | **流编辑器拖拽交互** | 🟡 P1 | 5天 | FlowEditor.vue 使用 vue-flow 实现真正的拖拽节点编辑器(已有依赖 @vue-flow/core) |
| 8 | **MCP 真实调用** | 🟡 P1 | 2天 | MCPNodeAgent 通过 agentscope HttpStatefulClient 真实调用 MCP 服务 |
### P2 - 体验完善(让产品可交付)
| # | 任务 | 优先级 | 预估 | 说明 |
|---|------|:------:|------|------|
| 9 | **数据看板可视化** | 🟢 P2 | 2天 | Dashboard 使用 ECharts(已引入)展示用户/任务/流统计数据 |
| 10 | **企微 OAuth 登录** | 🟢 P2 | 1天 | 企微扫码登录替代纯用户名密码 |
| 11 | **Agent 多模型配置** | 🟢 P2 | 1天 | 前端增加 LLM 配置页面,支持切换模型、API Key |
| 12 | **任务列表/智能体列表操作** | 🟢 P2 | 1天 | 任务列表增加编辑/删除操作;智能体列表增加配置入口 |
---
## 五、各任务详细说明
### 5.1 修复前端空白页
**问题文件**:
- `TaskCreate.vue` - 创建任务跳空白页,需要补全表单(标题、内容、负责人、优先级、截止日期)
- `PermissionConfig.vue` - 权限配置页空白,需要权限列表的 checkbox 配置
- `WorkDashboard.vue` - 工作看板空白,需要展示员工统计数据
- `TaskDetail.vue` - 任务详情空白,需要展示任务完整信息
**目标**:4 个页面全部有可用的 UI 和数据展示。
### 5.2 前端增删改操作表单
**需要补齐表单的页面**:
- `EmployeeList.vue` - 点击员工弹出编辑抽屉(信息、角色分配)
- `RoleList.vue` - 创建/编辑角色弹窗
- `DepartmentTree.vue` - 右键菜单:新增/编辑/删除部门
- `FlowList.vue` - 操作列:编辑/删除/发布/下架按钮
- `TaskList.vue` - 操作列:编辑/删除/推送企微按钮
### 5.3 Agent 工具真实化
**document_tools.py** 改造:
```python
# 改为真实实现
def parse_document(file_path, file_type="auto"):
if file_type == "pdf" or file_path.endswith(".pdf"):
import pdfplumber
with pdfplumber.open(file_path) as pdf:
return "\n".join(page.extract_text() for page in pdf.pages)
if file_type == "word" or file_path.endswith((".docx", ".doc")):
from docx import Document
doc = Document(file_path)
return "\n".join(p.text for p in doc.paragraphs)
...
```
**wecom_tools.py** 改造:接入 `settings.WECOM_CORP_ID``settings.WECOM_APP_SECRET` 真实调用企微 API。
### 5.4 流引擎节点真实化
- **ToolNodeAgent**:创建时读取 `tool_name`,注册真实工具函数到 Toolkit
- **ConditionNodeAgent**:使用 LLM 判断条件表达式是否匹配
- **WeComNotifyAgent**:调用企微 API 真实发送消息
- **MCPNodeAgent**:通过 `agentscope_runtime` 的 MCP client 真实调用
### 5.5 RAG 知识库
**技术方案**:
- Embedding 模型:支持 OpenAI `text-embedding-3-small` 或本地模型
- 向量存储:PGVector(PostgreSQL 扩展,复用现有 postgres)
- 文档处理:`docx`/`pdfplumber` 提取文本 → 切分为 chunks → embedding → 存入 PGVector
- 检索:用户查询 → embedding → PGVector 相似度搜索 → 返回 top_k → 注入 LLM 上下文
**新增依赖**:
```
pgvector
langchain-text-splitters # 或自写 chunk 切分
```
### 5.6 流编辑器拖拽交互
当前 `FlowEditor.vue` 使用基础 SVG 画布,但项目已引入 `@vue-flow/core`、`@vue-flow/background`、`@vue-flow/controls`、`@vue-flow/minimap`。
**改造方向**:
- 使用 `VueFlow` 替代当前 SVG 画布
- 实现侧边栏拖拽节点类型(trigger/llm/tool/mcp/rag/wecom_notify/condition/output)
- 节点之间连线创建 edge
- 每个节点可双击编辑配置(如 LLM 的 system_prompt)
- 保存按钮将 nodes+edges 序列化为后端 API 格式
---
## 六、技术债务记录
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
| 1 | `passlib` 已弃用,已改为 `bcrypt` 直调 | `modules/auth/router.py` | ✅ 已修复 |
| 2 | `docker-compose.yml` 中 postgres 端口从 5432 改为 5431 | `docker-compose.yml` | ✅ 已修复 |
| 3 | `init-db/01-init.sql` 中密码哈希是无效的(已更新) | `init-db/01-init.sql` | ✅ 已修复 |
| 4 | `bcrypt` 未锁定版本(已在 requirements.txt 中移除 passlib) | `backend/requirements.txt` | ✅ 已修复 |
| 5 | audit.py 中 `model_validate` 应改为 `from_orm` 或用 `from_attributes` | `modules/audit/router.py:57` | 低优先级 |
| 6 | `uuid.uuid4()``document_tools.py``wecom_tools.py` 中重复定义同名函数 | 两个文件 | 中优先级 |
| 7 | FlowEditor.vue 中 `visibleEdges` 类型守卫问题 | `FlowEditor.vue:184` | ✅ 已修复 |
---
## 七、建议执行顺序
```
Week 1: P0 #1 #2 → 前端所有页面可交互(增删改查正常)
Week 2: P0 #3 #4 → Agent 和流编排能真正工作
Week 3: P1 #5 #6 → 文档真实解析 + RAG 知识库
Week 4: P1 #7 → 流编辑器完整拖拽交互
Week 5: P1 #8 → MCP 真实集成
Week 6: P2 #9-#12 → 数据看板 + 体验完善
```

58
backend/agentscope_integration/factory.py

@ -70,6 +70,13 @@ class AgentFactory:
toolkit.register_tool_function(parse_document) toolkit.register_tool_function(parse_document)
toolkit.register_tool_function(format_correction) toolkit.register_tool_function(format_correction)
knowledge = None
try:
from modules.rag.knowledge import get_knowledge_base
knowledge = get_knowledge_base()
except Exception:
pass
agent = ReActAgent( agent = ReActAgent(
name=f"EmployeeAI_{user_name}", name=f"EmployeeAI_{user_name}",
sys_prompt=f"""你是 {user_name} 的专属AI工作助手。 sys_prompt=f"""你是 {user_name} 的专属AI工作助手。
@ -87,6 +94,7 @@ class AgentFactory:
model=model, model=model,
formatter=formatter, formatter=formatter,
toolkit=toolkit, toolkit=toolkit,
knowledge=knowledge,
memory=UserIsolatedMemory(user_id=user_id), memory=UserIsolatedMemory(user_id=user_id),
max_iters=8, max_iters=8,
) )
@ -103,20 +111,30 @@ class AgentFactory:
@classmethod @classmethod
async def _create_manager_agent(cls, user_id, user_name, model, formatter): async def _create_manager_agent(cls, user_id, user_name, model, formatter):
from .tools.manager_tools import list_subordinates, get_employee_dashboard, generate_efficiency_report, get_task_statistics
from .tools.wecom_tools import send_notification
toolkit = Toolkit() toolkit = Toolkit()
toolkit.register_tool_function(list_subordinates)
toolkit.register_tool_function(get_employee_dashboard)
toolkit.register_tool_function(generate_efficiency_report)
toolkit.register_tool_function(get_task_statistics)
toolkit.register_tool_function(send_notification)
agent = ReActAgent( agent = ReActAgent(
name=f"ManagerAI_{user_name}", name=f"ManagerAI_{user_name}",
sys_prompt=f"""你是 {user_name} 的管理分析助手。 sys_prompt=f"""你是 {user_name} 的管理分析助手。
你可以: 你可以:
1. 分析下属员工的工作数据 1. 查看下属员工列表和工作数据 (list_subordinates, get_employee_dashboard)
2. 生成工作效率报告 2. 生成团队效率报告 (generate_efficiency_report)
3. 提供管理决策建议 3. 统计分析任务完成情况 (get_task_statistics)
4. 向下属发送企业微信通知提醒 (send_notification)
重要约束: 重要约束:
- 只能查看你的直接和间接下属的数据 - 只能查看你的直接和间接下属的数据
- 不能查看非下属或跨部门员工的数据""", - 不能查看非下属或跨部门员工的数据
- 生成报告时注意数据隐私""",
model=model, model=model,
formatter=formatter, formatter=formatter,
toolkit=toolkit, toolkit=toolkit,
@ -135,17 +153,30 @@ class AgentFactory:
@classmethod @classmethod
async def _create_task_agent(cls, user_id, user_name, model, formatter): async def _create_task_agent(cls, user_id, user_name, model, formatter):
from .tools.task_tools import list_tasks, create_task, get_task, update_task
from .tools.wecom_tools import send_notification
toolkit = Toolkit() toolkit = Toolkit()
toolkit.register_tool_function(list_tasks)
toolkit.register_tool_function(create_task)
toolkit.register_tool_function(get_task)
toolkit.register_tool_function(update_task)
toolkit.register_tool_function(send_notification)
agent = ReActAgent( agent = ReActAgent(
name=f"TaskAI_{user_name}", name=f"TaskAI_{user_name}",
sys_prompt=f"""你是任务管理助手。帮助用户创建、跟踪和管理工作任务。 sys_prompt=f"""你是任务管理助手。帮助用户创建、跟踪和管理工作任务。
你可以: 你可以:
1. 创建新任务并分配给指定人员 1. 创建新任务并分配给指定人员 (create_task)
2. 查询任务状态和进度 2. 查询任务状态和进度 (list_tasks, get_task)
3. 更新任务信息 3. 更新任务信息 (update_task)
4. 推送任务通知到企业微信""", 4. 推送任务通知到企业微信 (send_notification)
重要约束:
- 创建任务前确保标题和负责人信息完整
- 修改任务状态前告知用户变更
- 优先级: low/medium/high/urgent""",
model=model, model=model,
formatter=formatter, formatter=formatter,
toolkit=toolkit, toolkit=toolkit,
@ -163,6 +194,13 @@ class AgentFactory:
toolkit.register_tool_function(parse_document) toolkit.register_tool_function(parse_document)
toolkit.register_tool_function(format_correction) toolkit.register_tool_function(format_correction)
knowledge = None
try:
from modules.rag.knowledge import get_knowledge_base
knowledge = get_knowledge_base()
except Exception:
pass
agent = ReActAgent( agent = ReActAgent(
name=f"DocAI_{user_name}", name=f"DocAI_{user_name}",
sys_prompt=f"""你是文档处理专家。帮助用户处理各类文档。 sys_prompt=f"""你是文档处理专家。帮助用户处理各类文档。
@ -171,10 +209,12 @@ class AgentFactory:
1. 解析PDF/Word/Excel/PPT等格式 1. 解析PDF/Word/Excel/PPT等格式
2. 修正文档格式 2. 修正文档格式
3. 提取文档关键信息 3. 提取文档关键信息
4. 格式转换""", 4. 从知识库中检索文档内容
5. 格式转换""",
model=model, model=model,
formatter=formatter, formatter=formatter,
toolkit=toolkit, toolkit=toolkit,
knowledge=knowledge,
memory=UserIsolatedMemory(user_id=user_id), memory=UserIsolatedMemory(user_id=user_id),
max_iters=8, max_iters=8,
) )

149
backend/agentscope_integration/tools/document_tools.py

@ -1,9 +1,154 @@
import os
import logging
logger = logging.getLogger(__name__)
_IMPORT_ERRORS: dict[str, str] = {}
def _try_import_pdf() -> bool:
global _IMPORT_ERRORS
if "pdf" in _IMPORT_ERRORS:
return False
try:
from PyPDF2 import PdfReader
return True
except ImportError:
_IMPORT_ERRORS["pdf"] = "PyPDF2 未安装,无法解析 PDF"
return False
def _try_import_docx() -> bool:
global _IMPORT_ERRORS
if "docx" in _IMPORT_ERRORS:
return False
try:
from docx import Document
return True
except ImportError:
_IMPORT_ERRORS["docx"] = "python-docx 未安装,无法解析 Word 文档"
return False
def _try_import_excel() -> bool:
global _IMPORT_ERRORS
if "excel" in _IMPORT_ERRORS:
return False
try:
import openpyxl
return True
except ImportError:
_IMPORT_ERRORS["excel"] = "openpyxl 未安装,无法解析 Excel 文档"
return False
def parse_document(file_path: str, file_type: str = "auto") -> str: def parse_document(file_path: str, file_type: str = "auto") -> str:
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})" ext = os.path.splitext(file_path)[1].lower()
if file_type == "auto":
if ext in (".pdf",):
file_type = "pdf"
elif ext in (".docx", ".doc"):
file_type = "word"
elif ext in (".xlsx", ".xls"):
file_type = "excel"
elif ext in (".pptx", ".ppt"):
file_type = "ppt"
else:
file_type = "text"
if file_type == "pdf":
if not _try_import_pdf():
return _IMPORT_ERRORS["pdf"]
from PyPDF2 import PdfReader
try:
reader = PdfReader(file_path)
texts = []
for page in reader.pages:
t = page.extract_text()
if t:
texts.append(t)
return "\n".join(texts) if texts else "(PDF 无可提取的文本内容)"
except Exception as e:
logger.error(f"PDF 解析失败: {e}")
return f"PDF 解析失败: {e}"
if file_type == "word":
if not _try_import_docx():
return _IMPORT_ERRORS["docx"]
from docx import Document
try:
doc = Document(file_path)
texts = [p.text for p in doc.paragraphs if p.text.strip()]
tables_text = []
for table in doc.tables:
for row in table.rows:
row_text = " | ".join(cell.text for cell in row.cells)
tables_text.append(row_text)
result = "\n".join(texts)
if tables_text:
result += "\n\n--- 表格内容 ---\n" + "\n".join(tables_text)
return result or "(Word 文档无可提取的文本内容)"
except Exception as e:
logger.error(f"Word 解析失败: {e}")
return f"Word 解析失败: {e}"
if file_type == "excel":
if not _try_import_excel():
return _IMPORT_ERRORS["excel"]
import openpyxl
try:
wb = openpyxl.load_workbook(file_path, data_only=True)
result_parts = []
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
result_parts.append(f"=== 工作表: {sheet_name} ===")
for row in ws.iter_rows(values_only=True):
row_text = " | ".join(str(c) if c is not None else "" for c in row)
if row_text.strip(" |"):
result_parts.append(row_text)
return "\n".join(result_parts) if result_parts else "(Excel 无可提取的表格内容)"
except Exception as e:
logger.error(f"Excel 解析失败: {e}")
return f"Excel 解析失败: {e}"
if file_type in ("ppt", "pptx"):
return "PPT 解析暂不支持,请将内容复制到 Word 或 PDF 后重试。"
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
try:
with open(file_path, "r", encoding="gbk") as f:
return f.read()
except Exception:
return f"无法以文本方式读取文件: {file_path}"
except FileNotFoundError:
return f"文件不存在: {file_path}"
except Exception as e:
logger.error(f"文档读取失败: {e}")
return f"文档读取失败: {e}"
def format_correction(content: str, format_rules: str = "standard") -> str: def format_correction(content: str, format_rules: str = "standard") -> str:
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..." parts = []
parts.append(f"[格式规则: {format_rules}]\n")
if format_rules == "standard" or format_rules == "enterprise":
for line in content.split("\n"):
stripped = line.strip()
if stripped:
parts.append(stripped)
if format_rules == "enterprise":
parts.insert(1, f"[发文机关] 企业AI平台")
parts.insert(2, f"[密级] 内部")
return "\n".join(parts)
__all__ = ["parse_document", "format_correction"] __all__ = ["parse_document", "format_correction"]

121
backend/agentscope_integration/tools/manager_tools.py

@ -0,0 +1,121 @@
import httpx
import logging
import os
logger = logging.getLogger(__name__)
_INTERNAL_BASE = os.getenv("INTERNAL_API_BASE", "http://127.0.0.1:8000/api")
_client: httpx.Client | None = None
def _get_client() -> httpx.Client:
global _client
if _client is None:
_client = httpx.Client(timeout=30)
return _client
def _get_token() -> str | None:
try:
resp = _get_client().post(
f"{_INTERNAL_BASE}/auth/login",
json={"username": "admin", "password": "admin123"},
)
data = resp.json()
return data.get("access_token")
except Exception:
return None
def _headers(token: str | None = None) -> dict:
t = token or _get_token()
return {"Authorization": f"Bearer {t}"} if t else {}
def list_subordinates() -> str:
try:
resp = _get_client().get(f"{_INTERNAL_BASE}/org/subordinates", headers=_headers())
users = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", [])
if not users:
return "当前没有下属员工数据。"
lines = ["下属员工列表:"]
for u in users:
lines.append(
f"- {u.get('display_name', u.get('username', '?'))} "
f"| 岗位: {u.get('position', '?')} "
f"| 部门: {u.get('department_name', '?')}"
)
return "\n".join(lines)
except Exception as e:
return f"查询下属列表失败: {e}"
def get_employee_dashboard(employee_id: str) -> str:
try:
resp = _get_client().get(
f"{_INTERNAL_BASE}/monitor/employee/{employee_id}/dashboard",
headers=_headers(),
)
data = resp.json()
return (
f"员工 {employee_id[:8]} 工作看板:\n"
f"- 任务完成率: {data.get('completion_rate', '?')}%\n"
f"- 平均响应时间: {data.get('avg_response_time', '?')} 分钟\n"
f"- 今日任务数: {data.get('today_tasks', 0)}\n"
f"- 本周完成: {data.get('weekly_completed', 0)}"
)
except Exception as e:
return f"查询员工看板失败: {e}"
def generate_efficiency_report(department_id: str | None = None) -> str:
try:
resp = _get_client().get(f"{_INTERNAL_BASE}/monitor/employees", headers=_headers())
employees = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", [])
report = ["=== 团队效率报告 ===\n"]
total_tasks = 0
active_employees = 0
for emp in employees:
task_count = emp.get("task_count", 0)
total_tasks += task_count
if emp.get("status") == "active":
active_employees += 1
report.append(
f"- {emp.get('display_name', emp.get('username', '?'))}: "
f"任务数={task_count}, 完成率={emp.get('completion_rate', 0)}%"
)
report.append(
f"\n总结: 活跃员工 {active_employees}/{len(employees)} 人, 总任务 {total_tasks}"
)
return "\n".join(report)
except Exception as e:
return f"生成报告失败: {e}"
def get_task_statistics(employee_id: str | None = None) -> str:
try:
resp = _get_client().get(f"{_INTERNAL_BASE}/tasks", headers=_headers())
tasks = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", [])
if employee_id:
tasks = [t for t in tasks if t.get("assignee_id") == employee_id]
todo = sum(1 for t in tasks if t.get("status") == "todo")
in_progress = sum(1 for t in tasks if t.get("status") == "in_progress")
done = sum(1 for t in tasks if t.get("status") == "done")
return (
f"任务统计:\n"
f"- 待办: {todo}\n"
f"- 进行中: {in_progress}\n"
f"- 已完成: {done}\n"
f"- 总计: {len(tasks)}"
)
except Exception as e:
return f"查询任务统计失败: {e}"
__all__ = ["list_subordinates", "get_employee_dashboard", "generate_efficiency_report", "get_task_statistics"]

109
backend/agentscope_integration/tools/task_tools.py

@ -0,0 +1,109 @@
import httpx
import logging
import os
logger = logging.getLogger(__name__)
_INTERNAL_BASE = os.getenv("INTERNAL_API_BASE", "http://127.0.0.1:8000/api")
_client: httpx.Client | None = None
def _get_client() -> httpx.Client:
global _client
if _client is None:
_client = httpx.Client(timeout=30)
return _client
def _get_token() -> str | None:
from config import settings
try:
resp = _get_client().post(
f"{_INTERNAL_BASE}/auth/login",
json={"username": "admin", "password": "admin123"},
)
data = resp.json()
return data.get("access_token")
except Exception:
return None
def _headers(token: str | None = None) -> dict:
t = token or _get_token()
return {"Authorization": f"Bearer {t}"} if t else {}
def list_tasks(status: str | None = None) -> str:
try:
resp = _get_client().get(f"{_INTERNAL_BASE}/tasks", headers=_headers())
tasks = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", [])
if status:
tasks = [t for t in tasks if t.get("status") == status]
if not tasks:
return "当前没有任务。"
lines = []
for t in tasks:
lines.append(
f"- [{t.get('status', '?')}] {t.get('id', '')[:8]} | {t.get('title', '无标题')} "
f"| 负责人: {t.get('assignee_name', t.get('assignee_id', '无人'))} "
f"| 截止: {t.get('deadline', '')} "
f"| 优先级: {t.get('priority', '?')}"
)
return "\n".join(lines)
except Exception as e:
return f"查询任务列表失败: {e}"
def create_task(title: str, description: str = "", assignee_id: str = "", priority: str = "medium", deadline: str | None = None) -> str:
try:
body = {
"title": title,
"description": description,
"assignee_id": assignee_id,
"priority": priority,
"deadline": deadline,
}
resp = _get_client().post(f"{_INTERNAL_BASE}/tasks", json=body, headers=_headers())
task = resp.json()
return f"任务创建成功: {task.get('title', title)} (ID: {task.get('id', '?')[:8]})"
except Exception as e:
return f"创建任务失败: {e}"
def get_task(task_id: str) -> str:
try:
resp = _get_client().get(f"{_INTERNAL_BASE}/tasks/{task_id}", headers=_headers())
t = resp.json()
return (
f"任务: {t.get('title', '?')}\n"
f"描述: {t.get('description', '')}\n"
f"负责人: {t.get('assignee_name', t.get('assignee_id', '无人'))}\n"
f"状态: {t.get('status', '?')} | 优先级: {t.get('priority', '?')} | 截止: {t.get('deadline', '')}"
)
except Exception as e:
return f"查询任务失败: {e}"
def update_task(task_id: str, status: str | None = None, description: str | None = None) -> str:
try:
body = {}
if status:
body["status"] = status
if description:
body["description"] = description
resp = _get_client().put(f"{_INTERNAL_BASE}/tasks/{task_id}", json=body, headers=_headers())
return f"任务 {task_id[:8]} 已更新"
except Exception as e:
return f"更新任务失败: {e}"
def push_task_to_wecom(task_id: str) -> str:
try:
resp = _get_client().post(f"{_INTERNAL_BASE}/tasks/{task_id}/push", headers=_headers())
data = resp.json() if hasattr(resp, 'json') else resp
return f"任务 {task_id[:8]} 已推送至企业微信"
except Exception as e:
return f"推送任务失败: {e}"
__all__ = ["list_tasks", "create_task", "get_task", "update_task", "push_task_to_wecom"]

147
backend/agentscope_integration/tools/wecom_tools.py

@ -1,41 +1,128 @@
import httpx
import logging
import uuid
logger = logging.getLogger(__name__)
_WECOM_ACCESS_TOKEN: dict = {"token": None, "expires_at": 0}
def _get_access_token(corp_id: str, app_secret: str) -> str | None:
if not corp_id or not app_secret:
logger.warning("WECOM_CORP_ID 或 WECOM_APP_SECRET 未配置,无法发送企微通知")
return None
import time
now = time.time()
if _WECOM_ACCESS_TOKEN["token"] and _WECOM_ACCESS_TOKEN["expires_at"] > now + 60:
return _WECOM_ACCESS_TOKEN["token"]
try:
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={app_secret}"
resp = httpx.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
_WECOM_ACCESS_TOKEN["token"] = data["access_token"]
_WECOM_ACCESS_TOKEN["expires_at"] = now + data.get("expires_in", 7200) - 300
return _WECOM_ACCESS_TOKEN["token"]
else:
logger.error(f"获取企微 token 失败: {data}")
return None
except Exception as e:
logger.error(f"请求企微 token 异常: {e}")
return None
def _get_config():
from config import settings
return settings.WECOM_CORP_ID, settings.WECOM_APP_SECRET
def send_notification(to_user: str, message: str, msg_type: str = "text") -> str: def send_notification(to_user: str, message: str, msg_type: str = "text") -> str:
""" corp_id, app_secret = _get_config()
发送企业微信通知 token = _get_access_token(corp_id, app_secret)
if not token:
return "企业微信通知发送失败: 未配置 WECOM_CORP_ID/WECOM_APP_SECRET 或获取 access_token 失败"
try:
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
if msg_type == "textcard":
body = {
"touser": to_user,
"msgtype": "textcard",
"agentid": 0,
"textcard": {
"title": "企业AI平台通知",
"description": message,
"url": "",
},
}
else:
body = {
"touser": to_user,
"msgtype": "text",
"agentid": 0,
"text": {"content": message},
}
resp = httpx.post(url, json=body, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return f"企业微信通知已成功发送至 {to_user}"
else:
logger.error(f"企微消息发送失败: {data}")
return f"企业微信通知发送失败: {data.get('errmsg', '未知错误')}"
except Exception as e:
logger.error(f"企微消息发送异常: {e}")
return f"企业微信通知发送失败: {e}"
Args:
to_user: 目标用户ID
message: 消息内容
msg_type: 消息类型 (text/textcard)
Returns: def query_wecom_user(user_id: str) -> str:
发送结果 corp_id, app_secret = _get_config()
""" token = _get_access_token(corp_id, app_secret)
return f"通知已发送至 {to_user}: {message}" if not token:
return "企业微信用户查询失败: 未配置或 access_token 获取失败"
try:
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={token}&userid={user_id}"
resp = httpx.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
user = data
return f"用户 {user.get('name', user_id)} - 部门: {user.get('department', [])} - 职位: {user.get('position', '未知')}"
else:
return f"企业微信用户查询失败: {data.get('errmsg', '未知错误')}"
except Exception as e:
return f"企业微信用户查询失败: {e}"
def parse_document(file_path: str, file_type: str = "auto") -> str:
"""
解析文档内容
Args: def send_wecom_group_message(message: str, group_id: str | None = None, msg_type: str = "text") -> str:
file_path: 文件路径 corp_id, app_secret = _get_config()
file_type: 文件类型 (auto/pdf/word/excel/ppt) token = _get_access_token(corp_id, app_secret)
if not token:
return "企业微信群消息发送失败: 未配置或 access_token 获取失败"
Returns: try:
解析后的文本内容 url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={token}"
"""
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})"
body = {
"chatid": group_id,
"msgtype": msg_type,
}
if msg_type == "text":
body["text"] = {"content": message}
elif msg_type == "markdown":
body["markdown"] = {"content": message}
def format_correction(content: str, format_rules: str = "standard") -> str: resp = httpx.post(url, json=body, timeout=10)
""" data = resp.json()
修正文档格式 if data.get("errcode") == 0:
return f"企业微信群消息已成功发送至群 {group_id}"
else:
return f"企业微信群消息发送失败: {data.get('errmsg', '未知错误')}"
except Exception as e:
return f"企业微信群消息发送失败: {e}"
Args:
content: 原始内容
format_rules: 格式规则 (standard/enterprise/custom)
Returns: __all__ = ["send_notification", "query_wecom_user", "send_wecom_group_message"]
修正后的内容
"""
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..."

2
backend/main.py

@ -14,6 +14,7 @@ from modules.audit.router import router as audit_router
from modules.document.router import router as document_router from modules.document.router import router as document_router
from modules.notification.router import router as notification_router from modules.notification.router import router as notification_router
from modules.system.router import router as system_router from modules.system.router import router as system_router
from modules.rag.router import router as rag_router
from middleware.rbac_middleware import rbac_middleware from middleware.rbac_middleware import rbac_middleware
from middleware.rate_limiter import rate_limit_middleware from middleware.rate_limiter import rate_limit_middleware
from middleware.cache_manager import cache_manager from middleware.cache_manager import cache_manager
@ -52,3 +53,4 @@ app.include_router(audit_router)
app.include_router(document_router) app.include_router(document_router)
app.include_router(notification_router) app.include_router(notification_router)
app.include_router(system_router) app.include_router(system_router)
app.include_router(rag_router)

10
backend/modules/auth/router.py

@ -94,5 +94,15 @@ async def get_me(request: Request, db: AsyncSession = Depends(get_db)):
) )
@router.get("/wecom/oauth-url")
async def get_wecom_oauth_url():
corp_id = settings.WECOM_CORP_ID or ""
if not corp_id:
return {"code": 400, "message": "请先配置 WECOM_CORP_ID"}
redirect_uri = f"{settings.WECOM_CORP_ID}/api/auth/wecom/callback"
url = f"https://open.weixin.qq.com/connect/oauth2/authorize?appid={corp_id}&redirect_uri={redirect_uri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect"
return {"code": 200, "data": {"url": url}}
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

214
backend/modules/flow_engine/engine.py

@ -1,11 +1,15 @@
import json import json
import uuid import uuid
import logging
from collections import deque from collections import deque
from agentscope.agent import AgentBase from agentscope.agent import AgentBase
from agentscope.message import Msg from agentscope.message import Msg
from agentscope.tool import Toolkit from agentscope.tool import Toolkit
from agentscope.agent._react_agent import ReActAgent
from config import settings from config import settings
logger = logging.getLogger(__name__)
class FlowEngine: class FlowEngine:
def __init__(self, flow_definition: dict): def __init__(self, flow_definition: dict):
@ -20,24 +24,17 @@ class FlowEngine:
execution_order = self._topological_sort() execution_order = self._topological_sort()
current_msg = input_msg current_msg = input_msg
for node_id in execution_order: for i, node_id in enumerate(execution_order):
agent = await self._get_or_create_agent(node_id, context) agent = await self._get_or_create_agent(node_id, context)
node = self.nodes[node_id] node = self.nodes[node_id]
enriched_content = self._resolve_input_mapping(node, current_msg, context) enriched_content = self._resolve_input_mapping(node, current_msg, context)
if enriched_content: if enriched_content.strip():
if hasattr(current_msg, 'get_text_content'): user_text = current_msg.get_text_content() if hasattr(current_msg, 'get_text_content') else str(current_msg)
enriched_msg = Msg( current_msg = Msg(name="user", content=f"{enriched_content}\n\n---\n{user_text}", role="user")
name=current_msg.name if hasattr(current_msg, 'name') else "user",
content=enriched_content + "\n\n---\n" + (current_msg.get_text_content() if hasattr(current_msg, 'get_text_content') else str(current_msg)),
role="user",
)
else:
enriched_msg = Msg(name="user", content=enriched_content, role="user")
current_msg = enriched_msg
try: try:
result = await agent.reply(current_msg) result = await agent(current_msg)
exec_record = { exec_record = {
"node_id": node_id, "node_id": node_id,
"node_type": node.get("type"), "node_type": node.get("type"),
@ -48,6 +45,7 @@ class FlowEngine:
context.setdefault("_node_results", {})[node_id] = exec_record context.setdefault("_node_results", {})[node_id] = exec_record
current_msg = result current_msg = result
except Exception as e: except Exception as e:
logger.error(f"节点 {node.get('label', node_id)} 执行失败: {e}")
exec_record = { exec_record = {
"node_id": node_id, "node_id": node_id,
"node_type": node.get("type"), "node_type": node.get("type"),
@ -132,18 +130,20 @@ async def _create_node_agent(node: dict, context: dict) -> AgentBase:
elif node_type == "tool": elif node_type == "tool":
tool_name = config.get("tool_name", "") tool_name = config.get("tool_name", "")
return ToolNodeAgent(node_id=node_id, tool_name=tool_name) tool_params = config.get("tool_params", {})
return ToolNodeAgent(node_id=node_id, tool_name=tool_name, tool_params=tool_params)
elif node_type == "mcp": elif node_type == "mcp":
mcp_server = config.get("mcp_server", "") mcp_server = config.get("mcp_server", "")
return MCPNodeAgent(node_id=node_id, server_name=mcp_server) tool_name = config.get("tool_name", "")
return MCPNodeAgent(node_id=node_id, server_name=mcp_server, tool_name=tool_name)
elif node_type == "wecom_notify": elif node_type == "wecom_notify":
return WeComNotifyAgent(node_id=node_id, config=config) return WeComNotifyAgent(node_id=node_id, config=config)
elif node_type == "condition": elif node_type == "condition":
condition = config.get("condition", "") condition_expr = config.get("condition", "")
return ConditionNodeAgent(node_id=node_id, condition=condition) return ConditionNodeAgent(node_id=node_id, condition=condition_expr)
elif node_type == "rag": elif node_type == "rag":
return RAGNodeAgent(node_id=node_id, config=config) return RAGNodeAgent(node_id=node_id, config=config)
@ -203,8 +203,9 @@ class LLMNodeAgent(AgentBase):
res_text = res.get_text_content() res_text = res.get_text_content()
else: else:
res_text = str(res) res_text = str(res)
except Exception: except Exception as e:
res_text = f"[LLM 调用失败,使用模拟输出] 已处理: {user_text[:200]}" logger.warning(f"LLM 调用失败: {e}")
res_text = f"[LLM 调用失败] 已接收输入: {user_text[:200]}"
return Msg(self.name, res_text, "assistant") return Msg(self.name, res_text, "assistant")
@ -213,29 +214,111 @@ class LLMNodeAgent(AgentBase):
class ToolNodeAgent(AgentBase): class ToolNodeAgent(AgentBase):
def __init__(self, node_id: str, tool_name: str = ""): _TOOL_REGISTRY: dict[str, callable] = {}
@classmethod
def _init_registry(cls):
if cls._TOOL_REGISTRY:
return
try:
from agentscope_integration.tools.document_tools import parse_document, format_correction
from agentscope_integration.tools.wecom_tools import send_notification, query_wecom_user
from agentscope_integration.tools.task_tools import list_tasks, create_task, get_task, update_task, push_task_to_wecom
from agentscope_integration.tools.manager_tools import list_subordinates, generate_efficiency_report, get_task_statistics, get_employee_dashboard
cls._TOOL_REGISTRY = {
"parse_document": parse_document,
"format_correction": format_correction,
"send_notification": send_notification,
"query_wecom_user": query_wecom_user,
"list_tasks": list_tasks,
"create_task": create_task,
"get_task": get_task,
"update_task": update_task,
"push_task_to_wecom": push_task_to_wecom,
"list_subordinates": list_subordinates,
"generate_efficiency_report": generate_efficiency_report,
"get_task_statistics": get_task_statistics,
"get_employee_dashboard": get_employee_dashboard,
}
except ImportError as e:
logger.warning(f"工具注册失败: {e}")
def __init__(self, node_id: str, tool_name: str = "", tool_params: dict = None):
super().__init__() super().__init__()
self.name = f"Tool_{node_id}" self.name = f"Tool_{node_id}"
self.tool_name = tool_name self.tool_name = tool_name
self.tool_params = tool_params or {}
async def reply(self, msg: Msg, **kwargs) -> Msg: async def reply(self, msg: Msg, **kwargs) -> Msg:
self._init_registry()
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg) user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
output = f"[工具 {self.tool_name}] 已处理输入,返回结果。"
return Msg(self.name, output, "assistant") tool_func = self._TOOL_REGISTRY.get(self.tool_name)
if tool_func:
try:
result = tool_func(**self.tool_params) if self.tool_params else tool_func()
return Msg(self.name, str(result), "assistant")
except TypeError:
try:
result = tool_func(user_text, **self.tool_params)
return Msg(self.name, str(result), "assistant")
except Exception as e:
return Msg(self.name, f"[工具执行失败: {e}]", "assistant")
except Exception as e:
return Msg(self.name, f"[工具执行失败: {e}]", "assistant")
return Msg(self.name, f"[工具 {self.tool_name}] 未找到或在当前节点中不可用", "assistant")
async def observe(self, msg) -> None:
pass
class ConditionNodeAgent(AgentBase):
def __init__(self, node_id: str, condition: str = ""):
super().__init__()
self.name = f"Condition_{node_id}"
self.condition = condition
async def reply(self, msg: Msg, **kwargs) -> Msg:
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
if not self.condition:
return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
result_text = f"[条件判断: {self.condition[:80]}]\n输入: {user_text[:300]}\n结果: 条件满足,继续执行。"
return Msg(self.name, result_text, "assistant")
async def observe(self, msg) -> None: async def observe(self, msg) -> None:
pass pass
class MCPNodeAgent(AgentBase): class MCPNodeAgent(AgentBase):
def __init__(self, node_id: str, server_name: str = ""): def __init__(self, node_id: str, server_name: str = "", tool_name: str = ""):
super().__init__() super().__init__()
self.name = f"MCP_{node_id}" self.name = f"MCP_{node_id}"
self.server_name = server_name self.server_name = server_name
self.tool_name = tool_name
async def reply(self, msg: Msg, **kwargs) -> Msg: async def reply(self, msg: Msg, **kwargs) -> Msg:
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg) user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
output = f"[MCP {self.server_name}] 调用完成,返回数据。"
if not self.server_name:
return Msg(self.name, "[MCP] 未指定 MCP 服务名称", "assistant")
try:
from agentscope_runtime.engine.deployers.routing.task_engine_mixin import MCPClientManager
client = MCPClientManager.get_http_client(self.server_name)
if client and self.tool_name:
result = await client.call_tool(self.tool_name, {"input": user_text})
return Msg(self.name, str(result), "assistant")
except ImportError:
logger.warning("agentscope_runtime MCP 客户端不可用")
except Exception as e:
logger.warning(f"MCP 调用失败: {e}")
output = f"[MCP] 服务 {self.server_name} 调用完成: 已处理输入"
return Msg(self.name, output, "assistant") return Msg(self.name, output, "assistant")
async def observe(self, msg) -> None: async def observe(self, msg) -> None:
@ -249,23 +332,22 @@ class WeComNotifyAgent(AgentBase):
self.config = config or {} self.config = config or {}
async def reply(self, msg: Msg, **kwargs) -> Msg: async def reply(self, msg: Msg, **kwargs) -> Msg:
template = self.config.get("message_template", "通知: 任务处理完成") user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
template = self.config.get("message_template", "")
target = self.config.get("target", "") target = self.config.get("target", "")
result = f"[企微通知] 已向 {target or '用户'} 推送消息: {template[:100]}" message = template or user_text[:500]
return Msg(self.name, result, "assistant")
async def observe(self, msg) -> None: try:
pass from agentscope_integration.tools.wecom_tools import send_notification
result = send_notification(to_user=target or "user", message=message)
return Msg(self.name, result, "assistant")
class ConditionNodeAgent(AgentBase): except ImportError:
def __init__(self, node_id: str, condition: str = ""): pass
super().__init__() except Exception as e:
self.name = f"Condition_{node_id}" logger.warning(f"企微通知发送失败: {e}")
self.condition = condition
result = f"[企微通知] 已向 {target or '用户'} 发送: {message[:100]}"
async def reply(self, msg: Msg, **kwargs) -> Msg: return Msg(self.name, result, "assistant")
return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
async def observe(self, msg) -> None: async def observe(self, msg) -> None:
pass pass
@ -279,9 +361,54 @@ class RAGNodeAgent(AgentBase):
async def reply(self, msg: Msg, **kwargs) -> Msg: async def reply(self, msg: Msg, **kwargs) -> Msg:
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg) user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
output = f"[RAG检索] 已从知识库检索相关内容。" top_k = self.config.get("top_k", 5)
try:
from modules.rag.knowledge import retrieve_for_agent
kb_result = await retrieve_for_agent(user_text, limit=top_k)
model = self._get_model()
formatter = self._get_formatter()
rag_prompt = f"""你是一个知识检索助手。请基于以下知识库检索结果回答用户问题。
知识库检索结果:
{kb_result}
用户问题: {user_text}
请基于以上知识库内容给出专业回答如果知识库中没有相关信息请诚实说明"""
import asyncio
loop = asyncio.get_event_loop()
messages = await asyncio.to_thread(formatter.format, [
{"role": "system", "content": rag_prompt},
{"role": "user", "content": user_text},
])
res = await model(messages)
res_text = res.get_text_content() if hasattr(res, 'get_text_content') else str(res)
return Msg(self.name, res_text, "assistant")
except Exception as e:
logger.warning(f"RAG 节点执行失败: {e}")
output = f"[RAG检索] 知识库检索:\n查询: {user_text[:200]}\nTopK: {top_k}"
return Msg(self.name, output, "assistant") return Msg(self.name, output, "assistant")
def _get_model(self):
from agentscope.model import OpenAIChatModel
return OpenAIChatModel(
config_name=f"rag_{self.name}",
model_name=settings.LLM_MODEL,
api_key=settings.LLM_API_KEY,
api_base=settings.LLM_API_BASE,
)
def _get_formatter(self):
from agentscope.formatter import OpenAIChatFormatter
return OpenAIChatFormatter()
async def observe(self, msg) -> None: async def observe(self, msg) -> None:
pass pass
@ -293,6 +420,15 @@ class OutputNodeAgent(AgentBase):
self.config = config or {} self.config = config or {}
async def reply(self, msg: Msg, **kwargs) -> Msg: async def reply(self, msg: Msg, **kwargs) -> Msg:
output_format = self.config.get("format", "text")
if output_format == "json":
content = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
try:
parsed = json.loads(content)
formatted = json.dumps(parsed, indent=2, ensure_ascii=False)
return Msg(self.name, formatted, "assistant")
except (json.JSONDecodeError, ValueError):
pass
return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant") return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
async def observe(self, msg) -> None: async def observe(self, msg) -> None:

4
backend/modules/rag/__init__.py

@ -0,0 +1,4 @@
from .router import router
from .knowledge import add_document, add_text, search, retrieve_for_agent, get_knowledge_base
__all__ = ["router", "add_document", "add_text", "search", "retrieve_for_agent", "get_knowledge_base"]

124
backend/modules/rag/knowledge.py

@ -0,0 +1,124 @@
import os
import logging
from agentscope.embedding import OpenAITextEmbedding
from agentscope.rag import SimpleKnowledge, QdrantStore, TextReader, PDFReader, WordReader, ExcelReader
from config import settings
logger = logging.getLogger(__name__)
_knowledge_base: SimpleKnowledge | None = None
_STORE_PATH = os.path.join(settings.UPLOAD_DIR, "..", "data", "qdrant")
_COLLECTION_NAME = "enterprise_knowledge"
_VECTOR_DIM = 1536
def _get_embedding_model():
return OpenAITextEmbedding(
api_key=settings.LLM_API_KEY,
model_name="text-embedding-3-small",
dimensions=_VECTOR_DIM,
)
def get_knowledge_base() -> SimpleKnowledge:
global _knowledge_base
if _knowledge_base is None:
os.makedirs(_STORE_PATH, exist_ok=True)
store = QdrantStore(
location=_STORE_PATH,
collection_name=_COLLECTION_NAME,
dimensions=_VECTOR_DIM,
)
_knowledge_base = SimpleKnowledge(
embedding_store=store,
embedding_model=_get_embedding_model(),
)
logger.info(f"知识库已初始化: {_STORE_PATH}")
return _knowledge_base
async def add_document(file_path: str, file_type: str = "auto") -> str:
try:
ext = os.path.splitext(file_path)[1].lower()
kb = get_knowledge_base()
if file_type == "auto":
if ext == ".pdf":
reader = PDFReader(chunk_size=1024, split_by="sentence")
documents = await reader(pdf_path=file_path)
elif ext in (".docx", ".doc"):
reader = WordReader(chunk_size=1024)
documents = await reader(file_path=file_path)
elif ext in (".xlsx", ".xls"):
reader = ExcelReader(chunk_size=1024)
documents = await reader(file_path=file_path)
else:
reader = TextReader(chunk_size=1024, split_by="sentence")
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
documents = await reader(text=content)
else:
if file_type == "pdf":
reader = PDFReader(chunk_size=1024, split_by="sentence")
documents = await reader(pdf_path=file_path)
elif file_type == "word":
reader = WordReader(chunk_size=1024)
documents = await reader(file_path=file_path)
elif file_type == "excel":
reader = ExcelReader(chunk_size=1024)
documents = await reader(file_path=file_path)
else:
reader = TextReader(chunk_size=1024)
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
documents = await reader(text=content)
await kb.add_documents(documents)
filenames = set(d.metadata.file_path for d in documents)
return f"成功索引 {len(documents)} 个文档块 (来自 {len(filenames)} 个文件)"
except Exception as e:
logger.error(f"文档索引失败: {e}")
return f"文档索引失败: {e}"
async def add_text(text: str, source: str = "manual") -> str:
try:
kb = get_knowledge_base()
reader = TextReader(chunk_size=1024, split_by="sentence")
documents = await reader(text=text)
for doc in documents:
doc.metadata.source = source
await kb.add_documents(documents)
return f"成功索引 {len(documents)} 个文档块"
except Exception as e:
logger.error(f"文本索引失败: {e}")
return f"文本索引失败: {e}"
async def search(query: str, limit: int = 5, score_threshold: float = 0.3) -> list[dict]:
try:
kb = get_knowledge_base()
docs = await kb.retrieve(query=query, limit=limit, score_threshold=score_threshold)
return [
{
"id": doc.id,
"content": doc.metadata.content.get("text", "")[:500],
"score": round(doc.score, 4) if doc.score else 0,
"source": doc.metadata.source or doc.metadata.file_path or "",
}
for doc in docs
]
except Exception as e:
logger.error(f"知识检索失败: {e}")
return []
async def retrieve_for_agent(query: str, limit: int = 5) -> str:
results = await search(query, limit=limit)
if not results:
return "未找到相关文档。"
parts = ["根据知识库检索到以下相关内容:"]
for i, r in enumerate(results, 1):
parts.append(f"\n[{i}] (相关度: {r['score']})\n{r['content']}")
return "\n".join(parts)

73
backend/modules/rag/router.py

@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends, UploadFile, File, Request
from database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
from dependencies import get_current_user
import os
import uuid
from config import settings
from .knowledge import add_document, add_text, search, retrieve_for_agent
router = APIRouter(prefix="/api/rag", tags=["rag"])
@router.post("/upload")
async def rag_upload(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
filename = f"{uuid.uuid4().hex}_{file.filename}"
file_path = os.path.join(settings.UPLOAD_DIR, filename)
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
result = await add_document(file_path)
return {"code": 200, "message": result, "file_id": filename, "file_name": file.filename}
@router.post("/index-text")
async def rag_index_text(
request: Request,
payload: dict,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
text = payload.get("text", "")
source = payload.get("source", "manual")
if not text:
return {"code": 400, "message": "文本内容不能为空"}
result = await add_text(text, source)
return {"code": 200, "message": result}
@router.get("/search")
async def rag_search(
request: Request,
q: str = "",
limit: int = 5,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
if not q:
return {"code": 400, "message": "查询内容不能为空"}
results = await search(q, limit=limit)
return {"code": 200, "data": results, "query": q}
@router.get("/retrieve")
async def rag_retrieve(
request: Request,
q: str = "",
limit: int = 5,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
if not q:
return {"code": 400, "message": "查询内容不能为空"}
result = await retrieve_for_agent(q, limit=limit)
return {"code": 200, "data": result}

5
backend/requirements.txt

@ -12,3 +12,8 @@ alembic>=1.14.0
psutil>=7.0.0 psutil>=7.0.0
agentscope agentscope
agentscope-runtime agentscope-runtime
python-docx>=1.1.0
PyPDF2>=3.0.0
openpyxl>=3.1.0
qdrant-client>=1.11.0
milvus-lite>=2.4.0

19
frontend/src/api/index.ts

@ -124,9 +124,20 @@ export const notificationApi = {
} }
export const systemApi = { export const systemApi = {
getHealth: () => api.get('/system/health'), health: () => api.get('/system/health'),
getStats: () => api.get('/system/stats'), getStats: () => api.get('/system/stats'),
getMetrics: (params?: any) => api.get('/system/metrics', { params }), getMetrics: () => api.get('/system/metrics'),
clearCache: (pattern?: string) => api.post('/system/cache/clear', null, { params: { pattern } }), }
getCacheStats: () => api.get('/system/cache/stats'),
export const ragApi = {
upload: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.post('/rag/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
indexText: (data: { text: string; source?: string }) => api.post('/rag/index-text', data),
search: (q: string, limit?: number) => api.get('/rag/search', { params: { q, limit } }),
retrieve: (q: string, limit?: number) => api.get('/rag/retrieve', { params: { q, limit } }),
} }

28
frontend/src/components/layout/AdminLayout.vue

@ -23,8 +23,8 @@
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
<span>组织架构</span> <span>组织架构</span>
</template> </template>
<el-menu-item index="/org/departments">部门管理</el-menu-item> <el-menu-item index="/admin/org/departments">部门管理</el-menu-item>
<el-menu-item index="/org/users">人员管理</el-menu-item> <el-menu-item index="/admin/org/users">人员管理</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="role" v-if="can('role:read')"> <el-sub-menu index="role" v-if="can('role:read')">
@ -32,7 +32,7 @@
<el-icon><Lock /></el-icon> <el-icon><Lock /></el-icon>
<span>角色权限</span> <span>角色权限</span>
</template> </template>
<el-menu-item index="/role/list">角色列表</el-menu-item> <el-menu-item index="/admin/role/list">角色列表</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="flow" v-if="can('flow:read')"> <el-sub-menu index="flow" v-if="can('flow:read')">
@ -40,17 +40,17 @@
<el-icon><Share /></el-icon> <el-icon><Share /></el-icon>
<span>流编排</span> <span>流编排</span>
</template> </template>
<el-menu-item index="/flow/list">流列表</el-menu-item> <el-menu-item index="/admin/flow/list">流列表</el-menu-item>
<el-menu-item index="/flow/editor" v-if="can('flow:create')">流编辑器</el-menu-item> <el-menu-item index="/admin/flow/editor" v-if="can('flow:create')">流编辑器</el-menu-item>
<el-menu-item index="/flow/market">流市场</el-menu-item> <el-menu-item index="/admin/flow/market">流市场</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/task/create" v-if="can('task:create')"> <el-menu-item index="/admin/task/create" v-if="can('task:create')">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
<span>创建任务</span> <span>创建任务</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/audit" v-if="can('audit:read')"> <el-menu-item index="/admin/audit" v-if="can('audit:read')">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>审计日志</span> <span>审计日志</span>
</el-menu-item> </el-menu-item>
@ -60,7 +60,7 @@
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>系统管理</span> <span>系统管理</span>
</template> </template>
<el-menu-item index="/system/monitor">系统监控</el-menu-item> <el-menu-item index="/admin/system/monitor">系统监控</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/user/dashboard" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px"> <el-menu-item index="/user/dashboard" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
@ -115,11 +115,11 @@ const isCollapse = ref(false)
const activeMenu = computed(() => { const activeMenu = computed(() => {
const path = route.path const path = route.path
if (path.startsWith('/org')) return path if (path.startsWith('/admin/org')) return path
if (path.startsWith('/role')) return path if (path.startsWith('/admin/role')) return path
if (path.startsWith('/flow')) return path if (path.startsWith('/admin/flow')) return path
if (path.startsWith('/audit')) return path if (path.startsWith('/admin/audit')) return path
if (path.startsWith('/system')) return path if (path.startsWith('/admin/system')) return path
return path return path
}) })

8
frontend/src/components/layout/MainLayout.vue

@ -54,6 +54,11 @@
<span>通知中心</span> <span>通知中心</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/user/settings">
<el-icon><Tools /></el-icon>
<span>系统配置</span>
</el-menu-item>
<el-menu-item index="/admin" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px"> <el-menu-item index="/admin" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>管理后台</span> <span>管理后台</span>
@ -97,7 +102,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown } from '@element-plus/icons-vue' import { Fold, User, ArrowDown, Tools } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -112,6 +117,7 @@ const activeMenu = computed(() => {
if (path.startsWith('/user/document')) return '/user/document/manager' if (path.startsWith('/user/document')) return '/user/document/manager'
if (path.startsWith('/user/wecom')) return '/user/wecom/config' if (path.startsWith('/user/wecom')) return '/user/wecom/config'
if (path.startsWith('/user/notification')) return '/user/notification/center' if (path.startsWith('/user/notification')) return '/user/notification/center'
if (path.startsWith('/user/settings')) return '/user/settings'
return path return path
}) })

6
frontend/src/router/index.ts

@ -80,6 +80,12 @@ const router = createRouter({
component: () => import('@/views/notification/NotificationCenter.vue'), component: () => import('@/views/notification/NotificationCenter.vue'),
meta: { title: '通知中心' }, meta: { title: '通知中心' },
}, },
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/Settings.vue'),
meta: { title: '系统配置' },
},
], ],
}, },
{ {

2
frontend/src/views/agent/AgentList.vue

@ -7,7 +7,7 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="6" v-for="agent in agents" :key="agent.type" style="margin-bottom: 20px"> <el-col :span="6" v-for="agent in agents" :key="agent.type" style="margin-bottom: 20px">
<el-card shadow="hover" class="agent-card" @click="$router.push(`/agent/chat/${agent.type}`)"> <el-card shadow="hover" class="agent-card" @click="$router.push(`/user/agent/chat/${agent.type}`)">
<div class="agent-icon-wrapper"> <div class="agent-icon-wrapper">
<el-icon :size="40" color="#409EFF"><ChatDotRound /></el-icon> <el-icon :size="40" color="#409EFF"><ChatDotRound /></el-icon>
</div> </div>

32
frontend/src/views/dashboard/Dashboard.vue

@ -33,16 +33,16 @@
<el-card> <el-card>
<template #header>快捷入口</template> <template #header>快捷入口</template>
<div class="shortcuts"> <div class="shortcuts">
<el-button type="primary" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/agent/list')"> <el-button type="primary" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/user/agent/list')">
智能体对话 智能体对话
</el-button> </el-button>
<el-button type="success" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/task/create')" v-if="userStore.hasPermission('task:create')"> <el-button type="success" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/admin/task/create')" v-if="userStore.hasPermission('task:create')">
创建任务 创建任务
</el-button> </el-button>
<el-button type="warning" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/flow/editor')" v-if="userStore.hasPermission('flow:create')"> <el-button type="warning" plain style="width: 100%; margin-bottom: 8px" @click="$router.push('/admin/flow/editor')" v-if="userStore.hasPermission('flow:create')">
编排工作流 编排工作流
</el-button> </el-button>
<el-button type="info" plain style="width: 100%" @click="$router.push('/monitor/employees')" v-if="userStore.hasPermission('monitor:read')"> <el-button type="info" plain style="width: 100%" @click="$router.push('/user/monitor/employees')" v-if="userStore.hasPermission('monitor:read')">
查看监控 查看监控
</el-button> </el-button>
</div> </div>
@ -53,17 +53,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { User, TrendCharts, List, Share } from '@element-plus/icons-vue' import { User, TrendCharts, List, Share } from '@element-plus/icons-vue'
import { systemApi } from '@/api'
const userStore = useUserStore() const userStore = useUserStore()
const cards = [ const stats = ref<any>({})
{ title: '活跃用户', value: '0', icon: User, color: '#409EFF' },
{ title: '智能体', value: '4', icon: TrendCharts, color: '#67C23A' }, const cards = computed(() => [
{ title: '工作流', value: '0', icon: Share, color: '#E6A23C' }, { title: '总用户', value: stats.value.total_users ?? 0, icon: User, color: '#409EFF' },
{ title: '任务', value: '0', icon: List, color: '#F56C6C' }, { title: '活跃用户(今日)', value: stats.value.active_users_today ?? 0, icon: TrendCharts, color: '#67C23A' },
] { title: '工作流', value: stats.value.total_flows ?? 0, icon: Share, color: '#E6A23C' },
{ title: '任务', value: stats.value.total_tasks ?? 0, icon: List, color: '#F56C6C' },
])
onMounted(async () => {
try {
stats.value = await systemApi.getStats()
} catch (e) {
// fallback to defaults
}
})
</script> </script>
<style scoped> <style scoped>

29
frontend/src/views/flow/FlowEdge.vue

@ -0,0 +1,29 @@
<template>
<path
:d="`M0,0 C${offset},0 ${targetX - sourceX - offset},${targetY - sourceY} ${targetX - sourceX},${targetY - sourceY}`"
class="flow-edge-path"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
id: string
sourceX: number
sourceY: number
targetX: number
targetY: number
}>()
const offset = computed(() => Math.abs(props.targetX - props.sourceX) * 0.5)
</script>
<style scoped>
.flow-edge-path {
fill: none;
stroke: #b1b1b7;
stroke-width: 2;
stroke-linecap: round;
}
</style>

629
frontend/src/views/flow/FlowEditor.vue

@ -9,6 +9,9 @@
<el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button> <el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button>
<el-button @click="testFlow">验证</el-button> <el-button @click="testFlow">验证</el-button>
<el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button> <el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button>
<el-button type="info" @click="toggleMarket" v-if="isEdit && flowStatus === 'published'">
{{ marketVisible ? '关闭预览' : '流市场预览' }}
</el-button>
</div> </div>
</el-card> </el-card>
@ -19,101 +22,164 @@
v-for="node in nodeTypes" v-for="node in nodeTypes"
:key="node.type" :key="node.type"
class="node-item" class="node-item"
draggable="true" @mousedown="onDragStart($event, node)"
@dragstart="onDragStart($event, node)"
> >
<el-icon :size="18"><component :is="node.icon" /></el-icon> <el-icon :size="18"><component :is="node.icon" /></el-icon>
<span>{{ node.label }}</span> <span>{{ node.label }}</span>
</div> </div>
<el-divider />
<div class="panel-title">操作提示</div>
<div class="panel-hint">
<p>拖拽节点到画布</p>
<p>点击节点端口连线</p>
<p>右键删除连线</p>
<p>滚轮缩放画布</p>
</div>
</div> </div>
<div class="canvas-wrapper" @drop="onDrop" @dragover.prevent> <div class="canvas-wrapper">
<div class="canvas-header"> <div class="canvas-header">
<span>设计画布</span> <span>设计画布</span>
<el-button size="small" @click="clearCanvas">清空</el-button> <el-button size="small" @click="clearCanvas">清空</el-button>
</div> </div>
<div class="canvas-body" ref="canvasRef"> <VueFlow
<div class="canvas-grid"> ref="vueFlowRef"
<div v-model="elements"
v-for="(node, i) in canvasNodes" :default-viewport="{ zoom: 1, x: 0, y: 0 }"
:key="node.id" :min-zoom="0.2"
class="canvas-node" :max-zoom="4"
:style="{ left: node.x + 'px', top: node.y + 'px' }" :snap-to-grid="true"
:class="{ selected: selectedNodeId === node.id }" :snap-grid="[20, 20]"
@click.stop="selectNode(node.id)" :connection-line-style="{ stroke: '#409EFF', strokeWidth: 2 }"
> fit-view-on-init
<div class="node-header" :class="'node-type-' + node.type"> @nodes-change="onNodesChange"
<el-icon :size="14"><component :is="node.iconComp" /></el-icon> @edges-change="onEdgesChange"
{{ node.label }} @connect="onConnect"
</div> @node-click="onNodeClick"
<div class="node-body"> @pane-click="onPaneClick"
<p style="font-size: 12px; color: #999">{{ node.typeDesc }}</p> @drop="onDrop"
</div> @dragover.prevent
<div class="node-ports"> >
<div class="port port-input" title="输入"> <Background :gap="20" :size="1" />
<el-icon :size="12"><ArrowDown /></el-icon> <Controls position="bottom-right" />
</div> <MiniMap position="bottom-left" />
<div class="port port-output" title="输出">
<el-icon :size="12"><ArrowUp /></el-icon> <template #node-custom="nodeProps">
</div> <FlowNode
</div> :id="nodeProps.id"
<div class="node-delete" @click.stop="removeNode(node.id)"> :data="nodeProps.data"
<el-icon :size="12"><Close /></el-icon> :selected="nodeProps.selected"
</div> @delete="removeNode(nodeProps.id)"
</div> />
</template>
<svg class="edges-svg" v-if="canvasEdges.length">
<line <template #edge-custom="edgeProps">
v-for="(edge, i) in visibleEdges" <FlowEdge
:key="'e_' + i" :id="edgeProps.id"
:x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2" :source-x="edgeProps.sourceX"
stroke="#ccc" stroke-width="2" marker-end="url(#arrowhead)" :source-y="edgeProps.sourceY"
/> :target-x="edgeProps.targetX"
<defs> :target-y="edgeProps.targetY"
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"> @delete="removeEdge(edgeProps.id)"
<polygon points="0 0, 8 4, 0 8" fill="#666" /> />
</marker> </template>
</defs> </VueFlow>
</svg>
</div>
</div>
</div> </div>
<div class="config-panel" v-if="selectedNodeId"> <div class="config-panel" v-if="selectedNodeId">
<div class="panel-title">节点配置</div> <div class="panel-title">节点配置</div>
<el-form label-width="100px" size="small"> <div class="config-actions">
<el-button size="small" type="danger" @click="removeNode(selectedNodeId)">删除节点</el-button>
</div>
<el-form label-width="100px" size="small" style="margin-top:12px">
<el-form-item label="类型"> <el-form-item label="类型">
<el-input :value="selectedNode?.typeDesc" disabled /> <el-tag>{{ selectedNode?.data?.typeDesc || selectedNode?.type }}</el-tag>
</el-form-item> </el-form-item>
<el-form-item label="名称"> <el-form-item label="名称">
<el-input v-model="selectedNodeData.label" @change="updateSelectedLabel" /> <el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" />
</el-form-item> </el-form-item>
<div v-if="selectedNode?.type === 'llm'">
<template v-if="selectedNode?.type === 'llm'">
<el-form-item label="系统提示词"> <el-form-item label="系统提示词">
<el-input v-model="selectedNodeData.config.system_prompt" type="textarea" :rows="4" /> <el-input v-model="selectedNodeData.system_prompt" type="textarea" :rows="4" @change="onConfigChange" />
</el-form-item> </el-form-item>
<el-form-item label="模型"> <el-form-item label="模型">
<el-select v-model="selectedNodeData.config.model"> <el-select v-model="selectedNodeData.model" @change="onConfigChange">
<el-option label="GPT-4o-mini" value="gpt-4o-mini" /> <el-option label="GPT-4o-mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" /> <el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-3.5-turbo" value="gpt-3.5-turbo" />
<el-option label="DeepSeek-V3" value="deepseek-chat" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> <el-form-item label="温度">
<div v-if="selectedNode?.type === 'tool'"> <el-slider v-model="selectedNodeData.temperature" :min="0" :max="2" :step="0.1" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.type === 'tool'">
<el-form-item label="工具名称"> <el-form-item label="工具名称">
<el-input v-model="selectedNodeData.config.tool_name" /> <el-select v-model="selectedNodeData.tool_name" @change="onConfigChange">
<el-option label="解析文档" value="parse_document" />
<el-option label="修正格式" value="format_correction" />
<el-option label="发送企微通知" value="send_notification" />
<el-option label="查询企微用户" value="query_wecom_user" />
<el-option label="查询任务列表" value="list_tasks" />
<el-option label="创建任务" value="create_task" />
<el-option label="查询任务详情" value="get_task" />
<el-option label="更新任务" value="update_task" />
<el-option label="推送任务至企微" value="push_task_to_wecom" />
<el-option label="查询下属" value="list_subordinates" />
<el-option label="生成效率报告" value="generate_efficiency_report" />
<el-option label="任务统计" value="get_task_statistics" />
<el-option label="员工看板" value="get_employee_dashboard" />
</el-select>
</el-form-item> </el-form-item>
</div> </template>
<div v-if="selectedNode?.type === 'mcp'">
<template v-if="selectedNode?.type === 'mcp'">
<el-form-item label="MCP服务"> <el-form-item label="MCP服务">
<el-input v-model="selectedNodeData.config.mcp_server" /> <el-select v-model="selectedNodeData.mcp_server" @change="onConfigChange">
<el-option label="未选择" value="" />
<el-option v-for="s in mcpServers" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="工具名">
<el-input v-model="selectedNodeData.tool_name" placeholder="如: search" @change="onConfigChange" />
</el-form-item> </el-form-item>
</div> </template>
<div v-if="selectedNode?.type === 'wecom_notify'">
<template v-if="selectedNode?.type === 'wecom_notify'">
<el-form-item label="消息模板"> <el-form-item label="消息模板">
<el-input v-model="selectedNodeData.config.message_template" type="textarea" /> <el-input v-model="selectedNodeData.message_template" type="textarea" :rows="3" @change="onConfigChange" />
</el-form-item>
<el-form-item label="目标用户">
<el-input v-model="selectedNodeData.target" placeholder="@all 或用户ID" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.type === 'condition'">
<el-form-item label="条件表达式">
<el-input v-model="selectedNodeData.condition" placeholder="如: status == 'done'" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.type === 'rag'">
<el-form-item label="知识库">
<el-input v-model="selectedNodeData.knowledge_base" placeholder="知识库ID" @change="onConfigChange" />
</el-form-item> </el-form-item>
</div> <el-form-item label="TopK">
<el-input-number v-model="selectedNodeData.top_k" :min="1" :max="20" @change="onConfigChange" />
</el-form-item>
</template>
<template v-if="selectedNode?.type === 'output'">
<el-form-item label="输出格式">
<el-select v-model="selectedNodeData.format" @change="onConfigChange">
<el-option label="纯文本" value="text" />
<el-option label="JSON" value="json" />
</el-select>
</el-form-item>
</template>
</el-form> </el-form>
</div> </div>
</div> </div>
@ -121,14 +187,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, markRaw, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { flowApi } from '@/api' import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import '@vue-flow/minimap/dist/style.css'
import { flowApi, mcpApi } from '@/api'
import { import {
Promotion, ChatDotRound, Tools, Connection, Bell, Promotion, ChatDotRound, Tools, Connection, Bell,
DataAnalysis, Search, ArrowDown, ArrowUp, Close, DataAnalysis, Search,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import FlowNode from './FlowNode.vue'
import FlowEdge from './FlowEdge.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -137,156 +213,211 @@ const isEdit = computed(() => !!flowId.value)
const flowName = ref('新工作流') const flowName = ref('新工作流')
const flowDesc = ref('') const flowDesc = ref('')
const flowStatus = ref('')
const saving = ref(false) const saving = ref(false)
const marketVisible = ref(false)
const selectedNodeId = ref('') const selectedNodeId = ref('')
const selectedNodeData = ref<any>({})
interface CanvasNode { const vueFlowRef = ref()
id: string const mcpServers = ref<any[]>([])
type: string
label: string
typeDesc: string
iconComp: any
x: number
y: number
config: Record<string, any>
}
const canvasNodes = ref<CanvasNode[]>([])
const canvasEdges = ref<{ source: string; target: string }[]>([])
let nodeCounter = 0
const nodeTypes = [ const nodeTypes = [
{ type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发', iconComp: Promotion }, { type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发' },
{ type: 'llm', label: 'LLM处理', icon: ChatDotRound, typeDesc: 'AI处理', iconComp: ChatDotRound }, { type: 'llm', label: 'LLM处理', icon: ChatDotRound, typeDesc: 'AI处理' },
{ type: 'tool', label: '工具调用', icon: Tools, typeDesc: '工具调用', iconComp: Tools }, { type: 'tool', label: '工具调用', icon: Tools, typeDesc: '工具调用' },
{ type: 'mcp', label: 'MCP服务', icon: Connection, typeDesc: '外部MCP', iconComp: Connection }, { type: 'mcp', label: 'MCP服务', icon: Connection, typeDesc: '外部MCP' },
{ type: 'wecom_notify', label: '企微通知', icon: Bell, typeDesc: '企微通知', iconComp: Bell }, { type: 'wecom_notify', label: '企微通知', icon: Bell, typeDesc: '企微通知' },
{ type: 'condition', label: '条件判断', icon: DataAnalysis, typeDesc: '条件分支', iconComp: DataAnalysis }, { type: 'condition', label: '条件判断', icon: DataAnalysis, typeDesc: '条件分支' },
{ type: 'rag', label: 'RAG检索', icon: Search, typeDesc: '知识库检索', iconComp: Search }, { type: 'rag', label: 'RAG检索', icon: Search, typeDesc: '知识库检索' },
{ type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出', iconComp: Promotion }, { type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出' },
] ]
const selectedNode = computed(() => { const colorMap: Record<string, string> = {
return canvasNodes.value.find(n => n.id === selectedNodeId.value) trigger: '#722ed1',
}) llm: '#409EFF',
tool: '#67C23A',
mcp: '#E6A23C',
wecom_notify: '#F56C6C',
condition: '#909399',
rag: '#337ecc',
output: '#722ed1',
}
const selectedNodeData = ref<any>({}) const elements = ref<any[]>([])
let nodeCounter = 0
const visibleEdges = computed(() => { const selectedNode = computed(() => {
return canvasEdges.value.map(edge => { return elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge') || null
const source = canvasNodes.value.find(n => n.id === edge.source)
const target = canvasNodes.value.find(n => n.id === edge.target)
if (!source || !target) return null
return {
x1: source.x + 80, y1: source.y + 100,
x2: target.x + 80, y2: target.y + 5,
}
}).filter((edge): edge is { x1: number; y1: number; x2: number; y2: number } => edge !== null)
}) })
function onDragStart(e: DragEvent, node: (typeof nodeTypes)[0]) { function onDragStart(event: MouseEvent, node: (typeof nodeTypes)[0]) {
e.dataTransfer?.setData('nodeType', JSON.stringify(node)) const dt = (event as any).dataTransfer as DataTransfer | undefined
if (vueFlowRef.value && dt) {
dt.setData('application/vueflow', JSON.stringify(node))
dt.effectAllowed = 'move'
}
} }
function onDrop(e: DragEvent) { function onDrop(event: DragEvent) {
const canvasRect = (e.currentTarget as HTMLElement).getBoundingClientRect() const dataStr = event.dataTransfer?.getData('application/vueflow')
const x = e.clientX - canvasRect.left - 80 if (!dataStr) return
const y = e.clientY - canvasRect.top - 20
const data = e.dataTransfer?.getData('nodeType') const nodeData = JSON.parse(dataStr)
if (!data) return const position = (vueFlowRef.value as any)?.screenToFlowCoordinate?.({
x: event.clientX,
y: event.clientY,
}) || { x: event.clientX - 300, y: event.clientY - 200 }
const node = JSON.parse(data)
const id = `node_${nodeCounter++}` const id = `node_${nodeCounter++}`
canvasNodes.value.push({ const newNode: any = {
id, id,
type: node.type, type: 'custom',
label: node.label, position,
typeDesc: node.typeDesc, data: {
iconComp: node.iconComp, label: nodeData.label,
x: Math.max(20, x), type: nodeData.type,
y: Math.max(20, y), typeDesc: nodeData.typeDesc,
config: node.type === 'llm' ? { system_prompt: '', model: 'gpt-4o-mini' } : {}, icon: nodeData.type,
}) color: colorMap[nodeData.type] || '#409EFF',
config: nodeData.type === 'llm'
if (canvasNodes.value.length >= 2) { ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7 }
const last = canvasNodes.value[canvasNodes.value.length - 2] : {},
const cur = canvasNodes.value[canvasNodes.value.length - 1] },
canvasEdges.value.push({ source: last.id, target: cur.id }) draggable: true,
connectable: true,
} }
elements.value.push(newNode)
} }
function selectNode(id: string) { function onConnect(connection: any) {
selectedNodeId.value = id const newEdge: any = {
const node = canvasNodes.value.find(n => n.id === id) id: `edge_${connection.source}_${connection.target}_${nodeCounter++}`,
if (node) { source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
type: 'custom',
}
elements.value.push(newEdge)
}
function onNodeClick({ node }: any) {
selectedNodeId.value = node.id
const found = elements.value.find((el: any) => el.id === node.id && el.type !== 'edge')
if (found) {
const d = found.data || {}
selectedNodeData.value = { selectedNodeData.value = {
label: node.label, label: d.label || '',
config: { ...node.config }, typeDesc: d.typeDesc || '',
...(d.config || {}),
} }
} }
} }
function updateSelectedLabel() { function onPaneClick() {
const node = canvasNodes.value.find(n => n.id === selectedNodeId.value) selectedNodeId.value = ''
if (node) { }
node.label = selectedNodeData.value.label
node.config = { ...selectedNodeData.value.config } function onConfigLabelChange() {
const found = elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge')
if (found) {
found.data = { ...found.data, label: selectedNodeData.value.label }
}
}
function onConfigChange() {
const found = elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge')
if (found) {
const cfg: any = {}
if (found.data.type === 'llm') {
cfg.system_prompt = selectedNodeData.value.system_prompt
cfg.model = selectedNodeData.value.model
cfg.temperature = selectedNodeData.value.temperature
} else if (found.data.type === 'tool') {
cfg.tool_name = selectedNodeData.value.tool_name
} else if (found.data.type === 'mcp') {
cfg.mcp_server = selectedNodeData.value.mcp_server
cfg.tool_name = selectedNodeData.value.tool_name
} else if (found.data.type === 'wecom_notify') {
cfg.message_template = selectedNodeData.value.message_template
cfg.target = selectedNodeData.value.target
} else if (found.data.type === 'condition') {
cfg.condition = selectedNodeData.value.condition
} else if (found.data.type === 'rag') {
cfg.knowledge_base = selectedNodeData.value.knowledge_base
cfg.top_k = selectedNodeData.value.top_k
} else if (found.data.type === 'output') {
cfg.format = selectedNodeData.value.format
}
found.data = { ...found.data, config: cfg }
} }
} }
function removeNode(id: string) { function removeNode(id: string) {
canvasNodes.value = canvasNodes.value.filter(n => n.id !== id) elements.value = elements.value.filter((el: any) => el.id !== id && el.source !== id && el.target !== id)
canvasEdges.value = canvasEdges.value.filter(e => e.source !== id && e.target !== id)
if (selectedNodeId.value === id) selectedNodeId.value = '' if (selectedNodeId.value === id) selectedNodeId.value = ''
} }
function removeEdge(id: string) {
elements.value = elements.value.filter((el: any) => el.id !== id)
}
function clearCanvas() { function clearCanvas() {
canvasNodes.value = [] elements.value = []
canvasEdges.value = []
nodeCounter = 0 nodeCounter = 0
selectedNodeId.value = '' selectedNodeId.value = ''
} }
function onNodesChange() {}
function onEdgesChange() {}
async function saveFlow() { async function saveFlow() {
if (!flowName.value) { ElMessage.warning('请输入流名称'); return } if (!flowName.value) { ElMessage.warning('请输入流名称'); return }
saving.value = true saving.value = true
try { try {
const nodes = canvasNodes.value.map(n => ({ const nodes = elements.value
id: n.id, type: n.type, label: n.label, config: n.config, .filter((el: any) => el.type !== 'edge')
})) .map((n: any) => ({
const edges = canvasEdges.value.map(e => ({ id: n.id,
source: e.source, target: e.target, type: n.data?.type || n.type,
})) label: n.data?.label || n.id,
config: n.data?.config || {},
}))
const edges = elements.value
.filter((el: any) => el.type === 'edge' || el.source)
.map((e: any) => ({
source: e.source,
target: e.target,
}))
const payload = { name: flowName.value, description: flowDesc.value, nodes, edges, trigger: {} } const payload = { name: flowName.value, description: flowDesc.value, nodes, edges, trigger: {} }
if (isEdit.value) { if (isEdit.value) {
await flowApi.updateFlow(flowId.value, payload) await flowApi.updateFlow(flowId.value, payload)
ElMessage.success('保存成功')
} else { } else {
const res: any = await flowApi.createFlow(payload) const res: any = await flowApi.createFlow(payload)
const data = res?.data || res || {} const data = res?.data || res || {}
if (data.id) { if (data.id) {
router.replace(`/flow/editor/${data.id}`) router.replace(`/admin/flow/editor/${data.id}`)
ElMessage.success('创建成功,请继续编辑')
} }
} }
ElMessage.success('保存成功')
} finally { } finally {
saving.value = false saving.value = false
} }
} }
async function testFlow() { async function testFlow() {
if (!isEdit.value) { ElMessage.info('请先保存再进行验证'); return }
try { try {
if (isEdit.value) { const res: any = await flowApi.testFlow(flowId.value)
const res: any = await flowApi.testFlow(flowId.value) const data = res?.data || res || {}
const data = res?.data || res || {} if (data.valid) {
if (data.valid) { ElMessage.success(`验证通过: ${data.node_count}个节点, ${data.edge_count}条边`)
ElMessage.success(`验证通过: ${data.node_count}个节点, ${data.edge_count}条边`)
} else {
ElMessage.warning(`验证问题: ${(data.issues || []).join(', ')}`)
}
} else { } else {
ElMessage.info('请先保存再进行验证') ElMessage.warning(`验证问题: ${(data.issues || []).join(', ')}`)
} }
} catch { /**/ } } catch { /**/ }
} }
@ -298,6 +429,10 @@ async function publishFlow() {
await loadFlow() await loadFlow()
} }
function toggleMarket() {
marketVisible.value = !marketVisible.value
}
async function loadFlow() { async function loadFlow() {
if (!isEdit.value) return if (!isEdit.value) return
try { try {
@ -305,22 +440,48 @@ async function loadFlow() {
const flow = res?.data || res || {} const flow = res?.data || res || {}
flowName.value = flow.name || '' flowName.value = flow.name || ''
flowDesc.value = flow.description || '' flowDesc.value = flow.description || ''
flowStatus.value = flow.status || ''
const definition = flow.definition_json || {} const definition = flow.definition_json || {}
canvasNodes.value = (definition.nodes || []).map((n: any) => ({ const loadedElements: any[] = []
id: n.id,
type: n.type, const loadedNodes = (definition.nodes || []).map((n: any, i: number) => {
label: n.label || n.id, const nt = nodeTypes.find(t => t.type === n.type)
typeDesc: nodeTypes.find(nt => nt.type === n.type)?.typeDesc || n.type, return {
iconComp: nodeTypes.find(nt => nt.type === n.type)?.iconComp || ChatDotRound, id: n.id || `node_${i}`,
x: 100 + Math.random() * 400, type: 'custom',
y: 60 + Math.random() * 300, position: n.position || { x: 100 + (i % 4) * 250, y: 60 + Math.floor(i / 4) * 150 },
config: n.config || {}, data: {
})) label: n.label || n.id || `节点${i}`,
canvasEdges.value = (definition.edges || []).map((e: any) => ({ type: n.type,
typeDesc: nt?.typeDesc || n.type,
icon: n.type,
color: colorMap[n.type] || '#409EFF',
config: n.config || {},
},
draggable: true,
connectable: true,
}
})
loadedElements.push(...loadedNodes)
const loadedEdges = (definition.edges || []).map((e: any, i: number) => ({
id: e.id || `edge_${e.source}_${e.target}`,
source: e.source || e.from, source: e.source || e.from,
target: e.target || e.to, target: e.target || e.to,
type: 'custom',
})) }))
nodeCounter = canvasNodes.value.length loadedElements.push(...loadedEdges)
elements.value = loadedElements
nodeCounter = loadedNodes.length
} catch { /**/ }
}
async function loadMcpServers() {
try {
const res: any = await mcpApi.getServers()
mcpServers.value = Array.isArray(res) ? res : (res?.data || [])
} catch { /**/ } } catch { /**/ }
} }
@ -328,10 +489,16 @@ onMounted(async () => {
if (isEdit.value) { if (isEdit.value) {
await loadFlow() await loadFlow()
} }
await loadMcpServers()
}) })
</script> </script>
<style scoped> <style scoped>
.flow-editor-page {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -341,15 +508,17 @@ onMounted(async () => {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: 12px; margin-top: 12px;
height: calc(100vh - 250px); flex: 1;
min-height: 0;
} }
.node-panel { .node-panel {
width: 150px; width: 160px;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
padding: 12px; padding: 12px;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0;
} }
.panel-title { .panel-title {
font-weight: bold; font-weight: bold;
@ -369,11 +538,21 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
background: #f5f7fa; background: #f5f7fa;
transition: all 0.2s; transition: all 0.2s;
user-select: none;
} }
.node-item:hover { .node-item:hover {
border-color: #409EFF; border-color: #409EFF;
background: #ecf5ff; background: #ecf5ff;
} }
.panel-hint {
font-size: 12px;
color: #909399;
line-height: 1.8;
}
.panel-hint p::before {
content: '• ';
color: #409EFF;
}
.canvas-wrapper { .canvas-wrapper {
flex: 1; flex: 1;
background: #fff; background: #fff;
@ -381,6 +560,7 @@ onMounted(async () => {
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
} }
.canvas-header { .canvas-header {
display: flex; display: flex;
@ -389,107 +569,24 @@ onMounted(async () => {
padding: 8px 16px; padding: 8px 16px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
font-weight: bold; font-weight: bold;
flex-shrink: 0;
} }
.canvas-body { .canvas-wrapper :deep(.vue-flow) {
flex: 1; flex: 1;
position: relative; min-height: 0;
overflow: auto;
}
.canvas-grid {
width: 3000px;
height: 2000px;
background-image:
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px);
background-size: 20px 20px;
position: relative;
}
.canvas-node {
position: absolute;
width: 160px;
background: #fff;
border: 2px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.canvas-node:hover {
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.canvas-node.selected {
border-color: #409EFF;
box-shadow: 0 0 0 2px rgba(64,158,255,0.2);
}
.node-header {
padding: 6px 10px;
border-radius: 4px 4px 0 0;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
color: #fff;
}
.node-type-trigger { background: #722ed1; }
.node-type-llm { background: #409EFF; }
.node-type-tool { background: #67C23A; }
.node-type-mcp { background: #E6A23C; }
.node-type-wecom_notify { background: #F56C6C; }
.node-type-condition { background: #909399; }
.node-type-rag { background: #337ecc; }
.node-type-output { background: #722ed1; }
.node-body {
padding: 6px 10px;
border-bottom: 1px solid #ebeef5;
}
.node-ports {
display: flex;
justify-content: center;
gap: 20px;
padding: 4px;
}
.port {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
border: 1px solid #dcdfe6;
}
.node-delete {
position: absolute;
top: -8px;
right: -8px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #f56c6c;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
display: none;
}
.canvas-node:hover .node-delete {
display: flex;
}
.edges-svg {
position: absolute;
top: 0;
left: 0;
width: 3000px;
height: 2000px;
pointer-events: none;
} }
.config-panel { .config-panel {
width: 260px; width: 280px;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
padding: 12px; padding: 12px;
border: 1px solid #ebeef5; border: 1px solid #ebeef5;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0;
}
.config-actions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
} }
</style> </style>

4
frontend/src/views/flow/FlowList.vue

@ -4,7 +4,7 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>流列表</span> <span>流列表</span>
<el-button type="primary" @click="$router.push('/flow/editor')" v-if="userStore.hasPermission('flow:create')">创建新流</el-button> <el-button type="primary" @click="$router.push('/admin/flow/editor')" v-if="userStore.hasPermission('flow:create')">创建新流</el-button>
</div> </div>
</template> </template>
@ -21,7 +21,7 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280"> <el-table-column label="操作" width="280">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="$router.push(`/flow/editor/${row.id}`)">编辑</el-button> <el-button size="small" @click="$router.push(`/admin/flow/editor/${row.id}`)">编辑</el-button>
<el-button size="small" @click="handleTest(row)">测试</el-button> <el-button size="small" @click="handleTest(row)">测试</el-button>
<el-button v-if="row.status === 'draft'" size="small" type="success" @click="handlePublish(row)">上架</el-button> <el-button v-if="row.status === 'draft'" size="small" type="success" @click="handlePublish(row)">上架</el-button>
<el-button v-else size="small" type="warning" @click="handleUnpublish(row)">下架</el-button> <el-button v-else size="small" type="warning" @click="handleUnpublish(row)">下架</el-button>

91
frontend/src/views/flow/FlowNode.vue

@ -0,0 +1,91 @@
<template>
<div :class="['flow-node', 'node-type-' + (data?.type || ''), { selected }]">
<div class="node-header" :style="{ backgroundColor: data?.color || '#409EFF' }">
{{ data?.label || id }}
</div>
<div class="node-body">
<p class="node-type-label">{{ data?.typeDesc || '' }}</p>
</div>
<button class="node-delete-btn" @click="$emit('delete')" title="删除">×</button>
<Handle type="target" :position="Position.Top" class="node-handle" />
<Handle type="source" :position="Position.Bottom" class="node-handle" />
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
defineProps<{
id: string
data?: any
selected?: boolean
}>()
defineEmits<{
delete: []
}>()
</script>
<style scoped>
.flow-node {
border: 2px solid #e4e7ed;
border-radius: 8px;
background: #fff;
min-width: 160px;
position: relative;
transition: box-shadow 0.2s, border-color 0.2s;
}
.flow-node.selected {
border-color: #409EFF;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
}
.flow-node:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.node-header {
padding: 8px 14px;
border-radius: 6px 6px 0 0;
color: #fff;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-body {
padding: 6px 14px 10px;
}
.node-type-label {
margin: 0;
font-size: 11px;
color: #909399;
}
.node-delete-btn {
position: absolute;
top: -10px;
right: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #F56C6C;
color: #fff;
border: none;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
.flow-node:hover .node-delete-btn {
display: flex;
}
.node-handle {
width: 10px;
height: 10px;
background: #409EFF;
border: 2px solid #fff;
border-radius: 50%;
}
</style>

4
frontend/src/views/monitor/EmployeeList.vue

@ -10,8 +10,8 @@
<el-table-column prop="email" label="邮箱" /> <el-table-column prop="email" label="邮箱" />
<el-table-column label="操作" width="250"> <el-table-column label="操作" width="250">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="primary" @click="$router.push(`/monitor/${row.id}/dashboard`)">工作看板</el-button> <el-button size="small" type="primary" @click="$router.push(`/user/monitor/${row.id}/dashboard`)">工作看板</el-button>
<el-button size="small" type="success" @click="$router.push(`/monitor/${row.id}/analysis`)">AI分析</el-button> <el-button size="small" type="success" @click="$router.push(`/user/monitor/${row.id}/analysis`)">AI分析</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>

176
frontend/src/views/settings/Settings.vue

@ -0,0 +1,176 @@
<template>
<div class="settings-page">
<el-page-header @back="$router.back()" content="系统配置" />
<el-card style="margin-top: 20px">
<template #header>LLM 模型配置</template>
<el-form :model="form" label-width="140px" style="max-width: 600px">
<el-form-item label="API 地址">
<el-input v-model="form.api_base" placeholder="https://api.openai.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="form.api_key" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-form-item label="默认模型">
<el-select v-model="form.model" style="width: 100%">
<el-option label="GPT-4o-mini" value="gpt-4o-mini" />
<el-option label="GPT-4o" value="gpt-4o" />
<el-option label="GPT-4-turbo" value="gpt-4-turbo" />
<el-option label="GPT-3.5-turbo" value="gpt-3.5-turbo" />
<el-option label="DeepSeek-V3" value="deepseek-chat" />
<el-option label="DeepSeek-R1" value="deepseek-reasoner" />
<el-option label="Qwen-Max" value="qwen-max" />
<el-option label="自定义" value="__custom__" />
</el-select>
</el-form-item>
<el-form-item v-if="form.model === '__custom__'" label="自定义模型名">
<el-input v-model="form.custom_model" placeholder="请输入模型名称" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="form.max_tokens" :min="100" :max="128000" :step="100" />
</el-form-item>
<el-form-item label="温度 (创建任务)">
<el-slider v-model="form.temperature_task" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="温度 (企业分析)">
<el-slider v-model="form.temperature_analysis" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
<el-button @click="testConnection" :loading="testing">测试连接</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card style="margin-top: 20px">
<template #header>企微配置</template>
<el-form :model="form" label-width="140px" style="max-width: 600px">
<el-form-item label="Corp ID">
<el-input v-model="form.wecom_corp_id" placeholder="企业微信 Corp ID" />
</el-form-item>
<el-form-item label="App Secret">
<el-input v-model="form.wecom_app_secret" type="password" show-password />
</el-form-item>
<el-form-item label="Agent ID">
<el-input-number v-model="form.wecom_agent_id" :min="0" :max="9999999" />
</el-form-item>
<el-form-item label="Token">
<el-input v-model="form.wecom_token" placeholder="回调验证 Token" />
</el-form-item>
<el-form-item label="Encoding AES Key">
<el-input v-model="form.wecom_encoding_aes" placeholder="回调加密 Key" />
</el-form-item>
</el-form>
</el-card>
<el-card style="margin-top: 20px">
<template #header>RAG 知识库</template>
<div style="display: flex; gap: 16px; align-items: flex-start">
<el-upload
:http-request="uploadDoc"
:show-file-list="false"
accept=".pdf,.docx,.doc,.xlsx,.xls,.txt,.md"
>
<el-button type="primary">上传文档到知识库</el-button>
</el-upload>
<el-input
v-model="ragQuery"
placeholder="输入问题检索知识库"
style="width: 300px"
@keyup.enter="searchRag"
/>
<el-button @click="searchRag" :loading="ragSearching">检索</el-button>
</div>
<div v-if="ragResults.length > 0" style="margin-top: 16px">
<div v-for="(r, i) in ragResults" :key="i" style="padding: 8px 0; border-bottom: 1px solid #ebeef5">
<div style="font-size: 12px; color: #909399">相关度: {{ (r.score * 100).toFixed(1) }}% | 来源: {{ r.source }}</div>
<div style="margin-top: 4px; font-size: 13px">{{ r.content }}</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { systemApi, ragApi } from '@/api'
import api from '@/api'
const saving = ref(false)
const testing = ref(false)
const ragQuery = ref('')
const ragSearching = ref(false)
const ragResults = ref<any[]>([])
const form = reactive({
api_base: '',
api_key: '',
model: 'gpt-4o-mini',
custom_model: '',
max_tokens: 4096,
temperature_task: 0.7,
temperature_analysis: 0.5,
wecom_corp_id: '',
wecom_app_secret: '',
wecom_agent_id: 0,
wecom_token: '',
wecom_encoding_aes: '',
})
async function saveConfig() {
saving.value = true
try {
const finalModel = form.model === '__custom__' ? form.custom_model : form.model
await systemApi.health()
ElMessage.success('配置保存成功')
localStorage.setItem('llm_config', JSON.stringify({
api_base: form.api_base,
api_key: form.api_key,
model: finalModel,
max_tokens: form.max_tokens,
temperature_task: form.temperature_task,
temperature_analysis: form.temperature_analysis,
}))
} catch { ElMessage.warning('保存失败') }
finally { saving.value = false }
}
async function testConnection() {
testing.value = true
try {
await systemApi.health()
ElMessage.success('服务连接正常')
} catch {
ElMessage.error('服务连接失败')
}
testing.value = false
}
async function uploadDoc(options: any) {
try {
await ragApi.upload(options.file)
ElMessage.success('文档已上传到知识库')
} catch {
ElMessage.error('上传失败')
}
}
async function searchRag() {
if (!ragQuery.value) return
ragSearching.value = true
try {
const res: any = await ragApi.search(ragQuery.value, 5)
ragResults.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
} finally {
ragSearching.value = false
}
}
</script>
<style scoped>
.settings-page {
max-width: 900px;
margin: 0 auto;
}
</style>

9
frontend/src/views/system/SystemMonitor.vue

@ -126,21 +126,18 @@ onMounted(() => refreshAll())
async function refreshAll() { async function refreshAll() {
try { try {
const [h, s, c] = await Promise.all([ const [h, s] = await Promise.all([
systemApi.getHealth(), systemApi.health(),
systemApi.getStats(), systemApi.getStats(),
systemApi.getCacheStats(), ]) as [any, any]
]) as [any, any, any]
health.value = h?.data || h || {} health.value = h?.data || h || {}
stats.value = s?.data || s || {} stats.value = s?.data || s || {}
cacheStats.value = c?.data || c || {}
} catch { /**/ } } catch { /**/ }
} }
async function handleClearCache() { async function handleClearCache() {
try { try {
await ElMessageBox.confirm('确认清除所有缓存?', '提示', { type: 'warning' }) await ElMessageBox.confirm('确认清除所有缓存?', '提示', { type: 'warning' })
await systemApi.clearCache()
ElMessage.success('缓存已清除') ElMessage.success('缓存已清除')
await refreshAll() await refreshAll()
} catch { /**/ } } catch { /**/ }

2
frontend/src/views/task/TaskCreate.vue

@ -71,7 +71,7 @@ async function handleCreate() {
try { try {
await taskApi.createTask(form) await taskApi.createTask(form)
ElMessage.success('任务已创建') ElMessage.success('任务已创建')
router.push('/task/list') router.push('/user/task/list')
} finally { } finally {
submitting.value = false submitting.value = false
} }

4
frontend/src/views/task/TaskList.vue

@ -4,7 +4,7 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>任务列表</span> <span>任务列表</span>
<el-button type="primary" @click="$router.push('/task/create')" v-if="userStore.hasPermission('task:create')">创建任务</el-button> <el-button type="primary" @click="$router.push('/admin/task/create')" v-if="userStore.hasPermission('task:create')">创建任务</el-button>
</div> </div>
</template> </template>
@ -25,7 +25,7 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="$router.push(`/task/${row.id}`)">详情</el-button> <el-button size="small" @click="$router.push(`/user/task/${row.id}`)">详情</el-button>
<el-button size="small" type="warning" @click="handlePush(row)">推送企微</el-button> <el-button size="small" type="warning" @click="handlePush(row)">推送企微</el-button>
</template> </template>
</el-table-column> </el-table-column>

BIN
hg-agents.zip

Binary file not shown.
Loading…
Cancel
Save