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:
@@ -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
@@ -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)。
|
||||
|
||||
|
||||
@@ -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`、路由守卫还是后端鉴权分叉。
|
||||
- 基于证据做最小修复,再做前后对比验证。
|
||||
@@ -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)
|
||||
@@ -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` 模型可以看作:
|
||||
|
||||
|
||||
@@ -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 模型路由。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 |
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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/`
|
||||
@@ -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 状态 | 响应 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 历史结构,而不是当前目标导航。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)**。
|
||||
核查其后端支撑:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 抽题
|
||||
|
||||
@@ -8,10 +8,6 @@ backend-go/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# 本地知识服务
|
||||
knowledge_service/data/
|
||||
knowledge_service/__pycache__/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
backend-go/.env
|
||||
|
||||
@@ -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 向量库
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user