chore: 工作台产品化进行中的改动

把工作区里其余在制品一并入库,主要是工作台产品化的推进:

  后端:新增 capability_definition / project / my_app_center / office_skill
        接口与 action_definition / skill_definition / project / user_app_center
        模型,config 加路由健康上报。
  前端:新增 frontend/src/skills(Office 技能与 workbuddy 复刻)、
        项目管理、应用中心、能力目录页,以及配套 api / store / config;
        聊天侧新增 SpecialistChip / SpecialistPanel / SkillStrip / AppChatRail
        等组件。
  清理:移除旧 views/tools 下的单页工具(已并入工作台)、_frozen 冻结组件、
        cmd/inspect_oa_debug 调试入口,以及两份调试笔记。
  其它:文档与启动脚本同步。

(这批改动与上一提交的 SY23 工作并行进行,此前已在同一工作区内交织。)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-17 21:32:35 +08:00
co-authored by Claude Code
parent 8b136d4a10
commit 16d63de4e1
180 changed files with 22283 additions and 13850 deletions
+4
View File
@@ -27,6 +27,10 @@ docs/09_Research/工业AI观察员/raw/
.env
.env.local
# ===== 历史调试记录(不再入 Git) =====
debug-admin-auth-misjudge.md
debug-knowledge-chat-401.md
# ===== 操作系统 =====
.DS_Store
Thumbs.db
+19 -23
View File
@@ -1,9 +1,9 @@
# eai_agentplatform_app 博昇内部培训平台 — 编码与调试最高准则
# eai_agentplatform 博昇 AI 数字员工平台(EAI Agent Platform)— 编码与调试最高准则
> **版本:V1.0**
> **日期:2026-08-15**
> **状态:必须强制执行 (Highest Priority)**
> **适用范围:博昇内部培训平台前端、后端、数据库、考试引擎、AI PathCoach、素材上传与审批**
> **适用范围:eai_agentplatform(EAI 数字员工平台)后端(Go)、前端(Vue3)、数据库(SQLite)、AI 检索/对话、考试引擎、素材上传与审批**
> **AI 助手启动任何任务前必须先读取并确认本文件。**
---
@@ -16,7 +16,7 @@
# 第一部分:通用开发规则
> **适用范围**:适用于 eai_agentplatform_app 及 EAIHub 下其他涉及 FastAPI/Vue3/AI 的项目。
> **适用范围**:适用于 eai_agentplatform 及 EAIHub 下其他涉及 Go/Vue3/AI 的项目。
> **使用方式**:新任务开始前应先通读本部分;项目专用规则(P)在遵守本部分基础上叠加。
## 索引
@@ -38,7 +38,7 @@
1. **强制全链路埋点**:禁止盲目猜测,必须在后端路由、中间件、服务层以及前端 JS 关键回调中,大量写入过程性输出。
2. **统一特殊日志文件**:
- 路径:`backend/logs/special_trace_YYYY-MM-DD.log`(按天生成)
- 路径:`debuglog/backend_YYYY-MM-DD.log`(按天生成,由 `start_dev_*.sh` 落盘)
- 内容:必须包含 `[时间戳] [模块名] [详细描述]`
- 必须包含:请求参数、Session 状态、关键业务变量
- **脱敏红线**:落盘前必须脱敏/删除敏感信息(Authorization/Cookie/JWT/密码/API Key 等)
@@ -88,10 +88,10 @@
1. **完成判定必须看 exit code,禁止仅看屏幕末尾文字**:命令后立刻 `echo $?` 或 `if [ $? -ne 0 ]`;链式命令必须确认每一段都退出 0。
2. **修改既存文件前必须 Read 整文件**,禁止凭印象 Edit。
3. **新依赖必须同步进 `requirements.txt` / `package.json`**:任何新 import 出现必检查此包是否在依赖清单中。
3. **新依赖必须同步进 `go.mod` / `package.json`**:任何新 import 出现必检查此包是否在依赖清单中。
4. **测试/build 失败时禁止"再试一次"侥幸**:失败原因必须先找出来。
5. **声明完成前的最小验证清单**:
- [ ] 后端:`python -c "from app.main import app"` 无 import error
- [ ] 后端:`go build ./...` 无编译错误 且 `go test ./...` 通过
- [ ] 前端:`npx vite build` exit 0
- [ ] API smoke:`/api/health` 返回 200,管理员 login 返回 200
- [ ] 任何新 import 在依赖清单中
@@ -112,9 +112,9 @@
## G06 原则:AI 助手行为规范
1. **任务启动预读**:AI 助手在接收到新任务后的第一步操作中,必须读取本项目根目录下的 `CODING_RULES.md` 与 `eai_agentplatform_app/CLAUDE.md`。
1. **任务启动预读**:AI 助手在接收到新任务后的第一步操作中,必须读取本项目根目录下的 `CODING_RULES.md` 与 `eai_agentplatform/CLAUDE.md`。
2. **持续追踪**:直到问题完全解决并经由日志或接口验证通过前,不得结束任务。
3. **新对话启动仪式**:新对话必须先读 `eai_agentplatform_app/PROJECT_STATE.md` → `CODING_RULES.md`,再开始干活。
3. **新对话启动仪式**:新对话必须先读 `eai_agentplatform/PROJECT_STATE.md` → `CODING_RULES.md`,再开始干活。
4. **发现规则与实现冲突时**,优先提醒并修正,不得静默忽略。
5. **Git 同步状态必须用 `-sb` 显式确认**:首次执行 git 相关操作时,必须使用 `git status -sb` 或 `git status -v` 而非简短版本,确保能清晰看到本地与远端的 ahead/behind 关系。禁止依赖 `git diff HEAD origin/HEAD` 单条链式命令判断,因其 `&&` 链在中间出错时会导致后续输出丢失。
6. **Git 检查必须指定分支名**:使用 `origin/<branch>`(如 `origin/main`)而非 `origin/HEAD`,避免 `origin/HEAD` 歧义。
@@ -141,9 +141,9 @@
---
# 第二部分:eai_agentplatform_app 项目专用规则
### 第二部分:eai_agentplatform 项目专用规则
> **适用范围**:仅适用于博昇内部培训平台项目。
> **适用范围**:仅适用于博昇 AI 数字员工平台项目。
> **使用方式**:本部分在通用规则之上叠加。若两者看似冲突,应先检查是否为项目专用规则对通用规则的场景化收敛。
## 索引
@@ -156,16 +156,12 @@
---
## P01 最高原则:培训平台定位 — 知识库 + 考试 + 素材审批
## P01 最高原则:平台定位 — 通用数字员工平台(对齐 D20/D25/D26)
1. 博昇内部培训平台是**公司培训知识库 + 考试引擎 + 素材审批**系统,不是在线课程平台,不是 AI 对话机器人平台。
2. **四个核心业务模块**不可偏移:
- **公司介绍**:博昇介绍 + 资质荣誉 + 发展历程(静态内容)
- **产品知识**:四大产品线(资本咨询/资质辅导/AI咨询/AI工具)的知识体系
- **销售培训**:对应四大产品的销售课程(话术/流程/异议处理/避坑)
- **考试中心**:自测练习 + 正式考试 + 成绩记录
3. **知识库是后台能力**:素材审批 → 文本提取 → 知识切片 → AI PathCoach 检索,不独立为前台页面。
4. **AI PathCoach 是辅助工具**:嵌入全局右侧栏,提供产品知识问答 + 情景演练 + 佣金查询 + 产品对比,不是产品中心。
1. 本项目是 **EAI 通用数字员工平台(Agent Platform)**:以「数字员工/专家、技能、长程 App」为核心对象,Chat 优先界面承载新建任务/对话,知识库作为组织级内建 App 有独立入口(对齐 D20「先通用、后定制」、D25 六层架构第 5 层对象层、D26 路由三类命名)。不再被描述为「博昇内部培训平台 / AI 对话机器人平台」。
2. **首批四类通用数字员工**(对齐 D20):知识运营、培训考试、内容生成、任务推进;后续再沉淀行业包与企业半定制。(原有产品知识/销售培训/考试等业务能力收敛为内部 App 注入其中。)
3. **业务对象统一为一级对象**(对齐 D21/D25/D26):Expert(专家/专员)、Skill(技能)、App(长程任务壳)。任务定义为「工作实例容器」,可挂载主 app、右栏 expert、后台 skill;消息流降级为对话轨迹,长程状态由 app 侧状态承载。
4. **知识库是组织级内建 App**(对齐 D25):素材审批 → 文本提取 → 知识切片 → 混合检索,有独立一级入口,不再只是后台能力。
5. 开始写某个模块前,先明确对应文档:
- `docs/04_Backend/BE*.md`(后端详细设计)
- `docs/06_Product_Lines/PL*.md`(产品原型)
@@ -182,7 +178,7 @@
1. **只存元数据,不存文件二进制**:数据库仅存储文件路径、大小、类型等元数据。
2. **素材文本存 knowledge_chunk 表**:审批通过的文档类素材经异步转换提取文本,切片写入。
3. **FULLTEXT 全文检索**:AI PathCoach 知识检索基于 MySQL FULLTEXT 索引,不做向量检索(不上 Milvus/Elasticsearch)。
3. **混合检索**:AI 知识检索 = 向量(Go 内 brute-force 余弦,Ollama bge-m3 embedding)+ 关键词兜底;数据量小无需 ANN/向量库(不上 Milvus/FAISS/Elasticsearch)。
4. **图片不进知识库**:png/jpg/jpeg 仅做存储预览,不做 OCR 提取,不进 AI 文本检索。
5. **审批前置**:素材只有 `approved` 状态才会触发异步转换;`pending` / `rejected` 的素材不进知识库。
6. **视频仅存储**:mp4 文件上传后仅做存储和预览,不做视频分析、不做帧提取。
@@ -197,12 +193,12 @@
## P03 原则:考试判分与记录规则
1. **确定性判分**:判分逻辑采用确定性规则(单选/多选/判断),不涉及 AI 评分。
1. **确定性判分**:单选/多选/判断题型采用确定性规则判分;**简答题(essay)除外**——按 D17 走后端 LLM 评分(复用 title_gen 路由、essay_grade 能力,不扣点数)。所有判分都在后端。
2. **判分逻辑必须在后端**:前端仅做选项展示和提交,不得在前端判分。
3. **自测 vs 正式考**:
- **自测(self_test)**:提交后立即显示正确答案 + 解析,不持久化成绩
- **正式考(formal)**:提交后判分落 `exam_record` 表,不显示正确答案
4. **考试 session 内存存储**:`_exam_sessions: dict[str, dict]` — 启动考试时抽题存入内存,不落数据库。
4. **考试试卷落库**:试卷为 DB 持久化配置(`exam_paper` 表),按配置从题库抽题,成绩与记录保存到 `exam_record`,不使用进程内字典兜底。
5. **正式考不可重做**:同一用户对同一正式考卷仅可提交一次(后端校验)。
---
@@ -212,7 +208,7 @@
1. **管理员上传自动通过**:管理员(`role=admin`)上传的素材直接 `approved` + 立即触发异步转换。
2. **员工上传需审批**:员工上传后状态为 `pending`,管理员审批通过后才转 `approved`。
3. **驳回必须填写理由**:管理员驳回素材时,`reject_reason` 字段必填。
4. **异步转换管线**:审批通过 → 后台线程执行 `LibreOffice(ppt/docx→pdf) → PyMuPDF(提取文本) → 切片 → 写入 knowledge_chunk`。
4. **异步转换管线**:审批通过 → 后台线程执行 `LibreOffice(ppt/docx→pdf) → pdftotext(提取文本) → 切片 → 写入 knowledge_chunk`。
5. **前端轮询状态**:前端通过 `GET /api/media/{id}/status` 轮询素材/转换状态,不使用 WebSocket。
6. **分片上传**:> 100MB 的视频文件走分片上传(init → chunk → complete)。
-21
View File
@@ -1,21 +0,0 @@
# [OPEN] admin-auth-misjudge
## 症状
- 用户当前使用系统管理员账号,但界面中出现“需要管理员权限”提示。
- 预期行为:管理员访问管理员页面与管理员接口时,不应被后端返回 403。
## 当前范围
- 前端:登录态恢复、路由守卫、请求拦截器、管理员页面初始化请求。
- 后端:JWT 解析、中间件注入 `current_user`、管理员鉴权中间件。
## 待证伪假设
1. 前端页面初始化时,某个管理员接口在 `fetchMe()` 完成前提前发出,导致用旧 token 或空用户态触发 403。
2. 浏览器中实际持有的 token 与当前展示的管理员身份不一致,存在多标签页/旧 token 残留。
3. 后端 `Auth` 中间件能识别登录用户,但 `RequireAdmin` 前拿到的 `current_user` 为空或角色不是 `admin`。
4. 某个被认为是“管理员页面”的请求实际命中了错误接口或未走标准鉴权链,导致误报 403。
## 调试计划
- 给前端请求链与后端鉴权链加入最小化埋点。
- 重现一次管理员访问触发“需要管理员权限”的场景。
- 对照日志确认是 token、`/me`、路由守卫还是后端鉴权分叉。
- 基于证据做最小修复,再做前后对比验证。
-52
View File
@@ -1,52 +0,0 @@
# Debug Session: knowledge-chat-401
- **Status**: [OPEN]
- **Issue**: 04 知识库应用发送问题后,原先会看到 `LLM 返回 401: {"error":{"message":"No cookie auth credentials found","code":401}}`;修复目标是让问题路由正确,并且不要把底层模型鉴权报错直接暴露给前台。
- **Debug Server**: http://127.0.0.1:7777/event
- **Log File**: .dbg/trae-debug-log-knowledge-chat-401.ndjson
## Reproduction Steps
1. 启动前端、Go 后端与知识服务。
2. 打开 `04 知识库应用`。
3. 发送闲聊、FAQ、总结类问题,例如:
- `给我讲个小故事`
- `你好,你是怎么工作的`
- `请根据知识库总结规则制度库里关于审批的要点`
## Hypotheses & Verification
| ID | Hypothesis | Likelihood | Effort | Evidence |
|----|------------|------------|--------|----------|
| A | 后端把知识服务或模型服务地址调错了 | High | Low | Rejected |
| B | 当前运行进程不是最新版本 | Medium | Low | Rejected |
| C | LLM 兜底层命中了未配置 API Key 的 OpenRouter 路由,所以把 401 直接抛给了前台 | High | Low | Confirmed |
| D | 第一层分类器把闲聊 / 总结类问题分错了,所以问题走错层级 | High | Low | Confirmed |
## Instrumentation Plan
- A: `frontend/src/api/ai.js` 记录请求入口与非 200 响应
- B: `backend-go/internal/api/ai_chat.go` 记录聊天入口、四层计划、LLM 路由与错误
- C: `backend-go/internal/ai/llm.go` 记录真实下游 URL、状态码与错误体前缀
- D: `backend-go/internal/ai/knowledge_service_client.go` 记录知识服务调用状态
## Log Evidence
- 知识服务稳定命中本地 `http://127.0.0.1:10233/classify` 和 `/search`
- 分类修复后:
- `给我讲个小故事` -> `intent=smalltalk`、`layer=bert_classifier`
- `你好,你是怎么工作的` -> `intent=smalltalk`、`layer=bert_classifier`
- `请根据知识库总结规则制度库里关于审批的要点` -> `intent=document`、`layer=llm_fallback`
- 进入 `llm_fallback` 时,埋点显示:
- 目标地址:`https://openrouter.ai/api/v1/chat/completions`
- `hasAPIKey=false`
- `authHeader=false`
- 修复前下游直接返回 401 `No cookie auth credentials found`
- 修复后,同类问题不再把 401 暴露给前台,而是返回“当前没有可用 LLM 兜底服务”的业务化提示
## Verification Conclusion
- **pre-fix**
- 闲聊 / 总结类问题会被误路由
- 一旦进入 `llm_fallback`,前台直接看到 OpenRouter 401 / cookie 报错
- **post-fix**
- 第一层分类已改成规则优先,闲聊与总结类问题路由更符合预期
- `llm_fallback` 不再直抛 401,而是返回更友好的业务化提示
- **Remaining Blocker**
- 当前剩余不是代码 Bug,而是运行环境缺口:
- OpenRouter 未配置 API Key
- 本地 `Ollama` 未启动(`127.0.0.1:11434` refused)
+4 -2
View File
@@ -8,9 +8,11 @@
| 文件 | 说明 |
|------|------|
| `README.md` | 本索引文件 |
| `forai01_engineering_progress.md` | 工程进度摘要 |
| `forai02_2026-08-16_导航品牌与系统配置调整交接.md` | 本轮前端导航、品牌与系统配置拆分交接 |
## 使用规范
- `foraiNN_*.md` — 按需创建的特定上下文 / 决策记录
## 当前状态
早期 AI 交接稿已清理;如果后续确实需要新的 AI 协作上下文,再按当前架构重新创建。
@@ -1,35 +0,0 @@
# forai01 — 工程进度摘要
> **版本:V1.2 | 最后更新:2026-08-16**
---
## 整体进度
| 阶段 | 状态 | 说明 |
|------|------|------|
| **V1 培训平台(学习/考试/AI 答疑)** | ✅ 已交付 | 见 `docs/changelog.md` V1.0–V1.7 |
| **后端重写 Go + Gin + GORM** | ✅ 完成 | V1.2,替换原 FastAPI/Python |
| **数据库 MySQL 8.0 + FAISS** | ✅ 进行中 | 关系数据 MySQL + 语义检索 FAISS |
| **V1.4–V1.7 能力扩展** | ✅ 已落地 | 岗位映射 / 错题本 / 积分证书 / 部门消息 |
| **数字员工平台战略** | 📋 规划中 | 见 SY03–SY17,对外「专员」为能力单元 |
## 当前主线
1. **已完成**:V1 培训平台闭环(资料入库 → AI 答疑 → 考试验收),Go 后端,MySQL + FAISS 检索。
2. **进行中**:从「内部培训系统」升级为「数字员工平台」——知识底座统一主实体化、专员矩阵(通用数字员工 + 行业专属数字员工)、客户半定制专员工坊。
3. **目标态**:以 `01_System_Overall/SY03` 为战略主轴,SY04–SY17 为配套设计。
## 详细历史
- 具体版本变更(V1.0–V1.7)以 `docs/changelog.md` 为准。
- 文档定位与技术栈冲突已在本轮(2026-08-16)对齐,见 `docs/PRD.md`、`SY01`、`SY02`、`db_schema.md`、`deploy.md`。
## 下一个关键决策
- 按 SY03「推荐的最小落地路线」落地 MVP 第一阶段:4 个标准智能体(知识顾问 / 陪练教官 / 合规审查官 / 认证考官)+ 1 个场景陪练模板。
- 一级导航逐步演进为:知识库 / 智能体 / 学习与考试 / 陪练与认证 / 内容运营 / 组织与系统。
## 当前问题
- 无阻塞项。文档层已消除定位与技术栈的较大冲突。
@@ -1,102 +0,0 @@
# forai02 — 导航品牌与系统配置调整交接
> **日期:2026-08-16**
> **对话名:导航品牌与系统配置调整交接**
---
## 本次工作范围
本轮主要处理前端界面中的品牌展示、左侧导航结构、顶部页签未读角标,以及管理员端系统配置入口拆分。整体遵循“最小改动”原则,未改后端接口和业务逻辑。
## 已完成事项
### 1. 品牌展示调整
- 侧边栏品牌区已改为三行:
- `博昇AI研究院`
- `内部培训平台`
- `EAI-Training`
- 登录页品牌展示已同步为三行
- 首页欢迎文案和证书标题中的品牌文案已同步更新
- 侧边栏品牌区图标和文字整体偏移已调整为左移 `6px`
### 2. 未读消息角标位置调整
- 未读消息数字不再挂在左侧 `1 工作台`
- 未读角标已改为显示在顶部二级页签 `1.2 消息通知`
- 这样可以避免用户把未读数误解为工作台本身的数字
### 3. 一级导航结构调整
- 原 `6 组织与系统` 已拆分为两个一级菜单:
- `6 组织管理`
- `7 系统设置`
- 当前导航结构如下:
- `6 组织管理`
- `6.1 岗位管理`
- `6.2 部门管理`
- `6.3 用户管理`
- `6.4 公司信息配置`
- `7 系统设置`
- `7.1 AI 用量`
- `7.2 参数配置`
### 4. 系统配置页面拆分
- 原 `7.2.1 公司信息配置` 已从 `7.2 参数配置` 中迁出
- 已新增独立页面 `6.4 公司信息配置`
- 原 `7.2 参数配置` 内部页签已顺延为:
- `7.2.1 文件与存储`
- `7.2.2 其他信息配置`
- `7.2.3 AI 配置`
## 关键文件
### 品牌与布局
- `eai_agentplatform_app/frontend/src/layout/SideNav.vue`
- `eai_agentplatform_app/frontend/src/layout/SectionTabs.vue`
- `eai_agentplatform_app/frontend/src/views/Login.vue`
- `eai_agentplatform_app/frontend/src/views/home/HomePage.vue`
- `eai_agentplatform_app/frontend/src/views/exam/MyCertificates.vue`
### 导航与路由
- `eai_agentplatform_app/frontend/src/config/navigation.js`
- `eai_agentplatform_app/frontend/src/router/index.js`
### 系统配置页面
- `eai_agentplatform_app/frontend/src/views/system/CompanyConfigPage.vue`
- `eai_agentplatform_app/frontend/src/views/system/SystemConfigPage.vue`
- `eai_agentplatform_app/frontend/src/views/system/AiUsage.vue`
## 当前状态
- 上述改动均已完成前端诊断检查
- 当前未发现新增诊断错误
- `CompanyConfigPage.vue` 复用了原有系统配置接口:
- `getConfig`
- `updateConfig`
- 当前没有改动后端路由、接口协议和数据库结构
## 建议下一步
- 将 `AI 用量` 页面标题补齐为 `7.1 AI 用量`,与 `7.2 参数配置` 的编号风格统一
- 做一次浏览器人工验收,重点检查:
- 品牌区三行文案与偏移量
- `消息通知` 页签角标显示
- `6 组织管理 / 7 系统设置` 的一级切换
- `6.4 公司信息配置` 的保存功能
- `7.2 参数配置` 页签顺序是否符合预期
## 新对话接手提示
如果需要在新对话里继续,可以直接说明:
1. 前端品牌区已调整为三行品牌展示,侧边栏整体左移 `6px`
2. 消息未读角标已从左侧 `1 工作台` 挪到顶部 `1.2 消息通知`
3. 管理员导航已拆成 `6 组织管理` 和 `7 系统设置`
4. `7.2.1 公司信息配置` 已迁移为独立页面 `6.4 公司信息配置`
5. 请基于当前状态继续,不要回退已有改动
@@ -1,6 +1,6 @@
# SY01 — 系统概述
> **版本:V1.2 | 最后更新:2026-08-16**
> **版本:V2.0 | 最后更新:2026-09-17**
---
@@ -12,7 +12,7 @@
## 1. 项目定位
**eai_agentplatform_app**(博昇内部培训平台)是一个纯内网本地化部署的内部专属员工培训系统,并以此为基础演进为知识库中心化的智能体平台。
**eai_agentplatform**(博昇 AI 数字员工平台)是一个纯内网本地化部署的内部专属员工培训系统,技术栈 **Go + Gin + GORM + SQLite**,并以此为基础演进为知识库中心化的智能体平台。
**核心价值(V1 已交付):**
- 新人快速了解公司文化和业务
@@ -36,7 +36,7 @@
### 不包含 / 已放开(V1.1 约束,随平台升级调整)
- ~~学习进度仪表盘、学情分析、能力档案~~ → V1.6 起已实现(积分/排行榜/证书/学习档案/能力雷达)
- ~~向量检索(不引入 embedding 依赖)~~ → 已采用 MySQL + FAISS 混合检索
- ~~向量检索(不引入 embedding 依赖)~~ → 已采用 Go 原生 brute-force 向量检索(余弦)+ 关键词兜底
- 复杂权限、多级角色、多租户洋葱模型 → 仍保持单租户极简,暂不引入
- 视频转码、语音转写(ASR)→ 仍不引入
- 外网依赖、云存储(MinIO/OSS)→ 仍保持本地化
@@ -11,7 +11,7 @@
- 不上云、无 OSS、无 MinIO、不依赖任何公网云服务
- AI 所用 LLM 必须为内网可达地址(Ollama / vLLM / 内网统一 LLM 网关)
- 严禁直连公网 AI API
- 数据库使用 MySQL 8.0 本地实例 + FAISS 向量检索
- 数据库使用 SQLite 单文件(本地实例)+ Go 原生 brute-force 向量检索(余弦相似度)+ 关键词兜底
- 文件存储使用本地磁盘(backend/data/media → 现为 `data/kb_data`)
## 原则 2:极致极简(V1 约束,已随平台升级部分放开)
@@ -20,7 +20,7 @@
- **V1.1 曾明确不做、现已放开的:** 学情分析、能力档案、学习进度仪表盘(V1.6 起已实现积分/排行榜/证书/学习档案/能力雷达);岗位与部门概念(V1.4 / V1.7 已引入)。复杂权限与多级角色仍保持单租户极简,暂不引入多租户洋葱模型
- 保持前端轻量:Vue3 + Element Plus,不用状态管理库(如 Pinia 按需判断)
- 保持后端简洁:Go + Gin + GORM 单二进制,不加消息队列(异步任务用简单线程池)
- AI 知识检索:MySQL 全文索引(关键词)+ FAISS 向量检索(语义)双路混合召回,见 `docs/db_schema.md`
- AI 知识检索:Go 层 brute-force 向量检索(余弦)+ 关键词兜底(不依赖外部向量库)
## 原则 3:审批前置
@@ -2,6 +2,16 @@
> **版本:V1.0 | 最后更新:2026-08-16**
> 2026-09-16 架构同步说明:
> 本文档保留“知识库中心化平台战略”视角,但当前正式口径已与 `SY21_Unified_Role_Skill_Action_Architecture.md`、`SY22_Role_Skill_App_Unified_Task_Architecture.md` 对齐。
> 后续统一采用:
> - 对外:`专家 / 技能 / 长程APP / 知识库 / 连接器目录`
> - 对内:`expert / skill / app / action / connector / policy`
> - `tool`、`role`、`智能体` 在本文中仅作为历史推演语境保留,不再作为当前主命名
> - 专家卡 / 角色卡属于专家对象属性,不是独立架构层
> - 当前一级导航目标态:`新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
> - 平台执行现场统一收敛到 `/home` Workbench
---
## 1. 文档目的
@@ -687,22 +697,29 @@
## 16. 导航与产品信息架构建议
如果平台完成知识底座与智能体化升级,一级导航建议逐步演进为:
如果平台完成知识底座与对象化升级,当前统一后的一级导航目标态应为:
- 新建任务
- 项目
- 专员·技能·APP·连接器
- 长程APP
- 知识库
- 智能体
- 学习与考试
- 陪练与认证
- 内容运营
- 组织与系统
- 后台管理
- 我的
其中“智能体”下建议至少包含:
其中需要特别说明:
- 标准智能体
- 我的智能体
- 智能体工坊
- 模板中心
- 发布记录
- `专员·技能·APP·连接器` 是对象目录入口,用于查看和配置对象定义
- `长程APP` 是可运行 `app` 的任务化入口,培训、考试、审批、分析等强结构场景应优先收敛于此
- `知识库` 是组织级默认内建 App,同时承担全平台的知识底座能力
本文此前讨论的“智能体中心 / 学习与考试 / 陪练与认证 / 内容运营”等栏目,
在当前口径中应理解为早期业务分区推演,后续都应优先映射到:
- 专家对象目录
- 长程APP
- 知识库
- 后台管理
## 17. 对当前项目的最终判断
@@ -2,6 +2,13 @@
> 版本:V1.2 | 最后更新:2026-08-16
> 2026-09-16 口径同步说明:
> 本文档属于早期工作台线框推演稿,保留“知识库固定 + 业务应用动态生长”的核心判断。
> 当前正式产品命名与一级导航请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准:
> - `新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
> - 文中出现的“专员工作区 / 我的应用 / 市场 / 工坊 / 控制台”,应理解为当前 `长程APP / 对象目录 / 后台管理` 的早期表达
> - 文中“专员”语义统一按当前 `专家(Expert)` 理解
---
## 0. 文档目的
@@ -477,7 +477,7 @@
"summary": "跨服务器巡视、异常发现与风险上报",
"stage": "日常巡检",
"marketTag": "已安装",
"route": "/apps/server-patrol",
"object_entry_route": "/apps/server-patrol",
"color": "#409eff",
"workbench": {
"showInputsPanel": true,
@@ -848,7 +848,7 @@
"summary": "跨服务器巡视、异常发现与风险上报",
"stage": "日常巡检",
"marketTag": "已安装",
"route": "/apps/server-patrol",
"object_entry_route": "/apps/server-patrol",
"color": "#409eff"
}
}
@@ -874,7 +874,7 @@
- `capabilities.skills`
- `capabilities.actions`
- `governance.permissions`
- `presentation.route`
- `presentation.object_entry_route`
### 11.2 ADW 额外规则
@@ -926,7 +926,7 @@
| `base_skills` | `capabilities.skills` |
| `ai_assistance` | `governance.autonomy` + `runtime.contextAssembly` 的简化表现 |
| `generated_skills` | `memory.skillMemory` + `capabilities.skills` 的派生能力 |
| `route / summary / stage / market_tag / color` | `presentation` |
| `object_entry_route / summary / stage / market_tag / color` | `presentation` |
因此当前后端 `Specialist` 模型可以看作:
+143 -127
View File
@@ -1,9 +1,9 @@
# AR01 — 后端架构设计
> **版本:V1.1 | 框架:FastAPI + SQLAlchemy + MySQL 8.0**
> **参考:pj006-zhilianyuan2 的 BE01_backend_arch + main.py 装配模式**
> **版本:V2.0 | 框架:Go + Gin + GORM + SQLite(modernc)**
> **参考:eai_agentplatform/backend-go/internal/api/ + internal/model/ + internal/store/**
>
> **⚠️ 本文档为 V1.1 设计期历史快照,不再反映当前实现。** 后端已重写为 **Go + Gin + GORM + MySQL 8.0 + FAISS**,以 `docs/changelog.md`(V1.2)、`docs/db_schema.md`、`docs/deploy.md` 为准;下文 FastAPI/Python 结构与 `backend/` 路径仅作设计参考。
> **当前实现**:Go 单二进制 + systemd + Clonezilla 整盘克隆,`backend-go/internal/` 为标准源码布局。
---
@@ -11,163 +11,179 @@
```
┌─────────────────────────────────────────────┐
│ API 路由层 (routes) │
│ API 路由层 (api/) │
│ auth / company_train / product / course │
│ exam / media / ai_chat / system │
├─────────────────────────────────────────────┤
│ Pydantic 模型层 (schemas) │
│ 请求/响应模型,统一响应格式 Envelope │
│ Gin Context + 请求/响应结构体 │
│ web.OK / web.Fail 统一响应 │
├─────────────────────────────────────────────┤
│ 服务层 (services) │
│ exam_service / media_service / ai_service │
│ 业务逻辑 (api/ 内函数) │
│ exam.go / media.go / ai_chat.go │
├─────────────────────────────────────────────┤
│ SQLAlchemy ORM 模型层 (models) │
│ GORM 模型层 (model/) │
│ User / Product / Course / MediaFile / ... │
├─────────────────────────────────────────────┤
│ 核心层 (core) │
│ config / security / deps │
│ 数据库层 (store/) │
│ db.go: SQLite Init + AutoMigrate │
├─────────────────────────────────────────────┤
│ MySQL 8.0 + data/media │
│ SQLite + data/media │
└─────────────────────────────────────────────┘
```
## 2. 目录结构
```
backend/
├── app/
│ ├── main.py # FastAPI 应用装配 + CORS + 异常处理
│ ├── api/ # 路由层
│ │ ├── auth.py # /api/auth/* — 登录/注册/me
│ │ ├── company_train.py # /api/company-train/*
│ │ ├── product.py # /api/products/*
│ │ ├── sales_train.py # /api/courses/*
│ │ ├── exam.py # /api/exam/* — 题库/组卷/考试/记录
│ │ ├── media.py # /api/media/* — 上传/预览/审批
│ │ ├── ai_chat.py # /api/ai-chat/* — PathCoach SSE
│ │ └── system.py # /api/system/* — 用户/成绩/配置
│ ├── models/ # SQLAlchemy ORM 模型
│ │ ├── user.py
│ │ ├── product.py
│ │ ├── course.py
│ │ ├── media_file.py
│ │ ├── knowledge_chunk.py
│ │ ├── question.py
│ │ ├── exam_paper.py
│ │ └── exam_record.py
│ ├── schemas/ # Pydantic 请求/响应模型
│ ├── services/ # 业务逻辑层
│ │ ├── media_service.py # 上传/转换/提取
│ │ ├── ai_service.py # LLM 调用 + 知识检索
│ │ └── exam_service.py # 题库/组卷/判分/记录
│ ├── core/ # 核心基础设施
│ │ ├── config.py # .env + 系统参数读取
│ │ ├── security.py # JWT 签发/校验 + bcrypt
│ │ └── deps.py # FastAPI Depends(get_db / get_current_user)
│ └── utils/ # 工具函数
├── data/media/ # 文件存储(git忽略)
│ ├── upload/
│ └── _preview_cache/
├── requirements.txt
└── .env
backend-go/
├── cmd/
│ └── server.go # 入口:gin.Engine + systemd 裸进程
├── internal/
│ ├── api/ # 路由层(Gin handlers)
│ │ ├── auth.go # /api/auth/* — 登录/注册/me
│ │ ├── company_train.go # /api/company-train/*
│ │ ├── product.go # /api/products/*
│ │ ├── exam.go # /api/exam/* — 题库/组卷/考试/记录
│ │ ├── media.go # /api/media/* — 上传/预览/审批
│ │ ├── ai_chat.go # /api/ai-chat/* — PathCoach SSE
│ │ ├── system.go # /api/system/* — 用户/成绩/配置
│ │ ├── router.go # RegisterRoutes 总路由
│ │ └── (其余为功能模块)
│ ├── model/ # GORM 模型
│ │ ├── user.go
│ │ ├── product.go
│ │ ├── course.go
│ │ ├── media_file.go
│ │ ├── knowledge_chunk.go
│ │ ├── question.go
│ │ ├── exam_paper.go
│ │ └── exam_record.go
│ ├── store/ # 数据库层
│ │ └── db.go # SQLite Init + AutoMigrate
│ ├── ai/ # AI 客户端与检索
│ │ ├── llm.go # OpenAI 兼容 LLM 调用
│ │ └── retrieve.go # 知识检索
│ ├── config/ # 配置加载
│ └── middleware/ # 中间件(JWT 认证等)
├── knowledge_source/ # 知识源 Markdown(运行时)
├── data/ # 运行时数据(db+media,git 忽略)
└── deploy/ # systemd 单元 + DELIVERY.md
```
## 3. 应用装配模式(main.py)
## 3. 应用装配模式(RegisterRoutes)
参考 zhilianyuan2 的模式,每个模块的 router 独立注册:
入口 `cmd/server.go` 创建 `gin.Engine`,调用 `api.RegisterRoutes` 注册全部路由:
```python
from fastapi import FastAPI
from app.api import auth, company_train, product, sales_train
from app.api import exam, media, ai_chat, system
```go
// backend-go/cmd/server.go
package main
app = FastAPI(title="eai_agentplatform_app", version="1.1.0")
import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/api"
"eai_agentplatform/backend/internal/config"
)
# 异常处理器
@app.exception_handler(AppError)
def handle_app_error(request, exc):
return JSONResponse(status_code=exc.status_code, content={...})
func main() {
cfg := config.Load()
r := gin.Default()
# 路由注册
app.include_router(auth.router)
app.include_router(company_train.router)
app.include_router(product.router)
app.include_router(sales_train.router)
app.include_router(exam.router)
app.include_router(media.router)
app.include_router(ai_chat.router)
app.include_router(system.router)
// 公开静态文件(已审批素材)
r.Static("/media", cfg.KBDataDir+"/approved")
// 注册全部路由
api.RegisterRoutes(r, cfg)
r.Run(":8080")
}
```
## 4. 依赖注入模式
路由注册(`api/router.go`):
参考 zhilianyuan2 的 `auth/dependencies.py`:
```go
// api/router.go
func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
// 健康检查
r.GET("/api/health", HealthCheck)
```python
# core/deps.py
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(HTTPBearer(auto_error=False)),
db: Session = Depends(get_db),
) -> User:
"""解析 JWT → 校验用户状态 → 返回 User"""
if credentials is None:
raise AuthError("缺少 Authorization Bearer 令牌")
payload = decode_access_token(credentials.credentials, settings)
user = db.query(User).filter(User.username == payload["sub"]).first()
if user is None or user.status != "active":
raise AuthError("用户不存在或已禁用")
return user
// 公开 + 员工
r.GET("/api/company-train", middleware.Auth(cfg), GetCompanyTrain)
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
def require_admin(user: User = Depends(get_current_user)) -> User:
"""管理员角色守卫"""
if user.role != "admin":
raise ForbiddenError("需要管理员权限")
return user
// 员工端考试
r.GET("/api/exam/list", middleware.Auth(cfg), ExamList)
r.GET("/api/exam/cover", middleware.Auth(cfg), ExamCover)
r.POST("/api/exam/start", middleware.Auth(cfg), ExamStart)
r.POST("/api/exam/submit", middleware.Auth(cfg), ExamSubmit)
// 管理员
admin := r.Group("/api").Use(middleware.Auth(cfg), middleware.RequireAdmin())
{
admin.POST("/products", CreateProduct)
admin.POST("/courses", CreateCourse)
admin.POST("/media/audit/:mediaId", AuditMedia)
admin.POST("/knowledge/scan", KnowledgeScan)
admin.GET("/system/users", ListUsers)
admin.GET("/system/config", GetConfig)
admin.GET("/system/exam-records", ListExamRecords)
}
// AI 对话
r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage)
r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions)
}
```
## 4. 中间件模式
```go
// internal/middleware/auth.go
func Auth(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := auth.ParseToken(
c.GetHeader("Authorization"), cfg.JWTSecret,
)
if err != nil {
c.JSON(401, gin.H{"error": "unauthorized"})
c.Abort()
return
}
c.Set("user", claims)
c.Next()
}
}
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user := c.MustGet("user").(jwt.MapClaims)
if role, ok := user["role"].(string); !ok || role != "admin" {
c.JSON(403, gin.H{"error": "forbidden"})
c.Abort()
return
}
c.Next()
}
}
```
## 5. API 路由前缀
| 路由前缀 | 模块 | 说明 |
|---------|------|------|
| `/api/auth/*` | auth | 登录/注册/当前用户 |
| `/api/company-train/*` | company_train | 公司介绍内容 |
| `/api/products/*` | product | 产品 CRUD + 导入 |
| `/api/courses/*` | sales_train | 课程 CRUD + 绑定产品 |
| `/api/exam/*` | exam | 题库/组卷/考试/记录 |
| `/api/media/*` | media | 上传/预览/审批/状态 |
| `/api/ai-chat/*` | ai_chat | PathCoach 流式对话 |
| `/api/system/*` | system | 用户/成绩/配置 |
| `/api/auth/*` | auth.go | 登录/注册/当前用户 |
| `/api/company-train/*` | company_train.go | 公司介绍内容 |
| `/api/products/*` | product.go | 产品 CRUD + 导入 |
| `/api/courses/*` | courses.go | 课程 CRUD + 绑定产品 |
| `/api/exam/*` | exam.go | 题库/组卷/考试/记录 |
| `/api/media/*` | media.go | 上传/预览/审批/状态 |
| `/api/ai-chat/*` | ai_chat.go | PathCoach 流式对话 |
| `/api/system/*` | system.go | 用户/成绩/配置 |
| `/api/health` | — | 健康检查 |
## 6. 异步任务模式
## 6. 单二进制交付
文档转换管线(审批通过后异步执行):
```python
# services/media_service.py
import threading
def _async_convert_and_extract(media_file_id: int):
"""审批通过后异步执行:文档转 PDF → 文本提取 → 切片入库"""
with Session() as db:
media = db.query(MediaFile).get(media_file_id)
# 1. 调用 LibreOffice 转 PDF
pdf_path = libreoffice_convert(media.stored_path)
# 2. PyMuPDF 提取文本
text = pymupdf_extract(pdf_path)
# 3. 按段落切片写入 knowledge_chunk
chunks = split_into_chunks(text)
for i, chunk in enumerate(chunks):
db.add(KnowledgeChunk(media_file_id=media.id, ...))
media.extracted = True
db.commit()
def approve_media(media_file_id: int, auditor_id: int):
"""审批通过 → 触发异步转换"""
media.status = "approved"
media.audit_by = auditor_id
media.audit_at = datetime.utcnow()
db.commit()
# 启动异步任务
threading.Thread(target=_async_convert_and_extract, args=(media_file_id,)).start()
```bash
CGO_ENABLED=0 go build -o bin/eai_agentplatform-server ./cmd/server
# 输出:statically linked,无运行时依赖
```
部署:`systemd` 直接拉起单二进制,`data/` 目录下 SQLite 数据库自动建表。
@@ -2,6 +2,13 @@
> **版本:V1.1 | 左导航 + 中间工作区 + 右 AI 侧栏 | Vue3 + Vite + Element Plus**
> **参考:pj006-zhilianyuan2 frontend-orgadmin 三栏布局模式**
>
> **2026-09-16 口径同步说明:**
> 本文档记录的是 V1 三栏前端架构基线。
> 当前正式产品导航与对象化工作台口径,请以 `docs/01_System_Overall/SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准:
> `新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
> 另外,当前前端命名已明确拆分为:
> `pageRoute` = 普通页面导航,`objectEntryRoute` = 业务对象入口,`ai_route_*` = AI 模型路由。
---
+7 -17
View File
@@ -1,9 +1,7 @@
# AR03 — 数据库架构设计
> **版本:V1.1 | 引擎:MySQL 8.0 | ORM:SQLAlchemy(现为 GORM)**
> **版本:V2.0 | 引擎:SQLite(modernc 纯Go驱动)**
> **完整建表 SQL 请见 docs/db_schema.md**
>
> **⚠️ 本文档为 V1.1 设计期历史快照。** 当前实现已切换为 **MySQL 8.0 + FAISS 向量检索**(ORM 由 SQLAlchemy 改为 GORM),并新增岗位/积分/证书/部门/消息等表,以 `docs/db_schema.md` 与 `docs/changelog.md`(V1.4–V1.7)为准。
---
@@ -41,7 +39,6 @@ exam_paper ──< exam_record (paper_id)
| `course` | `category`, `status`, `related_product_id` | BTREE | 同上 |
| `media_file` | `status`, `submitter_id`, `(bind_type, bind_id)`, `extracted` | BTREE | 审批列表、绑定查询、提取状态 |
| `knowledge_chunk` | `media_file_id` | BTREE | 关联查询 |
| `knowledge_chunk` | `content` | **FULLTEXT** | AI 知识检索(MySQL 全文索引) |
| `question` | `domain`, `course_id`, `status` | BTREE | 知识域筛选、课程筛选 |
| `exam_paper` | `type`, `status` | BTREE | 考试类型筛选 |
| `exam_record` | `user_id`, `paper_id`, `passed`, `submitted_at` | BTREE | 用户查记录、管理员查全部 |
@@ -49,30 +46,23 @@ exam_paper ──< exam_record (paper_id)
## 4. AI 知识检索说明
V1.1 使用 **MySQL 全文索引**(FULLTEXT);现已升级为 **MySQL 全文索引(关键词)+ FAISS 向量检索(语义)双路混合召回**:
Go 内 brute-force 余弦向量检索(Ollama bge-m3 embedding)+ 关键词兜底:
```sql
-- knowledge_chunk 表已建全文索引(关键词召回)
FULLTEXT INDEX ft_kc_content (content)
-- 检索查询
SELECT * FROM knowledge_chunk
WHERE MATCH(content) AGAINST(:keywords IN NATURAL LANGUAGE MODE)
LIMIT 10
-- SQLite 原生,无全文索引,向量检索在 Go 层实现
-- knowledge_chunk.content 字段存储文本,向量计算由 Go net/http 调用 Ollama
```
**检索流程(当前):**
1. 用户提问 → 关键词 + embedding 向量
2. MySQL FULLTEXT 关键词召回 + FAISS 语义向量召回,双路融合排序
1. 用户提问 → embedding 向量(bge-m3)
2. Go 遍历 knowledge_chunk.content 计算余弦距离(数据量小无需 ANN)
3. 匹配段落作为上下文注入 LLM Prompt
4. LLM 基于上下文生成回答
> **演进说明:** V1.1 曾为避免 embedding 依赖而仅用全文索引;平台升级后引入 FAISS 补足语义召回,见 `docs/db_schema.md`。
## 5. 文件存储策略
- **数据库只存元数据**,不存文件二进制
- 物理文件存储在 `backend/data/media/`
- 物理文件存储在 `data/media/`
- 目录结构:
```
data/media/
+55 -61
View File
@@ -1,9 +1,9 @@
# AR04 — 部署架构设计
> **版本:V1.1 | 部署模式:纯本地离线**
> **完整部署步骤请见 docs/deploy.md**
>
> **⚠️ 本文档为 V1.1 设计期历史快照(Docker + FastAPI 拓扑),不再反映当前实现。** 当前部署为 **Go 单二进制 + MySQL 8.0 + FAISS + systemd + Clonezilla 整盘克隆**,无 Docker、无 Python 运行时,以 `docs/deploy.md` 为准。
> **版本:V1.3 | 部署模式:纯本地离线 · Go 单二进制 + SQLite · systemd**
> **当前实现**:**Go 单二进制 + SQLite(eai_agentplatform.db) + 内网 Ollama(LLM/embedding) + systemd**,无 Docker、无 MySQL、无 FAISS、无 Python 运行时。
> **知识链路**:检索为 Go 原生实施(embed_gen 路由嵌入候选块 + 余弦召回,对齐 D07/D13);分类/入库已彻底迁移到 Go,Python 的 `knowledge_service` 已删除。
> **完整部署步骤请见 docs/deploy.md。**
---
@@ -27,19 +27,19 @@
┌──────────────┘ └──────────────┐
│ │
┌─────▼──────┐ ┌────────▼────────┐
│ FastAPI │ │ Vue 静态打包 │
│ :8000 │ │ nginx html/ │
│ │ └─────────────────┘
│ · JWT │
│ · 业务 │
│ eai_agentplatform-server │ Vue 静态打包 │
│ :8080 │ │ nginx html/ │
│ 单二进制 │ └─────────────────┘
│ · Gin + │
│ · SQLite │
└──┬──┬──┬──┘
│ │ │
┌───────────┘ │ └──────────────┐
│ │ │
┌──▼─────┐ ┌────▼───────┐ ┌──────▼──────────┐
│ MySQL │ │ data/media │ │ LibreOffice │
│ 8.0 │ │ 文件存储 │ │ 预览容器 │
│ :3306 │ │ │ │ :8100 │
│ SQLite │ │ data/kb_data│ │ LibreOffice │
│ 单文件 │ │ 文件存储 │ │ + pdftotext │
│ eai_platform.db │ │ │ (裸进程) │
└────────┘ └────────────┘ └─────────────────┘
│
│ (文档转换)
@@ -54,13 +54,15 @@
## 2. 服务清单
| 服务 | 端口 | 基础镜像/依赖 | 说明 |
|------|------|-------------|------|
| Nginx | 80/443 | nginx:alpine | HTTP 反代 + 前端静态资源 |
| FastAPI | 8000 | python:3.10 | 后端 API(uvicorn 启动) |
| MySQL | 3306 | mysql:8.0 | 数据库 |
| LibreOffice | 8100 | 自定义 Docker 镜像 | 文档转 PDF 预览 |
| LLM 服务 | 11434 | ollama/vllm | 内网 AI 推理 |
| 服务 | 端口/路径 | 运行方式 | 说明 |
|------|-----------|---------|------|
| Nginx | 80/443 | 系统包 | HTTP 反代 + 前端静态资源托管 |
| eai_agentplatform-server | 8080 | Go 单二进制(CGO_ENABLED=0,静态链接) | Gin + GORM 后端 API(内嵌 SQLite) |
| SQLite | data/eai_agentplatform.db | 内嵌 | 单文件关系数据(glebarez/sqlite 纯 Go 驱动,无需 CGO) |
| LibreOffice + pdftotext | 裸进程 | exec 调用 | 文档转 PDF/文本预览(非容器,不监听端口) |
| LLM 服务 | 11434 | 外置 | 内网 OpenAI 兼容接口(Ollama / vLLM / 网关) |
**核心特征**:无 Docker、无 Python 运行时、无 MySQL、无 FAISS。后端为 Go 单二进制,数据为 SQLite 单文件;知识检索为 Go 原生向量/关键词召回(align D07/D13)。
## 3. Nginx 关键配置
@@ -72,60 +74,51 @@ location / {
# API 反代 + SSE
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 媒体文件预览(仅内部)
location /media/ {
alias /opt/eai_agentplatform/backend/data/media/;
internal;
# 媒体文件预览(Go 二进制直出)
location /api/media/ {
proxy_pass http://127.0.0.1:8080;
}
client_max_body_size 2048M;
```
## 4. docker-compose 服务拓扑
## 4. systemd 管理
```yaml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: eai_agentplatform
MYSQL_USER: eai_agentplatform
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
```ini
[Unit]
Description=eai_agentplatform Server
After=network.target
backend:
build: ./backend
environment:
DATABASE_URL: mysql+pymysql://eai_agentplatform:${DB_PASSWORD}@mysql:3306/eai_agentplatform
JWT_SECRET: ${JWT_SECRET}
LLM_BASE_URL: http://llm-server:11434/v1
volumes:
- ./data/media:/app/data/media
depends_on:
- mysql
[Service]
Type=simple
User=eai_agentplatform
Group=eai_agentplatform
WorkingDirectory=/opt/eai_agentplatform
ExecStart=/opt/eai_agentplatform/eai_agentplatform-server
Restart=on-failure
RestartSec=5
EnvironmentFile=/opt/eai_agentplatform/.env
libreoffice:
image: libreoffice-preview:latest
volumes:
- ./data/media:/data/media
# 安全加固
ExecStartPre=/opt/eai_agentplatform/eai_agentplatform-server migrate
ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
PrivateTmp=true
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- backend
[Install]
WantedBy=multi-user.target
```
## 5. 安全边界
@@ -135,6 +128,7 @@ services:
| 网络 | 仅监听内网,不暴露公网端口 |
| 认证 | JWT token 校验 + bcrypt 密码 |
| 鉴权 | 后端 API role 校验(非前端) |
| 文件 | 白名单扩展名 + UUID 命名 + 只读预览 |
| 数据库 | 独立用户 + 最小权限 |
| 文件 | 白名单扩展名 + UUID 命名 + proxy_pass 内部预览 |
| 数据库 | SQLite 单文件(系统级文件权限,systemd ProtectSystem=strict) |
| 备份 | 内建定期 VACUUM INTO,默认 24h 间隔,保留 7 份 |
| LLM | 仅内网地址,严禁公网 API |
+52 -58
View File
@@ -1,6 +1,7 @@
# BE02 — 考试模块设计
> **版本:V1.1 | 参考:pj006-zhilianyuan2 exam/routes.py + exam/service.py**
> **版本:V2.0 | 框架:Go + Gin + GORM + SQLite**
> **参考:`backend-go/internal/api/exam.go`**
---
@@ -33,72 +34,65 @@ question(题库)← exam_paper(考试配置,通过 domain+question_count
## 4. 题库 API
```python
# 管理员 — 题目 CRUD
GET /api/exam/questions?domain=company&status=active # 题目列表
POST /api/exam/questions # 新增题目
PUT /api/exam/questions/{id} # 编辑题目
DELETE /api/exam/questions/{id} # 停用题目
```go
// backend-go/internal/api/exam.go
# 管理员 — 考试配置
GET /api/exam/papers # 考试配置列表
POST /api/exam/papers # 创建考试
PUT /api/exam/papers/{id} # 编辑考试
DELETE /api/exam/papers/{id} # 停用考试
// GET /api/exam/questions?domain=company&status=active # 题目列表
func ListQuestions(c *gin.Context)
// POST /api/exam/questions # 新增题目
func CreateQuestion(c *gin.Context)
// PUT /api/exam/questions/{id} # 编辑题目
func UpdateQuestion(c *gin.Context)
// DELETE /api/exam/questions/{id} # 停用题目
func DeleteQuestion(c *gin.Context)
// GET /api/exam/papers # 考试配置列表
func ListPapers(c *gin.Context)
// POST /api/exam/papers # 创建考试
func CreatePaper(c *gin.Context)
// PUT /api/exam/papers/{id} # 编辑考试
func UpdatePaper(c *gin.Context)
// DELETE /api/exam/papers/{id} # 停用考试
func DeletePaper(c *gin.Context)
```
## 5. 学员端考试 API
```python
GET /api/exam/list # 我的考试列表
GET /api/exam/cover?id={paperId} # 考试封面/说明
POST /api/exam/start # 开始考试 → 下发题目
POST /api/exam/submit # 交卷判分
GET /api/exam/record # 我的考试记录
GET /api/exam/record/{recordId} # 考试记录详情
```go
// GET /api/exam/list # 我的考试列表
func ExamList(c *gin.Context)
// GET /api/exam/cover?id={paperId} # 考试封面/说明
func ExamCover(c *gin.Context)
// POST /api/exam/start # 开始考试 → 下发题目
func ExamStart(c *gin.Context)
// POST /api/exam/submit # 交卷判分
func ExamSubmit(c *gin.Context)
// GET /api/exam/record # 我的考试记录
func ExamRecordList(c *gin.Context)
// GET /api/exam/record/{recordId} # 考试记录详情
func ExamRecordDetail(c *gin.Context)
```
## 6. 判分逻辑(参考 zhilianyuan2 _is_correct)
## 6. 判分逻辑(`exam.go` 内 `isCorrect` 函数)
```python
# services/exam_service.py
```go
// backend-go/internal/api/exam.go
def _is_correct(qtype: str, correct: list, user: any) -> bool:
"""确定性判分"""
if qtype == "multiple": # 多选题:集合相等
return sorted(correct) == sorted(user) if user else False
elif qtype == "judge": # 判断题:值相等
return str(correct).lower() == str(user).lower()
else: # 单选题:值相等
return correct == user
def grade_paper(questions: list, answers: dict) -> dict:
"""批卷:逐题比对 → 统计得分/正确数"""
correct_count = 0
total = len(questions)
score_per_question = 100 / total if total else 0
details = []
for q in questions:
user_ans = answers.get(str(q["id"]))
is_correct = _is_correct(q["type"], q["answer"], user_ans)
if is_correct:
correct_count += 1
details.append({
"question_id": q["id"],
"is_correct": is_correct,
"user_answer": user_ans,
"correct_answer": q["answer"],
})
score = round(score_per_question * correct_count)
return {
"score": score,
"correct_count": correct_count,
"wrong_count": total - correct_count,
"passed": score >= paper.pass_score,
"details": details,
func isCorrect(qtype string, correct []string, user any) bool {
switch qtype {
case "multiple": // 多选题:集合相等
return sortedEqual(correct, us)
case "judge": // 判断题:值相等
return strings.EqualFold(correct[0], us)
default: // 单选题:值相等
return correct[0] == us
}
}
func ExamSubmit(c *gin.Context) {
// 逐题比对 → 统计得分/正确数
// 简答题(essay)走 LLM 评分,其余题型确定性判分
}
```
## 7. 自测 vs 正式考区别
@@ -113,7 +107,7 @@ def grade_paper(questions: list, answers: dict) -> dict:
## 8. 考试记录设计
```json
// exam_record.detail_json 示例
// exam_record.detail_json 示例(SQLite TEXT 列)
{
"questions": [
{
+276 -160
View File
@@ -1,6 +1,6 @@
# BE03 — 素材模块设计
> **版本:V1.1 | 技术:分片上传 + LibreOffice + PyMuPDF**
> **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + LibreOffice + pdftotext**
---
@@ -12,6 +12,8 @@
- 文件预览
- 转换状态查询
---
## 2. 素材状态流转
```
@@ -22,212 +24,326 @@
│ │
│ └─→ 触发异步转换管线
│ │
│ ├─ 文档 → LibreOffice 转 PDF → PyMuPDF 提取
│ ├─ 文档 → LibreOffice 转 PDF → pdftotext 提取
│ │ 文本 → 切片写入 knowledge_chunk
│ └─ 视频/图片 → 仅标记预览可用
│
└─ reject → rejected(已驳回,前台不可见)
```
## 3. 上传 API
---
## 3. 数据模型
素材模块对应 `media_file` 表(`internal/model/media.go`):
```go
// internal/model/media.go
type MediaFile struct {
model.Base
UserID uint `gorm:"not null;index"`
Filename string `gorm:"size:256;not null"`
StoredPath string `gorm:"size:512;not null"`
FileExt string `gorm:"size:16;not null"` // ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg
FileSize int64 `gorm:"not null"`
BindType string `gorm:"size:32"` // company|product|course|none
BindID *uint `gorm:"index"`
Status string `gorm:"size:16;not null;default:pending;index"` // pending|approved|rejected
AuditBy *uint
AuditAt *time.Time
Extracted bool `gorm:"not null;default:false"`
RejectReason string `gorm:"size:512"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
---
## 4. 上传 API
### 直传(文档 ≤ 200MB)
```python
POST /api/media/upload
Content-Type: multipart/form-data
```go
// backend-go/internal/api/media.go
func Upload(c *gin.Context) {
// POST /api/media/upload
// Content-Type: multipart/form-data
Parameters:
- file: 文件二进制
- bind_type: company | product | course | none
- bind_id: 绑定实体 ID(可选)
file, _ := c.FormFile("file")
bindType := c.PostForm("bind_type") // company | product | course | none
bindIDStr := c.PostForm("bind_id") // 可选
Response:
{
uid := middleware.GetUserID(c)
mediaID, err := mediaSvc.Upload(c, uid, file, bindType, bindIDStr)
if err != nil { c.JSON(500, web.FAIL); return }
c.JSON(200, web.OK(gin.M{
"media_id": mediaID,
"status": "pending",
"filename": file.Filename,
}))
}
```
Response (SQLite TEXT 列):
```json
{
"code": 0,
"data": {
"media_id": 1,
"status": "pending",
"filename": "原始名称.pptx"
}
}
```
### 分片上传(视频 > 100MB)
```python
# 1. 初始化
POST /api/media/upload-init
{
"filename": "training.mp4",
"file_size": 524288000,
"bind_type": "course",
"bind_id": 1
}
Response: { "upload_id": "uuid", "chunk_size": 5242880, "chunk_count": 100 }
```go
// POST /api/media/upload-init
// → { "upload_id": "uuid", "chunk_size": 5242880, "chunk_count": 100 }
# 2. 上传分片(循环调用)
POST /api/media/upload-chunk
Content-Type: multipart/form-data
{
"upload_id": "uuid",
"chunk_index": 0,
"file": <binary>
}
// POST /api/media/upload-chunk (循环调用,Content-Type: multipart/form-data)
// → { "ok": true }
# 3. 完成合并
POST /api/media/upload-complete
{ "upload_id": "uuid" }
Response: { "media_id": 1, "status": "pending" }
// POST /api/media/upload-complete
// → { "media_id": 1, "status": "pending" }
```
## 4. 审批 API
### 前端分片上传流程(Axios + FormData)
```python
# 管理员
GET /api/media/audit-list?status=pending&page=1&size=20
```js
// 1. 初始化
const initResp = await axios.post('/api/media/upload-init', {
filename: 'training.mp4',
file_size: 524288000,
bind_type: 'course',
bind_id: 1,
})
const { upload_id, chunk_size, chunk_count } = initResp.data.data
POST /api/media/audit/{mediaId}
// 2. 逐片上传(并发或串行)
for (let i = 0; i < chunk_count; i++) {
const chunk = file.slice(i * chunk_size, (i + 1) * chunk_size)
await axios.post('/api/media/upload-chunk', chunk, {
params: { upload_id, chunk_index: i },
headers: { 'Content-Type': 'multipart/form-data' },
})
}
// 3. 完成合并
await axios.post('/api/media/upload-complete', null, {
params: { upload_id },
})
```
---
## 5. 审批 API
### 管理员端
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/media/audit-list?status=pending&page=1&size=20` | 审批列表 |
| POST | `/api/media/audit/{mediaId}` | 审批通过/驳回 |
POST 请求体:
```json
{
"action": "approve", # approve | reject
"reject_reason": "..." # 驳回时必填
"action": "approve", // approve | reject
"reject_reason": "..." // 驳回时必填
}
```
## 5. 异步转换管线
响应:
```python
# services/media_service.py
import threading
from datetime import datetime
def _async_convert_pipeline(media_id: int):
"""审批通过后的异步转换管线"""
try:
media = db.query(MediaFile).get(media_id)
# 仅文档需要转换(PPT/Word/PDF)
if media.file_ext in ("ppt", "pptx", "doc", "docx"):
# Step 1: LibreOffice 转 PDF
pdf_path = _libreoffice_to_pdf(media.stored_path)
# Step 2: PyMuPDF 提取文本
text = _pymupdf_extract(pdf_path)
# Step 3: 按段落切片入库
chunks = _split_into_chunks(text)
for i, chunk_text in enumerate(chunks):
db.add(KnowledgeChunk(
media_file_id=media.id,
source_type=media.file_ext,
chunk_index=i,
content=chunk_text,
))
elif media.file_ext == "pdf":
# PDF 直接 PyMuPDF 提取
text = _pymupdf_extract(media.stored_path)
chunks = _split_into_chunks(text)
for i, chunk_text in enumerate(chunks):
db.add(KnowledgeChunk(media_file_id=media.id, ...))
# 视频/图片:不提取文本
media.extracted = True
db.commit()
logger.info(f"转换完成: media_id={media_id}")
except Exception as e:
logger.error(f"转换失败: media_id={media_id}, error={e}")
media.extracted = False # 标记失败可重试
def approve_media(media_id: int, auditor_id: int):
"""审批通过 → 启动异步转换"""
media = db.query(MediaFile).get(media_id)
media.status = "approved"
media.audit_by = auditor_id
media.audit_at = datetime.utcnow()
db.commit()
thread = threading.Thread(target=_async_convert_pipeline, args=(media_id,))
thread.start()
```json
{ "code": 0, "data": { "media_id": 1, "status": "approved", "audit_by": 1 } }
```
## 6. LibreOffice 转换接口
### Go handler 实现
```python
# utils/libreoffice.py
import subprocess
import requests
```go
func AuditMedia(c *gin.Context) {
mediaID, _ := strconv.ParseUint(c.Param("mediaId"), 10, 32)
var req struct {
Action string `json:"action" binding:"required"` // approve | reject
RejectReason string `json:"reject_reason"`
}
c.ShouldBindJSON(&req)
def libreoffice_convert(input_path: str, output_dir: str) -> str:
"""调用 LibreOffice 容器将文档转 PDF"""
# 方式1:本地安装 libreoffice
subprocess.run([
"libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", output_dir, input_path
], check=True)
uid := middleware.GetUserID(c)
err := mediaSvc.Audit(c, uint(mediaID), uid, req.Action, req.RejectReason)
if err != nil { c.JSON(500, web.FAIL); return }
# 方式2:Docker 容器 HTTP 接口
# response = requests.post(
# f"{settings.libreoffice_url}/convert",
# files={"file": open(input_path, "rb")}
# )
# return response.json()["pdf_path"]
c.JSON(200, web.OK(gin.M{"media_id": mediaID, "status": req.Action}))
}
```
## 7. PyMuPDF 文本提取
---
```python
# utils/pdf_extractor.py
import fitz # PyMuPDF
## 6. 异步转换管线
def extract_text(pdf_path: str) -> str:
"""提取 PDF 全部文本"""
doc = fitz.open(pdf_path)
text = ""
for page in doc:
text += page.get_text()
doc.close()
return text
审批通过后,后台 goroutine 执行转换管线:
def split_into_chunks(text: str, max_chars: int = 1000) -> list[str]:
"""按段落 + 最大字符数切片"""
paragraphs = text.split("\n\n")
chunks = []
current = ""
for p in paragraphs:
if len(current) + len(p) > max_chars:
if current:
chunks.append(current.strip())
```go
// backend-go/internal/api/media.go
func approveMedia(tx *gorm.DB, mediaID uint, auditorID uint) error {
media := &model.MediaFile{}
tx.Model(media).Where("id = ?", mediaID).First(media)
media.Status = "approved"
media.AuditBy = &auditorID
now := time.Now()
media.AuditAt = &now
tx.Save(media)
go func() { _asyncConvertPipeline(tx, mediaID) }()
return nil
}
func _asyncConvertPipeline(tx *gorm.DB, mediaID uint) {
media := &model.MediaFile{}
tx.Model(media).Where("id = ?", mediaID).First(media)
// 仅文档需要转换(PPT/Word/PDF)
switch media.FileExt {
case "ppt", "pptx", "doc", "docx":
// Step 1: LibreOffice 转 PDF
pdfPath := _libreofficeToPDF(media.StoredPath)
// Step 2: pdftotext 提取文本
text := _pdftotextExtract(pdfPath)
// Step 3: 按段落切片写入 knowledge_chunk
chunks := _splitIntoChunks(text)
for i, chunkText := range chunks {
tx.Create(&model.KnowledgeChunk{
MediaFileID: media.ID,
SourceType: media.FileExt,
ChunkIndex: i,
Content: chunkText,
})
}
case "pdf":
text := _pdftotextExtract(media.StoredPath)
chunks := _splitIntoChunks(text)
for i, chunkText := range chunks {
tx.Create(&model.KnowledgeChunk{
MediaFileID: media.ID,
SourceType: "pdf",
ChunkIndex: i,
Content: chunkText,
})
}
}
// 视频/图片:不提取文本
media.Extracted = true
tx.Save(media)
}
```
---
## 7. LibreOffice 转换接口(systemd 裸进程)
```go
func _libreofficeToPDF(inputPath string) string {
// 调用本地安装的 LibreOffice(systemd 部署,非 Docker)
outDir := filepath.Dir(inputPath)
cmd := exec.Command("libreoffice",
"--headless",
"--convert-to", "pdf",
"--outdir", outDir,
inputPath,
)
cmd.Run()
return strings.TrimSuffix(inputPath, filepath.Ext(inputPath)) + ".pdf"
}
```
---
## 8. pdftotext 文本提取
```go
func _pdftotextExtract(pdfPath string) string {
// 使用系统 pdftotext(poppler-utils)提取 PDF 文本
cmd := exec.Command("pdftotext", "-layout", pdfPath, "-")
out, _ := cmd.Output()
return string(out)
}
func _splitIntoChunks(text string, maxChars int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
current := ""
for _, p := range paragraphs {
if len(current)+len(p) > maxChars {
if current != "" {
chunks = append(chunks, strings.TrimSpace(current))
}
current = p
else:
current += "\n\n" + p if current else p
if current:
chunks.append(current.strip())
} else {
if current != "" {
current += "\n\n" + p
} else {
current = p
}
}
}
if current != "" {
chunks = append(chunks, strings.TrimSpace(current))
}
return chunks
```
## 8. 预览 API
```python
GET /api/media/preview/{mediaId}
# 仅 approved 素材可预览
Response:
{
"preview_url": "/media/upload/uuid-filename.pdf",
"file_ext": "pdf",
"can_preview": true
}
GET /api/media/status/{mediaId}
# 查询素材状态(含提取进度)
Response:
{
"status": "approved",
"extracted": true,
"chunk_count": 42
}
```
## 9. 安全约束
---
- 扩展名白名单:ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg
## 9. 预览与状态 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/media/preview/{mediaId}` | 仅 approved 素材可预览 |
| GET | `/api/media/status/{mediaId}` | 查询素材状态(含提取进度) |
GET `/api/media/preview/{mediaId}` 响应:
```json
{
"code": 0,
"data": {
"preview_url": "/media/upload/uuid-filename.pdf",
"file_ext": "pdf",
"can_preview": true
}
}
```
GET `/api/media/status/{mediaId}` 响应:
```json
{
"code": 0,
"data": {
"status": "approved",
"extracted": true,
"chunk_count": 42
}
}
```
---
## 10. 安全约束
- 扩展名白名单:`ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg`
- 文件名重命名为 UUID,杜绝路径穿越
- 上传目录对静态预览只读,禁止直接执行
- 文件大小:文档 ≤ 200MB,视频 ≤ 2GB
- MIME 类型校验 + 扩展名双重校验
- SQLite 单文件,数据目录 `/opt/eai_agentplatform/data/media/`
+199 -236
View File
@@ -1,7 +1,7 @@
# BE04 — AI PathCoach 模块设计
> **版本:V1.1 | httpx 适配器 + 配置链 + Fail Fast | 参考:pj006-zhilianyuan2 llm/openai_adapter.py**
> **配置优先级:system_config 数据库表 → .env 文件**
> **版本:V2.0 | 技术:Go + Gin + GORM + SQLite + systemd + pdftotext + OpenAI 兼容接口**
> **参考:`backend-go/internal/api/ai_chat.go`**
---
@@ -9,10 +9,12 @@
- 全局 AI 聊天框的后端支持
- 上下文注入(当前产品/课程信息自动带入)
- 知识检索(MySQL FULLTEXT 召回 → Prompt 注入)
- 知识检索(Go 内 brute-force 余弦向量检索 + 关键词兜底 → Prompt 注入)
- SSE 流式响应 + 非流式调用(快捷动作)
- 3 个快捷动作(情景演练/查佣金/产品对比)
---
## 2. 架构
```
@@ -21,7 +23,8 @@
▼
┌──────────────────┐
│ 知识检索 │
│ MySQL FULLTEXT │
│ Go 内 brute-force │
│ 余弦 + 关键词 │
│ → 匹配段落 │
└──────┬───────────┘
│ 上下文片段
@@ -35,195 +38,140 @@
│
▼
┌──────────────────────────────┐
│ build_llm_adapter() 工厂 │
│ → 读取配置(库→.env) │
│ buildLLMAdapter() 工厂 │
│ → 读取配置(DB → .env) │
│ → Fail Fast 缺配置抛 501 │
│ → 返回 OpenAICompatible │
└──────┬───────────────────────┘
│
▼
┌──────────────────┐
│ httpx 调用 │
│ net/http 调用 │
│ /chat/completions│
│ SSE 流式 / 非流式│
└──────────────────┘
```
---
## 3. 配置优先级与 Fail Fast
```python
# services/ai_service.py
from __future__ import annotations
import httpx
from typing import Generator
from dataclasses import dataclass, field
from app.core.config import Settings
from app.errors import AppError
```go
// backend-go/internal/ai/llm.go
type LLMConfig struct {
BaseURL string
APIKey string
Model string
}
class LLMNotConfiguredError(AppError):
"""LLM 未配置(缺 api_key / base_url / model)"""
status_code = 501
error_code = "llm_not_configured"
func resolveLLMConfig(db *gorm.DB) (LLMConfig, error) {
// 从 system_config 表读取(优先级最高)
// 回退到 .env 文件
// 缺任何一项 → 返回错误,由 Gin handler 抛 501
baseURL := getSystemConfig(db, "llm_base_url")
apiKey := getSystemConfig(db, "llm_api_key")
model := getSystemConfig(db, "llm_model")
@dataclass
class LLMConfig:
"""LLM 连接所需的三项配置"""
base_url: str
api_key: str
model: str
def resolve_llm_config(settings: Settings) -> LLMConfig:
"""按优先级链解析 LLM 配置。缺任何一项即抛 LLMNotConfiguredError。
优先级(高 → 低):
1. settings.llm_*(来自 system_config 数据库表)
2. settings 中从 .env 读取的默认值
"""
base_url = getattr(settings, "llm_base_url", None) or ""
api_key = getattr(settings, "llm_api_key", None) or ""
model = getattr(settings, "llm_model", None) or ""
missing = []
if not base_url:
missing.append("llm_base_url")
if not api_key:
missing.append("llm_api_key")
if not model:
missing.append("llm_model")
if missing:
raise LLMNotConfiguredError(
f"LLM 服务未配置——缺失:{', '.join(missing)}。"
f"请管理员在【系统参数配置】中补充。"
)
return LLMConfig(base_url=base_url, api_key=api_key, model=model)
if baseURL == "" || apiKey == "" || model == "" {
return LLMConfig{}, fmt.Errorf("LLM 服务未配置——缺失配置项")
}
return LLMConfig{BaseURL: baseURL, APIKey: apiKey, Model: model}, nil
}
```
## 4. LLM 适配器(httpx 实现,参考 zhilianyuan2 openai_adapter.py)
---
使用 httpx 替代 OpenAI SDK,减少依赖、更可控、支持 token usage 采集。
## 4. LLM 适配器(Go net/http 实现)
```python
# services/ai_service.py
无 openai SDK 依赖,纯 Go 标准库实现,支持 SSE 流式 + token usage 采集。
class LLMAdapter:
"""OpenAI 兼容接口适配器(httpx 实现,无 openai SDK 依赖)"""
```go
// backend-go/internal/ai/llm.go
def __init__(self, *, config: LLMConfig, http_client: httpx.Client | None = None):
self._base_url = config.base_url.rstrip("/")
self._api_key = config.api_key
self._model = config.model
self._client = http_client or httpx.Client(timeout=60.0)
# 最后一次流式调用的 token 用量(供日志埋点)
self.last_stream_usage: dict[str, int] = {}
type LLMAdapter struct {
baseURL string
apiKey string
model string
client *http.Client
lastUsage map[string]int
}
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
func (a *LLMAdapter) generate(messages []map[string]interface{}, stream bool) (string, error) {
payload := map[string]interface{}{
"model": a.model,
"messages": messages,
"stream": stream,
"temperature": 0.7,
"max_tokens": 2048,
}
if stream {
payload["stream_options"] = map[string]interface{}{"include_usage": true}
}
resp, err := a.client.Post(
a.baseURL+"/chat/completions",
"application/json",
json.NewEncoder(io.NopCloser(bytes.NewBuffer(payload))),
)
if err != nil {
return "", fmt.Errorf("LLM 调用失败: %w", err)
}
defer resp.Body.Close()
if stream {
// SSE 流式处理
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil { break }
if !strings.HasPrefix(line, "data: ") { continue }
// parse SSE chunk...
}
def _payload(self, messages: list[dict], *, stream: bool, **kwargs) -> dict:
payload = {
"model": self._model,
"messages": messages,
"stream": stream,
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 2048),
}
// 非流式:直接解析 JSON
var result struct {
Choices []struct {
Message struct { Content string }
}
if stream:
payload["stream_options"] = {"include_usage": True}
return payload
def generate(self, messages: list[dict], **kwargs) -> str:
"""非流式调用,返回完整正文。用于快捷动作等一次性请求。"""
try:
resp = self._client.post(
f"{self._base_url}/chat/completions",
headers=self._headers(),
json=self._payload(messages, stream=False, **kwargs),
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
if not content or not content.strip():
raise LLMError("LLM 返回空正文")
return content
except httpx.HTTPError as e:
raise LLMError(f"LLM 调用失败:{e}") from e
except (KeyError, ValueError) as e:
raise LLMError(f"LLM 响应解析失败:{e}") from e
def generate_stream(self, messages: list[dict], **kwargs) -> Generator[str, None, None]:
"""SSE 流式调用。逐 chunk yield 文本,末包采集 usage。"""
self.last_stream_usage = {}
try:
with self._client.stream(
"POST",
f"{self._base_url}/chat/completions",
headers=self._headers(),
json=self._payload(messages, stream=True, **kwargs),
) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line or not line.startswith("data: "):
continue
payload = line[6:].strip()
if payload == "[DONE]":
break
chunk = json.loads(payload)
# 末包采集 usage(include_usage=true)
usage = chunk.get("usage")
if usage:
self.last_stream_usage = {
"input_tokens": int(usage.get("prompt_tokens", 0) or 0),
"output_tokens": int(usage.get("completion_tokens", 0) or 0),
}
choices = chunk.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except httpx.HTTPError as e:
raise LLMError(f"LLM 流式调用失败:{e}") from e
except (KeyError, ValueError) as e:
raise LLMError(f"LLM 流式响应解析失败:{e}") from e
def build_llm_adapter(settings: Settings) -> LLMAdapter:
"""工厂方法:解析配置 → 构造适配器。配置不全即 Fail Fast。"""
config = resolve_llm_config(settings)
return LLMAdapter(config=config)
Usage struct {
PromptTokens int
CompletionTokens int
}
}
json.NewDecoder(resp.Body).Decode(&result)
return result.Choices[0].Message.Content, nil
}
```
## 5. 知识检索
---
```python
# services/ai_service.py
## 5. 知识检索(Go 内 brute-force 余弦 + 关键词)
def retrieve_knowledge(keywords: str, db: Session, top_k: int = 5) -> list[str]:
"""MySQL 全文索引检索知识块"""
results = db.execute(
text(
"SELECT content FROM knowledge_chunk "
"WHERE MATCH(content) AGAINST(:keywords IN NATURAL LANGUAGE MODE) "
"LIMIT :limit"
),
{"keywords": keywords, "limit": top_k},
).fetchall()
return [r[0] for r in results]
```go
// backend-go/internal/ai/retrieve.go
func RetrieveKnowledge(db *gorm.DB, query string, topK int) ([]string, error) {
// 1. 向量检索:embedding 走 Ollama bge-m3,Go 内 brute-force 余弦相似度
queryVec := embedSingle(query) // []float32
// 2. 关键词兜底:LIKE 前缀匹配
var chunks []model.KnowledgeChunk
db.Model(&model.KnowledgeChunk{}).
Where("content LIKE ? LIMIT ?", "%"+query+"%", topK).
Find(&chunks)
// 合并去重,取 topK
return mergeAndTopK(queryVec, chunks, topK)
}
```
---
## 6. System Prompt
```python
SYSTEM_PROMPT = """你是一个博昇内部培训平台的 AI 助教 PathCoach。
```go
const SYSTEM_PROMPT = `你是一个博昇内部培训平台的 AI 助教 PathCoach。
你的职责:
1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关的问题
@@ -241,89 +189,100 @@ SYSTEM_PROMPT = """你是一个博昇内部培训平台的 AI 助教 PathCoach
知识库相关片段:
{knowledge_context}
"""
`
```
---
## 7. API 实现
```python
# api/ai_chat.py
from fastapi.responses import StreamingResponse
### 路由注册(`backend-go/internal/api/router.go`)
router = APIRouter(prefix="/api/ai-chat", tags=["ai_chat"])
@router.post("/message")
def chat_message(
request: ChatRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
):
"""SSE 流式对话"""
# 1. 检索知识
knowledge = retrieve_knowledge(request.message, db)
# 2. 组装 Prompt
system = SYSTEM_PROMPT.format(
page_context=json.dumps(request.context or {}),
knowledge_context="\n\n".join(knowledge),
)
messages = [
{"role": "system", "content": system},
*request.history,
{"role": "user", "content": request.message},
]
# 3. 构建 LLM 适配器(缺配置即抛 501)
llm = build_llm_adapter(settings)
def generate():
for chunk in llm.generate_stream(messages):
yield f"data: {json.dumps({'type': 'text', 'content': chunk})}\n\n"
yield "data: {\"type\": \"done\"}\n\n"
return StreamingResponse(
generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/quick-actions")
def get_quick_actions():
"""获取 3 个快捷按钮"""
return {"data": {"actions": [
{"id": "scenario", "label": "客户情景演练"},
{"id": "commission", "label": "查询佣金/规则"},
{"id": "compare", "label": "产品对比"},
]}}
@router.post("/quick-action")
def trigger_quick_action(
request: QuickActionRequest,
settings: Settings = Depends(get_settings),
):
"""触发快捷动作(非流式 LLM 调用)"""
llm = build_llm_adapter(settings)
if request.action_id == "commission":
# 查佣金:构建 prompt → 非流式调用
prompt = f"查询产品佣金信息,产品参数:{request.params}"
resp = llm.generate([{"role": "user", "content": prompt}])
return {"data": {"result": resp}}
elif request.action_id == "compare":
prompt = f"对比以下产品:{request.params}"
resp = llm.generate([{"role": "user", "content": prompt}])
return {"data": {"result": resp}}
elif request.action_id == "scenario":
# 情景演练:返回初始话术,后续走流式对话
prompt = f"开始销售情景演练,场景参数:{request.params}"
resp = llm.generate([{"role": "user", "content": prompt}])
return {"data": {"result": resp, "mode": "scenario"}}
```go
// ai 组
router.POST("/api/ai-chat/message", middleware.JWTAuth(), chat.ChatMessage)
router.GET("/api/ai-chat/quick-actions", middleware.JWTAuth(), chat.GetQuickActions)
router.POST("/api/ai-chat/quick-action", middleware.JWTAuth(), chat.TriggerQuickAction)
```
### ChatMessage(SSE 流式)
```go
// backend-go/internal/api/ai_chat.go
func ChatMessage(c *gin.Context) {
uid := middleware.GetUserID(c)
var req struct {
Message string `json:"message"`
Context map[string]any `json:"context"`
History []map[string]string `json:"history"`
}
c.ShouldBindJSON(&req)
// 1. 检索知识
knowledge, _ := ai.RetrieveKnowledge(store.DB, req.Message, 5)
// 2. 组装 Prompt
system := fmt.Sprintf(ai.SYSTEM_PROMPT,
"page_context": jsonEncode(req.Context),
"knowledge_context": strings.Join(knowledge, "\n\n"),
)
messages := []map[string]string{
{"role": "system", "content": system},
}
messages = append(messages, req.History...)
messages = append(messages, map[string]string{
"role": "user",
"content": req.Message,
})
// 3. 构建 LLM 适配器(缺配置即抛 501)
llm, err := ai.BuildLLMAdapter(store.DB)
if err != nil {
c.JSON(501, gin.M{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."})
return
}
// 4. SSE 流式响应
c.Stream(func(w io.Writer) bool {
// write SSE chunks...
return true
})
}
```
### 快捷动作
```go
func TriggerQuickAction(c *gin.Context) {
uid := middleware.GetUserID(c)
var req struct {
ActionID string `json:"action_id" binding:"required"`
Params string `json:"params"`
}
c.ShouldBindJSON(&req)
llm, _ := ai.BuildLLMAdapter(store.DB)
var prompt string
switch req.ActionID {
case "commission":
prompt = fmt.Sprintf("查询产品佣金信息,产品参数:%s", req.Params)
case "compare":
prompt = fmt.Sprintf("对比以下产品:%s", req.Params)
case "scenario":
prompt = fmt.Sprintf("开始销售情景演练,场景参数:%s", req.Params)
}
resp, _ := llm.Generate(messages)
c.JSON(200, gin.M{
"code": 0,
"data": gin.M{"result": resp},
})
}
```
---
## 8. 上下文注入规则
| 页面 | 自动注入上下文 | 说明 |
@@ -332,6 +291,8 @@ def trigger_quick_action(
| 课程详情 | `course_id`, `course_name`, `related_product` | 自动带入当前课程及关联产品 |
| 公司介绍 | `page: "company_intro"` | 提示 AI 当前页为公司介绍 |
---
## 9. 配置项
| 配置键 | 来源 | 说明 |
@@ -340,6 +301,8 @@ def trigger_quick_action(
| `llm_api_key` | system_config 表 / .env | API Key,本地 Ollama 可填 `ollama` |
| `llm_model` | system_config 表 / .env | 模型名,如 `qwen2.5:7b` |
---
## 10. 错误处理
| 场景 | HTTP 状态 | 响应 |
+255 -117
View File
@@ -1,8 +1,8 @@
# BE05 — 知识入库体系总设计(Knowledge Ingest)
> **版本:V1.1 | 定稿**
> **定位**:把「素材入库」与「结构化知识入库」统一为一套可复用的知识入库体系,覆盖上传 → 生成 → 审批 → 入库全链路。
> **配套**:BE03(素材转换管线)、BE04(AI 检索)、`docs/knowledge_source/README.md`(知识源格式契约)。
> **版本:V1.3 | 当前实现规范**
> **技术栈**:Go 后端 + SQLite + Vue3。
> **落点**:素材上传/审批/摄入、知识源(media_file/knowledge_source)审批与入库、切块与检索均由 Go 实现(`eai_agentplatform/backend-go/internal/api/media.go`、`knowledge.go`、`knowledge_pipeline.go` 等);知识分类/入库已彻底迁移到 Go,Python 的 `knowledge_service`(分类/FAISS 索引)已删除;检索为 Go 原生 brute-force 向量/关键词召回(对齐 D07/D13)。
---
@@ -19,10 +19,10 @@
---
## 2. 完整目录结构(定稿)
## 2. 完整目录结构
```
eai_agentplatform_app/
eai_agentplatform/
├── docs/
│ └── knowledge_source/ # 知识源文档(权威源,纳入 git 版本管理)
│ ├── README.md # 格式契约 + 答案契约 + 审批状态机
@@ -32,40 +32,41 @@ eai_agentplatform_app/
│ ├── 04_AI咨询与实施类.md
│ └── 05_企业级AI工具与平台.md
│
├── backend/
│ ├── app/
├── backend-go/
│ ├── config/ai_config.json # AI 路由配置(含 embed_gen/llm_fallback)
│ ├── internal/
│ │ ├── api/
│ │ │ ├── media.py # 【已有】素材上传/审批/预览
│ │ │ └── knowledge.py # 【新增】知识源扫描/审批/摄入
│ │ ├── models/
│ │ │ ├── media_file.py # 【已有】素材表
│ │ │ ├── knowledge_chunk.py # 【改造】来源扩展(见 §4)
│ │ │ └── knowledge_source.py # 【新增】知识源文档表
│ │ ├── services/
│ │ │ ├── media_service.py # 【已有】LibreOffice/PyMuPDF 转换管线
│ │ │ └── knowledge_service.py # 【新增】md 解析 + 摄入
│ │ └── scripts/
│ │ ├── init_db.py # 【已有】建表 + 种子
│ │ └── ingest_knowledge.py # 【新增】知识源扫描/摄入脚本
│ │ │ ├── media.go # 素材上传/审批/预览/提取管线
│ │ │ ├── knowledge.go # 知识源扫描/审批/摄入/解析
│ │ │ ├── knowledge_pipeline.go # 知识检索/分类/问答管线
│ │ │ └── knowledge_index.go # 知识索引重建(Go 原生)
│ │ ├── model/
│ │ │ ├── media_file.go # 素材表
│ │ │ ├── knowledge_source.go # 知识源文档表
│ │ │ ├── knowledge_chunk.go # 知识块表
│ │ │ └── question.go # 考试题目
│ │ └── store/ # DB 初始化 + GORM 连接
│ └── data/
│ ├── media/ # 【已有】素材物理文件(git 忽略)
│ │ ├── upload/
│ │ └── _preview_cache/
│ └── logs/ # 【已有】special_trace 按天日志
│
└── docker-compose.yml # 【待创建】
│ ├── eai_agentplatform.db # SQLite 单文件数据(自动建表)
│ ├── kb_data/ # 知识库物理文件(git 忽略)
│ │ ├── approved/
│ │ ├── pending/
│ │ └── rejected/
│ └── backups/ # 定期备份(git 忽略)
└── deploy/eai_agentplatform.env # 部署环境变量
```
**约定**:
- `docs/knowledge_source/` 是知识源 md 的唯一入库口(权威源,git 管理,可 diff 可回滚)。
- 运行时摄入**直接读取**该目录,不复制到 backend/data(单一事实源,避免双份漂移)。
- 素材物理文件仍在 `backend/data/media/`(git 忽略)。
- 运行时摄入**直接读取**该目录,不复制(单一事实源,避免双份漂移)。
- 素材物理文件存储在 `data/kb_data/`(git 忽略),分为 `approved/pending/rejected` 三个子目录。
- 数据库为 SQLite 单文件(`data/eai_agentplatform.db`),GORM 首次启动自动建表。
---
## 3. 两条流程的状态机(定稿)
## 3. 两条流程的状态机
### 流程 A:非结构化素材(BE03 已实现)
### 流程 A:非结构化素材
```
员工上传 ──▶ media_file(pending) ──审批──▶ approved ──▶ 异步转换
@@ -73,20 +74,20 @@ eai_agentplatform_app/
▼
┌──────────────────────────┐
│ 文档: LibreOffice→PDF→ │
│ PyMuPDF 提取→切片 │
│ pdftotext 提取→切片 │
│ 视频/图片: 仅预览 │
└──────────┬───────────────┘
▼
knowledge_chunk
```
### 流程 B:结构化知识源(本次新增)
### 流程 B:结构化知识源(知识源 md)
```
知识源 md(docs/knowledge_source/)
│
▼
【扫描】ingest_knowledge.py / POST /api/knowledge/scan
【扫描】POST /api/knowledge/scan
│ 解析 front-matter → 为每个 md 建 knowledge_source 记录
▼
knowledge_source(pending) ──审批──▶ approved ──▶ 解析摄入
@@ -102,125 +103,262 @@ knowledge_source(pending) ──审批──▶ approved ──▶ 解析摄入
---
## 4. 数据模型变更
## 4. 数据模型
### 4.1 新增 `knowledge_source` 表
### 4.1 `knowledge_source` 表
```sql
CREATE TABLE knowledge_source (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(256) NOT NULL COMMENT '文档标题',
file_path VARCHAR(512) NOT NULL UNIQUE COMMENT 'md 相对路径(docs/knowledge_source/ 下)',
category VARCHAR(64) NOT NULL COMMENT '分类:general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform',
domain ENUM('company','product','sales') NOT NULL DEFAULT 'product',
source_version VARCHAR(32) NOT NULL COMMENT '源版本,如 V1.0',
audit_status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
audit_by BIGINT UNSIGNED DEFAULT NULL COMMENT '审批人ID',
audit_at DATETIME(6) DEFAULT NULL COMMENT '审批时间',
reject_reason VARCHAR(512) DEFAULT NULL COMMENT '驳回理由',
ingested TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已摄入',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_ks_status (audit_status),
INDEX idx_ks_category (category),
CONSTRAINT fk_ks_auditor FOREIGN KEY (audit_by) REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
table: knowledge_source
columns:
id uint GORM primary key (auto-increment)
title string(256) 文档标题
file_path string(512) unique 相对路径(docs/knowledge_source/ 下)
category string(64) general/capital_consulting/qualification_counseling/ai_consist/ai_tools_platform
domain string(16) company / product / sales(默认 product)
source_version string(32) 源版本,如 V1.0
audit_status string(16) pending / approved / rejected(默认 pending)
audit_by *uint 审批人 ID
audit_at *time.Time 审批时间
reject_reason string(512) 驳回理由
ingested bool 是否已摄入(默认 false)
knowledge_space_key string(64) 知识空间键
created_at time.Time
updated_at time.Time
indexes:
idx_ks_status (audit_status)
idx_ks_category (category)
```
### 4.2 改造 `knowledge_chunk` 表(来源扩展)
### 4.2 `knowledge_chunk` 表(知识块,AI 检索最小单元)
现状 `media_file_id BIGINT UNSIGNED NOT NULL` 强绑定素材。改造后:
```sql
media_file_id BIGINT UNSIGNED DEFAULT NULL, -- 由 NOT NULL 改为可空
knowledge_source_id BIGINT UNSIGNED DEFAULT NULL, -- 新增
-- 约束:media_file_id 与 knowledge_source_id 二者必居其一(应用层校验,Fail Fast)
```
table: knowledge_chunk
columns:
id uint GORM primary key
media_file_id *uint 二选一(素材来源)
knowledge_source_id *uint 二选一(知识源来源)
source_type string(32) pdf/doc/md(来源类型标识)
source_id string(64) 来源 ID 字符串
knowledge_space_key string(64) 知识空间键
chunk_index int 块序号
content text 内容正文
created_at time.Time
indexes:
idx_mf (media_file_id)
idx_ks (knowledge_source_id)
idx_key (knowledge_space_key)
```
对应 ORM `models/knowledge_chunk.py`:`media_file_id` 改为可空,新增 `knowledge_source_id` 外键与 relationship。
### 4.3 `media_file` 表(素材文件)
```
table: media_file
columns:
id uint GORM primary key
file_name string(512) 文件名
file_path string(512) 物理路径(data/kb_data/...)
file_size int64 文件大小
file_type string(64) MIME 类型
status string(16) pending / approved / rejected(默认 pending)
approved_at *time.Time 审批时间
audit_by *uint 审批人 ID
audit_status string(16) 审批状态
file_category string(64) 分类(培训/产品/规则等)
extract_status string(64) 提取状态(pending / extracting / completed / failed)
created_at time.Time
updated_at time.Time
indexes:
idx_status (status)
idx_category (file_category)
```
---
## 5. 模块清单(8 模块)
## 5. 模块清单
| # | 模块 | 状态 | 职责 | 落点 |
|---|------|------|------|------|
| 1 | 上传模块 | ✅ 已有 | 直传 + 分片(>100MB) | `api/media.py` |
| 2 | 转换模块 | ✅ 已有 | LibreOffice → PDF | `services/media_service.py` |
| 3 | 提取切片模块 | ✅ 已有 | PyMuPDF 提取 + 段落切片 | `services/media_service.py` |
| 4 | **摄入模块** | 🆕 新增 | 解析知识源 md → product/question/chunk | `services/knowledge_service.py` |
| 5 | 审批模块 | 🔧 扩展 | 素材审批(已有)+ 知识源审批(新增) | `api/media.py` + `api/knowledge.py` |
| 6 | 入库模块 | 🔧 扩展 | 写 product/question/knowledge_chunk | `services/knowledge_service.py` |
| 7 | 预览模块 | ✅ 已有 | approved 素材预览 | `api/media.py` |
| 8 | 检索模块 | ✅ 已有 | MySQL FULLTEXT 召回 | `services/ai_service.py` |
| 1 | 上传模块 | ✅ | 直传 + 分片(>100MB) | `api/media.go` |
| 2 | 转换模块 | ✅ | LibreOffice→PDF,pdftotext→文本 | `api/media.go` |
| 3 | 提取切片模块 | ✅ | PDF→文本→段落切片 | `api/media.go` |
| 4 | 摄入模块 | ✅ | 解析知识源 md → product/question/chunk | `api/knowledge.go` |
| 5 | 审批模块 | ✅ | 素材审批 + 知识源审批 | `api/media.go` + `api/knowledge.go` |
| 6 | 检索模块 | ✅ | brute-force 向量/关键词召回 | `api/knowledge_pipeline.go` |
| 7 | 索引重建 | ✅ | 知识索引异步重建(Go 原生) | `api/knowledge_index.go` |
| 8 | 问答管线 | ✅ | 意图分类 → FAQ 匹配 → 向量/关键词检索 → LLM 兜底 | `api/knowledge_pipeline.go` |
---
## 6. 新增 API(knowledge.py)
## 6. API 端点
```python
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
### 知识源管理
# ── 扫描(管理员)──
POST /api/knowledge/scan
# 扫描 docs/knowledge_source/*.md
# 为新增/变更的 md 建 knowledge_source 记录(status=pending)
# 已存在且未摄入的记录跳过;返回扫描结果列表
| 端点 | 方法 | 职责 |
|------|------|------|
| `POST /api/knowledge/scan` | 管理员 | 扫描 `docs/knowledge_source/*.md`,为新增/变更的 md 建 `knowledge_source` 记录(status=pending)。已存在且未摄入的记录跳过。 |
| `GET /api/knowledge/audit-list?status=&page=&size=` | 管理员 | 按 `audit_status` 过滤,分页返回知识源列表。 |
| `POST /api/knowledge/audit/{sourceId}` | 管理员 | `{ "action": "approve" | "reject", "reject_reason": "..." }`。approve → 触发摄入(同步解析写入 product/question/chunk,标记 `ingested=1`,然后异步重建索引);reject → 必填 `reject_reason`,不摄入。 |
| `GET /api/knowledge/status/{sourceId}` | 管理员 | 返回 `{ "audit_status", "ingested", "reject_reason", "knowledge_space_key" }`。 |
# ── 审批(管理员)──
GET /api/knowledge/audit-list?status=pending
POST /api/knowledge/audit/{source_id}
# { "action": "approve" | "reject", "reject_reason": "..." }
# approve → 触发摄入(同步解析写入 product/question/chunk,标记 ingested=1)
# reject → 必填 reject_reason,不摄入
### 素材管理
# ── 状态查询 ──
GET /api/knowledge/status/{source_id}
# { "audit_status", "ingested", "reject_reason" }
| 端点 | 方法 | 职责 |
|------|------|------|
| `POST /api/media/upload` | 通用 | 文件上传(直传) |
| `POST /api/media/upload/init` | 通用 | 分片上传初始化(>100MB) |
| `POST /api/media/upload/chunk` | 通用 | 分片上传分片 |
| `POST /api/media/upload/complete` | 通用 | 分片上传完成 |
| `POST /api/media/audit/{fileId}` | 管理员 | `{ "action": "approve" | "reject" }`。approve → 触发异步提取;reject → 素材标记 rejected。 |
| `GET /api/media/preview/{fileId}` | 通用 | 预览素材(PDF/图片/视频) |
| `GET /api/knowledge/index/rebuild` | 管理员 | 手动触发知识索引重建(Go 原生,读取 `knowledge_chunk` + `media_file` + `knowledge_source` 全量数据重建索引)。返回 `{ "rebuild": true, "chunk_count": N, "service_enabled": false }`。 |
---
## 7. 摄入逻辑(`api/knowledge.go:ingestSource`)
### 解析规则
知识源 md 采用 **YAML front-matter + 三个固定 `## ` section** 格式,Go 后端按如下规则解析:
```
---
title: 文档标题
category: 分类
domain: domain(product/company/sales)
version: V1.0
---
```
---
然后按 `## ` 二级标题切分三个 section:
## 7. 摄入脚本(ingest_knowledge.py)
| Section 名 | 内容 | 解析 |
|-----------|------|------|
| `结构化产品数据` | 每个 `### code name` 键值对列表 → product | 解析 `code`/`name`/`category`/`tags`/`description`/`pricing`/`commission_recommend`/`commission_negotiate`/`public_course_bonus`/`version_risk`/`report_rules` |
| `AI 检索知识` | 每个 `### 标题` 段落 → knowledge_chunk | 段落正文写入 content,source_type=md |
| `考试题目` | 每个 `### Qn` 字段列表 → question | 解析 `type`/`stem`/`options`(JSON)/`answer`(JSON)/`explanation`/`domain` |
```python
"""知识源摄入脚本:扫描 md → 建记录 → (审批通过后)解析入库
### 断点摄入
用法:
cd backend && source venv/bin/activate
python -m app.scripts.ingest_knowledge --scan # 仅扫描建 pending 记录
python -m app.scripts.ingest_knowledge --ingest <source_id> # 摄入单个已审批源
"""
`ingestSource` 先检查 `src.Ingested`:若已为 true,直接返回(跳过重复解析写入)。
# 解析规则(严格对齐 knowledge_source/README.md 格式契约):
# 1. 读 YAML front-matter:category / domain / source_version
# 2. 按 "## " 二级标题切块:
# ## 结构化产品数据 → 解析 "### code name" + "key: value" 列表 → product
# ## AI 检索知识 → 每个 "### 标题" 段落 → knowledge_chunk
# ## 考试题目 → 每个 "### Qn" 的字段列表 → question
# 3. 摄入后 product/question 置 status=active
# 4. knowledge_chunk 写入 knowledge_source_id,media_file_id 留空
### 写入逻辑
```go
// ingestSource 伪代码
func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
data, _ := os.ReadFile(filepath.Join(Cfg.KnowledgeSourceDir, src.FilePath))
fm := parseFrontMatter(string(data))
sections := splitSections(string(data))
// 1. 产品数据 → product 表(upsert:按 code 查,已存在则更新)
for _, b := range splitBlocks(sections["结构化产品数据"]) {
kv := parseKV(b.body)
p := model.Product{Code: kv["code"], Name: kv["name"], ...}
store.DB.Save(&p) // upsert by code
}
// 2. AI 检索知识 → knowledge_chunk
for i, b := range splitBlocks(sections["AI 检索知识"]) {
store.DB.Create(&model.KnowledgeChunk{
KnowledgeSourceID: &src.ID,
SourceType: "md",
SourceID: strconv.FormatUint(uint64(src.ID), 10),
Content: b.body,
})
}
// 3. 考试题目 → question
for _, b := range splitBlocks(sections["考试题目"]) {
q := model.Question{...}
store.DB.Create(&q)
}
}
```
**Fail Fast 约束(G02)**:解析失败(front-matter 缺失、section 缺块、字段缺失)立即抛错并中断该源摄入,不静默跳过、不写半截数据。
### 断点约束
解析失败(front-matter 缺失、section 缺块、字段缺失)静默跳过对应块,不抛错中断。每个 section 的解析独立于其他 section。
---
## 8. 格式契约(已定,见 knowledge_source/README.md)
## 8. 格式契约
- 每个 md:YAML front-matter(`category` / `domain` / `source_version`)+ 三个固定 `## ` section。
- 题目答案契约:`judge=[bool]`、`single=[索引]`、`multiple=[索引列表]`。
- 已生成 5 个知识源:`01_通用规则` + `02/03/04/05` 四大分类,共 28 产品 + 20 题。
- 题目格式:
- 判断题:`- judge: true/false` + `- stem: 题干` + `- explanation: 解析`
- 单选题:`- type: single` + `- stem: 题干` + `- options: ["A. ...", "B. ..."]` + `- answer: [1]` + `- explanation: 解析`
- 多选题:`- type: multiple` + `- stem: 题干` + `- options: [...]` + `- answer: [1,2]` + `- explanation: 解析`
- 选项格式:`- 选项文本`(Go 端自动编号 A/B/C/D...)
- 已生成 5 个知识源:`01_通用规则` + `02/03/04/05` 四大分类。
---
## 9. 实现清单(代码侧,已完成)
## 9. 知识检索管线(`api/knowledge_pipeline.go`)
- [x] `models/knowledge_source.py` 新建
- [x] `models/knowledge_chunk.py` 来源扩展(media_file_id 可空 + knowledge_source_id)
- [x] `services/knowledge_service.py`(md 解析 + 摄入)
- [x] `api/knowledge.py`(scan / audit / status)
- [x] `scripts/ingest_knowledge.py`
- [x] `scripts/init_db.py` 追加 knowledge_source 建表 + knowledge_chunk 字段迁移
- [x] `docs/db_schema.md`、`docs/api.md` 同步更新
知识检索采用 **四层管线**,逐层降级:
```
用户提问
│
▼ ① 意图分类器(heuristicKnowledgeIntent)
│ 关键词规则匹配 → intent: invalid / smalltalk / out_of_scope / faq / document
│ 命中 smalltalk/out_of_scope → 直接返回答案,管线终止
│
▼ ② FAQ 匹配(matchKnowledgeFAQ)
│ 问题归一化 + 相似问题匹配 + 关键词重叠评分
│ 最佳分 >= 70 → 返回标准答案,管线终止
│
▼ ③ 向量检索(vectorRetrieveCitations)
│ 1) 调用 embed_gen 路由嵌入 query + 候选块(brute-force)
│ 2) 计算余弦相似度,降序排序
│ 3) 最高分 >= 0.35 且不需要 LLM 兜底 → 直接给出结果
│
▼ ④ LLM 兜底(llm_fallback)
│ 将检索结果 + 系统提示词发送给 LLM
│ LLM 基于知识片段生成最终回答
```
### 检索技术细节
- **嵌入路由**:通过 `config.GetRoute("embed_gen")` 获取嵌入模型配置,调用 Ollama embedding API。
- **向量检索**:brute-force 全量候选块嵌入 + 余弦相似度排序(对齐 D07/D13),不依赖外部向量库。
- **关键词兜底**:当嵌入模型不可用时(`embed_gen` 路由缺失/调用失败),降级为 `keywordRetrieveCitations`——term 重叠评分排序。
- **空间过滤**:支持按 `knowledge_space_key` 过滤候选块(如选择某知识库后再提问)。
- **直接回答判定**:`canDirectAnswerFromVector` 判定:最高分 ≥ 0.35 且不含 LLM 兜底关键词 → 直接返回。
- **LLM 兜底判定**:`needsLLMFallback` 检测 query 含「总结/梳理/分析/对比/归纳/起草/生成/提纲/报告」等关键词 → 强制 LLM 处理。
---
## 10. 知识索引重建(`api/knowledge_index.go`)
知识索引为 Go 原生实现,异步触发:
```
triggerKnowledgeIndexRebuild()
└─ go func()
└─ rebuildKnowledgeIndexNow()
└─ buildKnowledgeIndexItems()
├─ 读取 knowledge_chunk 全量
├─ 读取 approved media_file
├─ 读取 approved knowledge_source
└─ 合并 → 构建索引项列表
```
- 触发时机:知识源审批通过摄入后(`ingestSource` 成功后)、素材提取管线完成切片后。
- 锁机制:`knowledgeIndexRebuildLock` 防止并发重建。
- 索引项字段:`ID, Title, Content, ChunkIndex, SourceType, SourceID, KnowledgeSpaceKey, KnowledgeSpaceName`。
- 管理员也可通过 `POST /api/knowledge/index/rebuild` 手动触发重建。
---
## 11. 实现清单(Go 代码,已完成)
- [x] `internal/model/knowledge_source.go` — 知识源表
- [x] `internal/model/knowledge_chunk.go` — 知识块表(来源扩展:media_file_id/knowledge_source_id 二选一)
- [x] `internal/model/media_file.go` — 素材表(status + extract_status 字段)
- [x] `api/knowledge.go` — scan / audit / status / ingestSource(md 解析 + 摄入)
- [x] `api/media.go` — 上传 / 审批 / 提取管线 / 预览
- [x] `api/knowledge_pipeline.go` — 意图分类 / FAQ / 向量检索 / LLM 兜底
- [x] `api/knowledge_index.go` — 知识索引重建(Go 原生)
- [x] `internal/store/` — DB 初始化 + 自动建表
- [x] `config/ai_config.json` — AI 路由配置(embed_gen / llm_fallback)
@@ -1,7 +1,7 @@
# BE06 — AI 配置与算力点计费模块设计
> **版本:V1.0 | 从 pj034-oeamgt 完整移植 + 精简适配 | 参考:pj034 `core/ai_config.py`、`services/ai/router.py`、`models/ai_call_log.py`、`api/v1/ai_billing.py`**
> **目标:把 pj034 成熟的「AI 路由/Provider/密钥配置 + 算力点计费 + 调用审计」体系,按 pj0231「极致极简、单公司、按用户计点」落地。**
> **版本:V2.0 | Go + Gin + GORM + SQLite | 参考:`backend-go/internal/api/ai_admin.go`、`backend-go/internal/api/ai_usage.go`、`backend-go/internal/api/ai_chat.go`、`backend-go/internal/ai/credits.go`**
> **目标:把已有 AI 路由/Provider/密钥配置 + 算力点计费 + 调用审计体系,按「极致极简、单公司、按用户计点」落地。**
---
@@ -60,7 +60,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运
| `provider` | enum | provider | ✅ |
| `capability` | enum | AI 能力 | ✅ |
| `input_asset_id` / `output_asset_id` | int | 电商素材 | ❌ |
| `route_id` | str | 路由 ID | ✅ |
| `ai_route_id` | str | AI 路由 ID | ✅ |
| `model_id` | str | 模型 | ✅ |
| `input_summary` / `output_summary` | text | 摘要 | ❌ 极致极简 |
| `raw_request` / `raw_response` | text | 原始 IO | ❌(审计可后补) |
@@ -100,7 +100,7 @@ def compute_credits(capability, billing_mode, status):
`GET /data/ai-billing?days=30&group_by=month|capability|provider|month_capability`
返回 `{ summary: {total_calls, success_calls, failed_calls, total_credits_charged}, buckets: [...] }`。MySQL 下按月聚合走 Go 侧 group(不依赖 `date_trunc`)。
返回 `{ summary: {total_calls, success_calls, failed_calls, total_credits_charged}, buckets: [...] }`。SQLite 下按月聚合走 Go 侧 group。
---
@@ -130,7 +130,7 @@ def compute_credits(capability, billing_mode, status):
| `user_id` | uint index | 调用人 |
| `capability` | str | ai_chat / text_gen / embed |
| `provider` | str | 实际命中的 provider |
| `route_id` | str | 路由 ID |
| `ai_route_id` | str | AI 路由 ID |
| `model` | str | 模型名 |
| `tokens_input` | int | 输入 token |
| `tokens_output` | int | 输出 token |
+2 -2
View File
@@ -3,7 +3,7 @@
> **命名规则:** `BE{NN}_{描述}.md`
> **用途:** 后端实现细节、API 设计、服务层设计
>
> **⚠️ 本目录 BE 文档为 V1.1 设计期历史快照(FastAPI/Python)。** 当前后端已重写为 **Go + Gin + GORM + MySQL 8.0 + FAISS**,见 `docs/changelog.md`(V1.2)。V1.4–V1.7 新增的岗位/积分/证书/部门/消息等接口以 `docs/changelog.md` 为准。
> **当前后端为 Go + Gin + GORM + SQLite。** 详见 `docs/changelog.md`。
## 文件清单
@@ -15,4 +15,4 @@
| `BE03_Media_Module.md` | 素材模块设计(上传/审批/转换管线) |
| `BE04_AI_Chat_Module.md` | AI PathCoach 模块设计(V1 单一助手;智能体矩阵见 SY03) |
| `BE05_Knowledge_Ingest_Module.md` | 知识入库体系设计(上传→生成→审批→入库) |
| `BE06_AI_Config_Credits_Module.md` | AI 配置与算力点计费模块(移植自 pj034) |
| `BE06_AI_Config_Credits_Module.md` | AI 配置与算力点计费模块 |
@@ -3,6 +3,13 @@
> **版本:V1.1 | 左导航 + 中间工作区 + 右 AI 侧栏 | Vue3 + Element Plus**
> **参考:pj006-zhilianyuan2 frontend-orgadmin 三栏布局模式**
> **2026-09-16 口径同步说明:**
> 本文档记录的是 V1 培训平台三栏布局基线。
> 当前正式产品导航与运行形态,请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准:
> `新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
> 文中出现的“首页 / 公司介绍培训 / 产品知识 / 产品销售培训 / 考试 / 知识管理 / 系统管理 / PathCoach / 博昇培训”等词,均属于历史页面结构,不代表当前目标工作台命名。
> 当前命名约束同时补充如下:普通页面导航统一使用 `page_route / pageRoute`,业务对象入口统一使用 `object_entry_route / objectEntryRoute`,不得再混写为泛 `route`。
---
## 1. 布局结构
@@ -1,6 +1,11 @@
# PL02 — 员工端视图设计
> **版本:V1.1 | 框架:Vue3 + Element Plus**
>
> **2026-09-16 口径同步说明:**
> 本文档是 V1 员工端页面稿,保留培训平台时期的页面定义。
> 当前正式一级导航与对象化工作台口径,请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准。
> 文中“首页 / 公司介绍培训 / 产品知识 / 销售培训 / 考试”等名称,均属于历史页面模型,不代表当前导航目标态。
---
@@ -2,6 +2,11 @@
> **版本:V1.1 | 框架:Vue3 + Element Plus**
> **参考:pj006-zhilianyuan2 frontend-orgadmin 的 CRUD 页面模式**
>
> **2026-09-16 口径同步说明:**
> 本文档是 V1 管理端页面稿,保留“知识管理 / 系统管理”等早期后台结构。
> 当前正式一级导航与对象化工作台口径,请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准。
> 文中“知识管理 / 系统管理 / 全部考试成绩”等名称,均属于历史页面模型,不代表当前导航目标态。
---
@@ -3,7 +3,12 @@
> **版本:V1.1 | 左导航布局 | Vue3 + Element Plus + Vue Router**
> **参考:pj006-zhilianyuan2 frontend-orgadmin 的路由守卫模式**
>
> **⚠️ 本文为 V1 导航基线。** 管理员导航已拆为「6 组织管理 + 7 系统设置」(见 `00_AI_Context/forai02`);一级导航将按 SY03 演进为「知识库 / 智能体 / 学习与考试 / 陪练与认证 / 内容运营 / 组织与系统」。本文第 1 节的菜单树为 V1 历史结构。
> **⚠️ 本文为 V1 导航基线。**
> 当前正式导航与对象化工作台口径,请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准:
> `新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
>
> 本文第 1 节菜单树、路由示例和权限说明,主要用于保留早期培训平台的导航与鉴权基线。
> 其中出现的“首页 / 公司介绍培训 / 产品知识 / 产品销售培训 / 考试 / 知识管理 / 系统管理”,都应理解为 V1 历史结构,而不是当前目标导航。
---
+16 -10
View File
@@ -2,9 +2,15 @@
> **版本:V1.1**
> **日期:2026-09-14**
> **状态:实施中——路由接入 + 外壳重写已完成,AI 工具逐页迁移进行中**
> **状态:已完成主要收敛——统一工作台已落地,后续进入对象化深化阶段**
> **目标对齐:** `eai_agentplatform/frontend/src/views/tools/CHAT_UI_PREVIEW.html`
> **参考:** `CHAT_FIRST_REDESIGN_PLAN.md`
> **2026-09-16 当前口径说明:**
> 本文档记录的是 Chat-First 收敛阶段方案,保留历史实施细节。
> 当前正式产品命名与一级导航已统一为:
> `新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
> 文中出现的 `助理 / 定时任务 / 资料库 / 更多` 等导航词,均属于阶段性方案,不再作为当前 canonical 名称;最新导航口径以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准。
> 同时,当前实现已将路由命名拆分为 `ai_route_* / object_entry_route / page_route` 三类,避免 AI 路由、对象入口与普通页面导航混同。
---
@@ -20,7 +26,7 @@
| 培训平台定位(知识库+考试+素材审批) | AI 数字员工平台定位 |
- 涉及范围:`CLAUDE.md`、`CODING_RULES.md`、全部 `views/**` 与共享组件中的标题/文案、`docs` 相关文档。
- `CHAT_UI_PREVIEW.html`、`CHAT_FIRST_REDESIGN_PLAN.md` 中的旧标题一并替换。
- `CHAT_UI_PREVIEW.html` 中的旧标题一并替换。
---
@@ -42,17 +48,17 @@
### 2.1 全局外壳(MainLayout.vue → WorkBuddy Shell)
- 移除:深蓝渐变顶栏(72px)、应用卡片左栏、底部运行面板。
- 新增:极简顶栏 + 窄侧边栏(新建任务 / 助理 / 项目 / 专家·技能·连接器 / 定时任务 / 资料库 / 更多 / 任务列表 / 用户信息)。
- 新增:极简顶栏 + 窄侧边栏(新建任务 / 助理 / 项目 / 专员·技能·APP·连接器 / 定时任务 / 资料库 / 更多 / 任务列表 / 用户信息)。
- 内容区承载 Welcome 态 / Chat 态两种容器。
- **保留全部路由与业务跳转,仅改外观与信息架构。**
### 2.2 AI 工具页(完整 Chat-First)
### 2.2 AI 工具页(统一挂载到 `/home`)
| 页面 | 落地 |
|---|---|
| 智能助手 `SmartAssistantPage.vue` | 完整 Chat 优先 |
| 文档翻译 / 文案校对 / 语音转写 / 批量提取 | 表单 → Chat + 文件上传 |
| 合同审查 | Chat + 可展开配置面板 |
| 报告生成 | 保留复合表单,统一视觉 |
| 文档翻译 / 文案校对 / 语音转写 / 批量提取 | 已退出独立执行页,后续以对象化工具接入 |
| 合同审查 | 已退出独立执行页,后续以对象化配置面板接入 |
| 报告生成 | 已退出独立执行页,后续以对象化执行链接入 |
复用已有组件并统一到预览标准:`ChatLayout.vue` / `ChatInputBar.vue` / `ChatMessage.vue` / `HistorySidebar.vue`。
新增共享组件:全局顶栏、Welcome 欢迎态、分类/模式胶囊、最佳实践卡片。
@@ -67,7 +73,7 @@
1. **外壳**:重写 `MainLayout.vue` 为 WorkBuddy Shell + 极简顶栏。
2. **基建**:统一 `ChatLayout` 等共享组件到预览标准。
3. **AI 工具**:逐页 Chat-First(助手→翻译→校对→转录→提取→审查)。
3. **AI 技能**:继续从页面模型推进到 `src/skills/*` 对象化实现。
4. **业务页**:逐页统一视觉,接入新外壳。
5. **命名替换**:按第 0 节替换旧培训标题/文案。
6. **回归**:全部路由可达、登录/权限、交互不被破坏。
@@ -120,7 +126,7 @@ commit `5c0870a` 的 Chat 界面并非损坏,而是**入口全部指向 `/apps
### 5.5 侧边栏去重(重要)
`ChatLayout.vue` 内部**自带一份与预览稿几乎一致的工作台侧边栏**(220px / `#f7f8fa`,含新建任务、助理、项目、专家·技能·连接器、定时任务、资料库、更多、任务列表)。
`ChatLayout.vue` 内部**自带一份与预览稿几乎一致的工作台侧边栏**(220px / `#f7f8fa`,含新建任务、助理、项目、专员·技能·APP·连接器、定时任务、资料库、更多、任务列表)。
外壳重写后,AI 工具页会**叠出两条 220px 侧边栏(共 440px)**。
核查其后端支撑:
+4 -1
View File
@@ -3,7 +3,10 @@
> **命名规则:** `DR{NN}_{描述}.md`
> **用途:** 前端 UI 规范、交互规则、主题系统
>
> **注:** 导航规则(DR02)需随平台一级导航演进(知识库/智能体/学习与考试/陪练与认证/内容运营/组织与系统)同步,见 SY03。
> **当前统一口径:** 设计规范中的导航与工作台命名,请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准。
> 当前一级导航目标态为:`新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的`
>
> **注:** `DR02`、`DR04` 仍保留 V1 与 Chat-First 阶段方案,用于记录历史约束与演进过程;阅读时需以上述当前口径为准。
## 文件清单
@@ -31,7 +31,7 @@
| **前端** | Vue 3 + TypeScript + Pinia + Vue Router(响应式状态管理)|
| **移动端** | React Native + Flutter(iOS / Android 跨平台原生应用)|
| **身份认证** | JWT(无状态鉴权,支持多租户权限隔离)|
| **容器化部署** | Docker + docker-compose(环境一致性,支持一键私有化交付)|
| **部署** | systemd(裸进程守护,无 Docker 依赖)|
| **持续集成** | GitHub Actions CI/CD(自动化测试、构建与部署流水线)|
| **反向代理与进程管理** | Nginx + PM2 + Ubuntu 22.04(生产级流量管理与进程守护)|
| **代码架构** | 标准三层分离:endpoints / services / repositories |
@@ -69,10 +69,10 @@
| 前端 | Vue 3 + TypeScript + Pinia + Vue Router |
| 移动端 | React Native + Flutter(iOS / Android 跨平台)|
| 身份认证 | JWT(无状态鉴权,多租户权限隔离)|
| 容器化部署 | Docker + docker-compose(一键私有化交付)|
| 部署 | systemd(裸进程守护,无 Docker 依赖)|
| 持续集成 | GitHub Actions CI/CD |
| 反向代理与进程管理 | Nginx + PM2 + Ubuntu 22.04 |
| 代码架构 | 标准三层分离:endpoints / services / repositories |
| 反向代理与进程管理 | Nginx + Ubuntu 22.04 |
| 代码架构 | 标准三层分离:api / model / store |
### 4.3 六大场景架构
+24 -21
View File
@@ -1,9 +1,9 @@
# eai_agentplatform_app 博昇内部培训平台 — 产品需求文档
# eai_agentplatform — 产品需求文档
> **版本:V1.1 最终版(V1 培训底座基线)| 纯本地化部署 | 极简架构 | 全功能闭环**
> **最后更新:2026-08-15**
> **版本:V1.3 | 部署模式:纯本地化 · Go 单二进制 + SQLite · systemd**
> **最后更新:2026-08-16**
>
> **文档状态:V1 基线,已升级。** 本项目已于 2026-08 升级为「数字员工平台」(对外以「专员」为能力单元,分通用数字员工 / 行业专属数字员工),当前产品主轴、分层架构与目标态见 [01_System_Overall/SY03](01_System_Overall/SY03_Knowledge_Centric_Agent_Platform_Strategy.md) 及 SY04–SY17。本文档描述的是已交付的 V1 培训底座(学习/考试/AI 答疑闭环),其中 §7「明确禁止」已随 V1.6 起部分放开,实际以 `docs/changelog.md` 为准。
> **文档状态:** 本文档描述已交付的 V1 培训底座(学习/考试/AI 答疑闭环),产品已升级为「数字员工平台」。当前产品主轴、分层架构与目标态见 [01_System_Overall/SY03](01_System_Overall/SY03_Knowledge_Centric_Agent_Platform_Strategy.md) 及 SY04–SY17。实际以 `docs/changelog.md` 为准。
---
@@ -11,13 +11,13 @@
### 1.1 项目定位
博昇 纯内网本地化、内部专属员工培训系统。用于新人入职培训、公司认知、产品知识学习、销售能力培训、统一考试验收。配套全局 AI 助教 PathCoach,提供答疑、产品查询、销售情景演练。
eai_agentplatform — 纯内网本地化、内部专属数字员工培训平台。用于新人入职培训、公司认知、产品知识学习、销售能力培训、统一考试验收。配套全局 AI 助教 PathCoach,提供答疑、产品查询、销售情景演练。
### 1.2 核心设计原则(强制)
- 100% 本地化部署,不上云、无 OSS、无 MinIO、不依赖公网云服务
- AI 所用 LLM 必须为内网可访问地址(本地 Ollama / vLLM / 内网统一 LLM 网关),严禁直连公网 AI API
- 极致极简,杜绝所有冗余功能、统计、学情分析、复杂权限
- 功能以业务价值为准,杜绝为加而加的冗余功能;权限保持精简(employee/admin 两级)
- 所有产品数据严格对齐《博昇产品与渠道合作表 V1.0》
- 所有课件支持:PPT / PDF / Word / MP4 视频 / 图片
- 所有上传素材必须审批通过才可生效、进入知识库、页面展示
@@ -28,11 +28,11 @@
| 层级 | 技术 |
|------|------|
| **前端** | Vue3 + Vite + Element Plus + 本地 pdf.js(无 CDN)+ 原生 video |
| **后端** | Go + Gin + GORM(原 V1.1 为 FastAPI,已于 V1.2 重写)|
| **数据库** | MySQL 8.0 + FAISS 向量检索 |
| **后端** | Go + Gin + GORM + SQLite(modernc 纯Go驱动)|
| **数据库** | SQLite 单文件 `data/eai_agentplatform.db` + Go 原生 brute-force 向量检索(余弦)+ 关键词兜底 |
| **认证** | JWT(golang-jwt)+ bcrypt 密码哈希 |
| **PDF 提取** | LibreOffice + pdftotext(裸进程)|
| **依赖服务** | LibreOffice(PPT/Word → PDF 预览)+ 内网 LLM 服务(OpenAI 兼容接口)|
| **依赖服务** | LibreOffice(PPT/Word→PDF 预览)+ 内网 LLM 服务(OpenAI 兼容接口)|
### 1.4 硬性技术限制
@@ -120,19 +120,23 @@
## 4. 知识库课件体系
### 素材来源
- **员工提交素材建议**(弹窗提交,无独立页面)
- **管理员直接上传**(自动通过)
### 素材状态
`pending`(待审批)→ `approved`(已通过)→ `rejected`(已驳回)
### 核心规则
- 未审批 / 驳回素材前台完全不可见、不解析、不进 AI
- 审批通过才执行:文档转 PDF + 文本提取入库
- 视频(MP4)审批通过后仅支持在线预览,不提取文本
### 转换与提取管线
PPT/Word → LibreOffice 转 PDF → PyMuPDF 提取文本 → 按段落切片写入 knowledge_chunk
PPT/Word → LibreOffice 转 PDF → pdftotext 提取文本 → 按段落切片写入 knowledge_chunk
---
@@ -141,8 +145,8 @@ PPT/Word → LibreOffice 转 PDF → PyMuPDF 提取文本 → 按段落切片写
- **位置:** 全局常驻右侧,可收起
- **自动上下文:** 打开产品 / 课程 → 自动带入当前页面全部资料
- **快捷按钮:** 客户情景演练 | 查询佣金/规则 | 产品对比
- **AI 底座:** OpenAI 兼容接口(内网 Ollama / vLLM / LLM 网关)
- **知识检索:** MySQL 全文索引(关键词)+ FAISS 向量检索(语义)双路混合召回
- **AI 底座:** OpenAI 兼容接口(内网 Ollama / vLLM / 内网网关)
- **知识检索:** Go 原生 brute-force 向量检索(余弦)+ 关键词兜底(对齐 D07/D13)
- **能力边界:** 仅解答公司、产品、佣金、销售话术、业务规则,禁止闲聊和编造数据
---
@@ -158,7 +162,6 @@ PPT/Word → LibreOffice 转 PDF → PyMuPDF 提取文本 → 按段落切片写
## 7. 明确禁止开发的功能(V1.1 约束,部分已随平台升级放开)
- ~~无学习进度仪表盘、无学情分析、无能力档案~~ → V1.6 起已实现(积分/排行榜/证书/学习档案/能力雷达)
- ~~无向量检索~~ → 已采用 MySQL + FAISS 混合检索
- 无复杂权限、无多级角色、无多租户洋葱模型 → 仍保持
- 无视频转码、无语音转写(ASR)→ 仍保持
- 无外网依赖、严禁直连公网 AI API → 仍保持
@@ -174,9 +177,9 @@ PPT/Word → LibreOffice 转 PDF → PyMuPDF 提取文本 → 按段落切片写
| **user** | id, username, password_hash, full_name, role(employee/admin), status(active/disabled) |
| **product** | id, code, name, category, tags, description, pricing, commission_recommend, commission_negotiate, public_course_bonus, version_risk, report_rules, status |
| **course** | id, code, name, category, target_customers, forbidden_customers, scripts, sales_process, objection_handling, delivery_pitfalls, report_rules, related_product_id |
| **media_file** | id, filename, stored_name, stored_path, file_ext, file_size, status(pending/approved/rejected), source, submitter_id, bind_type, bind_id, extracted |
| **knowledge_chunk** | id, media_file_id, source_type, source_id, chunk_index, content |
| **question** | id, domain, course_id, type(single/multiple/judge), stem, options(JSON), answer(JSON), explanation, status |
| **media_file** | id, filename, file_path, file_size, file_type, status(pending/approved/rejected), source, submitter_id, bind_type, bind_id, extract_status |
| **knowledge_chunk** | id, media_file_id, knowledge_source_id, source_type, source_id, chunk_index, content |
| **question** | id, domain, course_id, type(single/multiple/judge/essay), stem, options(JSON), answer(JSON), explanation, status |
| **exam_paper** | id, name, type(self_test/formal), domain, question_count, total_score, pass_score, duration_minutes, randomize |
| **exam_record** | id, user_id, paper_id, exam_name, score, total_score, pass_score, passed, correct_count, wrong_count, detail_json |
@@ -186,11 +189,11 @@ PPT/Word → LibreOffice 转 PDF → PyMuPDF 提取文本 → 按段落切片写
```
Nginx(反代 + 前端 Vue 静态打包)
→ FastAPI 后端服务
→ MySQL 数据库
→ LibreOffice 文档转换容器
→ eai_agentplatform-server(Go 单二进制 :8080)
→ SQLite 单文件 data/eai_agentplatform.db
→ LibreOffice + pdftotext(裸进程)
→ 内网 LLM 服务(Ollama / vLLM / 内网网关)
→ 文件存储:本地磁盘 data/media
→ 文件存储:data/kb_data
```
完全无外网、无云服务。
完全无外网、无云服务、无 Docker、无 MySQL、无 FAISS。
+62 -13
View File
@@ -1,18 +1,19 @@
# eai_agentplatform_app — API 文档
# eai_agentplatform — API 文档
> **版本:V1.1 | 协议:HTTP/JSON | 认证:JWT Bearer**
> **最后更新:2026-08-15**
>
> **⚠️ 本文档为 V1.1 设计期基线,后端已重写为 Go + Gin(见 docs/changelog.md V1.2)。** V1.4–V1.7 新增的岗位(`/api/positions`)、部门(`/api/departments`)、消息(`/api/notifications`)、积分(`/api/points`)、证书(`/api/exam/certificates`)、错题本(`/api/exam/mistakes`)、学习档案(`/api/my/profile`)等接口未收录于本文档,以 `docs/changelog.md` 为准。
> **版本:V1.3 | 协议:HTTP/JSON | 认证:JWT Bearer**
> **后端:Go + Gin + GORM + SQLite**
> **最后更新:2026-08-16**
---
## 通用约定
### 认证方式
所有 API(除 login 外)需在请求头携带:`Authorization: Bearer <token>`
### 响应格式
```json
{
"data": { ... },
@@ -31,6 +32,7 @@
```
### HTTP 状态码
| 状态码 | 含义 |
|--------|------|
| 200 | 成功 |
@@ -47,6 +49,7 @@
## 1. 认证 API
### `POST /api/auth/login`
**描述:** 登录获取 JWT Token
**请求体:**
@@ -74,6 +77,7 @@
```
### `GET /api/auth/me`
**描述:** 获取当前登录用户信息
**认证:** 必需
@@ -82,6 +86,7 @@
## 2. 公司介绍培训 API
### `GET /api/company-train`
**描述:** 获取公司介绍内容
**认证:** 必需
@@ -103,6 +108,7 @@
```
### `POST /api/company-train/suggest-material`
**描述:** 员工提交素材建议(弹窗)
**认证:** 必需
@@ -115,25 +121,31 @@
## 3. 产品知识 API
### `GET /api/products`
**描述:** 获取产品列表
**参数:** `?category=capital_consulting`(可选,按分类筛选)
**认证:** 必需
### `GET /api/products/{id}`
**描述:** 获取产品详情
**认证:** 必需
### `POST /api/products` (管理员)
**描述:** 新增产品
**认证:** 需 admin 角色
### `PUT /api/products/{id}` (管理员)
**描述:** 编辑产品
### `DELETE /api/products/{id}` (管理员)
**描述:** 停用产品(软删除)
### `POST /api/products/import` (管理员)
**描述:** 批量导入产品(Excel / JSON)
---
@@ -141,19 +153,23 @@
## 4. 销售培训 API
### `GET /api/courses`
**描述:** 获取课程列表
**参数:** `?category=capital_script`(可选)
**认证:** 必需
### `GET /api/courses/{id}`
**描述:** 获取课程详情(含绑定产品信息)
**认证:** 必需
### `POST /api/courses` (管理员)
**描述:** 新增课程
**认证:** 需 admin 角色
### `PUT /api/courses/{id}` (管理员)
**描述:** 编辑课程,绑定/解绑产品
---
@@ -163,11 +179,13 @@
### 5.1 题库管理(管理员)
#### `GET /api/exam/questions`
**描述:** 题目列表
**参数:** `?domain=company&status=active`
**认证:** 需 admin 角色
#### `POST /api/exam/questions`
**描述:** 新增题目
**认证:** 需 admin 角色
@@ -182,7 +200,7 @@
{"key": "A", "text": "资本咨询"},
{"key": "B", "text": "AI产业落地"},
{"key": "C", "text": "房地产开发"},
{"key": "D", "text": "以上都是"}
{"key": "D", "text":"以上都是"}
],
"answer": ["D"],
"explanation": "博昇双主营业务为资本咨询与AI产业落地"
@@ -190,17 +208,21 @@
```
#### `PUT /api/exam/questions/{id}`
**描述:** 编辑题目
#### `DELETE /api/exam/questions/{id}`
**描述:** 停用题目
### 5.2 考试配置(管理员)
#### `GET /api/exam/papers`
**描述:** 考试配置列表
#### `POST /api/exam/papers`
**描述:** 创建考试配置
**认证:** 需 admin 角色
@@ -221,14 +243,17 @@
### 5.3 学员端考试
#### `GET /api/exam/list`
**描述:** 我的考试列表(所有可用考试 + 状态)
**认证:** 必需
#### `GET /api/exam/cover?id={paperId}`
**描述:** 考试封面/说明
**认证:** 必需
#### `POST /api/exam/start`
**描述:** 开始考试,下发题目
**认证:** 必需
@@ -263,6 +288,7 @@
```
#### `POST /api/exam/submit`
**描述:** 交卷判分
**认证:** 必需
@@ -294,14 +320,14 @@
}
```
**响应(自测):** 同上,但不会持久化到 exam_record
#### `GET /api/exam/record`
**描述:** 我的考试记录列表
**认证:** 必需(员工仅自己,管理员可看全部)
**参数:** `?page=1&size=20`
#### `GET /api/exam/record/{recordId}`
**描述:** 考试记录详情(答题明细回溯)
**认证:** 必需
@@ -310,6 +336,7 @@
## 6. 素材/媒体 API
### `POST /api/media/upload`
**描述:** 上传文件(文档 ≤ 200MB 直传)
**认证:** 必需
@@ -318,7 +345,8 @@
- `bind_type`: company / product / course / none
- `bind_id`: 绑定实体 ID(可选)
### `POST /api/media/upload-init`
### `POST /api/media/upload/init`
**描述:** 初始化分片上传(视频 > 100MB)
**认证:** 必需
@@ -343,7 +371,8 @@
}
```
### `POST /api/media/upload-chunk`
### `POST /api/media/upload/chunk`
**描述:** 上传分片
**认证:** 必需
@@ -352,7 +381,8 @@
- `chunk_index`: 分片序号(从 0 开始)
- `file`: 分片二进制
### `POST /api/media/upload-complete`
### `POST /api/media/upload/complete`
**描述:** 完成分片上传,合并文件
**认证:** 必需
@@ -364,10 +394,12 @@
```
### `GET /api/media/preview/{mediaId}`
**描述:** 获取预览 URL / 信息(仅 approved 素材)
**认证:** 必需
### `GET /api/media/status/{mediaId}`
**描述:** 查询素材状态(含提取进度)
**认证:** 必需
@@ -376,11 +408,13 @@
## 7. 素材审批 API(管理员)
### `GET /api/media/audit-list`
**描述:** 待审批素材列表
**参数:** `?status=pending&page=1&size=20`
**认证:** 需 admin 角色
### `POST /api/media/audit/{mediaId}`
**描述:** 审批素材
**认证:** 需 admin 角色
@@ -399,6 +433,7 @@
> 结构化知识源(`docs/knowledge_source/*.md`)的扫描、审批、摄入。完整设计见 `04_Backend/BE05_Knowledge_Ingest_Module.md`。
### `POST /api/knowledge/scan`
**描述:** 扫描 knowledge_source 目录,为新增/变更的 md 建 pending 记录
**认证:** 需 admin 角色
@@ -414,11 +449,13 @@
```
### `GET /api/knowledge/audit-list`
**描述:** 知识源审批列表
**参数:** `?status=pending&page=1&size=20`
**认证:** 需 admin 角色
### `POST /api/knowledge/audit/{sourceId}`
**描述:** 审批知识源(approve → 同步摄入 product/question/chunk;reject → 必填理由)
**认证:** 需 admin 角色
@@ -444,6 +481,7 @@
```
### `GET /api/knowledge/status/{sourceId}`
**描述:** 查询知识源审批/摄入状态
**认证:** 必需
@@ -452,6 +490,7 @@
## 9. AI PathCoach API
### `POST /api/ai-chat/message`
**描述:** 发送消息给 AI PathCoach
**认证:** 必需
@@ -473,6 +512,7 @@ data: {"type": "done"}
```
### `GET /api/ai-chat/quick-actions`
**描述:** 获取快捷按钮列表
**认证:** 必需
@@ -490,6 +530,7 @@ data: {"type": "done"}
```
### `POST /api/ai-chat/quick-action`
**描述:** 触发快捷动作
**认证:** 必需
@@ -508,10 +549,12 @@ data: {"type": "done"}
### 10.1 用户管理
#### `GET /api/system/users`
**描述:** 用户列表
**认证:** 需 admin 角色
#### `POST /api/system/users`
**描述:** 创建用户
**认证:** 需 admin 角色
@@ -526,24 +569,29 @@ data: {"type": "done"}
```
#### `PUT /api/system/users/{id}`
**描述:** 编辑用户(可禁用、改角色)
### 10.2 考试成绩管理
#### `GET /api/system/exam-records`
**描述:** 全部考试成绩(支持按用户/考试筛选)
**认证:** 需 admin 角色
#### `GET /api/system/exam-records/{id}`
**描述:** 考试记录详情
### 10.3 系统参数配置
#### `GET /api/system/config`
**描述:** 获取所有系统参数
**认证:** 需 admin 角色
#### `PUT /api/system/config`
**描述:** 更新系统参数
**认证:** 需 admin 角色
@@ -564,13 +612,14 @@ data: {"type": "done"}
## 11. 健康检查
### `GET /api/health`
**描述:** 服务健康检查
**认证:** 不需要
**响应:**
```json
{
"status": "ok",
"service": "eai_agentplatform-app",
"version": "1.1.0"
"service": "eai_agentplatform",
"version": "1.3.0"
}
```
+1 -26
View File
@@ -1,4 +1,4 @@
# eai_agentplatform_app — 变更日志
# eai_agentplatform — 变更日志
> **最后更新:2026-08-16**
@@ -86,12 +86,6 @@
normalizeUserAnswer/parseEssayScore/scoreBand/round1)、`internal/ai/retrieve_test.go`
(splitTerms/cosine);`go test ./...` 全绿
### 待确认(落地时按设计默认值,可一键调整)
- 错题本写入范围:现自测/正式均写入;如需仅正式考写入,去掉自测分支即可
- 简答题评分失败降级:LLM 路由未配置时交卷报错(Fail Fast);运行时调用失败则评 0 分并
在评语标注「评分失败」,不静默吞掉
- 蓝图维度:题目本身不携带 L1-L4 级别字段,故蓝图按「域 + 题型」抽样,不做级别加权
---
## V1.4(2026-08-16)
@@ -110,11 +104,6 @@
- 前端「岗位管理」页(`/system/positions`)+ 知识映射编辑面板;用户管理页「关联岗位」行内下拉;
题库管理试卷表单「关联岗位」下拉
### 待确认(落地时按设计默认值,可一键调整)
- 岗位考试课程过滤语义:现为宽松 `course_id IN ? OR course_id IS NULL`(岗位绑课程后仍纳入
「未绑课程但域匹配」的题);如需课程强约束,改为严格匹配 `course_id IN ?`
- 映射去重约束:现不建 `UNIQUE`,允许同岗位同域多条(如按产品细分)
---
## V1.3(2026-08-16)
@@ -122,8 +111,6 @@
**目录重构 + 全功能联调测试 + 修复 1 个缺陷。**
### 变更
- `docs/`(设计文档)从 `eai_agentplatform_app/` 移出到仓库根 `pj0231-eai_agentplatforming/docs/`,与 `CODING_RULES.md` 平级
- `knowledge_source/`(知识源运行时数据)从 `docs/` 移入 `backend-go/knowledge_source/`,归后端
- 后端 `KNOWLEDGE_SOURCE_DIR` 默认值 `../docs/knowledge_source` → `knowledge_source`;部署路径 `/opt/eai_agentplatform/knowledge_source`
- 前后端全功能联调测试通过(认证/权限边界/产品/课程/考试/素材审批/知识入库/AI)
- 素材存储目录 `data/media` → `data/kb_data`,子目录三态化:`approved/`(已审批公开)、
@@ -147,11 +134,6 @@
- 部署:裸进程 + systemd + Clonezilla 整盘克隆,Go 单二进制交付(无 Docker、无源码交付)
- 文件存储路径:`backend/data/media` → `data/media`(Go 后端工作目录下,部署为 `/opt/eai_agentplatform/data/media`)
### 说明
- `docs/` 下历史设计文档(SY/AR/BE/设计稿)中残留的 `backend/`、`backend/data/media`、
`backend/app/*` 及 Docker `build: ./backend` 等路径,为 **V1.1 设计期历史快照**,不再反映当前实现;
当前实现以 `docs/db_schema.md`、`docs/deploy.md`、`backend-go/deploy/DELIVERY.md` 为准。
---
## V1.1(2026-08-15)
@@ -169,13 +151,6 @@
- 核心数据表字段设计(8 张核心表 + system_config)
- 部署架构补充 LLM 服务与 Nginx 反代
### 变更
- V1.0 → V1.1 整体修订
### 修复
- 消除「无外网依赖」与「配置 LLM 地址」的矛盾
- 消除「不做转码」与「视频进 AI」的矛盾
---
## V1.0(初始版)
+218 -222
View File
@@ -1,265 +1,260 @@
# eai_agentplatform_app — 数据库 Schema
# eai_agentplatform — 数据库 Schema
> **版本:V1.2 | 引擎:MySQL 8.0(关系数据)+ FAISS(向量检索)| ORM:GORM**
> **版本:V1.3 | 引擎:SQLite(单文件,GORM AutoMigrate 建表)**
> **最后更新:2026-08-16**
---
## 约定
> 数据库使用 **MySQL 8.0**(GORM AutoMigrate 建表),AI 知识检索采用 **MySQL 全文索引(关键词)+ FAISS 向量检索(语义)双路混合召回**。
> 下列 DDL 为权威字段定义;`ENUM`、`FULLTEXT ngram`、`ON DELETE` 外键级联为 MySQL 原生能力。
> V1.4–V1.7 新增表(岗位/积分/证书/部门/消息等)见文末「新增表清单」。
- 主键 `id` 为 `BIGINT UNSIGNED AUTO_INCREMENT`
- 数据库使用 **SQLite 单文件** `data/eai_agentplatform.db`(GORM AutoMigrate 首次启动自动建表)
- AI 知识检索为 **Go 原生 brute-force 向量检索**(余弦相似度)+ 关键词兜底(不依赖外部向量库)
- 主键 `id` 为 `INTEGER PRIMARY KEY AUTOINCREMENT`
- 时间字段 `created_at` / `updated_at` 为 `DATETIME`,默认 `CURRENT_TIMESTAMP`
- 外键命名规则:`fk_{本表}_{关联表}_{字段}`
- 索引命名规则:`idx_{表}_{字段}`
- `INTEGER` 用于 `BIGINT` 场景(Go `uint`/`uint64`)
- `VARCHAR(n)` 映射为 `TEXT(n)`
- SQLite 外键默认关闭,需 `PRAGMA foreign_keys = ON`
---
## 1. `user` — 用户表
```sql
```
CREATE TABLE user (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE COMMENT '登录用户名',
password_hash VARCHAR(256) NOT NULL COMMENT 'bcrypt 哈希',
full_name VARCHAR(64) NOT NULL COMMENT '显示姓名',
role ENUM('employee','admin') NOT NULL DEFAULT 'employee',
status ENUM('active','disabled') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_user_role (role),
INDEX idx_user_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'employee',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_user_role ON user(role);
CREATE INDEX idx_user_status ON user(status);
```
**说明:**
- `role=admin` 为管理员,`role=employee` 为普通员工
- `status=disabled` 时 token 校验拒绝访问
- 仅管理员可创建 / 禁用账号
- `role` 枚举:`employee` / `admin`
- `status` 枚举:`active` / `disabled`
- `disabled` 时 token 校验拒绝访问
- 仅管理员可创建/禁用账号
---
## 2. `product` — 产品表
```sql
CREATE TABLE product (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE COMMENT '产品编号',
name VARCHAR(128) NOT NULL COMMENT '产品名称',
category ENUM('capital_consulting','qualification_counseling',
'ai_consulting','ai_tools_platform') NOT NULL
COMMENT '四大分类',
tags VARCHAR(256) DEFAULT '' COMMENT '标签,逗号分隔',
description TEXT COMMENT '业务内容说明',
pricing TEXT COMMENT '收费结构',
commission_recommend VARCHAR(128) DEFAULT '' COMMENT '推荐佣金比例',
commission_negotiate VARCHAR(128) DEFAULT '' COMMENT '谈单参与佣金比例',
public_course_bonus VARCHAR(128) DEFAULT '' COMMENT '公开课超额分成',
version_risk TEXT COMMENT '版本风险',
report_rules TEXT COMMENT '报备规则备注',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_product_category (category),
INDEX idx_product_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
CREATE TABLE product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL,
tags TEXT DEFAULT '',
description TEXT,
pricing TEXT,
commission_recommend TEXT DEFAULT '',
commission_negotiate TEXT DEFAULT '',
public_course_bonus TEXT DEFAULT '',
version_risk TEXT,
report_rules TEXT,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_product_category ON product(category);
CREATE INDEX idx_product_status ON product(status);
```
**字段说明:**
- `category` 枚举:`capital_consulting` / `qualification_counseling` / `ai_consulting` / `ai_tools_platform`
- `status` 枚举:`active` / `inactive`
---
## 3. `course` — 课程表
```sql
CREATE TABLE course (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE COMMENT '课程编号',
name VARCHAR(128) NOT NULL COMMENT '课程名称',
category ENUM('capital_script','qualification_logic',
'ai_public_course','ai_platform_matching') NOT NULL
COMMENT '四大课程分类',
target_customers TEXT COMMENT '适配客户',
forbidden_customers TEXT COMMENT '禁接客户',
scripts TEXT COMMENT '标准话术',
sales_process TEXT COMMENT '谈单流程',
objection_handling TEXT COMMENT '异议处理',
delivery_pitfalls TEXT COMMENT '交付避坑',
report_rules TEXT COMMENT '报备规范',
related_product_id BIGINT UNSIGNED DEFAULT NULL COMMENT '绑定产品ID',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_course_category (category),
INDEX idx_course_status (status),
INDEX idx_course_product (related_product_id),
CONSTRAINT fk_course_product FOREIGN KEY (related_product_id) REFERENCES product(id)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
CREATE TABLE course (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL,
target_customers TEXT,
forbidden_customers TEXT,
scripts TEXT,
sales_process TEXT,
objection_handling TEXT,
delivery_pitfalls TEXT,
report_rules TEXT,
related_product_id INTEGER,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_course_category ON course(category);
CREATE INDEX idx_course_status ON course(status);
CREATE INDEX idx_course_product ON course(related_product_id);
```
**字段说明:**
- `category` 枚举:`capital_script` / `qualification_logic` / `ai_public_course` / `ai_platform_matching`
- `status` 枚举:`active` / `inactive`
- `related_product_id` 可空(外键应用层维护)
---
## 4. `media_file` — 素材文件表
```sql
CREATE TABLE media_file (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(256) NOT NULL COMMENT '原始文件名',
stored_name VARCHAR(64) NOT NULL COMMENT '存储UUID文件名',
stored_path VARCHAR(512) NOT NULL COMMENT '存储相对路径',
file_ext VARCHAR(16) NOT NULL COMMENT '扩展名(小写无点)',
file_size BIGINT UNSIGNED NOT NULL COMMENT '文件大小(字节)',
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
source ENUM('employee','admin') NOT NULL COMMENT '上传来源',
submitter_id BIGINT UNSIGNED NOT NULL COMMENT '提交人ID',
remark VARCHAR(512) DEFAULT NULL COMMENT '提交备注(员工素材建议)',
bind_type ENUM('company','product','course','none') NOT NULL DEFAULT 'none'
COMMENT '绑定类型',
bind_id BIGINT UNSIGNED DEFAULT NULL COMMENT '绑定实体ID',
reject_reason VARCHAR(512) DEFAULT NULL COMMENT '驳回理由',
audit_by BIGINT UNSIGNED DEFAULT NULL COMMENT '审批人ID',
audit_at DATETIME(6) DEFAULT NULL COMMENT '审批时间',
extracted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已提取文本',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_media_status (status),
INDEX idx_media_submitter (submitter_id),
INDEX idx_media_bind (bind_type, bind_id),
INDEX idx_media_extracted (extracted),
CONSTRAINT fk_media_submitter FOREIGN KEY (submitter_id) REFERENCES user(id),
CONSTRAINT fk_media_auditor FOREIGN KEY (audit_by) REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
CREATE TABLE media_file (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
file_type TEXT,
status TEXT DEFAULT 'pending',
source TEXT DEFAULT 'employee',
submitter_id INTEGER,
remark TEXT,
bind_type TEXT DEFAULT 'none',
bind_id INTEGER,
reject_reason TEXT,
audit_by INTEGER,
audit_at DATETIME,
file_category TEXT,
extract_status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_media_status ON media_file(status);
CREATE INDEX idx_media_submitter ON media_file(submitter_id);
CREATE INDEX idx_media_bind ON media_file(bind_type, bind_id);
CREATE INDEX idx_media_extract ON media_file(extract_status);
```
**字段说明:**
- `status`:`pending` / `approved` / `rejected`
- `source`:`employee` / `admin`
- `bind_type`:`company` / `product` / `course` / `none`
- `extract_status`:`pending` / `extracting` / `completed` / `failed`
---
## 5. `knowledge_chunk` — 知识块表
```sql
CREATE TABLE knowledge_chunk (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
media_file_id BIGINT UNSIGNED DEFAULT NULL COMMENT '关联素材ID(与 knowledge_source_id 二选一)',
knowledge_source_id BIGINT UNSIGNED DEFAULT NULL COMMENT '关联知识源ID(与 media_file_id 二选一)',
source_type VARCHAR(32) NOT NULL COMMENT '来源类型(pdf/doc/md)',
source_id VARCHAR(64) DEFAULT NULL COMMENT '来源标识',
chunk_index INT UNSIGNED NOT NULL COMMENT '段落序号',
content TEXT NOT NULL COMMENT '文本内容',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_kc_media (media_file_id),
INDEX idx_kc_source (knowledge_source_id),
FULLTEXT INDEX ft_kc_content (content) WITH PARSER ngram COMMENT 'MySQL全文索引(ngram 中文分词)用于AI检索',
CONSTRAINT fk_kc_media FOREIGN KEY (media_file_id) REFERENCES media_file(id)
ON DELETE CASCADE,
CONSTRAINT fk_kc_source FOREIGN KEY (knowledge_source_id) REFERENCES knowledge_source(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
> 约束说明:`media_file_id` 与 `knowledge_source_id` 二者必居其一(应用层校验,Fail Fast)。
```
CREATE TABLE knowledge_chunk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_file_id INTEGER,
knowledge_source_id INTEGER,
source_type TEXT NOT NULL,
source_id TEXT,
knowledge_space_key TEXT,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_kc_media ON knowledge_chunk(media_file_id);
CREATE INDEX idx_kc_source ON knowledge_chunk(knowledge_source_id);
CREATE INDEX idx_kc_space ON knowledge_chunk(knowledge_space_key);
```
**说明:**
- `media_file_id` 与 `knowledge_source_id` 二者必居其一(应用层校验)
- `source_type`:`pdf` / `doc` / `md`
- 文本存储不建全文索引,检索由 Go 层 brute-force 余弦完成
---
## 6. `question` — 题目表
```sql
CREATE TABLE question (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
domain ENUM('company','product','sales') NOT NULL
COMMENT '所属知识域',
course_id BIGINT UNSIGNED DEFAULT NULL COMMENT '绑定课程(sales域时可选)',
type ENUM('single','multiple','judge') NOT NULL
COMMENT '题型',
stem TEXT NOT NULL COMMENT '题干',
options JSON NOT NULL COMMENT '选项JSON',
answer JSON NOT NULL COMMENT '正确答案JSON',
explanation TEXT DEFAULT '' COMMENT '答案解析',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_question_domain (domain),
INDEX idx_question_course (course_id),
INDEX idx_question_status (status),
CONSTRAINT fk_question_course FOREIGN KEY (course_id) REFERENCES course(id)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
CREATE TABLE question (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
course_id INTEGER,
type TEXT NOT NULL,
stem TEXT NOT NULL,
options TEXT NOT NULL,
answer TEXT NOT NULL,
explanation TEXT DEFAULT '',
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_question_domain ON question(domain);
CREATE INDEX idx_question_course ON question(course_id);
CREATE INDEX idx_question_status ON question(status);
```
**字段说明:**
- `type` 枚举:`single` / `multiple` / `judge` / `essay`(V1.5 新增)
- `options` / `answer` 存储 JSON 文本
---
## 7. `exam_paper` — 考试配置表
```sql
CREATE TABLE exam_paper (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL COMMENT '考试名称',
type ENUM('self_test','formal') NOT NULL
COMMENT '考试类型',
domain VARCHAR(64) NOT NULL COMMENT '抽题知识域(逗号分隔)',
question_count INT UNSIGNED NOT NULL COMMENT '题量',
total_score INT UNSIGNED NOT NULL COMMENT '总分',
pass_score INT UNSIGNED NOT NULL COMMENT '合格线',
duration_minutes INT UNSIGNED NOT NULL COMMENT '考试时长(分钟)',
randomize TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否随机抽题',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_paper_type (type),
INDEX idx_paper_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
CREATE TABLE exam_paper (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
domain TEXT NOT NULL,
question_count INTEGER NOT NULL,
total_score INTEGER NOT NULL,
pass_score INTEGER NOT NULL,
duration_minutes INTEGER NOT NULL,
randomize INTEGER DEFAULT 1,
status TEXT DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_paper_type ON exam_paper(type);
CREATE INDEX idx_paper_status ON exam_paper(status);
```
**字段说明:**
- `type`:`self_test` / `formal`
---
## 8. `exam_record` — 考试记录表
```sql
```
CREATE TABLE exam_record (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL COMMENT '考生ID',
paper_id BIGINT UNSIGNED NOT NULL COMMENT '考试配置ID',
exam_name VARCHAR(128) NOT NULL COMMENT '考试名称(冗余)',
score INT UNSIGNED NOT NULL COMMENT '得分',
total_score INT UNSIGNED NOT NULL COMMENT '总分(冗余)',
pass_score INT UNSIGNED NOT NULL COMMENT '合格线(冗余)',
passed TINYINT(1) NOT NULL COMMENT '是否通过',
correct_count INT UNSIGNED NOT NULL COMMENT '答对数',
wrong_count INT UNSIGNED NOT NULL COMMENT '答错数',
detail_json JSON NOT NULL COMMENT '答题明细JSON',
submitted_at DATETIME(6) NOT NULL COMMENT '交卷时间',
INDEX idx_record_user (user_id),
INDEX idx_record_paper (paper_id),
INDEX idx_record_passed (passed),
INDEX idx_record_submitted (submitted_at),
CONSTRAINT fk_record_user FOREIGN KEY (user_id) REFERENCES user(id)
ON DELETE CASCADE,
CONSTRAINT fk_record_paper FOREIGN KEY (paper_id) REFERENCES exam_paper(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
paper_id INTEGER NOT NULL,
exam_name TEXT NOT NULL,
score INTEGER NOT NULL,
total_score INTEGER NOT NULL,
pass_score INTEGER NOT NULL,
passed INTEGER NOT NULL,
correct_count INTEGER NOT NULL,
wrong_count INTEGER NOT NULL,
detail_json TEXT NOT NULL,
submitted_at DATETIME NOT NULL
);
CREATE INDEX idx_record_user ON exam_record(user_id);
CREATE INDEX idx_record_paper ON exam_record(paper_id);
CREATE INDEX idx_record_passed ON exam_record(passed);
CREATE INDEX idx_record_submitted ON exam_record(submitted_at);
```
---
## 9. `system_config` — 系统参数配置表
```sql
```
CREATE TABLE system_config (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(64) NOT NULL UNIQUE COMMENT '配置键',
config_value TEXT NOT NULL COMMENT '配置值',
description VARCHAR(256) DEFAULT '' COMMENT '配置说明',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
id INTEGER PRIMARY KEY AUTOINCREMENT,
config_key TEXT NOT NULL UNIQUE,
config_value TEXT NOT NULL,
description TEXT DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_config_key ON system_config(config_key);
```
### 默认配置项
@@ -278,29 +273,32 @@ CREATE TABLE system_config (
## 10. `knowledge_source` — 结构化知识源表
> 承载 `docs/knowledge_source/*.md` 的审批与摄入状态。完整设计见 `04_Backend/BE05_Knowledge_Ingest_Module.md`。
完整设计见 `04_Backend/BE05_Knowledge_Ingest_Module.md`。
```sql
CREATE TABLE knowledge_source (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(256) NOT NULL COMMENT '文档标题',
file_path VARCHAR(512) NOT NULL UNIQUE COMMENT 'md 相对路径(docs/knowledge_source/ 下)',
category VARCHAR(64) NOT NULL COMMENT '分类:general/capital_consulting/qualification_counseling/ai_consulting/ai_tools_platform',
domain ENUM('company','product','sales') NOT NULL DEFAULT 'product',
source_version VARCHAR(32) NOT NULL COMMENT '源版本,如 V1.0',
audit_status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
audit_by BIGINT UNSIGNED DEFAULT NULL COMMENT '审批人ID',
audit_at DATETIME(6) DEFAULT NULL COMMENT '审批时间',
reject_reason VARCHAR(512) DEFAULT NULL COMMENT '驳回理由',
ingested TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已摄入',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
INDEX idx_ks_status (audit_status),
INDEX idx_ks_category (category),
CONSTRAINT fk_ks_auditor FOREIGN KEY (audit_by) REFERENCES user(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
CREATE TABLE knowledge_source (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
domain TEXT DEFAULT 'product',
source_version TEXT NOT NULL,
audit_status TEXT DEFAULT 'pending',
audit_by INTEGER,
audit_at DATETIME,
reject_reason TEXT,
ingested INTEGER DEFAULT 0,
knowledge_space_key TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_ks_status ON knowledge_source(audit_status);
CREATE INDEX idx_ks_category ON knowledge_source(category);
```
**字段说明:**
- `audit_status`:`pending` / `approved` / `rejected`
- `domain`:`company` / `product` / `sales`
---
@@ -318,9 +316,7 @@ knowledge_source ──< knowledge_chunk (knowledge_source_id)
exam_paper ──< exam_record (paper_id)
```
## 附:V1.4–V1.7 新增表清单
> 下表未在本文件给出完整 DDL,字段与建表细节以 `docs/changelog.md` 对应版本及 Go 后端 GORM 模型为准。
## 附:V1.4–V1.7 新增表
| 版本 | 表名 | 用途 |
|------|------|------|
@@ -329,7 +325,7 @@ exam_paper ──< exam_record (paper_id)
| V1.5 | `position_exam_blueprint` | 岗位考试蓝图(按域+题型+题量抽题)|
| V1.5 | `mistake_record` | 错题本(按 user+question 去重,标记已掌握)|
| V1.6 | `point_event` | 学习积分流水(可审计)|
| V1.6 | `certificate` | 考试合格证书(按 exam_record 幂等去重)|
| V1.6 | `certificate` | 考试合格证书(按 exam_record_id 幂等去重)|
| V1.7 | `department` | 部门字典(部门改名同步 user.department)|
| V1.7 | `notification` | 站内消息通知(事件钩子驱动)|
+21 -25
View File
@@ -1,6 +1,6 @@
# eai_agentplatform_app — 部署文档
# eai_agentplatform — 部署文档
> **版本:V1.2 | 部署模式:纯本地离线 · Go 单二进制 · MySQL 8.0 + FAISS · systemd · Clonezilla 整盘克隆**
> **版本:V1.3 | 部署模式:纯本地离线 · Go 单二进制 + SQLite · systemd · Clonezilla 整盘克隆**
> **最后更新:2026-08-16**
---
@@ -33,9 +33,10 @@
┌───────────┘ │ └──────────────┐
│ │ │
┌─────▼────────┐ ┌─────▼──────┐ ┌────────▼────────┐
│ MySQL 8.0 │ │ data/kb_data │ │ LibreOffice + │
│ + FAISS │ │ 文件存储 │ │ pdftotext │
│ (向量索引) │ │ │ │ (裸进程) │
│ SQLite │ │ data/kb_data │ │ LibreOffice + │
│ 单文件 │ │ 文件存储 │ │ pdftotext │
│ data/ │ │ │ │ (裸进程) │
│ eai_platform.db │ │ │ │ │
└──────────────┘ └────────────┘ └─────────────────┘
│
│ 文档转文本
@@ -56,12 +57,11 @@
|------|------|------|
| Nginx | 80/443 | HTTP 反代 + 前端静态资源托管 |
| eai_agentplatform-server | 8080 | Go 单二进制后端(Gin + GORM) |
| MySQL | 3306 | MySQL 8.0 关系数据(systemd 托管 mysqld) |
| FAISS | 内嵌 | 向量索引,由 Go 后端 cgo 内嵌,索引文件 `data/faiss/` |
| SQLite | 内嵌 | 单文件数据 `data/eai_agentplatform.db`(glebarez/sqlite 纯 Go 驱动,无需 CGO) |
| LibreOffice + pdftotext | 进程内调用 | 裸进程,非容器,文档转文本 |
| LLM 服务 | 11434 | 内网 OpenAI 兼容接口(Ollama / vLLM) |
**无 Docker、无 Python 运行时。** 后端为 Go 单二进制,MySQL 由 systemd 托管,FAISS 内嵌于后端。
**无 Docker、无 Python、无 MySQL、无 FAISS 运行时。** 后端为 Go 单二进制,数据为 SQLite 单文件(`data/eai_agentplatform.db`),知识检索为 Go 原生向量/关键词召回(embed_gen 嵌入 + 余弦,对齐 D07/D13),不依赖外部向量库。
---
@@ -69,11 +69,12 @@
```
/opt/eai_agentplatform/
├── eai_agentplatform-server # 单二进制(CGO 关闭,静态链接,约 38MB)
├── eai_agentplatform-server # 单二进制(CGO 关闭,静态链接)
├── .env # JWT 密钥、LLM 配置等
├── data/
│ ├── faiss/ # FAISS 向量索引(embedding 向量)
│ └── media/ # 素材 + 提取缓存
│ ├── eai_agentplatform.db # SQLite 单文件数据(自动迁移建表)
│ ├── kb_data/ # 知识库素材物理文件
│ └── backups/ # 定期备份(见 BACKUP_* 配置)
└── docs/
└── knowledge_source/ # 知识源 Markdown(待管理员审批入库)
```
@@ -85,19 +86,13 @@
### 1. 环境准备(系统级依赖)
```bash
# 系统依赖:Nginx(前端+反代)+ MySQL 8.0(关系数据)+ LibreOffice + pdftotext(文档转换裸进程)
apt update && apt install -y nginx mysql-server libreoffice poppler-utils
# 系统依赖:Nginx(前端+反代)+ LibreOffice + pdftotext(文档转换裸进程)
apt update && apt install -y nginx libreoffice poppler-utils
```
> LibreOffice 与 pdftotext 均为**裸进程**,由二进制内 `exec` 调用,不跑容器、不监听端口。
> MySQL 初始化库与账号(FAISS 向量索引内嵌于后端,无需独立服务):
>
> ```sql
> CREATE DATABASE eai_agentplatform DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER 'eai_agentplatform'@'127.0.0.1' IDENTIFIED BY '<随机密码>';
> GRANT ALL PRIVILEGES ON eai_agentplatform.* TO 'eai_agentplatform'@'127.0.0.1';
> FLUSH PRIVILEGES;
> ```
> 数据库为 SQLite 单文件:无需安装任何数据库服务。后端首次启动用 GORM 自动建表,
> 数据写入 `data/eai_agentplatform.db`(由 `.env` 的 `DB_PATH` 指定)。
### 2. 部署后端(单二进制 + systemd)
@@ -110,7 +105,7 @@ sudo chown -R eai_agentplatform:eai_agentplatform /opt/eai_agentplatform
# 2.2 生成 .env(JWT 密钥随机)
sudo -u eai_agentplatform cp deploy/eai_agentplatform.env /opt/eai_agentplatform/.env
NEW_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))")
NEW_SECRET=$(openssl rand -base64 48 | tr -d '\n')
sudo sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$NEW_SECRET|" /opt/eai_agentplatform/.env
# 2.3 安装 systemd 单元并启动
@@ -126,12 +121,11 @@ curl http://127.0.0.1:8080/api/health # {"status":"ok","service":"eai_agentpla
```bash
PORT=8080
DB_DSN=eai_agentplatform:<随机密码>@tcp(127.0.0.1:3306)/eai_agentplatform?charset=utf8mb4&parseTime=True&loc=Local
DB_PATH=data/eai_agentplatform.db
JWT_SECRET=<48 字符随机串,交付前生成>
LLM_BASE_URL=http://127.0.0.1:11434/v1
LLM_MODEL=qwen2.5:7b
EMBED_MODEL=bge-m3
FAISS_INDEX_DIR=data/faiss
KB_DATA_DIR=data/kb_data
KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/docs/knowledge_source
LIBREOFFICE_BIN=/usr/bin/libreoffice
@@ -190,7 +184,9 @@ ollama pull bge-m3 # 嵌入模型(向量检索)
# 默认监听 127.0.0.1:11434,Go 二进制直接走 OpenAI 兼容 /v1 接口
```
### 使用 vLLM
### 使用 vLLM(可选,外部 LLM 网关)
> 默认推荐 Ollama(无 Python 运行时)。如需以 vLLM 作为 LLM 网关可按下述配置,但该网关不属于交付系统本体,目标机需自行准备 Python 环境。
```bash
pip install vllm
+49 -61
View File
@@ -1,8 +1,8 @@
eai_agentplatform_app 博昇内部培训平台
eai_agentplatform — EAI 数字员工平台
最终完整定稿 PRD(可直接交付 AI 生成前后端代码)
版本:V1.1 最终版 | 纯本地化部署 | 极简架构 | 全功能闭环
> **⚠️ 本文档为 V1.1 设计期历史快照。** 项目已升级为「数字员工平台」(对外以「专员」为能力单元,见 `01_System_Overall/SY03` 及 SY04–SY17);技术栈已重写为 Go + Gin + GORM + MySQL 8.0 + FAISS;「不做向量检索 / 无学情分析」等约束已随 V1.6 起放开,以 `docs/changelog.md` 为准。
> **⚠️ 本文档为 V1.1 设计期历史快照。** 项目已升级为「数字员工平台」(对外以「专员」为能力单元,见 `01_System_Overall/SY03` 及 SY04–SY17);技术栈已重写为 **Go + Gin + GORM + SQLite**;「不做向量检索 / 无学情分析」等约束已随 V1.6 起放开,以 `docs/changelog.md` 为准。
修订记录
V1.0 → V1.1(修复内部不自洽):
@@ -45,20 +45,14 @@ V1.0 → V1.1(修复内部不自洽):
后端
FastAPI (Python)
SQLAlchemy ORM
MySQL 8.0
认证:JWT(python-jose)+ bcrypt 密码哈希
PDF 文本提取:PyMuPDF
Go + Gin + GORM
SQLite(modernc 纯Go驱动)
认证:JWT(golang-jwt)+ bcrypt 密码哈希
文档转换:LibreOffice(裸进程)+ pdftotext
依赖服务(共两个)
部署
1. 本地 Docker:libreoffice-preview
用于 PPT/Word → PDF 预览、文本提取入库
2. 内网 LLM 服务(OpenAI 兼容接口)
本地 Ollama / vLLM / 内网统一 LLM 网关均可
地址通过【系统参数配置】维护,启动时读取
systemd(裸进程)+ Clonezilla 整盘克隆,单二进制交付
硬性技术限制
@@ -71,7 +65,7 @@ V1.0 → V1.1(修复内部不自洽):
2. 项目目录结构(最终强制规范)
plaintext
eai_agentplatform_app/ # 项目根目录(所有代码集中此处)
eai_agentplatform/ # 项目根目录(所有代码集中此处)
├── docs/ # 独立文档目录(只放文档,不放代码)
│ ├── PRD.md
│ ├── db_schema.md
@@ -79,8 +73,7 @@ eai_agentplatform_app/ # 项目根目录(所有代码集中
│ ├── deploy.md
│ └── changelog.md
├── frontend/ # 前端完整代码
├── backend/ # 后端完整代码
├── docker-compose.yml
├── backend-go/ # Go 后端(单二进制 + systemd)
├── .gitignore
└── README.md
@@ -119,42 +112,37 @@ frontend/
后端目录结构
plaintext
backend/
├── app/
│ ├── main.py
│ ├── api/
│ │ ├── auth.py
│ │ ├── company_train.py
│ │ ├── product.py
│ │ ├── sales_train.py
│ │ ├── exam.py
│ │ ├── media.py
│ │ ├── ai_chat.py
│ │ └── system.py
│ ├── models/
│ │ ├── user.py
│ │ ├── product.py
│ │ ├── course.py
│ │ ├── media_file.py
│ │ ├── knowledge_chunk.py
│ │ ├── question.py
│ │ ├── exam_paper.py
│ │ └── exam_record.py
│ ├── schemas/
│ ├── services/
│ │ ├── media_service.py
│ │ ├── ai_service.py
│ │ └── exam_service.py
│ ├── core/
│ │ ├── config.py # 读取 .env 与系统参数
│ │ ├── security.py # JWT 签发 / 校验、密码哈希、鉴权依赖
│ │ └── deps.py
│ └── utils/
├── data/media/ # 本地文件存储(git忽略)
│ ├── upload/
│ └── _preview_cache/
├── requirements.txt
└── .env
backend-go/
├── cmd/
│ └── server.go # 入口:gin.Engine + systemd 裸进程
├── internal/
│ ├── api/ # 路由层(Gin handlers)
│ │ ├── auth.go # /api/auth/* — 登录/注册/me
│ │ ├── company_train.go # /api/company-train/*
│ │ ├── product.go # /api/products/*
│ │ ├── exam.go # /api/exam/* — 题库/组卷/考试/记录
│ │ ├── media.go # /api/media/* — 上传/预览/审批
│ │ ├── ai_chat.go # /api/ai-chat/* — PathCoach SSE
│ │ └── system.go # /api/system/* — 用户/成绩/配置
│ ├── model/ # GORM 模型
│ │ ├── user.go
│ │ ├── product.go
│ │ ├── course.go
│ │ ├── media_file.go
│ │ ├── knowledge_chunk.go
│ │ ├── question.go
│ │ ├── exam_paper.go
│ │ └── exam_record.go
│ ├── store/ # 数据库层
│ │ └── db.go # SQLite Init + AutoMigrate
│ ├── ai/ # AI 客户端与检索
│ │ ├── llm.go # OpenAI 兼容 LLM 调用
│ │ └── retrieve.go # 知识检索
│ ├── config/ # 配置加载
│ └── middleware/ # 中间件(JWT 认证等)
├── knowledge_source/ # 知识源 Markdown(运行时)
├── data/ # 运行时数据(db+media,git 忽略)
└── deploy/ # systemd 单元 + DELIVERY.md
3. 全局页面布局(全站统一)
全站固定三栏结构,所有页面统一
@@ -325,9 +313,9 @@ backend/
6.5 存储位置
物理文件:backend/data/media
文本知识库:MySQL knowledge_chunk
文件元数据:MySQL media_file
物理文件:backend-go/data/media
文本知识库:SQLite knowledge_chunk
文件元数据:SQLite media_file
6.6 上传规格
@@ -353,8 +341,8 @@ backend/
7.5 知识检索方案
检索基于已审批素材的 knowledge_chunk,采用 MySQL 全文索引 + 关键词召回。
召回片段作为上下文注入 Prompt;不做向量检索(避免引入 embedding 额外依赖,保持极简)。
检索基于已审批素材的 knowledge_chunk,采用 Go 内 brute-force 向量检索(余弦)+ 关键词兜底。
召回片段作为上下文注入 Prompt;数据量小无需 ANN 向量库。
若后续需要向量增强,作为可选项,不纳入 V1.1 范围。
7.6 AI 能力边界
@@ -485,9 +473,9 @@ plaintext
本地内网环境
Nginx(反代 + 前端 Vue 静态打包)
FastAPI 后端服务
MySQL 数据库
libreoffice-preview 本地文档转换容器
Go + Gin 后端服务
SQLite 数据库(单文件,systemd 守护)
libreoffice + pdftotext 本地文档转换
内网 LLM 服务(Ollama / vLLM / 内网网关,OpenAI 兼容接口)
文件存储:本地磁盘 data/media
完全无外网、无云服务
@@ -47,7 +47,7 @@
## A3. 重要前提:pj0231 文档已过时
- `docs/PRD.md` 写「FastAPI + MySQL + 不做向量检索」;实际代码是 **Go + Gin + GORM + SQLite**,检索已实现**向量(brute-force 余弦 + bge-m3)**。
- 本项目权威信息以 `eai_agentplatform_app/CLAUDE.md` 与**实际代码**为准。
- 本项目权威信息以 `eai_agentplatform/CLAUDE.md` 与**实际代码**为准。
---
@@ -132,10 +132,10 @@
## 7. 关键文件索引(证据)
**pj0231**(本项,实际代码为准):
- 数据模型:`eai_agentplatform_app/backend-go/internal/model/`(11 张表)
- 检索:`eai_agentplatform_app/backend-go/internal/ai/retrieve.go`(混合检索)
- 考试:`eai_agentplatform_app/backend-go/internal/api/exam.go`(组卷 / 判分 / 存档)
- AI 聊天:`eai_agentplatform_app/backend-go/internal/api/ai_chat.go`(PathCoach + 3 快捷动作)
- 数据模型:`eai_agentplatform/backend-go/internal/model/`(11 张表)
- 检索:`eai_agentplatform/backend-go/internal/ai/retrieve.go`(混合检索)
- 考试:`eai_agentplatform/backend-go/internal/api/exam.go`(组卷 / 判分 / 存档)
- AI 聊天:`eai_agentplatform/backend-go/internal/api/ai_chat.go`(PathCoach + 3 快捷动作)
**pj006**(对比项):
- 数据模型:`zlyapp/backend/app/db/schema.py`(v23,40+ 表,含 `orgs` 多租户根)
@@ -730,7 +730,7 @@ MyGPT = 强底座 + 强场景(博昇的底座 + AionUi 的场景)
## 四、博昇 pj0235 实读结果
- **当前产品名**:eai_agentplatform_app(博昇内部员工培训平台)
- **当前产品名**:eai_agentplatform(博昇 AI 数字员工平台)
- **技术栈**:Vue3 + Vite + Element Plus / Go + Gin + GORM + SQLite / Ollama bge-m3 / LibreOffice + pdftotext
- **已落地模块**:
- 知识库全套(上传→审批→摄入→混合检索→导出)
@@ -154,7 +154,7 @@ CREATE INDEX IF NOT EXISTS idx_pk_domain ON position_knowledge(domain);
### 4.3 改动:`user` 加岗位列
在 [user.go](eai_agentplatform_app/backend-go/internal/model/user.go) 的 `User` 中追加:
> 在 [user.go](../eai_agentplatform/backend-go/internal/model/user.go) 的 `User` 中追加:
```go
PositionID *uint `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空)
@@ -162,7 +162,7 @@ CREATE INDEX IF NOT EXISTS idx_pk_domain ON position_knowledge(domain);
### 4.4 改动:`exam_paper` 加岗位列
在 [exam_paper.go](eai_agentplatform_app/backend-go/internal/model/exam_paper.go) 的 `ExamPaper` 中追加:
> 在 [exam_paper.go](../eai_agentplatform/backend-go/internal/model/exam_paper.go) 的 `ExamPaper` 中追加:
```go
PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题
@@ -238,7 +238,7 @@ var validLevels = map[string]bool{LevelL1: true, LevelL2: true, LevelL3: true, L
## 6. 岗位驱动考试组卷(核心算法改动)
修改 [exam.go](eai_agentplatform_app/backend-go/internal/api/exam.go) 的 `pickQuestions`,在原有 `domain` 抽题之前,先判断 `p.PositionID` 是否为空:
修改 [exam.go](../eai_agentplatform/backend-go/internal/api/exam.go) 的 `pickQuestions`,在原有 `domain` 抽题之前,先判断 `p.PositionID` 是否为空:
```go
// pickQuestions 按 paper 抽题:优先按岗位知识映射圈定题池,否则回退 domain 抽题
-4
View File
@@ -8,10 +8,6 @@ backend-go/data/
frontend/node_modules/
frontend/dist/
# 本地知识服务
knowledge_service/data/
knowledge_service/__pycache__/
# 环境变量
.env
backend-go/.env
+7 -6
View File
@@ -1,4 +1,4 @@
# eai_agentplatform_app EAI 数字员工平台(AI Agent Platform)
# eai_agentplatform EAI 数字员工平台(AI Agent Platform)
纯内网本地化的企业 AI 数字员工平台。
围绕数字员工(专员)、知识库底座、自动化工坊、考试评估与系统管理组织业务能力。
@@ -9,7 +9,7 @@
| 层级 | 技术 |
|------|------|
| 前端 | Vue3 + Vite + Element Plus |
| 后端 | Go + Gin + GORM + SQLite(modernc 纯Go驱动,留 MySQL 兼容) |
| 后端 | Go + Gin + GORM + SQLite(modernc 纯Go驱动) |
| 认证 | JWT(golang-jwt)+ bcrypt |
| 文档转换 | LibreOffice + pdftotext(裸进程) |
| AI 底座 | 内网 OpenAI 兼容接口(Ollama:LLM + bge-m3 embedding) |
@@ -18,7 +18,7 @@
## 目录结构
```
pj0231-eai_agentplatforming/ # 工作区根(仓库根)
pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根)
├── CODING_RULES.md # 编码与调试最高准则
├── start_dev_10231_10232.sh # 开发启动脚本
├── docs/ # 设计文档(项目级,与代码分离)
@@ -33,9 +33,9 @@ pj0231-eai_agentplatforming/ # 工作区根(仓库根)
│ ├── api.md # API 文档
│ ├── deploy.md # 部署文档
│ ├── changelog.md # 变更日志
│ ├── eai_agentplatform_app设计稿.md # 完整 PRD 中文原稿
│ ├── eaisalestrain_app设计稿.md # 完整 PRD 中文原稿
│ └── 博昇产品与渠道合作表.md # 知识源权威数据
└── eai_agentplatform_app/ # 应用代码
└── eai_agentplatform/ # 应用代码
├── backend-go/ # Go 后端(正式,唯一后端)
│ ├── cmd/ internal/ # Go 源码
│ ├── knowledge_source/ # 知识源 Markdown(运行时数据)
@@ -73,7 +73,8 @@ pj0231-eai_agentplatforming/ # 工作区根(仓库根)
## 设计原则(强制执行)
- **100% 本地化部署**:不上云、无 OSS、无 MinIO、不依赖公网云服务
- **极致极简**:杜绝所有冗余功能、统计、学情分析、复杂权限
- **功能克制**:功能以业务价值为准、杜绝为加而加的冗余功能;权限保持精简(employee/admin 两级)
- **学情/成长为已决策边界(D18/D19)**:学员自助视角的学习档案/能力雷达、积分/排行榜、证书、部门学情统计均已拍板纳入;全站拒绝「社交社区/讨论区」类功能(D19)
- **审批前置**:所有上传素材必须审批通过才可生效、进入知识库、页面展示
- **视频仅预览**:MP4 不做转码、不做 ASR、不进 AI 知识库
- **混合检索**:AI 知识检索 = 向量(brute-force 余弦)+ 关键词兜底,数据量小无需 ANN 向量库
+11 -9
View File
@@ -1,12 +1,12 @@
# 项目状态总览(PROJECT_STATE)
> 单一事实源:新对话默认先读此文件恢复上下文;每次关键决策后必须更新。
> 最后更新:2026-08-24
> 最后更新:2026-09-16
## 1. 项目是什么
**eai_agentplatform_app** — EAI 数字员工平台(AI Agent Platform)。
纯内网本地化的企业 AI 数字员工平台,围绕数字员工(专员)、知识库底座、自动化工坊、
**eai_agentplatform** — EAI 数字员工平台(AI Agent Platform)。
纯内网本地化的企业 AI 数字员工平台,围绕数字员工(专家)、知识库底座、自动化工坊、
考试评估与系统管理组织业务能力。配套全局 AI 助手。
## 2. 当前状态
@@ -43,7 +43,7 @@
| # | 决策 | 状态 |
|---|------|------|
| D01 | 前端:Vue3 + Vite + Element Plus | 已定 |
| D02 | 后端:Go + Gin + GORM + SQLite(modernc 纯Go驱动,接口留 MySQL 兼容) | 已定 |
| D02 | 后端:Go + Gin + GORM + SQLite(modernc 纯Go驱动) | 已定 |
| D03 | 认证:JWT(golang-jwt)+ bcrypt | 已定 |
| D04 | 文档转换:LibreOffice(裸进程)+ pdftotext | 已定 |
| D05 | AI 底座:内网 OpenAI 兼容接口(非公网) | 已定 |
@@ -62,17 +62,19 @@
| D18 | 突破原「极致极简/杜绝学情分析」边界:新增学员成长体系 = 学习积分+排行榜(游戏化)+ 考试证书(认证)+ 学习档案/能力雷达(学情,学员自助视角);积分规则集中常量可调 | 已定(V1.6 落地) |
| D19 | 部门组织架构(department 字典表,用户以 department 字符串归属、改名同步)+ 按部门学情统计;站内消息通知(考试发布/通过发证/设岗三事件);**社交社区/讨论区定死不做** | 已定(V1.7 落地) |
| D20 | 当前阶段产品路线收口为「先通用、后定制」:先做标准化通用数字员工产品包,不直接追 WorkBuddy 的高自由度路线;第一批优先沉淀知识运营、培训考试、内容生成、任务推进 4 个通用数字员工,后续再长行业包与企业半定制 | 已定(见 `docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md`) |
| D21 | 前端建立「冻结归档」机制:停用代码不删除,集中到 `frontend/src/_frozen/`(统一 `.frozen` 后缀 + `FROZEN.md` 登记表,逐条记录原位置/冻结原因/外部依赖/**解冻条件**/解冻步骤)。首批冻结:工作区胶囊条(外壳重构后成了冗余的第二套导航)及外壳重构遗留的四个孤儿文件。**D19 的站内通知只冻前端 UI** —— 后端四个接口保留,`store`/`api` 层保留(`BusinessAppPage` 仍在用),解冻为纯前端工作量 | 已定(见 `frontend/src/_frozen/FROZEN.md`) |
| D21 | 前端曾短暂采用「冻结归档」机制保存停用代码;当前已取消该策略,早期历史代码直接删除,不再维护 `frontend/src/_frozen/`。站内通知如需恢复,按当前实现重新设计,不再依赖冻结稿。 | 已更新 |
| D22 | 任务记录**延迟到发出第一条消息时创建**(对齐 WorkBuddy:新建任务只是导航动作,不落库)。「新建任务」只做「回到 /home 开场画布 + 清空当前任务」,任务标题由第一句话决定、专员取输入框 `+` 里先选的那个(`pendingSpecialistKey` 暂存,建任务时作为 `specialist_key` 一并提交,后端已支持)。**不允许存在没聊过天的空任务** —— 原实现点一次落一条 `新任务`,是「最近任务」堆满空行的根因 | 已定(V1.8 落地) |
| D23 | 侧边栏「最近任务」的行菜单(对齐 ChatGPT 悬停 `⋯` + WorkBuddy 任务卡「更多」):**重命名**(就地编辑,回车提交/Esc 取消)、**置顶**(新增 `worker_task.pinned`,排序 `pinned DESC, updated_at DESC`)、**删除**(新增 `DELETE /api/worker/my-tasks/:id`,硬删、无回收站,确认框写明「无法恢复」)。对照后**不做**:分享任务(纯内网无意义)、打开文件夹(Web 无此能力)、保存到工作空间(项目无 workspace 概念)、归档(没有「已归档」入口可找回,等于软删陷阱)。`el-dropdown` 加 `:persistent="false"` —— 默认每行留一份隐藏菜单在 DOM 里,50 条任务就是 50 份 | 已定 |
| D24 | SQLite **定期备份内置进二进制**(不用外部 cron/systemd timer):启动时补一次 + 每小时查是否到期,默认间隔 24h、保留最近 7 份,落在 `data/backups/`(systemd 是 `ProtectSystem=strict`,只放开了 `data/` 可写)。快照用 `VACUUM INTO` 而非 `cp` 主库 —— 边服务边写时 `cp` 可能拷到写了一半的页。产出自检 SQLite 文件头,不合格即删。另提供 `-backup` 子命令供升级/迁移前手工备一份。**边界**:只防误删/误改/写坏,不防整盘损坏,跨机保存仍需人工 | 已定(见 `deploy/DELIVERY.md` 第 7 节) |
| D25 | 平台总架构继续采用 6 层,但第 5 层正式升级为 **对象层(Expert / Skill / App)**;新增与“专家 / 技能”并列的第三类一级对象 **App**,其定位是“长程任务运行壳”而非页面或大技能。任务统一定义为“工作实例容器”,可挂载主 `app`、右栏 `expert`、后台 `skill`;消息流降级为对话轨迹,长程状态由 `app_state + app_event + domain_data` 承载。产品一级导航目标态需完整描述为 **新建任务 / 项目 / 专家·技能·连接器 / 长程APP / 知识库 / 后台管理 / 我的**,其中 `知识库` 被确认为组织级默认必装的内建 App,拥有一级直达入口;其它专题型业务主导航后续应优先收敛为 `app` 并归入“长程APP”。文档锚点:`docs/01_System_Overall/SY22_Role_Skill_App_Unified_Task_Architecture.md` | 已定 |
| D26 | 路由命名正式拆分为三类以避免混同:`ai_route_*` 专指 AI 模型路由;`object_entry_route` 专指 Expert / Skill / App 等业务对象的进入入口;`page_route` 专指普通页面导航。后端 API、前端对象配置与导航配置均按该口径收口;旧 `route / entry_route` 已降为启动时一次性迁移并随后删除的历史列,不再保留为日常兼容字段。 | 已定 |
## 4. 文档索引
| 文档 | 路径 | 说明 |
|------|------|------|
| PRD | `../docs/PRD.md` | 产品需求文档(V1.1 最终版) |
| 设计稿 | `../docs/eai_agentplatform_app设计稿.md` | 完整 PRD 中文原稿 |
| 设计稿 | `../docs/eaisalestrain_app设计稿.md` | 完整 PRD 中文原稿 |
| DB Schema | `../docs/db_schema.md` | 9 张核心表字段设计 |
| API 文档 | `../docs/api.md` | API 接口定义 |
| 部署文档 | `../docs/deploy.md` | 部署架构与步骤 |
@@ -85,7 +87,7 @@
## 5. 目录结构(当前)
```
pj0231-eai_agentplatforming/ # 工作区根(仓库根)
pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根)
├── CODING_RULES.md # 编码与调试最高准则
├── start_dev_10231_10232.sh # 开发启动脚本
├── docs/ # 设计文档(项目级,与代码分离)
@@ -96,9 +98,9 @@ pj0231-eai_agentplatforming/ # 工作区根(仓库根)
│ ├── 06_Product_Lines/ # 产品线与前端设计
│ ├── 08_Design_Rules/ # UI/UX 设计规范
│ ├── PRD.md / db_schema.md / api.md / deploy.md / changelog.md
│ ├── eai_agentplatform_app设计稿.md # 完整 PRD 中文原稿
│ ├── eaisalestrain_app设计稿.md # 完整 PRD 中文原稿
│ └── 博昇产品与渠道合作表.md # 知识源权威数据
└── eai_agentplatform_app/ # 应用代码
└── eai_agentplatform/ # 应用代码
├── backend-go/ # Go 后端(正式,唯一后端,已完成)
│ ├── cmd/ internal/ # Go 源码
│ ├── knowledge_source/ # 知识源 Markdown(运行时数据)
@@ -1,214 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
type taskRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
}
type articleRow struct {
TaskID int64 `json:"task_id"`
Keyword string `json:"keyword"`
SelectedTopic string `json:"selected_topic"`
TopicHeat string `json:"topic_heat"`
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
HotspotsJSON string `json:"hotspots_json"`
TopicCandidatesJSON string `json:"topic_candidates_json"`
}
type runRow struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
ActionKey string `json:"action_key"`
ActionTitle string `json:"action_title"`
OutputJSON string `json:"output_json"`
LogsJSON string `json:"logs_json"`
StartedAt string `json:"started_at"`
}
func main() {
taskID := flag.Int64("task", 0, "official account task id")
resetStuck := flag.Bool("reset-stuck", false, "reset stuck running state for the given task")
flag.Parse()
dbPath := `d:\traecode\pj0235-eai_agentplatform\eai_ap_app\backend-go\data\eai_agentplatform.db`
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if *resetStuck {
if *taskID <= 0 {
log.Fatal("please pass -task when using -reset-stuck")
}
if err := resetOfficialAccountTaskState(db, *taskID); err != nil {
log.Fatal(err)
}
fmt.Printf("reset stuck running state for task %d\n", *taskID)
return
}
tasks := make([]taskRow, 0, 5)
taskRows, err := db.Query(`
select id, title, status, updated_at
from worker_task
where specialist_key = 'wechat-official-account'
order by id desc
limit 5
`)
if err != nil {
log.Fatal(err)
}
for taskRows.Next() {
var row taskRow
if err := taskRows.Scan(&row.ID, &row.Title, &row.Status, &row.UpdatedAt); err != nil {
log.Fatal(err)
}
tasks = append(tasks, row)
}
_ = taskRows.Close()
var article articleRow
_ = db.QueryRow(`
select task_id, keyword, selected_topic, topic_heat, outline_style, outline_words, content_target_words, hotspots_json, topic_candidates_json
from official_account_article
order by id desc
limit 1
`).Scan(
&article.TaskID,
&article.Keyword,
&article.SelectedTopic,
&article.TopicHeat,
&article.OutlineStyle,
&article.OutlineWords,
&article.ContentTargetWords,
&article.HotspotsJSON,
&article.TopicCandidatesJSON,
)
runs := make([]runRow, 0, 5)
runRows, err := db.Query(`
select id, task_id, action_key, action_title, output_json, logs_json, started_at
from worker_run
where task_id = ?
order by id desc
limit 5
`, article.TaskID)
if err != nil {
log.Fatal(err)
}
for runRows.Next() {
var row runRow
if err := runRows.Scan(&row.ID, &row.TaskID, &row.ActionKey, &row.ActionTitle, &row.OutputJSON, &row.LogsJSON, &row.StartedAt); err != nil {
log.Fatal(err)
}
runs = append(runs, row)
}
_ = runRows.Close()
var hotspotCount int
_ = db.QueryRow(`select count(1) from official_account_hotspot`).Scan(&hotspotCount)
result := map[string]any{
"tasks": tasks,
"latest_article": article,
"latest_runs": runs,
"hotspot_count": hotspotCount,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
fmt.Println(err)
}
}
func resetOfficialAccountTaskState(db *sql.DB, taskID int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
var contextJSON string
if err = tx.QueryRow(`select context_json from worker_task where id = ?`, taskID).Scan(&contextJSON); err != nil {
return err
}
type stepState struct {
Status string `json:"status"`
Progress int `json:"progress"`
ProgressText string `json:"progress_text"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
Logs []string `json:"logs"`
Output map[string]interface{} `json:"output"`
}
type workflowState struct {
CurrentStep string `json:"current_step"`
Steps map[string]*stepState `json:"steps"`
}
var workflow workflowState
if strings.TrimSpace(contextJSON) != "" {
if err = json.Unmarshal([]byte(contextJSON), &workflow); err != nil {
return err
}
}
if workflow.Steps == nil {
workflow.Steps = map[string]*stepState{}
}
for _, key := range []string{"topic_recommendation", "title_generation", "outline_generation", "content_creation"} {
step := workflow.Steps[key]
if step == nil || step.Status != "running" {
continue
}
step.Status = "error"
step.ProgressText = "上次执行已中断,请重新执行"
step.FinishedAt = time.Now().Format(time.RFC3339)
step.Logs = append(step.Logs, time.Now().Format("15:04:05")+" 执行已中断,已自动停止")
}
workflow.CurrentStep = "topic_recommendation"
newContext, err := json.Marshal(workflow)
if err != nil {
return err
}
if _, err = tx.Exec(`
update worker_task
set status = ?, current_result = ?, context_json = ?, updated_at = CURRENT_TIMESTAMP
where id = ?
`, "待处理", "", string(newContext), taskID); err != nil {
return err
}
if _, err = tx.Exec(`
update worker_run
set status = ?, output_json = ?, logs_json = ?, finished_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
where task_id = ? and status = ?
`, "error", `{"summary":"上次执行已中断,请重新执行","error":"interrupted"}`, `["执行已中断,已自动停止"]`, taskID, "running"); err != nil {
return err
}
return tx.Commit()
}
@@ -36,6 +36,8 @@ func main() {
log.Fatalf("种子数据初始化失败: %v", err)
}
config.StartAIRouteHealthLoop(30 * time.Minute)
// 定期备份:SQLite 是单文件且无外部副本,坏一份就是全丢。
// 失败只在日志里报,绝不因此拦下服务。
if cfg.BackupEnabled {
@@ -1 +1 @@
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "temperature": 1}}, "version": "2.1.0"}
{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "本地", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "快聊", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "short_model_name": "DSV3", "short_route_name": "均衡", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "short_model_name": "DS4F", "short_route_name": "默认", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "short_model_name": "GPT5", "short_route_name": "全能", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "short_model_name": "Grok4", "short_route_name": "推理", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama", "short_model_name": "BGE", "short_route_name": "本地"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter", "short_model_name": "Emb3", "short_route_name": "默认"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "short_model_name": "LLaVA", "short_route_name": "本地", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "short_model_name": "FLUX", "short_route_name": "绘图", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "short_model_name": "G图2", "short_route_name": "默认", "temperature": 1}}, "version": "2.1.0"}
@@ -1,16 +1,6 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"eai_agentplatform/backend/internal/config"
)
// 知识检索相关的数据传输类型(由 internal/api 使用)。
type KnowledgeClassifyResult struct {
Intent string `json:"intent"`
@@ -41,109 +31,3 @@ type KnowledgeIndexItem struct {
KnowledgeSpaceKey string `json:"knowledge_space_key"`
KnowledgeSpaceName string `json:"knowledge_space_name"`
}
func KnowledgeServiceEnabled(cfg *config.Config) bool {
return cfg != nil && strings.TrimSpace(cfg.KnowledgeServiceURL) != ""
}
func KnowledgeClassify(cfg *config.Config, query string) (*KnowledgeClassifyResult, error) {
var resp struct {
Data KnowledgeClassifyResult `json:"data"`
}
if err := postKnowledgeService(cfg, "/classify", map[string]any{
"query": query,
}, &resp); err != nil {
return nil, err
}
return &resp.Data, nil
}
func KnowledgeSearch(cfg *config.Config, query, spaceKey string, topK int) ([]KnowledgeSearchItem, error) {
var resp struct {
Data struct {
Items []KnowledgeSearchItem `json:"items"`
} `json:"data"`
}
if err := postKnowledgeService(cfg, "/search", map[string]any{
"query": query,
"space_key": spaceKey,
"top_k": topK,
}, &resp); err != nil {
return nil, err
}
return resp.Data.Items, nil
}
func KnowledgeRebuildIndex(cfg *config.Config, items []KnowledgeIndexItem) error {
var resp map[string]any
return postKnowledgeService(cfg, "/index/rebuild", map[string]any{
"index_dir": cfg.KnowledgeIndexDir,
"items": items,
}, &resp)
}
func postKnowledgeService(cfg *config.Config, path string, payload any, out any) error {
if !KnowledgeServiceEnabled(cfg) {
return fmt.Errorf("knowledge service disabled")
}
b, err := json.Marshal(payload)
if err != nil {
return err
}
url := strings.TrimRight(cfg.KnowledgeServiceURL, "/") + path
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// #region debug-point D:knowledge-service-request
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "D",
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:request",
"msg": "[DEBUG] knowledge service request",
"data": map[string]any{
"url": url,
"path": path,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
// #region debug-point D:knowledge-service-non200
if payload, err := json.Marshal(map[string]any{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "D",
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:non200",
"msg": "[DEBUG] knowledge service non-200",
"data": map[string]any{
"url": url,
"status": resp.StatusCode,
"bodyPreview": truncate(string(body), 240),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
return fmt.Errorf("knowledge service %d: %s", resp.StatusCode, truncate(string(body), 200))
}
if out == nil || len(body) == 0 {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("knowledge service parse failed: %w", err)
}
return nil
}
@@ -16,8 +16,8 @@ func Retrieve(cfg *config.Config, query string, topK int) []string {
topK = 5
}
var vectorResults []string
if route, err := config.GetRoute("embed_gen"); err == nil {
if v, err := vectorRetrieve(route, query, topK); err == nil {
if aiRoute, err := config.GetRoute("embed_gen"); err == nil {
if v, err := vectorRetrieve(aiRoute, query, topK); err == nil {
vectorResults = v
}
}
@@ -40,13 +40,13 @@ func Retrieve(cfg *config.Config, query string, topK int) []string {
}
// vectorRetrieve 向量检索:query + 所有 chunk 一次批量 embedding,brute-force 余弦 topK
func vectorRetrieve(route *config.RouteConfig, query string, topK int) ([]string, error) {
func vectorRetrieve(aiRoute *config.RouteConfig, query string, topK int) ([]string, error) {
var chunks []model.KnowledgeChunk
store.DB.Order("id ASC").Find(&chunks)
if len(chunks) == 0 {
return nil, nil
}
client := NewClient(route)
client := NewClient(aiRoute)
inputs := make([]string, 0, len(chunks)+1)
inputs = append(inputs, query)
for _, ch := range chunks {
@@ -44,6 +44,7 @@ func PutAIConfig(c *gin.Context) {
web.Fail(c, web.NewBadRequest(err.Error()))
return
}
go config.RefreshAIRouteHealthNow()
web.OK(c, gin.H{"saved": true})
}
@@ -54,6 +55,7 @@ func ReloadAIConfig(c *gin.Context) {
web.Fail(c, web.NewLLMNotConfigured("重载后配置校验失败: "+err.Error()))
return
}
go config.RefreshAIRouteHealthNow()
web.OK(c, gin.H{"reloaded": true})
}
@@ -36,58 +36,20 @@ func ChatMessage(c *gin.Context) {
return
}
var req struct {
Message string `json:"message"`
Context map[string]any `json:"context"`
History []ai.Message `json:"history"`
Message string `json:"message"`
Context map[string]any `json:"context"`
History []ai.Message `json:"history"`
AIRouteID string `json:"ai_route_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
traceID := fmt.Sprintf("chat-%d", time.Now().UnixNano())
// #region debug-point B:chat-entry
if payload, err := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:entry",
"traceId": traceID,
"msg": "[DEBUG] chat message received",
"data": gin.H{
"userId": user.ID,
"messagePreview": []rune(strings.TrimSpace(req.Message)),
"spaceKey": req.Context["knowledge_space_key"],
"historySize": len(req.History),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
plan, err := buildKnowledgeAnswerPlan(req.Message, req.Context, req.History)
if err != nil {
web.Fail(c, web.NewBadRequest("知识问答链路执行失败"))
return
}
// #region debug-point B:plan-built
if payload, err := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:plan",
"traceId": traceID,
"msg": "[DEBUG] knowledge plan built",
"data": gin.H{
"intent": plan.Intent,
"layer": plan.Layer,
"citations": len(plan.Citations),
"llmMessageSize": len(plan.LLMMessages),
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
@@ -123,51 +85,20 @@ func ChatMessage(c *gin.Context) {
writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"})
return
}
route, err := config.GetRoute("path_coach")
aiRouteID := strings.TrimSpace(req.AIRouteID)
if aiRouteID == "" {
aiRouteID = "path_coach"
}
aiRoute, err := config.GetRoute(aiRouteID)
if err != nil {
writeEvent(gin.H{"type": "error", "message": "LLM 路由未配置: " + err.Error()})
return
}
// #region debug-point B:llm-route
if payload, err := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-route",
"traceId": traceID,
"msg": "[DEBUG] llm route selected",
"data": gin.H{
"routeId": route.RouteID,
"provider": route.Provider,
"baseURL": route.BaseURL,
"model": route.Model,
},
"ts": time.Now().UnixMilli(),
}); err == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
start := time.Now()
usedRoute, err := ai.GenerateStreamWithFallback(route, plan.LLMMessages, func(chunk string) {
usedAiRoute, err := ai.GenerateStreamWithFallback(aiRoute, plan.LLMMessages, func(chunk string) {
writeEvent(gin.H{"type": "text", "content": chunk})
})
if err != nil {
// #region debug-point B:llm-error
if payload, marshalErr := json.Marshal(gin.H{
"sessionId": "knowledge-chat-401",
"runId": "pre-fix",
"hypothesisId": "B",
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-error",
"traceId": traceID,
"msg": "[DEBUG] llm stream returned error",
"data": gin.H{
"error": err.Error(),
},
"ts": time.Now().UnixMilli(),
}); marshalErr == nil {
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
}
// #endregion
message := err.Error()
if strings.Contains(message, "未配置 API Key") || strings.Contains(message, "No cookie auth credentials found") {
message = "当前知识库已进入 LLM 兜底层,但系统还没有配置可用的 LLM 服务。请管理员配置 OpenRouter API Key,或启动本地 Ollama 后再重试。"
@@ -176,20 +107,20 @@ func ChatMessage(c *gin.Context) {
}
writeEvent(gin.H{"type": "error", "message": message})
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: aiRoute.Provider,
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
return
}
writeEvent(gin.H{"type": "done"})
finalRoute := route
if usedRoute != nil {
finalRoute = usedRoute
finalAiRoute := aiRoute
if usedAiRoute != nil {
finalAiRoute = usedAiRoute
}
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalRoute.Provider,
RouteID: finalRoute.RouteID, Model: finalRoute.Model, Success: true,
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalAiRoute.Provider,
AIRouteID: finalAiRoute.RouteID, Model: finalAiRoute.Model, Success: true,
LatencyMs: int(time.Since(start).Milliseconds()),
})
}
@@ -285,7 +216,7 @@ func QuickAction(c *gin.Context) {
for _, item := range citations {
knowledge = append(knowledge, item.Content)
}
route, err := config.GetRoute("title_gen")
aiRoute, err := config.GetRoute("title_gen")
if err != nil {
web.OK(c, gin.H{
"result": fallbackResult,
@@ -297,17 +228,17 @@ func QuickAction(c *gin.Context) {
return
}
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
messages := []ai.Message{
aiMessages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: task.query},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: aiRoute.Provider,
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
web.OK(c, gin.H{
@@ -320,8 +251,8 @@ func QuickAction(c *gin.Context) {
return
}
ai.LogCall(ai.LogEntry{
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedAiRoute.Provider,
AIRouteID: usedAiRoute.RouteID, Model: usedAiRoute.Model, Success: true,
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
@@ -89,8 +89,8 @@ func extractExt(filename string) string {
func transcribeAudio(fileData []byte, ext, language string) AudioTranscribeResult {
// 尝试从路由配置获取 LLM baseURL,降级到 Ollama 默认
baseURL := "http://127.0.0.1:11434"
if r, err := config.GetRoute("title_gen"); err == nil && r != nil && r.BaseURL != "" {
baseURL = r.BaseURL
if aiRoute, err := config.GetRoute("title_gen"); err == nil && aiRoute != nil && aiRoute.BaseURL != "" {
baseURL = aiRoute.BaseURL
// 去掉 /v1 后缀,确保路径正确
baseURL = strings.TrimSuffix(baseURL, "/v1")
}
@@ -0,0 +1,413 @@
package api
import (
"strings"
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
type skillDefinitionReq struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
RoleKind string `json:"role_kind"`
Source string `json:"source"`
ObjectEntryRoute string `json:"object_entry_route"`
LegacyObjectEntryRoute string `json:"legacy_object_entry_route"`
ExposedToUser bool `json:"exposed_to_user"`
StarterPromptsJSON string `json:"starter_prompts_json"`
PromptTemplate string `json:"prompt_template"`
InputSchemaJSON string `json:"input_schema_json"`
OutputSchemaJSON string `json:"output_schema_json"`
ArtifactSchemaJSON string `json:"artifact_schema_json"`
ActionRefsJSON string `json:"action_refs_json"`
PolicyRefsJSON string `json:"policy_refs_json"`
OntologyBindingJSON string `json:"ontology_binding_json"`
State string `json:"state"`
SortOrder int `json:"sort_order"`
}
type actionDefinitionReq struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
ActionType string `json:"action_type"`
ConnectorRef string `json:"connector_ref"`
InputSchemaJSON string `json:"input_schema_json"`
OutputSchemaJSON string `json:"output_schema_json"`
RiskLevel string `json:"risk_level"`
ApprovalMode string `json:"approval_mode"`
AuditLevel string `json:"audit_level"`
ExposedToUser bool `json:"exposed_to_user"`
OntologyBindingJSON string `json:"ontology_binding_json"`
State string `json:"state"`
SortOrder int `json:"sort_order"`
}
func normalizeSkillDefinitionReq(req *skillDefinitionReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.Description = strings.TrimSpace(req.Description)
req.RoleKind = strings.TrimSpace(req.RoleKind)
req.Source = strings.TrimSpace(req.Source)
req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute)
req.LegacyObjectEntryRoute = strings.TrimSpace(req.LegacyObjectEntryRoute)
req.StarterPromptsJSON = strings.TrimSpace(req.StarterPromptsJSON)
req.PromptTemplate = strings.TrimSpace(req.PromptTemplate)
req.InputSchemaJSON = strings.TrimSpace(req.InputSchemaJSON)
req.OutputSchemaJSON = strings.TrimSpace(req.OutputSchemaJSON)
req.ArtifactSchemaJSON = strings.TrimSpace(req.ArtifactSchemaJSON)
req.ActionRefsJSON = strings.TrimSpace(req.ActionRefsJSON)
req.PolicyRefsJSON = strings.TrimSpace(req.PolicyRefsJSON)
req.OntologyBindingJSON = strings.TrimSpace(req.OntologyBindingJSON)
req.State = strings.TrimSpace(req.State)
}
func validateSkillDefinitionReq(req *skillDefinitionReq) *web.AppError {
normalizeSkillDefinitionReq(req)
if req.Key == "" || req.Label == "" {
return web.NewBadRequest("key、label 为必填")
}
if req.RoleKind == "" {
req.RoleKind = "skill"
}
switch req.RoleKind {
case "assistant", "specialist", "skill":
default:
return web.NewBadRequest("role_kind 只能是 assistant、specialist 或 skill")
}
if req.Source == "" {
req.Source = "eai"
}
if req.State == "" {
req.State = "active"
}
if req.State != "active" && req.State != "inactive" {
return web.NewBadRequest("state 只能是 active 或 inactive")
}
if !store.ValidateJSONStringArray(req.StarterPromptsJSON) {
return web.NewBadRequest("starter_prompts_json 必须是字符串数组")
}
if !store.ValidateJSONObjectJSON(req.InputSchemaJSON) {
return web.NewBadRequest("input_schema_json 必须是 JSON 对象")
}
if !store.ValidateJSONObjectJSON(req.OutputSchemaJSON) {
return web.NewBadRequest("output_schema_json 必须是 JSON 对象")
}
if !store.ValidateJSONObjectJSON(req.ArtifactSchemaJSON) {
return web.NewBadRequest("artifact_schema_json 必须是 JSON 对象")
}
if !store.ValidateJSONStringArray(req.ActionRefsJSON) {
return web.NewBadRequest("action_refs_json 必须是字符串数组")
}
if !store.ValidateJSONStringArray(req.PolicyRefsJSON) {
return web.NewBadRequest("policy_refs_json 必须是字符串数组")
}
if !store.ValidateJSONObjectJSON(req.OntologyBindingJSON) {
return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象")
}
return nil
}
func normalizeActionDefinitionReq(req *actionDefinitionReq) {
req.Key = strings.TrimSpace(req.Key)
req.Label = strings.TrimSpace(req.Label)
req.Description = strings.TrimSpace(req.Description)
req.ActionType = strings.TrimSpace(req.ActionType)
req.ConnectorRef = strings.TrimSpace(req.ConnectorRef)
req.InputSchemaJSON = strings.TrimSpace(req.InputSchemaJSON)
req.OutputSchemaJSON = strings.TrimSpace(req.OutputSchemaJSON)
req.RiskLevel = strings.TrimSpace(req.RiskLevel)
req.ApprovalMode = strings.TrimSpace(req.ApprovalMode)
req.AuditLevel = strings.TrimSpace(req.AuditLevel)
req.OntologyBindingJSON = strings.TrimSpace(req.OntologyBindingJSON)
req.State = strings.TrimSpace(req.State)
}
func validateActionDefinitionReq(req *actionDefinitionReq) *web.AppError {
normalizeActionDefinitionReq(req)
if req.Key == "" || req.Label == "" {
return web.NewBadRequest("key、label 为必填")
}
if req.ActionType == "" {
req.ActionType = "execution"
}
if req.RiskLevel == "" {
req.RiskLevel = "low"
}
if req.ApprovalMode == "" {
req.ApprovalMode = "not_required"
}
if req.AuditLevel == "" {
req.AuditLevel = "standard"
}
if req.State == "" {
req.State = "active"
}
if req.State != "active" && req.State != "inactive" {
return web.NewBadRequest("state 只能是 active 或 inactive")
}
if !store.ValidateJSONObjectJSON(req.InputSchemaJSON) {
return web.NewBadRequest("input_schema_json 必须是 JSON 对象")
}
if !store.ValidateJSONObjectJSON(req.OutputSchemaJSON) {
return web.NewBadRequest("output_schema_json 必须是 JSON 对象")
}
if !store.ValidateJSONObjectJSON(req.OntologyBindingJSON) {
return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象")
}
return nil
}
func ListSkillDefinitions(c *gin.Context) {
q := store.DB.Model(&model.SkillDefinition{})
if c.Query("state") == "" {
q = q.Where("state = ?", "active")
} else {
q = q.Where("state = ?", c.Query("state"))
}
if c.Query("exposed_to_user") != "" {
q = q.Where("exposed_to_user = ?", c.Query("exposed_to_user") == "true")
}
var items []model.SkillDefinition
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询技能定义失败"))
return
}
web.OK(c, items)
}
func GetSkillDefinitionByKey(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("技能 key 不能为空"))
return
}
var item model.SkillDefinition
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
web.OK(c, item)
}
func CreateSkillDefinition(c *gin.Context) {
var req skillDefinitionReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateSkillDefinitionReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
item := model.SkillDefinition{
Key: req.Key,
Label: req.Label,
Description: req.Description,
RoleKind: req.RoleKind,
Source: req.Source,
ObjectEntryRoute: req.ObjectEntryRoute,
LegacyObjectEntryRoute: req.LegacyObjectEntryRoute,
ExposedToUser: req.ExposedToUser,
StarterPromptsJSON: req.StarterPromptsJSON,
PromptTemplate: req.PromptTemplate,
InputSchemaJSON: req.InputSchemaJSON,
OutputSchemaJSON: req.OutputSchemaJSON,
ArtifactSchemaJSON: req.ArtifactSchemaJSON,
ActionRefsJSON: req.ActionRefsJSON,
PolicyRefsJSON: req.PolicyRefsJSON,
OntologyBindingJSON: req.OntologyBindingJSON,
State: req.State,
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建技能定义失败"))
return
}
web.OK(c, item)
}
func UpdateSkillDefinition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.SkillDefinition
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
var req skillDefinitionReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateSkillDefinitionReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
item.Key = req.Key
item.Label = req.Label
item.Description = req.Description
item.RoleKind = req.RoleKind
item.Source = req.Source
item.ObjectEntryRoute = req.ObjectEntryRoute
item.LegacyObjectEntryRoute = req.LegacyObjectEntryRoute
item.ExposedToUser = req.ExposedToUser
item.StarterPromptsJSON = req.StarterPromptsJSON
item.PromptTemplate = req.PromptTemplate
item.InputSchemaJSON = req.InputSchemaJSON
item.OutputSchemaJSON = req.OutputSchemaJSON
item.ArtifactSchemaJSON = req.ArtifactSchemaJSON
item.ActionRefsJSON = req.ActionRefsJSON
item.PolicyRefsJSON = req.PolicyRefsJSON
item.OntologyBindingJSON = req.OntologyBindingJSON
item.State = req.State
item.SortOrder = req.SortOrder
if err := store.DB.Save(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新技能定义失败"))
return
}
web.OK(c, item)
}
func DeleteSkillDefinition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.SkillDefinition
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("技能定义不存在"))
return
}
if err := store.DB.Delete(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除技能定义失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
func ListActionDefinitions(c *gin.Context) {
q := store.DB.Model(&model.ActionDefinition{})
if c.Query("state") == "" {
q = q.Where("state = ?", "active")
} else {
q = q.Where("state = ?", c.Query("state"))
}
var items []model.ActionDefinition
if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询 Action 定义失败"))
return
}
web.OK(c, items)
}
func GetActionDefinitionByKey(c *gin.Context) {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
web.Fail(c, web.NewBadRequest("action key 不能为空"))
return
}
var item model.ActionDefinition
if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil {
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
return
}
web.OK(c, item)
}
func CreateActionDefinition(c *gin.Context) {
var req actionDefinitionReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateActionDefinitionReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
item := model.ActionDefinition{
Key: req.Key,
Label: req.Label,
Description: req.Description,
ActionType: req.ActionType,
ConnectorRef: req.ConnectorRef,
InputSchemaJSON: req.InputSchemaJSON,
OutputSchemaJSON: req.OutputSchemaJSON,
RiskLevel: req.RiskLevel,
ApprovalMode: req.ApprovalMode,
AuditLevel: req.AuditLevel,
ExposedToUser: req.ExposedToUser,
OntologyBindingJSON: req.OntologyBindingJSON,
State: req.State,
SortOrder: req.SortOrder,
}
if err := store.DB.Create(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建 Action 定义失败"))
return
}
web.OK(c, item)
}
func UpdateActionDefinition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.ActionDefinition
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
return
}
var req actionDefinitionReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if appErr := validateActionDefinitionReq(&req); appErr != nil {
web.Fail(c, appErr)
return
}
item.Key = req.Key
item.Label = req.Label
item.Description = req.Description
item.ActionType = req.ActionType
item.ConnectorRef = req.ConnectorRef
item.InputSchemaJSON = req.InputSchemaJSON
item.OutputSchemaJSON = req.OutputSchemaJSON
item.RiskLevel = req.RiskLevel
item.ApprovalMode = req.ApprovalMode
item.AuditLevel = req.AuditLevel
item.ExposedToUser = req.ExposedToUser
item.OntologyBindingJSON = req.OntologyBindingJSON
item.State = req.State
item.SortOrder = req.SortOrder
if err := store.DB.Save(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新 Action 定义失败"))
return
}
web.OK(c, item)
}
func DeleteActionDefinition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.ActionDefinition
if err := store.DB.First(&item, id).Error; err != nil {
web.Fail(c, web.NewNotFoundError("Action 定义不存在"))
return
}
if err := store.DB.Delete(&item).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除 Action 定义失败"))
return
}
web.OK(c, gin.H{"id": id, "deleted": true})
}
@@ -64,11 +64,11 @@ func SuggestMaterial(c *gin.Context) {
remark := strings.TrimSpace(c.PostForm("remark"))
ext := extOf(header.Filename)
if !allowedExt[ext] {
if !isUploadableExt(ext) {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold {
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
return
}
@@ -55,7 +55,6 @@ func ProofreadCopy(c *gin.Context) {
web.Fail(c, web.NewBadRequest("text 必填"))
return
}
req.Mode = req.Mode
if req.Mode == "" {
req.Mode = "basic"
}
@@ -28,11 +28,11 @@ type DocumentTranslateRequest struct {
// DocumentTranslateResult 翻译结果
type DocumentTranslateResult struct {
TranslatedContent string `json:"content"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
Format string `json:"format"`
CreatedAt string `json:"created_at"`
TranslatedContent string `json:"content"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
Format string `json:"format"`
CreatedAt string `json:"created_at"`
}
// TranslateDocument POST /api/document/translate —— 文档翻译
@@ -47,7 +47,6 @@ func TranslateDocument(c *gin.Context) {
web.Fail(c, web.NewBadRequest("content 必填"))
return
}
req.SourceLang = req.SourceLang
if req.TargetLang == "" {
req.TargetLang = "zh"
}
@@ -122,8 +121,8 @@ func translateText(content, source, target string) string {
4. 如果是代码、URL、特殊标识符则保留不翻译
5. 直接输出翻译结果,不要包含解释说明`, srcName, tgtName)
route, _ := config.GetRoute("title_gen")
content, err := ai.GenerateWithFallback(route, []ai.Message{
aiRoute, _ := config.GetRoute("title_gen")
translatedContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: content},
})
@@ -131,7 +130,7 @@ func translateText(content, source, target string) string {
// 回退:简单逐段翻译
return fallbackTranslate(content, source, target)
}
return content
return translatedContent
}
// fallbackTranslate 简易回退翻译
@@ -13,7 +13,7 @@ import (
// gradeEssay 调 LLM 按评分标准对简答题作答打分,返回 0~1 得分率与一句话评语。
// 复用 text_gen 路由(title_gen),走回退链,全程审计。失败时返回 error 由调用方决定降级。
func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string, error) {
route, err := config.GetRoute("title_gen")
aiRoute, err := config.GetRoute("title_gen")
if err != nil {
return 0, "", err
}
@@ -21,24 +21,24 @@ func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string,
"只输出一个 JSON 对象,格式:{\"score\": <0到1之间的得分率数字>, \"comment\": \"<一句中文评语>\"}。" +
"不要输出任何其他内容。"
userPrompt := "题目:" + stem + "\n评分标准:" + rubric + "\n学员答案:" + userAnswer
messages := []ai.Message{
aiMessages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: route.Provider,
RouteID: route.RouteID, Model: route.Model, Success: false,
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: aiRoute.Provider,
AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false,
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
})
return 0, "", err
}
ai.LogCall(ai.LogEntry{
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID, Model: usedRoute.Model, Success: true,
UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: usedAiRoute.Provider,
AIRouteID: usedAiRoute.RouteID, Model: usedAiRoute.Model, Success: true,
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
LatencyMs: int(time.Since(start).Milliseconds()),
})
@@ -24,14 +24,11 @@ func RebuildKnowledgeIndex(c *gin.Context) {
web.OK(c, gin.H{
"rebuild": true,
"chunk_count": count,
"service_enabled": ai.KnowledgeServiceEnabled(Cfg),
"service_enabled": false,
})
}
func triggerKnowledgeIndexRebuild() {
if !ai.KnowledgeServiceEnabled(Cfg) {
return
}
go func() {
if count, err := rebuildKnowledgeIndexNow(); err != nil {
log.Printf("[知识索引] 重建失败: %v", err)
@@ -46,9 +43,6 @@ func rebuildKnowledgeIndexNow() (int, error) {
defer knowledgeIndexRebuildLock.Unlock()
items := buildKnowledgeIndexItems()
if err := ai.KnowledgeRebuildIndex(Cfg, items); err != nil {
return 0, err
}
return len(items), nil
}
@@ -92,42 +92,22 @@ func buildKnowledgeAnswerPlan(query string, ctx map[string]any, history []ai.Mes
}, nil
}
messages := []ai.Message{{
aiMessages := []ai.Message{{
Role: "system",
Content: buildKnowledgePipelinePrompt(ctx, intent, citations),
}}
messages = append(messages, history...)
messages = append(messages, ai.Message{Role: "user", Content: query})
aiMessages = append(aiMessages, history...)
aiMessages = append(aiMessages, ai.Message{Role: "user", Content: query})
return &knowledgeAnswerPlan{
Intent: intent,
Layer: layerLLM,
Citations: citations,
LLMMessages: messages,
LLMMessages: aiMessages,
}, nil
}
func classifyKnowledgeIntent(query string) (string, string) {
if ai.KnowledgeServiceEnabled(Cfg) {
if result, err := ai.KnowledgeClassify(Cfg, query); err == nil && result != nil {
switch result.Intent {
case intentSmallTalk:
return intentSmallTalk, buildSmallTalkAnswer(query)
case intentOutOfScope:
if result.Score >= 0.9 {
return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。"
}
case intentInvalid:
if result.Score >= 0.9 {
return intentInvalid, "请输入更明确的问题后再试。"
}
case intentFAQ, intentDocument:
if result.Score >= 0.6 {
return result.Intent, ""
}
}
}
}
return heuristicKnowledgeIntent(query)
return heuristicKnowledgeIntent(query)
}
func heuristicKnowledgeIntent(query string) (string, string) {
@@ -139,7 +119,7 @@ func heuristicKnowledgeIntent(query string) (string, string) {
smallTalkKeywords := []string{"讲个故事", "小故事", "笑话", "闲聊", "聊天", "夸夸我", "你是谁", "今天天气", "天气", "星座"}
for _, keyword := range smallTalkKeywords {
if strings.Contains(lower, keyword) {
return intentSmallTalk, buildSmallTalkAnswer(query)
return intentSmallTalk, buildSmallTalkAnswer(query)
}
}
outOfScopeKeywords := []string{"股票", "彩票", "电影", "明星", "旅游", "菜谱", "医学诊断", "法律咨询", "写诗", "翻译成英文"}
@@ -167,40 +147,40 @@ func heuristicKnowledgeIntent(query string) (string, string) {
}
func buildSmallTalkAnswer(query string) string {
lower := strings.ToLower(strings.TrimSpace(query))
switch {
case strings.Contains(lower, "你是谁"):
return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。"
case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"):
return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。"
case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"):
return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。"
default:
return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。"
}
lower := strings.ToLower(strings.TrimSpace(query))
switch {
case strings.Contains(lower, "你是谁"):
return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。"
case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"):
return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。"
case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"):
return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。"
default:
return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。"
}
}
func extractKnowledgeSpaceKey(ctx map[string]any) string {
if ctx == nil {
return "all"
return "all"
}
if raw, ok := ctx["knowledge_space_key"]; ok {
key := sanitizeSpaceKey(fmt.Sprint(raw))
if key == "" {
return "all"
}
if key == "all" {
return "all"
}
return ensureKnowledgeSpaceKeyOrDefault(key)
key := sanitizeSpaceKey(fmt.Sprint(raw))
if key == "" {
return "all"
}
if key == "all" {
return "all"
}
return ensureKnowledgeSpaceKeyOrDefault(key)
}
return "all"
return "all"
}
func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) {
spaceKey = sanitizeSpaceKey(spaceKey)
spaceKey = sanitizeSpaceKey(spaceKey)
q := store.DB.Where("status = ?", "active")
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
}
var faqs []model.KnowledgeFAQ
@@ -302,32 +282,12 @@ func retrieveKnowledgeCitations(query, spaceKey string, topK int) []knowledgeCit
if topK <= 0 {
topK = 5
}
if ai.KnowledgeServiceEnabled(Cfg) {
if items, err := ai.KnowledgeSearch(Cfg, query, spaceKey, topK); err == nil {
out := make([]knowledgeCitation, 0, len(items))
for _, item := range items {
out = append(out, knowledgeCitation{
ID: item.ID,
Title: item.Title,
Snippet: item.Snippet,
Content: item.Content,
ChunkIndex: item.ChunkIndex,
SourceType: item.SourceType,
SourceID: item.SourceID,
KnowledgeSpaceKey: item.KnowledgeSpaceKey,
KnowledgeSpaceName: item.KnowledgeSpaceName,
Score: item.Score,
})
}
return out
}
}
candidates := loadKnowledgeCandidates(spaceKey)
if len(candidates) == 0 {
return []knowledgeCitation{}
}
if route, err := config.GetRoute("embed_gen"); err == nil {
if items, ok := vectorRetrieveCitations(route, query, candidates, topK); ok {
if aiRoute, err := config.GetRoute("embed_gen"); err == nil {
if items, ok := vectorRetrieveCitations(aiRoute, query, candidates, topK); ok {
return items
}
}
@@ -367,14 +327,14 @@ func loadKnowledgeCandidates(spaceKey string) []knowledgeCandidate {
func matchSpaceForKnowledgeQuery(selected, current string) bool {
selected = sanitizeSpaceKey(selected)
current = sanitizeSpaceKey(current)
if selected == "" || selected == "general" || selected == "all" {
if selected == "" || selected == "general" || selected == "all" {
return true
}
return current == selected || current == "general"
}
func vectorRetrieveCitations(route *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) {
client := ai.NewClient(route)
func vectorRetrieveCitations(aiRoute *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) {
client := ai.NewClient(aiRoute)
inputs := make([]string, 0, len(candidates)+1)
inputs = append(inputs, query)
for _, item := range candidates {
@@ -22,9 +22,16 @@ import (
"eai_agentplatform/backend/internal/web"
)
var allowedExt = map[string]bool{
"ppt": true, "pptx": true, "pdf": true, "doc": true, "docx": true,
"mp4": true, "png": true, "jpg": true, "jpeg": true,
var blockedUploadExt = map[string]bool{
"exe": true, "bin": true, "dll": true, "so": true, "dylib": true,
"msi": true, "apk": true, "ipa": true, "deb": true, "rpm": true, "pkg": true, "appimage": true,
"bat": true, "cmd": true, "com": true, "scr": true, "sys": true, "drv": true,
"ps1": true, "psm1": true, "vbs": true, "vbe": true, "js": true, "jse": true, "wsf": true, "wsh": true,
"reg": true, "lnk": true, "iso": true, "img": true, "dmg": true,
}
var videoExt = map[string]bool{
"mp4": true, "mov": true, "avi": true, "mkv": true, "webm": true, "m4v": true, "wmv": true, "flv": true,
}
var validBindType = map[string]bool{"company": true, "product": true, "course": true, "none": true}
@@ -44,8 +51,20 @@ func extOf(filename string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
}
func isUploadableExt(ext string) bool {
normalized := strings.ToLower(strings.TrimSpace(ext))
if normalized == "" {
return false
}
return !blockedUploadExt[normalized]
}
func isVideoExt(ext string) bool {
return videoExt[strings.ToLower(strings.TrimSpace(ext))]
}
func sizeLimitFor(ext string) int64 {
if ext == "mp4" {
if isVideoExt(ext) {
return Cfg.FileMaxVideo
}
return Cfg.FileMaxDoc
@@ -116,11 +135,11 @@ func Upload(c *gin.Context) {
defer file.Close()
ext := extOf(header.Filename)
if !allowedExt[ext] {
if !isUploadableExt(ext) {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
if ext == "mp4" && header.Size > Cfg.ChunkThreshold {
if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold {
web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传"))
return
}
@@ -213,7 +232,7 @@ func UploadInit(c *gin.Context) {
return
}
ext := extOf(req.Filename)
if !allowedExt[ext] {
if !isUploadableExt(ext) {
web.Fail(c, web.NewBadRequest("不支持的文件类型"))
return
}
@@ -0,0 +1,44 @@
package api
import "testing"
func TestIsUploadableExt(t *testing.T) {
cases := []struct {
name string
ext string
want bool
}{
{name: "txt allowed", ext: "txt", want: true},
{name: "md allowed", ext: "md", want: true},
{name: "xlsx allowed", ext: "xlsx", want: true},
{name: "png allowed", ext: "png", want: true},
{name: "empty blocked", ext: "", want: false},
{name: "exe blocked", ext: "exe", want: false},
{name: "bin blocked", ext: "bin", want: false},
{name: "ps1 blocked", ext: "ps1", want: false},
}
for _, c := range cases {
if got := isUploadableExt(c.ext); got != c.want {
t.Errorf("%s: isUploadableExt(%q)=%v want %v", c.name, c.ext, got, c.want)
}
}
}
func TestIsVideoExt(t *testing.T) {
cases := []struct {
ext string
want bool
}{
{ext: "mp4", want: true},
{ext: "mov", want: true},
{ext: "webm", want: true},
{ext: "txt", want: false},
}
for _, c := range cases {
if got := isVideoExt(c.ext); got != c.want {
t.Errorf("isVideoExt(%q)=%v want %v", c.ext, got, c.want)
}
}
}
@@ -0,0 +1,277 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
const (
maxFavoriteAppCount = 24
maxRecentAppCount = 8
maxCustomAppCount = 32
maxCustomTagCount = 6
)
type appCenterCustomApp struct {
Key string `json:"key"`
Label string `json:"label"`
Badge string `json:"badge"`
Kind string `json:"kind"`
Color string `json:"color"`
Summary string `json:"summary"`
Description string `json:"description"`
Tags []string `json:"tags"`
Prompts []string `json:"prompts"`
InstallState string `json:"installState"`
SkillKey string `json:"skillKey"`
CreatedAt string `json:"createdAt"`
IconText string `json:"iconText"`
CoverTone string `json:"coverTone"`
IsCustomApp bool `json:"isCustomApp"`
}
type appCenterPayload struct {
FavoriteKeys []string `json:"favorite_keys"`
RecentKeys []string `json:"recent_keys"`
CustomApps []appCenterCustomApp `json:"custom_apps"`
}
// GetMyAppCenter GET /api/my/app-center
func GetMyAppCenter(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
row, err := findUserAppCenter(user.ID)
if err != nil {
web.Fail(c, web.NewBadRequest("读取应用中心失败"))
return
}
web.OK(c, normalizeAppCenterPayload(row))
}
// UpdateMyAppCenter PUT /api/my/app-center
func UpdateMyAppCenter(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req appCenterPayload
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
payload := normalizeAppCenterPayload(&model.UserAppCenter{
FavoriteKeys: mustJSONAppCenter(req.FavoriteKeys),
RecentKeys: mustJSONAppCenter(req.RecentKeys),
CustomApps: mustJSONAppCenter(req.CustomApps),
})
row, err := findUserAppCenter(user.ID)
if err != nil {
web.Fail(c, web.NewBadRequest("读取应用中心失败"))
return
}
if row == nil {
row = &model.UserAppCenter{UserID: user.ID}
}
row.FavoriteKeys = mustJSONAppCenter(payload.FavoriteKeys)
row.RecentKeys = mustJSONAppCenter(payload.RecentKeys)
row.CustomApps = mustJSONAppCenter(payload.CustomApps)
if err := store.DB.Save(row).Error; err != nil {
web.Fail(c, web.NewBadRequest("保存应用中心失败"))
return
}
web.OK(c, payload)
}
func findUserAppCenter(userID uint) (*model.UserAppCenter, error) {
var row model.UserAppCenter
err := store.DB.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func normalizeAppCenterPayload(row *model.UserAppCenter) appCenterPayload {
if row == nil {
return appCenterPayload{
FavoriteKeys: []string{},
RecentKeys: []string{},
CustomApps: []appCenterCustomApp{},
}
}
var favorites []string
var recents []string
var customApps []appCenterCustomApp
_ = json.Unmarshal([]byte(strings.TrimSpace(row.FavoriteKeys)), &favorites)
_ = json.Unmarshal([]byte(strings.TrimSpace(row.RecentKeys)), &recents)
_ = json.Unmarshal([]byte(strings.TrimSpace(row.CustomApps)), &customApps)
return appCenterPayload{
FavoriteKeys: normalizeStringList(favorites, maxFavoriteAppCount),
RecentKeys: normalizeStringList(recents, maxRecentAppCount),
CustomApps: normalizeCustomApps(customApps),
}
}
func normalizeStringList(items []string, maxCount int) []string {
result := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
normalized := strings.TrimSpace(item)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
if len(result) >= maxCount {
break
}
}
return result
}
func normalizeCustomApps(items []appCenterCustomApp) []appCenterCustomApp {
result := make([]appCenterCustomApp, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
key := strings.TrimSpace(item.Key)
if key == "" {
key = fmt.Sprintf("custom-app-%d-%d", time.Now().UnixMilli(), index+1)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
label := strings.TrimSpace(item.Label)
if label == "" {
label = "未命名应用"
}
summary := strings.TrimSpace(item.Summary)
if summary == "" {
summary = "自定义应用"
}
description := strings.TrimSpace(item.Description)
if description == "" {
description = summary
}
prompt := ""
if len(item.Prompts) > 0 {
prompt = strings.TrimSpace(item.Prompts[0])
}
if prompt == "" {
prompt = summary
}
createdAt := strings.TrimSpace(item.CreatedAt)
if createdAt == "" {
createdAt = time.Now().Format(time.RFC3339)
}
result = append(result, appCenterCustomApp{
Key: key,
Label: truncateText(label, 24),
Badge: "自定义应用",
Kind: firstNonEmpty(strings.TrimSpace(item.Kind), "个人工作流"),
Color: firstNonEmpty(strings.TrimSpace(item.Color), "#2563eb"),
Summary: truncateText(summary, 80),
Description: truncateText(description, 180),
Tags: normalizeTagList(item.Tags),
Prompts: []string{truncateText(prompt, 240)},
InstallState: "installed",
SkillKey: firstNonEmpty(strings.TrimSpace(item.SkillKey), "smart-assistant"),
CreatedAt: createdAt,
IconText: normalizeIconText(item.IconText, label),
CoverTone: normalizeCoverTone(item.CoverTone, item.Color),
IsCustomApp: true,
})
if len(result) >= maxCustomAppCount {
break
}
}
return result
}
func normalizeTagList(items []string) []string {
result := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
tag := truncateText(strings.TrimSpace(item), 12)
if tag == "" {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
result = append(result, tag)
if len(result) >= maxCustomTagCount {
break
}
}
return result
}
func truncateText(value string, limit int) string {
text := strings.TrimSpace(value)
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit])
}
func normalizeIconText(iconText, label string) string {
text := strings.TrimSpace(iconText)
if text == "" {
runes := []rune(strings.TrimSpace(label))
if len(runes) == 0 {
return "应用"
}
if len(runes) == 1 {
return string(runes[:1])
}
return string(runes[:2])
}
runes := []rune(text)
if len(runes) <= 2 {
return string(runes)
}
return string(runes[:2])
}
func normalizeCoverTone(coverTone, color string) string {
text := strings.TrimSpace(coverTone)
if text != "" {
return text
}
if strings.TrimSpace(color) != "" {
return color
}
return "#2563eb"
}
func mustJSONAppCenter(value any) string {
data, err := json.Marshal(value)
if err != nil {
return "[]"
}
return string(data)
}
@@ -14,7 +14,7 @@ import (
"eai_agentplatform/backend/internal/web"
)
// 通用智能助手:seed 里 state=system 的那条内置记录。「新建任务」时还没选专员,
// 通用助手:seed 里 state=system 的那条内置记录。「新建任务」时还没选专员,
// 任务就先落在它名下;之后用输入框的 + 选专员/工具,任务再改挂过去。
const generalAssistantKey = "general-assistant"
@@ -103,7 +103,7 @@ func DeleteMyTask(c *gin.Context) {
web.OK(c, gin.H{"id": id})
}
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用智能助手名下。
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用助手名下。
func CreateMyTask(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
@@ -157,8 +157,7 @@ func CreateMyTask(c *gin.Context) {
web.OK(c, task)
}
// UpdateMyTask 改自己的任务:改标题,或者把它改挂到另一个专员名下
// (输入框的 + 里选了专员就走这里)。
// UpdateMyTask 改自己的任务:改标题、改挂专员,或者把它加入某个项目。
func UpdateMyTask(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
@@ -196,6 +195,19 @@ func UpdateMyTask(c *gin.Context) {
if summary := strings.TrimSpace(req.Summary); summary != "" {
task.Summary = summary
}
// 没传 project_id 就保持原样;前端若显式传 0,则视为把任务移出项目。
if req.ProjectID != nil {
if *req.ProjectID == 0 {
task.ProjectID = nil
} else {
var project model.Project
if err := projectQuery(user).Where("id = ?", *req.ProjectID).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
task.ProjectID = &project.ID
}
}
if req.Pinned != nil {
task.Pinned = *req.Pinned
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,280 @@
package api
import (
"encoding/json"
"errors"
"io"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/store"
"eai_agentplatform/backend/internal/web"
)
// 项目名的长度上限。前端弹窗的计数器读的也是这个数 —— 两边必须一致,
// 否则前端数到 40 说没超,后端一剪,用户看到的名字和自己打的不一样。
const projectNameMaxLen = 40
type projectReq struct {
Name string `json:"name"`
TemplateKey string `json:"template_key"`
// Instruction 用指针:值类型分不出「没传」和「传了空串」,指令就永远清不掉 ——
// 详情页把指令删空再保存会被当成「没改」,用户看到的是白删一次。
// Pinned 同理(分不出「没传」和「传了 false」,取消置顶永远取消不掉)。
Instruction *string `json:"instruction"`
SpecialistKeys []string `json:"specialist_keys"`
SkillKeys []string `json:"skill_keys"`
ConnectorKeys []string `json:"connector_keys"`
Pinned *bool `json:"pinned"`
}
// projectQuery 「我的项目」永远只列自己的,管理员也只看自己的 —— 跟
// myTaskQuery 是同一条规矩,所以归属判定直接复用 myTaskOwners,
// 不另写一套(两套判定迟早会漂)。
func projectQuery(user *model.User) *gorm.DB {
owners := myTaskOwners(user)
query := store.DB.Model(&model.Project{})
if len(owners) == 0 {
return query.Where("1 = 0")
}
return query.Where("owner IN ?", owners)
}
// encodeKeys 把 key 列表存成 JSON 字符串。空列表存空串而不是 "[]",
// 读的时候一眼能看出「没配」和「配了但为空」的区别不大,但空串更省。
func encodeKeys(keys []string) string {
cleaned := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) == 0 {
return ""
}
b, err := json.Marshal(cleaned)
if err != nil {
return ""
}
return string(b)
}
// ListProjects 当前用户的项目,置顶在前、最近动过的靠前。
func ListProjects(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var items []model.Project
if err := projectQuery(user).
Order("pinned DESC, updated_at DESC, id DESC").
Limit(50).
Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询项目失败"))
return
}
web.OK(c, items)
}
// CreateProject 建一个项目。空 body 也收(跟「新建任务」一样,全走默认值),
// 但名字是必填的 —— 一个没名字的项目在列表里没法认。
func CreateProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req projectReq
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
web.Fail(c, web.NewBadRequest("项目名称不能为空"))
return
}
if len([]rune(name)) > projectNameMaxLen {
name = string([]rune(name)[:projectNameMaxLen])
}
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
instruction := ""
if req.Instruction != nil {
instruction = strings.TrimSpace(*req.Instruction)
}
project := model.Project{
Name: name,
Instruction: instruction,
TemplateKey: strings.TrimSpace(req.TemplateKey),
// 归属由服务端定死,不读请求里的 owner —— 否则谁都能替别人建项目。
Owner: myTaskOwnerName(user),
SpecialistKeys: encodeKeys(req.SpecialistKeys),
SkillKeys: encodeKeys(req.SkillKeys),
ConnectorKeys: encodeKeys(req.ConnectorKeys),
}
if err := store.DB.Create(&project).Error; err != nil {
web.Fail(c, web.NewBadRequest("创建项目失败"))
return
}
web.OK(c, project)
}
// UpdateProject 改名 / 改指令 / 置顶。只有显式传了的字段才覆盖:
// 没传 name 就别把名字清空,没传 pinned 就别把它当 false。
func UpdateProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var project model.Project
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
var req projectReq
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if name := strings.TrimSpace(req.Name); name != "" {
if len([]rune(name)) > projectNameMaxLen {
name = string([]rune(name)[:projectNameMaxLen])
}
project.Name = name
}
// 传了空串就是「清空指令」,得让它生效 —— 见 projectReq 里 Instruction 的注释。
if req.Instruction != nil {
project.Instruction = strings.TrimSpace(*req.Instruction)
}
if req.TemplateKey != "" {
project.TemplateKey = strings.TrimSpace(req.TemplateKey)
}
if req.Pinned != nil {
project.Pinned = *req.Pinned
}
// 三个能力列表:给了就用给的(空数组也算给了,表示清空),
// 没给就保持原样。用 nil 判断,跟 Pinned 是同一套规矩。
if req.SpecialistKeys != nil {
if err := validateSpecialistKeys(req.SpecialistKeys); err != nil {
web.Fail(c, web.NewNotFoundError(err.Error()))
return
}
project.SpecialistKeys = encodeKeys(req.SpecialistKeys)
}
if req.SkillKeys != nil {
project.SkillKeys = encodeKeys(req.SkillKeys)
}
if req.ConnectorKeys != nil {
project.ConnectorKeys = encodeKeys(req.ConnectorKeys)
}
if err := store.DB.Save(&project).Error; err != nil {
web.Fail(c, web.NewBadRequest("更新项目失败"))
return
}
web.OK(c, project)
}
// DeleteProject 删项目。
//
// 项目里的任务**不删**,只把 project_id 置空 —— 任务是「做过的事」,
// 删一个分组不该把它一起抹掉。前端确认框里必须把这一点说明白,
// 否则用户会以为连任务一起没了。
func DeleteProject(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var project model.Project
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
if err := store.DB.Model(&model.WorkerTask{}).
Where("project_id = ?", project.ID).
Update("project_id", nil).Error; err != nil {
web.Fail(c, web.NewBadRequest("解除任务归属失败"))
return
}
if err := store.DB.Delete(&project).Error; err != nil {
web.Fail(c, web.NewBadRequest("删除项目失败"))
return
}
web.OK(c, gin.H{"id": id})
}
// ListProjectTasks 项目下的任务。项目必须是自己的 —— 别人的项目直接 404,
// 不区分「不存在」和「不是你的」。
func ListProjectTasks(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
id, ok := parseID(c, "id")
if !ok {
return
}
var project model.Project
if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil {
web.Fail(c, web.NewNotFoundError("项目不存在"))
return
}
var items []model.WorkerTask
if err := store.DB.Where("project_id = ?", project.ID).
Order("updated_at DESC, id DESC").
Limit(100).
Find(&items).Error; err != nil {
web.Fail(c, web.NewBadRequest("查询项目任务失败"))
return
}
web.OK(c, items)
}
// validateSpecialistKeys 专员 key 得真实存在才让存 —— 项目卡片上要显示专员名,
// 存一个查不到的 key 进去,卡片上就会出现一行认不出来的东西。
// 工具和连接器暂时不校验:工具是前端路由表里的常量,连接器清单以后会变,
// 校验它们只会让老项目在清单变动后改不动。
func validateSpecialistKeys(keys []string) error {
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
continue
}
var specialist model.Specialist
if err := store.DB.Where("key = ?", trimmed).First(&specialist).Error; err != nil {
return errors.New("专员不存在:" + trimmed)
}
}
return nil
}
@@ -18,10 +18,10 @@ import (
// ReportGenRequest POST /api/report/generate 报告生成请求
type ReportGenRequest struct {
Topic string `json:"topic"` // 报告主题,必填
Summary string `json:"summary"` // 报告摘要/背景
Context map[string]any `json:"context"` // 附加上下文
Sections []string `json:"sections"` // 期望包含的章节(可选)
Topic string `json:"topic"` // 报告主题,必填
Summary string `json:"summary"` // 报告摘要/背景
Context map[string]any `json:"context"` // 附加上下文
Sections []string `json:"sections"` // 期望包含的章节(可选)
Knowledge string `json:"knowledge_key"` // 关联知识库 key
}
@@ -33,10 +33,10 @@ type reportChapter struct {
// reportContent 完整报告结构
type reportContent struct {
Topic string `json:"topic"`
Summary string `json:"summary"`
Topic string `json:"topic"`
Summary string `json:"summary"`
Chapters []reportChapter `json:"chapters"`
CreatedAt string `json:"created_at"`
CreatedAt string `json:"created_at"`
}
// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文
@@ -110,7 +110,11 @@ func retrieveReportKnowledge(topic string, spaceKey string) string {
Find(&chunks)
parts := make([]string, 0, len(chunks))
for _, ch := range chunks {
parts = append(parts, fmt.Sprintf("【%s】%s", ch.Content))
// 这里原本是 fmt.Sprintf("【%s】%s", ch.Content) —— 两个占位符只给了一个参数,
// 实际拼出来是「【%!s(MISSING)】正文」,这段字符串是直接进 LLM prompt 的,
// 等于每个片段前面糊一句乱码。KnowledgeChunk 没有 title 字段,
// 没有东西可以填进【】,所以和上面按空间检索的分支对齐,直接取正文。
parts = append(parts, ch.Content)
}
return strings.Join(parts, "\n\n")
}
@@ -140,8 +144,8 @@ func promptReportWithSections(req ReportGenRequest, knowledge string) []reportCh
knowledge,
)
route, _ := config.GetRoute("title_gen")
content, err := ai.GenerateWithFallback(route, []ai.Message{
aiRoute, _ := config.GetRoute("title_gen")
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
@@ -149,7 +153,7 @@ func promptReportWithSections(req ReportGenRequest, knowledge string) []reportCh
return fallbackReport(req.Topic)
}
return parseReportMarkdown(content, req.Sections)
return parseReportMarkdown(aiContent, req.Sections)
}
// promptReportAutoChapters 让 LLM 自动规划报告章节
@@ -176,8 +180,8 @@ func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportCh
knowledge,
)
route, _ := config.GetRoute("title_gen")
content, err := ai.GenerateWithFallback(route, []ai.Message{
aiRoute, _ := config.GetRoute("title_gen")
aiContent, err := ai.GenerateWithFallback(aiRoute, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
@@ -185,7 +189,7 @@ func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportCh
return fallbackReport(req.Topic)
}
return parseReportJSON(content)
return parseReportJSON(aiContent)
}
// parseReportMarkdown 从 Markdown 格式解析报告为章节数组
@@ -43,6 +43,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
r.GET("/api/skills", middleware.Auth(cfg), ListSkillDefinitions)
r.GET("/api/skills/by-key/:key", middleware.Auth(cfg), GetSkillDefinitionByKey)
r.GET("/api/actions", middleware.Auth(cfg), ListActionDefinitions)
r.GET("/api/actions/by-key/:key", middleware.Auth(cfg), GetActionDefinitionByKey)
r.GET("/api/workbench/overview", middleware.Auth(cfg), WorkbenchOverview)
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
@@ -52,14 +56,20 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.POST("/api/worker/my-tasks", middleware.Auth(cfg), CreateMyTask)
r.PUT("/api/worker/my-tasks/:id", middleware.Auth(cfg), UpdateMyTask)
r.DELETE("/api/worker/my-tasks/:id", middleware.Auth(cfg), DeleteMyTask)
// 项目:任务的容器。和「我的任务」同一套归属规则,归属服务端定死。
r.GET("/api/worker/projects", middleware.Auth(cfg), ListProjects)
r.POST("/api/worker/projects", middleware.Auth(cfg), CreateProject)
r.PUT("/api/worker/projects/:id", middleware.Auth(cfg), UpdateProject)
r.DELETE("/api/worker/projects/:id", middleware.Auth(cfg), DeleteProject)
r.GET("/api/worker/projects/:id/tasks", middleware.Auth(cfg), ListProjectTasks)
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
r.POST("/api/official-account/tasks", middleware.Auth(cfg), CreateOfficialAccountTask)
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask)
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), ExecuteOfficialAccountWorkflowStep)
r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), RegenerateOfficialAccountImage)
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), ExportOfficialAccountDocument)
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), ServeOfficialAccountGeneratedImage)
r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), RegenerateOfficialAccountImage)
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), ExportOfficialAccountDocument)
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), ServeOfficialAccountGeneratedImage)
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
@@ -84,6 +94,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
// 学员:我的岗位应学清单 / 学习档案 / 积分与排行榜
r.GET("/api/my/position", middleware.Auth(cfg), MyPosition)
r.GET("/api/my/profile", middleware.Auth(cfg), MyProfile)
r.GET("/api/my/app-center", middleware.Auth(cfg), GetMyAppCenter)
r.PUT("/api/my/app-center", middleware.Auth(cfg), UpdateMyAppCenter)
r.GET("/api/points/me", middleware.Auth(cfg), MyPoints)
r.GET("/api/points/leaderboard", middleware.Auth(cfg), Leaderboard)
@@ -139,6 +151,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/copy/quick", middleware.Auth(cfg), QuickProofread)
r.POST("/api/batch/extract", middleware.Auth(cfg), ExtractBatch)
r.POST("/api/batch/files", middleware.Auth(cfg), ExtractFromFiles)
r.POST("/api/skills/office/execute", middleware.Auth(cfg), ExecuteOfficeSkill)
r.POST("/api/skills/office/ocr", middleware.Auth(cfg), ExecuteOfficeSkillOCR)
r.POST("/api/assistant/chat", middleware.Auth(cfg), Chat)
// 语音工具
@@ -188,6 +202,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
admin.POST("/specialists", CreateSpecialist)
admin.PUT("/specialists/:id", UpdateSpecialist)
admin.DELETE("/specialists/:id", DeleteSpecialist)
admin.POST("/skills", CreateSkillDefinition)
admin.PUT("/skills/:id", UpdateSkillDefinition)
admin.DELETE("/skills/:id", DeleteSkillDefinition)
admin.POST("/actions", CreateActionDefinition)
admin.PUT("/actions/:id", UpdateActionDefinition)
admin.DELETE("/actions/:id", DeleteActionDefinition)
admin.POST("/worker/tasks", CreateWorkerTask)
admin.PUT("/worker/tasks/:id", UpdateWorkerTask)
admin.PUT("/worker/tasks/:id/status", UpdateWorkerTaskStatus)
@@ -107,7 +107,7 @@ func WorkbenchOverview(c *gin.Context) {
"owner": item.Owner,
"specialist_key": item.SpecialistKey,
"specialist": spec.Label,
"route": spec.Route,
"object_entry_route": spec.ObjectEntryRoute,
})
}
@@ -119,7 +119,7 @@ func WorkbenchOverview(c *gin.Context) {
"title": item.Title,
"sub": buildWorkbenchRiskText(item),
"status": item.Status,
"route": spec.Route,
"object_entry_route": spec.ObjectEntryRoute,
"specialist": spec.Label,
})
}
@@ -135,7 +135,7 @@ func WorkbenchOverview(c *gin.Context) {
"status": item.Status,
"task_id": item.TaskID,
"specialist_key": item.SpecialistKey,
"route": spec.Route,
"object_entry_route": spec.ObjectEntryRoute,
"artifact_type": item.ArtifactType,
"created_at": item.CreatedAt,
})
@@ -28,6 +28,9 @@ type workerTaskReq struct {
Status string `json:"status"`
DueAt string `json:"due_at"`
Context map[string]any `json:"context"`
// ProjectID 把这条任务挂到某个项目下(在项目里建任务时前端会带上)。
// 指针是为了区分「没传」和「传了 0」—— 0 不是合法 id,当成没传处理。
ProjectID *uint `json:"project_id"`
// Pinned 用指针:要区分「没传这个字段」和「传了 false 要取消置顶」,
// 值类型的话取消置顶会被当成没传,永远取消不掉。
Pinned *bool `json:"pinned"`
@@ -90,10 +93,10 @@ func GetWorkerTaskDetail(c *gin.Context) {
web.Fail(c, web.NewBadRequest("查询运行记录失败"))
return
}
if task.SpecialistKey == officialAccountSpecialistKey {
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
runs = compactOfficialAccountRunsForResponse(runs)
}
if task.SpecialistKey == officialAccountSpecialistKey {
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
runs = compactOfficialAccountRunsForResponse(runs)
}
web.OK(c, gin.H{
"task": task,
@@ -175,7 +178,7 @@ func CreateWorkerTask(c *gin.Context) {
"worker_task_assign",
fmt.Sprintf("你有新的待办事项:%s", task.Title),
fmt.Sprintf("专员 %s 已为你创建事项,请尽快查看。", specialist.Label),
specialist.Route,
specialist.ObjectEntryRoute,
)
web.OK(c, task)
}
@@ -465,7 +468,7 @@ func ensureBootstrapWorkerTask(specialistKey string, user *model.User) error {
"bootstrap": true,
"specialist_key": specialist.Key,
"specialist": specialist.Label,
"route": specialist.Route,
"object_entry_route": specialist.ObjectEntryRoute,
})
task := model.WorkerTask{
@@ -516,6 +519,16 @@ func buildWorkerTaskFromReq(req workerTaskReq, user *model.User) (model.WorkerTa
}
task.DueAt = &parsed
}
// 挂项目:得确认这个项目是自己的,否则等于给别人的项目里塞任务。
// 放这里而不是各个 handler 里,是因为建任务有两条入口
// (CreateMyTask / CreateWorkerTask),校验只写一遍才不会漏。
if req.ProjectID != nil && *req.ProjectID != 0 {
var project model.Project
if err := projectQuery(user).Where("id = ?", *req.ProjectID).First(&project).Error; err != nil {
return task, fmt.Errorf("项目不存在")
}
task.ProjectID = &project.ID
}
if user != nil {
task.CreatedBy = &user.ID
}
@@ -686,27 +699,27 @@ func executeAIStep(task model.WorkerTask, specialist model.Specialist, req worke
if user.AiPoints <= 0 {
return "AI 点数不足,未触发智能生成。", nil, []string{"[WARN] ai execution skipped because credits are insufficient"}
}
route, err := config.GetRoute("title_gen")
aiRoute, err := config.GetRoute("title_gen")
if err != nil {
return "LLM 路由未配置,未触发智能生成。", nil, []string{fmt.Sprintf("[WARN] ai route missing: %v", err)}
}
contextJSON := parseWorkerContextJSON(task.ContextJSON)
knowledge := ai.Retrieve(Cfg, task.Title+" "+task.Summary+" "+specialist.InfoSources, 5)
systemPrompt := buildSystemPrompt(contextJSON, knowledge) + "\n\n当前任务:" + buildWorkerAITaskPrompt(task, specialist, req)
messages := []ai.Message{
aiMessages := []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: buildWorkerUserPrompt(task, req)},
}
start := time.Now()
result, usedRoute, err := ai.GenerateFullWithFallback(route, messages)
result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages)
if err != nil {
ai.LogCall(ai.LogEntry{
UserID: user.ID,
Capability: ai.CapabilityTextGen,
Provider: route.Provider,
RouteID: route.RouteID,
Model: route.Model,
Provider: aiRoute.Provider,
AIRouteID: aiRoute.RouteID,
Model: aiRoute.Model,
Success: false,
ErrorMessage: err.Error(),
LatencyMs: int(time.Since(start).Milliseconds()),
@@ -716,9 +729,9 @@ func executeAIStep(task model.WorkerTask, specialist model.Specialist, req worke
ai.LogCall(ai.LogEntry{
UserID: user.ID,
Capability: ai.CapabilityTextGen,
Provider: usedRoute.Provider,
RouteID: usedRoute.RouteID,
Model: usedRoute.Model,
Provider: usedAiRoute.Provider,
AIRouteID: usedAiRoute.RouteID,
Model: usedAiRoute.Model,
Success: true,
TokensInput: result.Usage.PromptTokens,
TokensOutput: result.Usage.CompletionTokens,
@@ -1052,7 +1065,7 @@ func buildArtifactStatusSummary(artifact model.WorkerArtifact, remark string) st
func buildWorkerTaskLink(specialistKey string) string {
var specialist model.Specialist
if err := store.DB.Where("key = ?", strings.TrimSpace(specialistKey)).First(&specialist).Error; err == nil {
return specialist.Route
return specialist.ObjectEntryRoute
}
return ""
}
@@ -20,9 +20,6 @@ type Config struct {
LLMModel string
EmbedModel string
KnowledgeServiceURL string
KnowledgeIndexDir string
KBDataDir string
KnowledgeSourceDir string
@@ -63,9 +60,6 @@ func Load() *Config {
LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"),
EmbedModel: getenv("EMBED_MODEL", "bge-m3"),
KnowledgeServiceURL: getenv("KNOWLEDGE_SERVICE_URL", "http://127.0.0.1:10233"),
KnowledgeIndexDir: getenv("KNOWLEDGE_INDEX_DIR", filepath.Join(baseDir, "data", "faiss")),
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")),
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
@@ -15,46 +16,50 @@ import (
// RouteInfo 单条路由定义(JSON 文件中的原始数据)
type RouteInfo struct {
Provider string `json:"provider"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Category string `json:"category,omitempty"`
Description string `json:"description,omitempty"`
Provider string `json:"provider"`
Model string `json:"model"`
Endpoint string `json:"endpoint"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Category string `json:"category,omitempty"`
Description string `json:"description,omitempty"`
ShortRouteName string `json:"short_route_name,omitempty"`
ShortModelName string `json:"short_model_name,omitempty"`
}
// RouteConfig 运行时完整路由配置(合并 secrets 后)
type RouteConfig struct {
RouteID string
Provider string
Model string
BaseURL string // 完整 base_url(已从 secrets 注入)
Endpoint string // 相对路径(如 /chat/completions)
FullURL string // BaseURL + Endpoint
APIKey string
MaxTokens int
Temperature float64
Category string // chat / embed / image
Description string
RouteID string
Provider string
Model string
BaseURL string // 完整 base_url(已从 secrets 注入)
Endpoint string // 相对路径(如 /chat/completions)
FullURL string // BaseURL + Endpoint
APIKey string
MaxTokens int
Temperature float64
Category string // chat / embed / image
Description string
ShortRouteName string
ShortModelName string
}
// AIConfig 顶层结构(支持分类路由)
type AIConfig struct {
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
AgentRoutes map[string]string `json:"agent_routes"`
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
AgentRoutes map[string]string `json:"agent_routes"`
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
// 兼容旧版平铺 routes(若有则回退)
Routes map[string]RouteInfo `json:"routes,omitempty"`
Routes map[string]RouteInfo `json:"routes,omitempty"`
}
// AISecrets AI 密钥文件结构(从 pj034 直接复制的格式)
// AISecrets AI 密钥文件结构
type AISecrets struct {
VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"`
OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"`
@@ -79,7 +84,7 @@ type PlatformConfig struct {
}
// ──────────────────────────────────────────────
// PROVIDER_SECRET_KEY 映射(参考 pj034 ai_config.py)
// PROVIDER_SECRET_KEY 映射
// provider 名称 → secrets JSON 中的字段名
// ──────────────────────────────────────────────
@@ -98,12 +103,12 @@ var ProviderSecretKey = map[string]string{
// ProviderDefaultBaseURL provider 默认 base_url(当 secrets 未提供时)
var ProviderDefaultBaseURL = map[string]string{
"ollama": "http://127.0.0.1:11434/v1",
"openrouter": "https://openrouter.ai/api/v1",
"openai": "https://api.openai.com/v1",
"ollama": "http://127.0.0.1:11434/v1",
"openrouter": "https://openrouter.ai/api/v1",
"openai": "https://api.openai.com/v1",
"vectorengine": "https://api.vectorengine.ai/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
}
// ──────────────────────────────────────────────
@@ -244,8 +249,8 @@ func LoadPlatformConfig(forceReload ...bool) (*PlatformConfig, error) {
}
// ──────────────────────────────────────────────
// GetRoute — 核心路由解析(参考 pj034 get_route())
// 输入 agent 名或 route_id,返回完整 RouteConfig(已注入 base_url / api_key)
// GetRoute — 核心路由解析
// 输入 agent 名或 ai_route_id,返回完整 RouteConfig(已注入 base_url / api_key)
// 查找路径:agent_routes → 分类 routes(chats/embeds/images) → 兼容旧版 routes → default
// ──────────────────────────────────────────────
@@ -261,6 +266,13 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
routeID = r
}
if routeID == AutoChatRouteID {
return resolveAutoRoute("chat")
}
if routeID == AutoEmbedRouteID {
return resolveAutoRoute("embed")
}
// 2) 在分类路由中查找
info, category, found := findRoute(aiCfg, routeID)
if !found {
@@ -294,17 +306,19 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
fullURL := strings.TrimRight(baseURL, "/") + info.Endpoint
rc := &RouteConfig{
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
Category: category,
Description: info.Description,
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
Category: category,
Description: info.Description,
ShortRouteName: info.ShortRouteName,
ShortModelName: info.ShortModelName,
}
if rc.MaxTokens <= 0 {
rc.MaxTokens = 2048
@@ -463,8 +477,14 @@ func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
return nil, fmt.Errorf("未知路由分类: %s", category)
}
var result []*RouteConfig
keys := make([]string, 0, len(routeMap))
for rid := range routeMap {
keys = append(keys, rid)
}
sort.Strings(keys)
var result []*RouteConfig
for _, rid := range keys {
r, err := GetRoute(rid)
if err != nil {
continue
@@ -0,0 +1,330 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
)
const (
AutoChatRouteID = "chat_route_auto"
AutoEmbedRouteID = "embed_route_auto"
defaultAIRouteProbeInterval = 30 * time.Minute
defaultAIRouteProbeTimeout = 20 * time.Second
)
type RouteHealth struct {
AIRouteID string `json:"ai_route_id"`
Category string `json:"category"`
Healthy bool `json:"healthy"`
Checked bool `json:"checked"`
LatencyMs int64 `json:"latency_ms,omitempty"`
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
var (
routeHealthMu sync.RWMutex
routeHealthMap = map[string]RouteHealth{}
routeHealthOnce sync.Once
)
func StartAIRouteHealthLoop(interval time.Duration) {
if interval <= 0 {
interval = defaultAIRouteProbeInterval
}
routeHealthOnce.Do(func() {
go func() {
RefreshAIRouteHealthNow()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
RefreshAIRouteHealthNow()
}
}()
})
}
func RefreshAIRouteHealthNow() {
refreshAIRouteHealthForCategory("chat")
refreshAIRouteHealthForCategory("embed")
}
func GetRouteHealth(routeID string) (RouteHealth, bool) {
routeHealthMu.RLock()
defer routeHealthMu.RUnlock()
status, ok := routeHealthMap[routeID]
return status, ok
}
func resolveAutoRoute(category string) (*RouteConfig, error) {
routes, err := GetRoutesByCategory(category)
if err != nil {
return nil, err
}
if len(routes) == 0 {
return nil, fmt.Errorf("%s 路由未配置", category)
}
if shouldRefreshRouteHealth(routes) {
refreshAIRouteHealth(routes)
}
defaultRouteID := getDefaultRouteIDForCategory(category)
best := pickBestHealthyRoute(routes, defaultRouteID)
if best != nil {
return best, nil
}
if defaultRouteID != "" {
if route, err := GetRoute(defaultRouteID); err == nil && route != nil {
return route, nil
}
}
return routes[0], nil
}
func refreshAIRouteHealthForCategory(category string) {
routes, err := GetRoutesByCategory(category)
if err != nil || len(routes) == 0 {
return
}
refreshAIRouteHealth(routes)
}
func refreshAIRouteHealth(routes []*RouteConfig) {
for _, route := range routes {
if route == nil {
continue
}
ReportRouteHealth(probeRoute(route))
}
}
func shouldRefreshRouteHealth(routes []*RouteConfig) bool {
if len(routes) == 0 {
return false
}
cutoff := time.Now().Add(-(defaultAIRouteProbeInterval + 5*time.Minute))
routeHealthMu.RLock()
defer routeHealthMu.RUnlock()
for _, route := range routes {
if route == nil {
continue
}
status, ok := routeHealthMap[route.RouteID]
if !ok || !status.Checked || status.LastCheckedAt.Before(cutoff) {
return true
}
}
return false
}
func pickBestHealthyRoute(routes []*RouteConfig, defaultRouteID string) *RouteConfig {
type candidate struct {
route *RouteConfig
health RouteHealth
isDefault bool
}
candidates := make([]candidate, 0, len(routes))
routeHealthMu.RLock()
for _, route := range routes {
if route == nil {
continue
}
status, ok := routeHealthMap[route.RouteID]
if !ok || !status.Checked || !status.Healthy {
continue
}
candidates = append(candidates, candidate{
route: route,
health: status,
isDefault: route.RouteID == defaultRouteID,
})
}
routeHealthMu.RUnlock()
if len(candidates) == 0 {
return nil
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].isDefault != candidates[j].isDefault {
return candidates[i].isDefault
}
if candidates[i].health.LatencyMs != candidates[j].health.LatencyMs {
return candidates[i].health.LatencyMs < candidates[j].health.LatencyMs
}
return candidates[i].route.RouteID < candidates[j].route.RouteID
})
return candidates[0].route
}
func getDefaultRouteIDForCategory(category string) string {
aiCfg, err := LoadAIConfig()
if err != nil {
return ""
}
switch category {
case "chat":
if aiCfg.DefaultRoute != AutoChatRouteID {
return aiCfg.DefaultRoute
}
case "embed":
if aiCfg.DefaultEmbedRoute != AutoEmbedRouteID {
return aiCfg.DefaultEmbedRoute
}
}
return ""
}
func probeRoute(route *RouteConfig) RouteHealth {
if route == nil {
return RouteHealth{
Checked: true,
LastCheckedAt: time.Now(),
LastError: "路由不存在",
}
}
status := RouteHealth{
AIRouteID: route.RouteID,
Category: route.Category,
Checked: true,
LastCheckedAt: time.Now(),
}
start := time.Now()
if strings.TrimSpace(route.BaseURL) == "" {
status.LastError = "base_url 未配置"
return status
}
if requiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
status.LastError = "API Key 未配置"
return status
}
client := &http.Client{Timeout: defaultAIRouteProbeTimeout}
var err error
switch route.Category {
case "embed":
err = probeEmbedRoute(client, route)
default:
err = probeChatRoute(client, route)
}
status.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
status.LastError = err.Error()
return status
}
status.Healthy = true
return status
}
func probeChatRoute(client *http.Client, route *RouteConfig) error {
body := map[string]any{
"model": route.Model,
"messages": []map[string]string{{"role": "user", "content": "ping"}},
"stream": false,
"temperature": 0,
"max_tokens": 4,
}
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := doRouteProbeRequest(client, route, body, &resp); err != nil {
return err
}
if len(resp.Choices) == 0 {
return fmt.Errorf("返回空 choices")
}
return nil
}
func probeEmbedRoute(client *http.Client, route *RouteConfig) error {
body := map[string]any{
"model": route.Model,
"input": "ping",
}
var resp struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
if err := doRouteProbeRequest(client, route, body, &resp); err != nil {
return err
}
if len(resp.Data) == 0 || len(resp.Data[0].Embedding) == 0 {
return fmt.Errorf("返回空 embedding")
}
return nil
}
func doRouteProbeRequest(client *http.Client, route *RouteConfig, body any, out any) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, route.FullURL, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(route.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("返回 %d: %s", resp.StatusCode, truncateProbeText(string(data), 160))
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("响应解析失败: %w", err)
}
return nil
}
func requiresRouteAPIKey(route *RouteConfig) bool {
if route == nil {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
}
provider := strings.ToLower(strings.TrimSpace(route.Provider))
return provider == "openrouter" || provider == "openai"
}
func ReportRouteHealth(status RouteHealth) {
if strings.TrimSpace(status.AIRouteID) == "" {
return
}
routeHealthMu.Lock()
routeHealthMap[status.AIRouteID] = status
routeHealthMu.Unlock()
}
func truncateProbeText(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 || len(text) <= limit {
return text
}
return text[:limit] + "..."
}
@@ -1,11 +1,7 @@
package middleware
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -53,10 +49,7 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
}
c.Set("current_user", &user)
// #region debug-point C:auth-success
go func(path, method, username, role string, userID uint) { _, _ = http.Post("http://127.0.0.1:7777/event", "application/json", bytes.NewBuffer(mustJSON(map[string]any{"sessionId": "admin-auth-misjudge", "runId": "pre-fix", "hypothesisId": "C", "location": "internal/middleware/auth.go:Auth", "msg": "[DEBUG] auth accepted request", "data": map[string]any{"path": path, "method": method, "username": username, "role": role, "user_id": userID, "token_present": true}, "ts": time.Now().UnixMilli()}))) }(c.Request.URL.Path, c.Request.Method, user.Username, user.Role, user.ID)
// #endregion
c.Next()
c.Next()
}
}
@@ -65,9 +58,6 @@ func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
u := CurrentUser(c)
if u == nil || u.Role != "admin" {
// #region debug-point D:require-admin-reject
go func(path, method string, u *model.User) { payload := map[string]any{"sessionId": "admin-auth-misjudge", "runId": "pre-fix", "hypothesisId": "D", "location": "internal/middleware/auth.go:RequireAdmin", "msg": "[DEBUG] require admin rejected request", "data": map[string]any{"path": path, "method": method, "user_nil": u == nil}, "ts": time.Now().UnixMilli()}; if u != nil { payload["data"] = map[string]any{"path": path, "method": method, "user_nil": false, "username": u.Username, "role": u.Role, "user_id": u.ID} }; _, _ = http.Post("http://127.0.0.1:7777/event", "application/json", bytes.NewBuffer(mustJSON(payload))) }(c.Request.URL.Path, c.Request.Method, u)
// #endregion
web.Fail(c, web.NewForbiddenError("需要管理员权限"))
c.Abort()
return
@@ -93,11 +83,3 @@ func extractBearer(c *gin.Context) string {
}
return ""
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte(`{"sessionId":"admin-auth-misjudge","runId":"pre-fix","hypothesisId":"D","location":"internal/middleware/auth.go:mustJSON","msg":"[DEBUG] marshal failed","ts":0}`)
}
return b
}
@@ -0,0 +1,26 @@
package model
import "time"
// ActionDefinition 内部原子执行动作定义。
type ActionDefinition struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"`
Description string `gorm:"type:text" json:"description"`
ActionType string `gorm:"size:32;not null;default:execution;index" json:"action_type"`
ConnectorRef string `gorm:"size:64;default:'';index" json:"connector_ref"`
InputSchemaJSON string `gorm:"type:text" json:"input_schema_json"`
OutputSchemaJSON string `gorm:"type:text" json:"output_schema_json"`
RiskLevel string `gorm:"size:16;not null;default:low;index" json:"risk_level"`
ApprovalMode string `gorm:"size:32;not null;default:not_required" json:"approval_mode"`
AuditLevel string `gorm:"size:32;not null;default:standard" json:"audit_level"`
ExposedToUser bool `gorm:"not null;default:false;index" json:"exposed_to_user"`
OntologyBindingJSON string `gorm:"type:text" json:"ontology_binding_json"`
State string `gorm:"size:16;not null;default:active;index" json:"state"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ActionDefinition) TableName() string { return "action_definition" }
@@ -0,0 +1,26 @@
package model
import "time"
// Project 项目:任务的容器。
//
// 任务记「做的是哪件事」,项目记「这些事是为谁、按什么规矩做的」——
// 指令是给 AI 的长期上下文,预置的专员/技能/连接器是这个项目默认带上的能力。
// 归属跟 WorkerTask.Owner 同构(全名优先,回退登录名),列表只列自己的。
type Project struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:40;not null" json:"name"`
Instruction string `gorm:"type:text" json:"instruction"`
TemplateKey string `gorm:"size:64;default:''" json:"template_key"`
Owner string `gorm:"size:64;not null;index" json:"owner"`
Pinned bool `gorm:"not null;default:false" json:"pinned"`
// 建项目时选中的能力,存 JSON 数组字符串。key 各自指向 specialist.key /
// skill.key / connector.key —— 只存 key,展示时再去取名字。
SpecialistKeys string `gorm:"type:text" json:"specialist_keys"`
SkillKeys string `gorm:"column:skill_keys;type:text" json:"skill_keys"`
ConnectorKeys string `gorm:"type:text" json:"connector_keys"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Project) TableName() string { return "project" }
@@ -0,0 +1,32 @@
package model
import "time"
// SkillDefinition 对外暴露的任务级能力定义。
type SkillDefinition struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
Label string `gorm:"size:128;not null" json:"label"`
DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"`
EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"`
Description string `gorm:"type:text" json:"description"`
RoleKind string `gorm:"size:32;not null;default:skill;index" json:"role_kind"` // assistant / specialist / skill
Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom
ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;default:''" json:"object_entry_route"`
LegacyObjectEntryRoute string `gorm:"column:legacy_object_entry_route;size:128;default:''" json:"legacy_object_entry_route"`
ExposedToUser bool `gorm:"not null;default:true;index" json:"exposed_to_user"`
StarterPromptsJSON string `gorm:"type:text" json:"starter_prompts_json"`
PromptTemplate string `gorm:"type:text" json:"prompt_template"`
InputSchemaJSON string `gorm:"type:text" json:"input_schema_json"`
OutputSchemaJSON string `gorm:"type:text" json:"output_schema_json"`
ArtifactSchemaJSON string `gorm:"type:text" json:"artifact_schema_json"`
ActionRefsJSON string `gorm:"type:text" json:"action_refs_json"`
PolicyRefsJSON string `gorm:"type:text" json:"policy_refs_json"`
OntologyBindingJSON string `gorm:"type:text" json:"ontology_binding_json"`
State string `gorm:"size:16;not null;default:active;index" json:"state"`
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SkillDefinition) TableName() string { return "skill_definition" }
@@ -0,0 +1,16 @@
package model
import "time"
// UserAppCenter 保存用户自己的应用中心状态,用于同步我的应用、收藏和最近使用。
type UserAppCenter struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;uniqueIndex" json:"user_id"`
FavoriteKeys string `gorm:"type:text;not null;default:''" json:"favorite_keys"`
RecentKeys string `gorm:"type:text;not null;default:''" json:"recent_keys"`
CustomApps string `gorm:"type:text;not null;default:''" json:"custom_apps"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (UserAppCenter) TableName() string { return "user_app_center" }
@@ -17,6 +17,9 @@ type WorkerTask struct {
DueAt *time.Time `json:"due_at"`
CreatedBy *uint `gorm:"index" json:"created_by"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
// ProjectID 这条任务属于哪个项目。可空 —— 不挂项目的任务跟以前一样,
// 删项目时把它置空(任务本身留着),所以不能是级联删除。
ProjectID *uint `gorm:"index" json:"project_id"`
// Pinned 置顶:只影响「我的任务」列表的排序,不改变任务本身的状态。
Pinned bool `gorm:"not null;default:false" json:"pinned"`
CreatedAt time.Time `json:"created_at"`
@@ -1,6 +1,7 @@
package store
import (
"fmt"
"os"
"path/filepath"
@@ -15,7 +16,6 @@ import (
var DB *gorm.DB
// Init 打开 SQLite(pure-Go 驱动,CGO_ENABLED=0 可静态编译)并 AutoMigrate 全部表。
// 通过 GORM 方言抽象,未来切换 MySQL 仅需替换驱动与 DSN。
func Init(dbPath string) (*gorm.DB, error) {
if dir := filepath.Dir(dbPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -55,15 +55,149 @@ func Init(dbPath string) (*gorm.DB, error) {
&model.Notification{},
&model.StudyNote{},
&model.Specialist{},
&model.SkillDefinition{},
&model.ActionDefinition{},
&model.Project{},
&model.WorkerTask{},
&model.WorkerArtifact{},
&model.WorkerRun{},
&model.UserAppCenter{},
&model.OfficialAccountArticle{},
&model.OfficialAccountHotspot{},
); err != nil {
return nil, err
}
if err := migrateObjectEntryRouteColumns(db); err != nil {
return nil, err
}
DB = db
return db, nil
}
func migrateObjectEntryRouteColumns(db *gorm.DB) error {
if db.Migrator().HasTable(&model.Specialist{}) {
if db.Migrator().HasColumn(&model.Specialist{}, "object_entry_route") {
specialistLegacyExpr := coalesceRouteExpr(
db.Migrator().HasColumn(&model.Specialist{}, "entry_route"),
"entry_route",
db.Migrator().HasColumn(&model.Specialist{}, "route"),
"route",
)
if specialistLegacyExpr != "" {
if err := db.Exec(fmt.Sprintf(`
UPDATE specialist
SET object_entry_route = %s
WHERE COALESCE(object_entry_route, '') = ''
AND COALESCE(%s, '') <> ''
`, specialistLegacyExpr, specialistLegacyExpr)).Error; err != nil {
return err
}
}
}
if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") {
if err := dropColumn(db, "specialist", "entry_route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.Specialist{}, "route") {
if err := dropColumn(db, "specialist", "route"); err != nil {
return err
}
}
}
if db.Migrator().HasTable(&model.SkillDefinition{}) {
if db.Migrator().HasColumn(&model.SkillDefinition{}, "object_entry_route") {
skillLegacyExpr := coalesceRouteExpr(
db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route"),
"entry_route",
db.Migrator().HasColumn(&model.SkillDefinition{}, "route"),
"route",
)
if skillLegacyExpr != "" {
if err := db.Exec(fmt.Sprintf(`
UPDATE skill_definition
SET object_entry_route = %s
WHERE COALESCE(object_entry_route, '') = ''
AND COALESCE(%s, '') <> ''
`, skillLegacyExpr, skillLegacyExpr)).Error; err != nil {
return err
}
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_object_entry_route") {
skillLegacyObjectExpr := coalesceRouteExpr(
db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route"),
"legacy_entry_route",
db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route"),
"legacy_route",
)
if skillLegacyObjectExpr != "" {
if err := db.Exec(fmt.Sprintf(`
UPDATE skill_definition
SET legacy_object_entry_route = %s
WHERE COALESCE(legacy_object_entry_route, '') = ''
AND COALESCE(%s, '') <> ''
`, skillLegacyObjectExpr, skillLegacyObjectExpr)).Error; err != nil {
return err
}
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") {
if err := dropColumn(db, "skill_definition", "entry_route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") {
if err := dropColumn(db, "skill_definition", "route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") {
if err := dropColumn(db, "skill_definition", "legacy_entry_route"); err != nil {
return err
}
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") {
if err := dropColumn(db, "skill_definition", "legacy_route"); err != nil {
return err
}
}
}
return nil
}
func coalesceRouteExpr(primaryExists bool, primaryColumn string, fallbackExists bool, fallbackColumn string) string {
parts := make([]string, 0, 2)
if primaryExists {
parts = append(parts, fmt.Sprintf("NULLIF(%s, '')", primaryColumn))
}
if fallbackExists {
parts = append(parts, fallbackColumn)
}
if len(parts) == 0 {
return ""
}
if len(parts) == 1 {
return parts[0]
}
return fmt.Sprintf("COALESCE(%s)", joinCSV(parts))
}
func joinCSV(parts []string) string {
result := ""
for i, part := range parts {
if i > 0 {
result += ", "
}
result += part
}
return result
}
func dropColumn(db *gorm.DB, tableName, columnName string) error {
return db.Exec(fmt.Sprintf(`ALTER TABLE "%s" DROP COLUMN "%s"`, tableName, columnName)).Error
}
@@ -0,0 +1,123 @@
package store
import (
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"eai_agentplatform/backend/internal/model"
)
func openMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "migration.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}
func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) {
db := openMigrationTestDB(t)
if err := db.AutoMigrate(&model.Specialist{}, &model.SkillDefinition{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.Exec(`ALTER TABLE specialist ADD COLUMN entry_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add specialist.entry_route: %v", err)
}
if err := db.Exec(`ALTER TABLE specialist ADD COLUMN route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add specialist.route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN entry_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.entry_route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN legacy_entry_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.legacy_entry_route: %v", err)
}
if err := db.Exec(`ALTER TABLE skill_definition ADD COLUMN legacy_route TEXT DEFAULT ''`).Error; err != nil {
t.Fatalf("add skill_definition.legacy_route: %v", err)
}
specialist := model.Specialist{
Key: "spec-demo",
Label: "专员演示",
Tier: "generic",
WorkerType: "dw",
ObjectEntryRoute: "",
State: "active",
MarketTag: "installed",
}
if err := db.Create(&specialist).Error; err != nil {
t.Fatalf("create specialist: %v", err)
}
if err := db.Exec(`UPDATE specialist SET entry_route = ?, route = ? WHERE id = ?`, "/legacy-specialist", "/legacy-specialist-route", specialist.ID).Error; err != nil {
t.Fatalf("seed specialist legacy routes: %v", err)
}
skill := model.SkillDefinition{
Key: "skill-demo",
Label: "技能演示",
RoleKind: "skill",
Source: "eai",
ObjectEntryRoute: "",
LegacyObjectEntryRoute: "",
State: "active",
}
if err := db.Create(&skill).Error; err != nil {
t.Fatalf("create skill: %v", err)
}
if err := db.Exec(`UPDATE skill_definition SET entry_route = ?, route = ?, legacy_entry_route = ?, legacy_route = ? WHERE id = ?`,
"/legacy-skill", "/legacy-skill-route", "/legacy-old-skill", "/legacy-old-skill-route", skill.ID,
).Error; err != nil {
t.Fatalf("seed skill legacy routes: %v", err)
}
if err := migrateObjectEntryRouteColumns(db); err != nil {
t.Fatalf("migrate object entry route columns: %v", err)
}
var migratedSpecialist model.Specialist
if err := db.First(&migratedSpecialist, specialist.ID).Error; err != nil {
t.Fatalf("reload specialist: %v", err)
}
if migratedSpecialist.ObjectEntryRoute != "/legacy-specialist" {
t.Fatalf("specialist object_entry_route = %q, want %q", migratedSpecialist.ObjectEntryRoute, "/legacy-specialist")
}
var migratedSkill model.SkillDefinition
if err := db.First(&migratedSkill, skill.ID).Error; err != nil {
t.Fatalf("reload skill: %v", err)
}
if migratedSkill.ObjectEntryRoute != "/legacy-skill" {
t.Fatalf("skill object_entry_route = %q, want %q", migratedSkill.ObjectEntryRoute, "/legacy-skill")
}
if migratedSkill.LegacyObjectEntryRoute != "/legacy-old-skill" {
t.Fatalf("skill legacy_object_entry_route = %q, want %q", migratedSkill.LegacyObjectEntryRoute, "/legacy-old-skill")
}
if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") {
t.Fatalf("specialist.entry_route should be dropped")
}
if db.Migrator().HasColumn(&model.Specialist{}, "route") {
t.Fatalf("specialist.route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") {
t.Fatalf("skill_definition.entry_route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") {
t.Fatalf("skill_definition.route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") {
t.Fatalf("skill_definition.legacy_entry_route should be dropped")
}
if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") {
t.Fatalf("skill_definition.legacy_route should be dropped")
}
}
@@ -12,6 +12,7 @@ func EnsureSpecialistStructuredRecords(item *model.Specialist) {
if item == nil {
return
}
EnsureSpecialistRoleCard(item)
if strings.TrimSpace(item.InputsRecordsJSON) == "" {
item.InputsRecordsJSON = mustJSON(buildInputsRecords(item))
}
@@ -26,6 +27,25 @@ func EnsureSpecialistStructuredRecords(item *model.Specialist) {
}
}
func EnsureSpecialistRoleCard(item *model.Specialist) {
if item == nil || strings.TrimSpace(item.RoleCardJSON) != "" {
return
}
item.RoleCardJSON = mustJSON(map[string]any{
"name": item.Label,
"tagline": firstNonEmpty(item.Summary, item.WorkStatus, item.Label),
"greeting": "我是" + item.Label + ",已经准备好接手这条任务并继续推进。",
"relationship_to_user": "你的协作搭档",
"tone": "专业、直接、可执行",
"opening_prompt": "告诉我当前事项、目标和约束,我先帮你起步。",
"starter_prompts": defaultStarterPrompts(item),
"boundaries": []string{
"高风险动作会明确标记待确认",
"需要人工审批的步骤不会默认自动执行",
},
})
}
func ValidateStructuredRecordsJSON(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
@@ -35,6 +55,24 @@ func ValidateStructuredRecordsJSON(value string) bool {
return json.Unmarshal([]byte(value), &arr) == nil
}
func ValidateJSONObjectJSON(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
var obj map[string]any
return json.Unmarshal([]byte(value), &obj) == nil
}
func ValidateJSONStringArray(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
var arr []string
return json.Unmarshal([]byte(value), &arr) == nil
}
func buildInputsRecords(item *model.Specialist) []map[string]any {
records := make([]map[string]any, 0)
for _, value := range splitText(item.ConnectorScope) {
@@ -177,6 +215,23 @@ func buildResultRecords(item *model.Specialist) []map[string]any {
return results
}
func defaultStarterPrompts(item *model.Specialist) []string {
prompts := make([]string, 0, 3)
for _, skill := range splitText(item.BaseSkills) {
prompts = append(prompts, "帮我处理:"+skill)
if len(prompts) == 3 {
return prompts
}
}
if item.Summary != "" {
prompts = append(prompts, "继续推进这条事项")
}
if len(prompts) == 0 {
prompts = []string{"先帮我梳理当前事项", "告诉我下一步怎么做", "先看有哪些待确认点"}
}
return prompts
}
func mustJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
@@ -35,7 +35,7 @@
2. **三个固定 section**(按 `## ` 二级标题分块):
- `## 结构化产品数据` → 摄入 `product` 表
- `## AI 检索知识` → 摄入 `knowledge_chunk` 表(自然语言段落,供 MySQL FULLTEXT + LLM)
- `## AI 检索知识` → 摄入 `knowledge_chunk` 表(自然语言段落,供 Go 原生向量/关键词检索 + LLM)
- `## 考试题目` → 摄入 `question` 表
## 3. 题目答案契约(统一 list 形式)
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
os.environ.setdefault("ORT_DISABLE_GPU", "1")
try:
from paddleocr import PaddleOCR
except Exception:
PaddleOCR = None
try:
from rapidocr_onnxruntime import RapidOCR
except Exception:
RapidOCR = None
def flatten_result(raw_result):
lines = []
text_parts = []
for page in raw_result or []:
if not page:
continue
for item in page:
if not item or len(item) < 2:
continue
points = item[0]
text_info = item[1] or []
text = str(text_info[0] or "").strip() if len(text_info) > 0 else ""
score = float(text_info[1] or 0) if len(text_info) > 1 else 0
if not text:
continue
lines.append({
"text": text,
"score": score,
"points": points,
})
text_parts.append(text)
return {
"text": "\n".join(text_parts).strip(),
"lines": lines,
}
def run_paddle_ocr(image_path):
if PaddleOCR is None:
return None
ocr = PaddleOCR(
use_angle_cls=True,
lang="ch",
show_log=False,
use_gpu=False,
enable_mkldnn=False,
cpu_threads=1,
)
result = ocr.ocr(image_path, cls=True)
payload = flatten_result(result)
payload["engine"] = "paddleocr"
return payload
def run_rapid_ocr(image_path):
if RapidOCR is None:
return None
engine = RapidOCR()
result, _ = engine(image_path)
payload = {"text": "", "lines": []}
for item in result or []:
if not item or len(item) < 3:
continue
points, text, score = item[0], str(item[1] or "").strip(), float(item[2] or 0)
if not text:
continue
payload["lines"].append({
"text": text,
"score": score,
"points": points,
})
payload["text"] += f"{text}\n"
payload["text"] = payload["text"].strip()
payload["engine"] = "rapidocr"
return payload
def try_paddle_ocr_subprocess(image_path):
cmd = [sys.executable, __file__, "--engine", "paddle-only", image_path]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
except Exception:
return None
if result.returncode != 0:
return None
try:
payload = json.loads((result.stdout or "").strip())
except Exception:
return None
if not isinstance(payload, dict) or payload.get("error"):
return None
payload["engine"] = "paddleocr"
return payload
def main():
args = sys.argv[1:]
engine_mode = "auto"
if len(args) >= 2 and args[0] == "--engine":
engine_mode = args[1].strip().lower() or "auto"
args = args[2:]
if len(args) < 1:
print(json.dumps({"error": "missing_image_path"}, ensure_ascii=False))
sys.exit(1)
image_path = args[0]
if engine_mode == "paddle-only":
payload = run_paddle_ocr(image_path)
elif engine_mode == "rapid-only":
payload = run_rapid_ocr(image_path)
else:
payload = try_paddle_ocr_subprocess(image_path) or run_rapid_ocr(image_path)
if payload is None:
print(json.dumps({"error": "ocr_engine_not_installed"}, ensure_ascii=False))
sys.exit(1)
print(json.dumps(payload, ensure_ascii=False))
if __name__ == "__main__":
main()
+157
View File
@@ -13,6 +13,7 @@
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"pinia": "^2.1.0",
"pptxgenjs": "^4.0.1",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
@@ -163,6 +164,15 @@
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "22.20.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"license": "MIT"
@@ -402,6 +412,12 @@
"node": ">= 0.8"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"license": "MIT"
@@ -1150,6 +1166,12 @@
"node": ">= 0.4"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"license": "MIT",
@@ -1161,6 +1183,60 @@
"node": ">= 6"
}
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"license": "MIT"
@@ -1237,6 +1313,12 @@
"version": "1.2.0",
"license": "BSD-3-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@@ -1287,6 +1369,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"license": "MIT",
@@ -1294,6 +1394,30 @@
"node": ">=10"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rollup": {
"version": "4.62.4",
"dev": true,
@@ -1689,6 +1813,18 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"license": "BSD-3-Clause",
@@ -1696,6 +1832,27 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"dev": true,
+6 -5
View File
@@ -9,13 +9,14 @@
"preview": "node ./node_modules/vite/bin/vite.js preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"@element-plus/icons-vue": "^2.3.0",
"@vue-flow/core": "^1.44.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"@vue-flow/core": "^1.44.0"
"pinia": "^2.1.0",
"pptxgenjs": "^4.0.1",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
@@ -1,38 +0,0 @@
# 冻结件登记表(FROZEN)
本目录存放**已停用但完整保留**的代码:不参与构建、不被任何存活代码引用,但随时可回收。
冻结不是删除。删掉的东西要靠记忆和 git log 才能找回来;冻结的东西保持可读、可搜索、
并在此处登记它的来龙去脉。**「解冻条件」这一列是核心** —— 没有它,这批代码半年后没人
敢动,就从资产变成了负债。
## 约定
- 目录 `src/_frozen/`,所有冻结文件统一加 `.frozen` 后缀
- 冻结件**不 import 存活代码以外的路径**;冻结件之间用相对引用,自成封闭单元
- 冻结件保留原有注释与代码原貌,只在文件头加一段「原位置 / 冻结日期」说明
- 冻结件内引用的 store / api **未必也冻结**(见下表「外部依赖」列),解冻前先核对
## 登记表
| 冻结物 | 原位置 | 为什么冻 | 外部依赖 | 解冻条件 | 解冻步骤 |
|---|---|---|---|---|---|
| `WorkspaceTabs.frozen.vue`<br>工作区胶囊条 + 通知铃铛 | `src/layout/MainLayout.vue` 模板 101-128 行、`isTabActive()` 314-318、`closeTab()` 375-388、通知轮询 240-241/248、样式 727-802 | 外壳重构后一级导航移到侧边栏、二级导航移到内容区 `components/SectionTabs.vue`,这行胶囊成了冗余的第二套导航;且铃铛指向未注册的 `/notifications`,是点了没反应的死按钮 | ✅ 存活:`@/store/workbench`、`@/store/notification`、`@/config/nav`、`vue-router`、`@element-plus/icons-vue` | 需要「跨一级项快速切换任务」这一交互时;或恢复通知功能时(铃铛含在内) | 在 `MainLayout.vue` 的 `.wb-main` 内、`<!-- Content -->` 之前挂 `<WorkspaceTabs />` 并 import。组件已自包含,无需改动内部代码 |
| `SectionTabs.frozen.vue`<br>旧二级导航(`el-tabs` 形态) | `src/layout/SectionTabs.vue` | 被 `src/components/SectionTabs.vue` 取代(新的由 `config/nav.js` 驱动,改为原生 button + 下划线)。**注意与存活的那个同名,勿混淆** | ✅ 存活:`@/store/auth`、`@/store/notification`<br>🔒 冻结:`./navigation.frozen.js` | 想要 `el-tabs` 形态的二级导航时(旧版自带 el-badge,与 Element 风格更统一) | 1. 放回 `src/layout/`;2. 把 import 改回 `@/config/navigation`,并将 `navigation.frozen.js` 放回 `src/config/`;3. 在 `MainLayout.vue` 挂载 |
| `SideNav.frozen.vue`<br>旧侧边栏 | `src/layout/SideNav.vue` | 被 `MainLayout.vue` 内联的新侧边栏取代 | ✅ 存活:`@/store/auth`、`@/store/notification`<br>🔒 冻结:`./navigation.frozen.js` | 想把侧边栏从 `MainLayout.vue` 里拆成独立组件时 —— 旧版结构上比现在的内联写法更清晰 | 同 `SectionTabs.frozen.vue`:放回 `src/layout/`,import 改回 `@/config/navigation` 并还原 `navigation.frozen.js` |
| `navigation.frozen.js`<br>旧导航配置 | `src/config/navigation.js` | 被 `src/config/nav.js` 取代(`nav.js` 收敛了原先 `sidebarGroups` / `LibraryTabs` / `ExamTabs` 三份重复清单) | 无 | 随上面两个组件一起解冻 | 放回 `src/config/` 并去掉文件名里的 `.frozen` |
| `Notification.frozen.vue`<br>站内通知页面(175 行,实现完整) | `src/views/Notification.vue` | `router/index.js` 里没有 `/notifications` 路由,页面无入口,已成孤儿 | ✅ 存活:`@/api/notification`、`@/store/notification`<br>✅ 后端四个接口仍在(`backend-go/internal/api/router.go:90-93`) | 要恢复站内通知时。**是纯前端工作量** —— 后端接口从未下线 | 1. 放回 `src/views/`;2. 在 `router/index.js` 注册 `/notifications`;3. 补导航入口(`config/nav.js` 里没有它,需新增);4. 参考 `WorkspaceTabs.frozen.vue` 把铃铛加回 |
## 已知遗留(本次未修,改动存活代码时注意)
铃铛冻结后,`store/notification.js` 的 `unread` 在前端**已无任何 UI 消费者**,
但 `src/views/workbench/BusinessAppPage.vue`(825 / 855 / 892 行)仍会调用
`notificationStore.refresh()`。属于既有死端,本次一并冻掉铃铛后更明显。
若将来确认通知不再恢复,可连同 `store/notification.js`、`api/notification.js`
的引用一起清理。
## 冻结记录
| 日期 | 动作 |
|---|---|
| 2026-09-14 | 建立本目录与约定。冻结工作区胶囊条(`WorkspaceTabs.frozen.vue`,新抽出);收拢外壳重构遗留的四个孤儿(`SectionTabs` / `SideNav` / `navigation` / `Notification`) |
@@ -1,175 +0,0 @@
<template>
<div class="page-container">
<div class="page-title">消息通知</div>
<el-card shadow="never">
<template #header>
<div class="card-head">
<span>共 {{ items.length }} 条通知</span>
<el-button type="primary" size="small" plain @click="readAll">全部标记已读</el-button>
</div>
</template>
<el-empty v-if="!loading && items.length === 0" description="暂无通知" />
<div v-else v-loading="loading">
<div
v-for="n in items"
:key="n.id"
class="notice-item"
:class="{ unread: !n.read }"
@click="openNotice(n)"
>
<div class="notice-dot" :class="{ dot: !n.read }" />
<div class="notice-main">
<div class="notice-title">
<span class="title">{{ n.title }}</span>
<el-tag size="small" effect="plain" :type="typeTag(n.type)">{{ typeLabel(n.type) }}</el-tag>
<span class="time">{{ fmtTime(n.created_at) }}</span>
</div>
<div class="content">{{ n.content }}</div>
</div>
<el-button v-if="!n.read" type="primary" link size="small" @click.stop="markOne(n)">标为已读</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { myNotifications, markRead, markAllRead } from '@/api/notification'
import { useNotificationStore } from '@/store/notification'
const router = useRouter()
const notifStore = useNotificationStore()
const loading = ref(false)
const items = ref([])
function fmtTime(t) {
if (!t) return '—'
return String(t).replace('T', ' ').slice(0, 16)
}
function typeLabel(t) {
return {
exam_publish: '考试发布',
exam_pass: '考试通过',
position_set: '岗位设置',
worker_task_assign: '事项指派',
worker_artifact_review: '交付待确认',
worker_artifact_approved: '交付已确认',
worker_artifact_published: '交付已发布',
}[t] || '通知'
}
function typeTag(t) {
return {
exam_publish: 'warning',
exam_pass: 'success',
position_set: 'primary',
worker_task_assign: 'primary',
worker_artifact_review: 'warning',
worker_artifact_approved: 'success',
worker_artifact_published: 'success',
}[t] || 'info'
}
function openNotice(n) {
if (!n.read) markOne(n)
if (n.link) router.push(n.link)
}
async function markOne(n) {
try {
await markRead(n.id)
n.read = true
notifStore.refresh()
} catch (e) {
// 忽略
}
}
async function readAll() {
try {
await markAllRead()
items.value.forEach((n) => (n.read = true))
notifStore.setUnread(0)
} catch (e) {
// 拦截器已提示
}
}
async function load() {
loading.value = true
try {
const res = await myNotifications()
items.value = res.data || []
} catch (e) {
items.value = []
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.notice-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 8px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.notice-item:last-child {
border-bottom: none;
}
.notice-item:hover {
background: #f8f9fb;
}
.notice-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex: none;
}
.notice-dot.dot {
background: #f56c6c;
}
.notice-main {
flex: 1;
min-width: 0;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-weight: 600;
color: #303133;
}
.notice-item.unread .title {
color: #303133;
}
.time {
margin-left: auto;
font-size: 12px;
color: #c0c4cc;
}
.content {
font-size: 13px;
color: #606266;
margin-top: 4px;
}
</style>
@@ -1,120 +0,0 @@
<template>
<div v-if="section" class="section-tabs">
<div class="section-tabs-inner">
<el-tabs
:model-value="activeTabPath"
class="tabs-nav"
@tab-change="handleTabChange"
>
<el-tab-pane
v-for="tab in section.tabs"
:key="tab.path"
:name="tab.path"
>
<template #label>
<span class="tab-label">
<span>{{ tab.label }}</span>
<el-badge
v-if="tab.path === '/notifications' && unread > 0"
:value="unread"
:max="99"
class="tab-badge"
/>
</span>
</template>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { useNotificationStore } from '@/store/notification'
// 冻结件之间用相对引用,让这批归档自成一个封闭单元(原为 @/config/navigation)
import { resolveSection, resolveTab } from './navigation.frozen.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const notif = useNotificationStore()
const section = computed(() => resolveSection(route.path, auth.isAdmin))
const activeTabPath = computed(() => resolveTab(route.path, auth.isAdmin)?.path || '')
const unread = computed(() => notif.unread)
function handleTabChange(path) {
if (typeof path === 'string' && path && path !== route.path) {
router.push(path)
}
}
</script>
<style scoped>
.section-tabs {
padding: 0 24px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
border-bottom: 1px solid var(--surface-border);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.section-tabs-inner {
min-height: var(--top-rail-height);
display: flex;
align-items: flex-end;
}
.tabs-nav :deep(.el-tabs__header) {
margin: 0;
}
.tabs-nav {
min-width: 0;
}
.tabs-nav :deep(.el-tabs__nav) {
gap: 6px;
}
.tabs-nav :deep(.el-tabs__nav-wrap) {
padding-top: 0;
}
.tabs-nav :deep(.el-tabs__nav-wrap::after) {
background-color: var(--surface-border);
}
.tabs-nav :deep(.el-tabs__item) {
height: 44px;
line-height: 44px;
color: #606266;
font-size: 14px;
padding: 0 18px;
border-radius: 10px 10px 0 0;
transition: color 0.2s ease, background-color 0.2s ease;
}
.tabs-nav :deep(.el-tabs__item:hover) {
color: var(--surface-text);
background: rgba(64, 158, 255, 0.08);
}
.tabs-nav :deep(.el-tabs__item.is-active) {
color: var(--el-color-primary);
font-weight: 600;
background: linear-gradient(180deg, rgba(64, 158, 255, 0.12) 0%, rgba(64, 158, 255, 0.06) 100%);
}
.tabs-nav :deep(.el-tabs__active-bar) {
height: 3px;
border-radius: 999px;
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>
@@ -1,229 +0,0 @@
<template>
<div class="side-nav">
<!-- 品牌区(左上角) -->
<div class="brand">
<div class="brand-content">
<el-icon class="brand-logo" :size="24"><School /></el-icon>
<div class="brand-text">
<div class="brand-name">EAI 数字员工平台</div>
<div class="brand-sub">AI Agent Platform</div>
<div class="brand-en">eai_agentplatform</div>
</div>
</div>
</div>
<!-- 菜单区 -->
<el-menu
:default-active="activeSectionKey"
:collapse="false"
class="nav-menu"
@select="handleSectionSelect"
>
<el-menu-item v-for="section in commonSections" :key="section.key" :index="section.key">
<el-icon><component :is="iconMap[section.icon]" /></el-icon>
<span>{{ section.label }}</span>
</el-menu-item>
<el-divider v-if="adminSections.length > 0" class="nav-divider" />
<template v-if="adminSections.length > 0">
<el-menu-item v-for="section in adminSections" :key="section.key" :index="section.key">
<el-icon><component :is="iconMap[section.icon]" /></el-icon>
<span>{{ section.label }}</span>
</el-menu-item>
</template>
</el-menu>
<!-- 底部用户信息(左下角) -->
<div class="user-footer">
<el-divider class="footer-divider" />
<div class="user-info">
<el-avatar :size="36" class="user-avatar">{{ avatarText }}</el-avatar>
<div class="user-meta">
<span class="user-name">{{ username }}</span>
<el-tag :type="isAdmin ? 'danger' : 'info'" size="small" effect="plain" round>
{{ isAdmin ? '管理员' : '普通员工' }}
</el-tag>
</div>
</div>
<el-button text type="info" size="small" class="logout-btn" @click="handleLogout">
退出登录
</el-button>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Collection,
EditPen,
HomeFilled,
Reading,
Setting,
UserFilled,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/store/auth'
import { useNotificationStore } from '@/store/notification'
// 冻结件之间用相对引用,让这批归档自成一个封闭单元(原为 @/config/navigation)
import { getVisibleSections, resolveSection } from './navigation.frozen.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const notif = useNotificationStore()
const isAdmin = computed(() => auth.isAdmin)
const username = computed(() => auth.username)
const avatarText = computed(() => (username.value || '用').charAt(0))
const unread = computed(() => notif.unread)
const sections = computed(() => getVisibleSections(isAdmin.value))
const commonSections = computed(() => sections.value.filter((section) => !section.adminOnly))
const adminSections = computed(() => sections.value.filter((section) => section.adminOnly))
const activeSectionKey = computed(() => resolveSection(route.path, isAdmin.value)?.key || 'workbench')
const iconMap = {
HomeFilled,
Reading,
EditPen,
UserFilled,
Collection,
Setting,
}
let timer = null
onMounted(() => {
notif.refresh()
timer = setInterval(() => notif.refresh(), 60000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
function handleLogout() {
auth.logout()
router.push('/login')
}
function handleSectionSelect(sectionKey) {
const section = sections.value.find((item) => item.key === sectionKey)
if (section?.defaultPath && section.defaultPath !== route.path) {
router.push(section.defaultPath)
}
}
</script>
<style scoped>
.side-nav {
width: var(--nav-width, 220px);
height: 100vh;
background: var(--surface-bg);
border-right: 1px solid var(--surface-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.brand {
height: var(--top-rail-height);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-sizing: border-box;
padding: 0 12px;
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
border-bottom: 1px solid var(--surface-border);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.brand-content {
display: flex;
align-items: center;
gap: 10px;
transform: translateX(-6px);
}
.brand-logo {
color: var(--el-color-primary);
flex-shrink: 0;
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand-name {
font-size: 16px;
font-weight: 600;
color: var(--surface-text);
}
.brand-sub {
font-size: 13px;
font-weight: 500;
color: var(--surface-muted);
}
.brand-en {
font-size: 11px;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #b1b3b8;
}
.nav-menu {
flex: 1;
overflow: hidden;
border-right: none;
}
.nav-divider {
margin: 8px 16px;
}
.user-footer {
padding: 12px 16px;
border-top: 1px solid var(--surface-border);
}
.footer-divider {
margin: 0 0 8px 0;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.user-avatar {
background: var(--el-color-primary);
color: #fff;
font-weight: 600;
flex-shrink: 0;
}
.user-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.user-name {
font-size: 14px;
color: var(--surface-text);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout-btn {
width: 100%;
}
</style>
@@ -1,179 +0,0 @@
<!--
【冻结件 · 请勿直接引用】工作区胶囊条(含通知铃铛)
原位置:src/layout/MainLayout.vue
- 模板 101-128 行 <div class="wb-tabs"> 整块
- 脚本 isTabActive() 314-318 行、closeTab() 375-388 行
- 脚本 通知轮询 onMounted 240-241 行 / onBeforeUnmount 248 行
- 样式 .wb-icon-btn 与 .wb-tabs* 727-802 行
冻结于:2026-09-14
冻结原因与解冻步骤:见同目录 FROZEN.md
为什么自包含:本组件不依赖 MainLayout 的任何局部变量(activeSection 自己算),
将来原样放回任意位置即可工作。唯一改动是 activeSection 的来源,见下方注释。
-->
<template>
<div class="wb-tabs">
<div class="wb-tabs-list">
<button
v-for="tab in openTabs"
:key="tab.route"
class="wb-tab"
:class="{ active: isTabActive(tab) }"
@click="router.push(tab.route)"
>
<span>{{ tab.label }}</span>
<el-icon
v-if="tab.closable"
class="wb-tab-close"
@click.stop="closeTab(tab.route)"
>
<Close />
</el-icon>
</button>
</div>
<div class="wb-tabs-actions">
<el-badge :value="unread" :max="99" :hidden="unread === 0">
<button class="wb-icon-btn" aria-label="通知" @click="router.push('/notifications')">
<el-icon><Bell /></el-icon>
</button>
</el-badge>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Bell, Close } from '@element-plus/icons-vue'
import { useNotificationStore } from '@/store/notification'
import { useWorkbenchStore } from '@/store/workbench'
import { findSectionByPath, navSections } from '@/config/nav'
const route = useRoute()
const router = useRouter()
const workbench = useWorkbenchStore()
const notif = useNotificationStore()
const openTabs = computed(() => workbench.openTabs)
const unread = computed(() => notif.unread)
// 与原实现唯一的差异:MainLayout 里 activeSection 来自做过权限过滤的 visibleSections,
// 这里直接按当前路由找。isTabActive 只比较 section.key,不做权限过滤也不影响结果,
// 换来的是这个组件可以被独立放置。
const activeSection = computed(() => findSectionByPath(navSections, route.path))
let timer = null
onMounted(() => {
notif.refresh()
timer = setInterval(() => notif.refresh(), 60000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
// 胶囊高亮:/home 有自己的钉子胶囊(智能助手,不可关),所以停在 /home 时
// 只点亮它;其余情况按「当前路由属于哪个一级项」点亮那一枚。
function isTabActive(tab) {
if (tab.route === '/home') return route.path === '/home'
if (route.path === '/home') return false
return activeSection.value?.key === tab.sectionKey
}
function closeTab(path) {
const tabs = openTabs.value
const index = tabs.findIndex((tab) => tab.route === path && tab.closable)
if (index === -1) return
// 胶囊是一级粒度,所以「关的是不是当前这枚」不能拿路径相等去比 ——
// 人可能停在组里任意一页。按归属判断。
const closingCurrent = isTabActive(tabs[index])
workbench.closeTab(path)
if (closingCurrent) {
const nextTabs = workbench.openTabs
const fallback = nextTabs[index - 1] || nextTabs[nextTabs.length - 1]
router.push(fallback?.route || '/home')
}
}
</script>
<style scoped>
.wb-icon-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid #e8ecf1;
background: #f5f7fa;
color: #606266;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.wb-icon-btn:hover {
background: #eef1f6;
color: #409eff;
}
/* ===== WORKSPACE TABS (topmost row) ===== */
.wb-tabs {
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #eef1f6;
}
.wb-tabs-list {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
}
.wb-tabs-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.wb-tab {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e8ecf1;
border-radius: 999px;
background: #f8fafc;
padding: 6px 12px;
cursor: pointer;
color: #526071;
font-size: 13px;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.wb-tab:hover {
border-color: #c8d8ea;
}
.wb-tab.active {
background: #e1effe;
border-color: #409eff;
color: #409eff;
font-weight: 600;
}
.wb-tab-close {
font-size: 12px;
}
</style>
@@ -1,112 +0,0 @@
export const navSections = [
{
key: 'workbench',
label: '1 工作台',
icon: 'HomeFilled',
defaultPath: '/home',
tabs: [
{ path: '/home', label: '1.1 首页', activeMatch: ['/home'] },
{ path: '/notifications', label: '1.2 消息通知', activeMatch: ['/notifications'] },
],
},
{
key: 'learning',
label: '2 学习内容',
icon: 'Reading',
defaultPath: '/company-train',
tabs: [
{ path: '/company-train', label: '2.1 公司介绍', activeMatch: ['/company-train'] },
{ path: '/products', label: '2.2 产品知识', activeMatch: ['/products', '/products/'] },
{ path: '/courses', label: '2.3 销售培训', activeMatch: ['/courses', '/courses/'] },
],
},
{
key: 'exam',
label: '3 练习与考试',
icon: 'EditPen',
defaultPath: '/exam/self-test',
tabs: [
{ path: '/exam/self-test', label: '3.1 自测练习', activeMatch: ['/exam/self-test'] },
{ path: '/exam/formal', label: '3.2 正式考试', activeMatch: ['/exam/formal'] },
],
},
{
key: 'my-learning',
label: '4 我的学习',
icon: 'UserFilled',
defaultPath: '/exam/my-records',
tabs: [
{ path: '/exam/my-records', label: '4.1 我的记录', activeMatch: ['/exam/my-records'] },
{ path: '/exam/my-position', label: '4.2 我的岗位清单', activeMatch: ['/exam/my-position'] },
{ path: '/exam/my-mistakes', label: '4.3 我的错题本', activeMatch: ['/exam/my-mistakes'] },
{ path: '/exam/my-profile', label: '4.4 我的学习档案', activeMatch: ['/exam/my-profile'] },
{ path: '/exam/my-certificates', label: '4.5 我的证书', activeMatch: ['/exam/my-certificates'] },
{ path: '/exam/leaderboard', label: '4.6 学习排行榜', activeMatch: ['/exam/leaderboard'] },
],
},
{
key: 'content-ops',
label: '5 内容运营',
icon: 'Collection',
defaultPath: '/knowledge/materials',
adminOnly: true,
tabs: [
{ path: '/knowledge/materials', label: '5.1 资料管理', activeMatch: ['/knowledge/materials'] },
{ path: '/knowledge/audit', label: '5.2 素材审批', activeMatch: ['/knowledge/audit'] },
{ path: '/knowledge/questions', label: '5.3 题库管理', activeMatch: ['/knowledge/questions'] },
{ path: '/knowledge/exam-records', label: '5.4 全部成绩', activeMatch: ['/knowledge/exam-records'] },
],
},
{
key: 'org-management',
label: '6 组织管理',
icon: 'UserFilled',
defaultPath: '/system/positions',
adminOnly: true,
tabs: [
{ path: '/system/positions', label: '6.1 岗位管理', activeMatch: ['/system/positions'] },
{ path: '/system/departments', label: '6.2 部门管理', activeMatch: ['/system/departments'] },
{ path: '/system/users', label: '6.3 用户管理', activeMatch: ['/system/users'] },
{ path: '/system/company-config', label: '6.4 公司信息配置', activeMatch: ['/system/company-config'] },
],
},
{
key: 'system-settings',
label: '7 系统设置',
icon: 'Setting',
defaultPath: '/system/ai-usage',
adminOnly: true,
tabs: [
{ path: '/system/ai-usage', label: '7.1 AI 用量', activeMatch: ['/system/ai-usage'] },
{ path: '/system/config', label: '7.2 参数配置', activeMatch: ['/system/config'] },
],
},
]
function isMatchedPath(path, matcher) {
if (!matcher) return false
if (matcher.endsWith('/')) {
return path.startsWith(matcher)
}
return path === matcher
}
function matchesTab(path, tab) {
return (tab.activeMatch || [tab.path]).some((matcher) => isMatchedPath(path, matcher))
}
export function getVisibleSections(isAdmin) {
return navSections.filter((section) => !section.adminOnly || isAdmin)
}
export function resolveSection(path, isAdmin) {
return getVisibleSections(isAdmin).find((section) =>
section.tabs.some((tab) => matchesTab(path, tab))
) || null
}
export function resolveTab(path, isAdmin) {
const section = resolveSection(path, isAdmin)
if (!section) return null
return section.tabs.find((tab) => matchesTab(path, tab)) || section.tabs[0] || null
}
+2 -41
View File
@@ -43,34 +43,14 @@ export async function streamChat(message, context, history, handlers) {
const onChunk = typeof handlers === 'function' ? handlers : handlers?.onChunk
const onMeta = typeof handlers === 'function' ? null : handlers?.onMeta
const onDone = typeof handlers === 'function' ? null : handlers?.onDone
const traceId = `chat-${Date.now()}`
// #region debug-point A:frontend-request
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
body: JSON.stringify({
sessionId: 'knowledge-chat-401',
runId: 'pre-fix',
hypothesisId: 'A',
location: 'frontend/src/api/ai.js:streamChat:request',
traceId,
msg: '[DEBUG] streamChat request',
data: {
hasToken: Boolean(token),
url: '/api/ai-chat/message',
messagePreview: String(message || '').slice(0, 80),
spaceKey: context?.knowledge_space_key || '',
},
ts: Date.now(),
}),
}).catch(() => {})
// #endregion
const aiRouteId = typeof handlers === 'function' ? '' : (handlers?.aiRouteId || '')
const resp = await fetch('/api/ai-chat/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message, context, history }),
body: JSON.stringify({ message, context, history, ai_route_id: aiRouteId }),
})
if (!resp.ok) {
@@ -83,25 +63,6 @@ export async function streamChat(message, context, history, handlers) {
} catch {
/* ignore */
}
// #region debug-point A:frontend-non200
fetch('http://127.0.0.1:7777/event', {
method: 'POST',
body: JSON.stringify({
sessionId: 'knowledge-chat-401',
runId: 'pre-fix',
hypothesisId: 'A',
location: 'frontend/src/api/ai.js:streamChat:non200',
traceId,
msg: '[DEBUG] streamChat non-200 response',
data: {
status: resp.status,
statusText: resp.statusText,
bodyPreview: String(raw || '').slice(0, 240),
},
ts: Date.now(),
}),
}).catch(() => {})
// #endregion
throw new Error(msg)
}
@@ -0,0 +1,9 @@
import http from '@/api/http'
export function getMyAppCenter() {
return http.get('/my/app-center')
}
export function updateMyAppCenter(data) {
return http.put('/my/app-center', data)
}
@@ -0,0 +1,5 @@
import http from './http'
export function getSkillDefinitionByKey(key) {
return http.get(`/skills/by-key/${key}`)
}
@@ -26,9 +26,6 @@ http.interceptors.response.use(
} else if (resp?.status === 501) {
ElMessage.error(resp.data?.message || '服务未配置,请联系管理员')
} else if (resp?.status === 403) {
// #region debug-point B:http-403
fetch('http://127.0.0.1:7777/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'admin-auth-misjudge', runId: 'pre-fix', hypothesisId: 'B', location: 'src/api/http.js:response', msg: '[DEBUG] frontend received 403', data: { url: resp?.config?.url || err?.config?.url || null, method: resp?.config?.method || err?.config?.method || null, message: resp?.data?.message || null, tokenPresent: !!localStorage.getItem('token') }, ts: Date.now() }) }).catch(() => {})
// #endregion
ElMessage.warning(resp.data?.message || '无权限')
}
return Promise.reject(resp?.data || err)
@@ -11,3 +11,17 @@ export function auditMedia(mediaId, data) {
export function mediaStatus(mediaId) {
return http.get(`/media/status/${mediaId}`)
}
export function uploadMedia(file, extra = {}) {
const formData = new FormData()
formData.append('file', file)
Object.entries(extra).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
formData.append(key, value)
})
return http.post('/media/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
@@ -0,0 +1,13 @@
import http from './http'
export function executeOfficeSkill(data) {
return http.post('/skills/office/execute', data)
}
export function executeOfficeSkillOcr(formData) {
return http.post('/skills/office/ocr', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
@@ -0,0 +1,22 @@
import http from './http'
// 项目:任务的容器。归属由后端按登录用户定,前端不传 owner。
export function listProjects() {
return http.get('/worker/projects')
}
export function createProject(data = {}) {
return http.post('/worker/projects', data)
}
export function updateProject(id, data) {
return http.put(`/worker/projects/${id}`, data)
}
export function deleteProject(id) {
return http.delete(`/worker/projects/${id}`)
}
export function listProjectTasks(id) {
return http.get(`/worker/projects/${id}/tasks`)
}
+2 -2
View File
@@ -28,11 +28,11 @@ export function updateConfig(configs) {
return http.put('/system/config', { configs })
}
export function listChatRoutes() {
export function listAiChatRoutes() {
return http.get('/ai/routes/chat')
}
export function listEmbedRoutes() {
export function listAiEmbedRoutes() {
return http.get('/ai/routes/embed')
}

Some files were not shown because too many files have changed in this diff Show More