diff --git a/.gitignore b/.gitignore index 59af153..5970616 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ ali-agentscope-src/ .env +log/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..51ce3fd --- /dev/null +++ b/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 → 数据看板 + 体验完善 +``` \ No newline at end of file diff --git a/backend/agentscope_integration/factory.py b/backend/agentscope_integration/factory.py index 9eca10c..96d5f12 100644 --- a/backend/agentscope_integration/factory.py +++ b/backend/agentscope_integration/factory.py @@ -70,6 +70,13 @@ class AgentFactory: toolkit.register_tool_function(parse_document) 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( name=f"EmployeeAI_{user_name}", sys_prompt=f"""你是 {user_name} 的专属AI工作助手。 @@ -87,6 +94,7 @@ class AgentFactory: model=model, formatter=formatter, toolkit=toolkit, + knowledge=knowledge, memory=UserIsolatedMemory(user_id=user_id), max_iters=8, ) @@ -103,20 +111,30 @@ class AgentFactory: @classmethod 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.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( name=f"ManagerAI_{user_name}", sys_prompt=f"""你是 {user_name} 的管理分析助手。 你可以: -1. 分析下属员工的工作数据 -2. 生成工作效率报告 -3. 提供管理决策建议 +1. 查看下属员工列表和工作数据 (list_subordinates, get_employee_dashboard) +2. 生成团队效率报告 (generate_efficiency_report) +3. 统计分析任务完成情况 (get_task_statistics) +4. 向下属发送企业微信通知提醒 (send_notification) 重要约束: - 只能查看你的直接和间接下属的数据 -- 不能查看非下属或跨部门员工的数据""", +- 不能查看非下属或跨部门员工的数据 +- 生成报告时注意数据隐私""", model=model, formatter=formatter, toolkit=toolkit, @@ -135,17 +153,30 @@ class AgentFactory: @classmethod 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.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( name=f"TaskAI_{user_name}", sys_prompt=f"""你是任务管理助手。帮助用户创建、跟踪和管理工作任务。 你可以: -1. 创建新任务并分配给指定人员 -2. 查询任务状态和进度 -3. 更新任务信息 -4. 推送任务通知到企业微信""", +1. 创建新任务并分配给指定人员 (create_task) +2. 查询任务状态和进度 (list_tasks, get_task) +3. 更新任务信息 (update_task) +4. 推送任务通知到企业微信 (send_notification) + +重要约束: +- 创建任务前确保标题和负责人信息完整 +- 修改任务状态前告知用户变更 +- 优先级: low/medium/high/urgent""", model=model, formatter=formatter, toolkit=toolkit, @@ -163,6 +194,13 @@ class AgentFactory: toolkit.register_tool_function(parse_document) 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( name=f"DocAI_{user_name}", sys_prompt=f"""你是文档处理专家。帮助用户处理各类文档。 @@ -171,10 +209,12 @@ class AgentFactory: 1. 解析PDF/Word/Excel/PPT等格式 2. 修正文档格式 3. 提取文档关键信息 -4. 格式转换""", +4. 从知识库中检索文档内容 +5. 格式转换""", model=model, formatter=formatter, toolkit=toolkit, + knowledge=knowledge, memory=UserIsolatedMemory(user_id=user_id), max_iters=8, ) diff --git a/backend/agentscope_integration/tools/document_tools.py b/backend/agentscope_integration/tools/document_tools.py index 215fc84..47c4f08 100644 --- a/backend/agentscope_integration/tools/document_tools.py +++ b/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: - 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: - 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"] \ No newline at end of file diff --git a/backend/agentscope_integration/tools/manager_tools.py b/backend/agentscope_integration/tools/manager_tools.py new file mode 100644 index 0000000..52bf7f3 --- /dev/null +++ b/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"] \ No newline at end of file diff --git a/backend/agentscope_integration/tools/task_tools.py b/backend/agentscope_integration/tools/task_tools.py new file mode 100644 index 0000000..37315c8 --- /dev/null +++ b/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"] \ No newline at end of file diff --git a/backend/agentscope_integration/tools/wecom_tools.py b/backend/agentscope_integration/tools/wecom_tools.py index 1f73fb4..6cdd22c 100644 --- a/backend/agentscope_integration/tools/wecom_tools.py +++ b/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: - """ - 发送企业微信通知。 + 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: - 发送结果 - """ - return f"通知已发送至 {to_user}: {message}" +def query_wecom_user(user_id: str) -> str: + corp_id, app_secret = _get_config() + token = _get_access_token(corp_id, app_secret) + 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: - file_path: 文件路径 - file_type: 文件类型 (auto/pdf/word/excel/ppt) +def send_wecom_group_message(message: str, group_id: str | None = None, msg_type: str = "text") -> str: + corp_id, app_secret = _get_config() + token = _get_access_token(corp_id, app_secret) + if not token: + return "企业微信群消息发送失败: 未配置或 access_token 获取失败" - Returns: - 解析后的文本内容 - """ - return f"[模拟] 已解析文档 {file_path} (类型: {file_type})" + try: + url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={token}" + 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: - 修正后的内容 - """ - return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..." \ No newline at end of file +__all__ = ["send_notification", "query_wecom_user", "send_wecom_group_message"] \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 30ab69a..cd69cf3 100644 --- a/backend/main.py +++ b/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.notification.router import router as notification_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.rate_limiter import rate_limit_middleware from middleware.cache_manager import cache_manager @@ -51,4 +52,5 @@ app.include_router(flow_router) app.include_router(audit_router) app.include_router(document_router) app.include_router(notification_router) -app.include_router(system_router) \ No newline at end of file +app.include_router(system_router) +app.include_router(rag_router) \ No newline at end of file diff --git a/backend/modules/auth/router.py b/backend/modules/auth/router.py index d17a6aa..ab1eb21 100644 --- a/backend/modules/auth/router.py +++ b/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: return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') \ No newline at end of file diff --git a/backend/modules/flow_engine/engine.py b/backend/modules/flow_engine/engine.py index e3cc0eb..f0f9073 100644 --- a/backend/modules/flow_engine/engine.py +++ b/backend/modules/flow_engine/engine.py @@ -1,11 +1,15 @@ import json import uuid +import logging from collections import deque from agentscope.agent import AgentBase from agentscope.message import Msg from agentscope.tool import Toolkit +from agentscope.agent._react_agent import ReActAgent from config import settings +logger = logging.getLogger(__name__) + class FlowEngine: def __init__(self, flow_definition: dict): @@ -20,24 +24,17 @@ class FlowEngine: execution_order = self._topological_sort() 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) node = self.nodes[node_id] enriched_content = self._resolve_input_mapping(node, current_msg, context) - if enriched_content: - if hasattr(current_msg, 'get_text_content'): - enriched_msg = Msg( - 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 + if enriched_content.strip(): + user_text = current_msg.get_text_content() if hasattr(current_msg, 'get_text_content') else str(current_msg) + current_msg = Msg(name="user", content=f"{enriched_content}\n\n---\n{user_text}", role="user") try: - result = await agent.reply(current_msg) + result = await agent(current_msg) exec_record = { "node_id": node_id, "node_type": node.get("type"), @@ -48,6 +45,7 @@ class FlowEngine: context.setdefault("_node_results", {})[node_id] = exec_record current_msg = result except Exception as e: + logger.error(f"节点 {node.get('label', node_id)} 执行失败: {e}") exec_record = { "node_id": node_id, "node_type": node.get("type"), @@ -132,18 +130,20 @@ async def _create_node_agent(node: dict, context: dict) -> AgentBase: elif node_type == "tool": 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": 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": return WeComNotifyAgent(node_id=node_id, config=config) elif node_type == "condition": - condition = config.get("condition", "") - return ConditionNodeAgent(node_id=node_id, condition=condition) + condition_expr = config.get("condition", "") + return ConditionNodeAgent(node_id=node_id, condition=condition_expr) elif node_type == "rag": return RAGNodeAgent(node_id=node_id, config=config) @@ -203,8 +203,9 @@ class LLMNodeAgent(AgentBase): res_text = res.get_text_content() else: res_text = str(res) - except Exception: - res_text = f"[LLM 调用失败,使用模拟输出] 已处理: {user_text[:200]}" + except Exception as e: + logger.warning(f"LLM 调用失败: {e}") + res_text = f"[LLM 调用失败] 已接收输入: {user_text[:200]}" return Msg(self.name, res_text, "assistant") @@ -213,29 +214,111 @@ class LLMNodeAgent(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__() self.name = f"Tool_{node_id}" self.tool_name = tool_name + self.tool_params = tool_params or {} 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) - 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: pass 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__() self.name = f"MCP_{node_id}" self.server_name = server_name + self.tool_name = tool_name async def reply(self, msg: Msg, **kwargs) -> 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") async def observe(self, msg) -> None: @@ -249,23 +332,22 @@ class WeComNotifyAgent(AgentBase): self.config = config or {} 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", "") - result = f"[企微通知] 已向 {target or '用户'} 推送消息: {template[:100]}" - return Msg(self.name, result, "assistant") + message = template or user_text[:500] - 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: - return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant") + try: + 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") + except ImportError: + pass + except Exception as e: + logger.warning(f"企微通知发送失败: {e}") + + result = f"[企微通知] 已向 {target or '用户'} 发送: {message[:100]}" + return Msg(self.name, result, "assistant") async def observe(self, msg) -> None: pass @@ -279,9 +361,54 @@ class RAGNodeAgent(AgentBase): async def reply(self, msg: Msg, **kwargs) -> 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") + 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: pass @@ -293,6 +420,15 @@ class OutputNodeAgent(AgentBase): self.config = config or {} 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") async def observe(self, msg) -> None: diff --git a/backend/modules/rag/__init__.py b/backend/modules/rag/__init__.py new file mode 100644 index 0000000..c2410b5 --- /dev/null +++ b/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"] \ No newline at end of file diff --git a/backend/modules/rag/knowledge.py b/backend/modules/rag/knowledge.py new file mode 100644 index 0000000..702af1a --- /dev/null +++ b/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) \ No newline at end of file diff --git a/backend/modules/rag/router.py b/backend/modules/rag/router.py new file mode 100644 index 0000000..4a30212 --- /dev/null +++ b/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} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 4648827..6445a8e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,9 @@ pydantic-settings>=2.0.0 alembic>=1.14.0 psutil>=7.0.0 agentscope -agentscope-runtime \ No newline at end of file +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 \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b522cdd..3525c81 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -124,9 +124,20 @@ export const notificationApi = { } export const systemApi = { - getHealth: () => api.get('/system/health'), + health: () => api.get('/system/health'), getStats: () => api.get('/system/stats'), - getMetrics: (params?: any) => api.get('/system/metrics', { params }), - clearCache: (pattern?: string) => api.post('/system/cache/clear', null, { params: { pattern } }), - getCacheStats: () => api.get('/system/cache/stats'), + getMetrics: () => api.get('/system/metrics'), +} + +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 } }), } \ No newline at end of file diff --git a/frontend/src/components/layout/AdminLayout.vue b/frontend/src/components/layout/AdminLayout.vue index 60a9975..a7a1fef 100644 --- a/frontend/src/components/layout/AdminLayout.vue +++ b/frontend/src/components/layout/AdminLayout.vue @@ -23,8 +23,8 @@ 组织架构 - 部门管理 - 人员管理 + 部门管理 + 人员管理 @@ -32,7 +32,7 @@ 角色权限 - 角色列表 + 角色列表 @@ -40,17 +40,17 @@ 流编排 - 流列表 - 流编辑器 - 流市场 + 流列表 + 流编辑器 + 流市场 - + 创建任务 - + 审计日志 @@ -60,7 +60,7 @@ 系统管理 - 系统监控 + 系统监控 @@ -115,11 +115,11 @@ const isCollapse = ref(false) const activeMenu = computed(() => { const path = route.path - if (path.startsWith('/org')) return path - if (path.startsWith('/role')) return path - if (path.startsWith('/flow')) return path - if (path.startsWith('/audit')) return path - if (path.startsWith('/system')) return path + if (path.startsWith('/admin/org')) return path + if (path.startsWith('/admin/role')) return path + if (path.startsWith('/admin/flow')) return path + if (path.startsWith('/admin/audit')) return path + if (path.startsWith('/admin/system')) return path return path }) diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index 31dfdf0..d33f2e4 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -54,6 +54,11 @@ 通知中心 + + + 系统配置 + + 管理后台 @@ -97,7 +102,7 @@ import { ref, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' 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 router = useRouter() @@ -112,6 +117,7 @@ const activeMenu = computed(() => { if (path.startsWith('/user/document')) return '/user/document/manager' if (path.startsWith('/user/wecom')) return '/user/wecom/config' if (path.startsWith('/user/notification')) return '/user/notification/center' + if (path.startsWith('/user/settings')) return '/user/settings' return path }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9b3b956..9f7feb1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -80,6 +80,12 @@ const router = createRouter({ component: () => import('@/views/notification/NotificationCenter.vue'), meta: { title: '通知中心' }, }, + { + path: 'settings', + name: 'Settings', + component: () => import('@/views/settings/Settings.vue'), + meta: { title: '系统配置' }, + }, ], }, { diff --git a/frontend/src/views/agent/AgentList.vue b/frontend/src/views/agent/AgentList.vue index c4dc74c..e18c1c3 100644 --- a/frontend/src/views/agent/AgentList.vue +++ b/frontend/src/views/agent/AgentList.vue @@ -7,7 +7,7 @@ - + diff --git a/frontend/src/views/dashboard/Dashboard.vue b/frontend/src/views/dashboard/Dashboard.vue index 30bab70..43fdae9 100644 --- a/frontend/src/views/dashboard/Dashboard.vue +++ b/frontend/src/views/dashboard/Dashboard.vue @@ -33,16 +33,16 @@ 快捷入口 - + 智能体对话 - + 创建任务 - + 编排工作流 - + 查看监控 @@ -53,17 +53,29 @@ \ No newline at end of file diff --git a/frontend/src/views/flow/FlowEditor.vue b/frontend/src/views/flow/FlowEditor.vue index 331925b..c7f155e 100644 --- a/frontend/src/views/flow/FlowEditor.vue +++ b/frontend/src/views/flow/FlowEditor.vue @@ -9,6 +9,9 @@ 保存 验证 上架到企微 + + {{ marketVisible ? '关闭预览' : '流市场预览' }} + @@ -19,101 +22,164 @@ v-for="node in nodeTypes" :key="node.type" class="node-item" - draggable="true" - @dragstart="onDragStart($event, node)" + @mousedown="onDragStart($event, node)" > {{ node.label }} + + 操作提示 + + 拖拽节点到画布 + 点击节点端口连线 + 右键删除连线 + 滚轮缩放画布 + - + 设计画布 清空 - - - - - - {{ node.label }} - - - {{ node.typeDesc }} - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + 节点配置 - + + 删除节点 + + - + {{ selectedNode?.data?.typeDesc || selectedNode?.type }} - + - + + - + - + + + - - + + + + + + - + + + + + + + + + + + + + + + - - + + + - + + + + + + + - - + + + - + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + @@ -121,14 +187,24 @@ \ No newline at end of file diff --git a/frontend/src/views/flow/FlowList.vue b/frontend/src/views/flow/FlowList.vue index c431edc..86b078b 100644 --- a/frontend/src/views/flow/FlowList.vue +++ b/frontend/src/views/flow/FlowList.vue @@ -4,7 +4,7 @@ 流列表 - 创建新流 + 创建新流 @@ -21,7 +21,7 @@ - 编辑 + 编辑 测试 上架 下架 diff --git a/frontend/src/views/flow/FlowNode.vue b/frontend/src/views/flow/FlowNode.vue new file mode 100644 index 0000000..bf21613 --- /dev/null +++ b/frontend/src/views/flow/FlowNode.vue @@ -0,0 +1,91 @@ + + + + {{ data?.label || id }} + + + {{ data?.typeDesc || '' }} + + × + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/monitor/EmployeeList.vue b/frontend/src/views/monitor/EmployeeList.vue index 5ef693c..968a74e 100644 --- a/frontend/src/views/monitor/EmployeeList.vue +++ b/frontend/src/views/monitor/EmployeeList.vue @@ -10,8 +10,8 @@ - 工作看板 - AI分析 + 工作看板 + AI分析 diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue new file mode 100644 index 0000000..327cabd --- /dev/null +++ b/frontend/src/views/settings/Settings.vue @@ -0,0 +1,176 @@ + + + + + + LLM 模型配置 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 保存配置 + 测试连接 + + + + + + 企微配置 + + + + + + + + + + + + + + + + + + + + + RAG 知识库 + + + 上传文档到知识库 + + + 检索 + + + + 相关度: {{ (r.score * 100).toFixed(1) }}% | 来源: {{ r.source }} + {{ r.content }} + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/system/SystemMonitor.vue b/frontend/src/views/system/SystemMonitor.vue index 5223d7c..c96b1c1 100644 --- a/frontend/src/views/system/SystemMonitor.vue +++ b/frontend/src/views/system/SystemMonitor.vue @@ -126,21 +126,18 @@ onMounted(() => refreshAll()) async function refreshAll() { try { - const [h, s, c] = await Promise.all([ - systemApi.getHealth(), + const [h, s] = await Promise.all([ + systemApi.health(), systemApi.getStats(), - systemApi.getCacheStats(), - ]) as [any, any, any] + ]) as [any, any] health.value = h?.data || h || {} stats.value = s?.data || s || {} - cacheStats.value = c?.data || c || {} } catch { /**/ } } async function handleClearCache() { try { await ElMessageBox.confirm('确认清除所有缓存?', '提示', { type: 'warning' }) - await systemApi.clearCache() ElMessage.success('缓存已清除') await refreshAll() } catch { /**/ } diff --git a/frontend/src/views/task/TaskCreate.vue b/frontend/src/views/task/TaskCreate.vue index c84e39c..5bcf53c 100644 --- a/frontend/src/views/task/TaskCreate.vue +++ b/frontend/src/views/task/TaskCreate.vue @@ -71,7 +71,7 @@ async function handleCreate() { try { await taskApi.createTask(form) ElMessage.success('任务已创建') - router.push('/task/list') + router.push('/user/task/list') } finally { submitting.value = false } diff --git a/frontend/src/views/task/TaskList.vue b/frontend/src/views/task/TaskList.vue index 53627f5..6502778 100644 --- a/frontend/src/views/task/TaskList.vue +++ b/frontend/src/views/task/TaskList.vue @@ -4,7 +4,7 @@ 任务列表 - 创建任务 + 创建任务 @@ -25,7 +25,7 @@ - 详情 + 详情 推送企微 diff --git a/hg-agents.zip b/hg-agents.zip new file mode 100644 index 0000000..a01520c Binary files /dev/null and b/hg-agents.zip differ
拖拽节点到画布
点击节点端口连线
右键删除连线
滚轮缩放画布
{{ node.typeDesc }}
{{ data?.typeDesc || '' }}