30 changed files with 1959 additions and 391 deletions
@ -1,2 +1,3 @@ |
|||
ali-agentscope-src/ |
|||
.env |
|||
log/ |
|||
|
|||
@ -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 → 数据看板 + 体验完善 |
|||
``` |
|||
@ -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"] |
|||
@ -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"] |
|||
@ -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"] |
|||
@ -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]}..." |
|||
__all__ = ["send_notification", "query_wecom_user", "send_wecom_group_message"] |
|||
@ -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"] |
|||
@ -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) |
|||
@ -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} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
Binary file not shown.
Loading…
Reference in new issue